Hypertext Editor
Live Demos HubHypertext Editor
Interactive Shadcn-quality hypermedia component for Hyperstack.
Interactive Preview
Hypertext Editor
A zero-bloat, Notion-grade WYSIWYG document editor with inline resizable HyperSheet tables, 2D arrow key navigation, Alt + ↑/↓/←/→ row & column splitters, document right-click context menu, and Levenshtein auto-fix spellchecker.
Welcome to Hypertext Editor
Hypertext Editor is an ultra-lean, hypermedia-driven rich text suite designed for modern web applications. It replaces heavy WYSIWYG packages with zero external dependencies.
Inline Resizable HyperSheet Table
Navigate cells with ↑ / ↓ / ← / →. Press Alt + ↓ to add a row below, or Alt + → to add a column. Drag column & row border handles to resize.
| # | Module (A) | Owner (B) | Velocity (C) | Action |
|---|---|---|---|---|
| 1 | HxBolt Reactivity Engine | Core Team | 60 FPS | |
| 2 | HyperSheet 2D Matrix Engine | Data Team | Sub-1ms | |
| 3 | Levenshtein Spellcheck | AI Coders | Zero Bloat |
Designed for Fullstack Developers
Everything you type synchronizes instantly into hidden form fields and updates telemetry in real-time.
- Zero React, zero ProseMirror, zero bloat
- Sub-millisecond signal updates via
htmx-bolt - Native 1:1 Go DSL for server-side HTML rendering
HTMX Form Integration
Hypertext Editor connects directly to standard HTMX forms. It updates a hidden content_html payload on every change.
<!-- HTMX Form Submission -->
<form hx-post="/api/posts/create" hx-target="#post-feedback" class="space-y-4">
<input type="text" name="title" placeholder="Post Title..." class="w-full px-4 py-2 border rounded-lg">
<!-- Drop-in Hypertext Editor -->
<div hx-ext="reactive" ...>
<input type="hidden" name="content_html" id="hypertext-hidden-input">
</div>
<button type="submit" class="px-5 py-2.5 rounded-lg bg-primary text-primary-foreground font-semibold">
Publish Article →
</button>
</form>Go DSL Usage
import "github.com/hyperlibs/hyperstack/go/components"
// Server-rendered Hypertext Editor with inline HyperSheet table
editor := components.HypertextEditor(components.HypertextEditorProps{
Name: "article_body",
Placeholder: "Write your document...",
InitialHTML: "<h1>My Notes</h1><p>Start writing here...</p>",
MinHeight: "350px",
})HTML Source
<div class="space-y-12 max-w-5xl">
<!-- Header -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wider text-primary bg-primary/10 px-2.5 py-0.5 rounded-full">Content & Forms</span>
<span class="text-xs font-mono text-muted-foreground">views/components/hypertext-editor.html</span>
</div>
<h1 class="text-3xl font-extrabold tracking-tight">Hypertext Editor</h1>
<p class="text-base text-muted-foreground">
A zero-bloat, Notion-grade WYSIWYG document editor with inline resizable HyperSheet tables, 2D arrow key navigation, <kbd class="px-1.5 py-0.5 rounded bg-muted text-[11px] font-mono border">Alt + ↑/↓/←/→</kbd> row & column splitters, document right-click context menu, and Levenshtein auto-fix spellchecker.
</p>
</div>
<!-- Interactive Live Component Preview -->
<div class="space-y-4" hx-ext="reactive" hx-state='{
"viewMode": "visual",
"wordCount": 142,
"charCount": 890,
"readingTime": "1 min",
"slashMenuOpen": false,
"saveStatus": "All changes saved"
}'>
<!-- Editor Outer Shell -->
<div class="rounded-2xl border border-border bg-card shadow-sm overflow-hidden flex flex-col focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary transition-all relative">
<!-- Top Action Toolbar -->
<div class="border-b border-border bg-muted/40 px-3 py-2 flex flex-wrap items-center justify-between gap-2 select-none">
<!-- Formatting Button Groups -->
<div class="flex flex-wrap items-center gap-1">
<!-- History -->
<div class="flex items-center border-r border-border/80 pr-1 mr-1 gap-0.5">
<button type="button" onclick="document.execCommand('undo')" class="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title="Undo (Ctrl+Z)">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>
</button>
<button type="button" onclick="document.execCommand('redo')" class="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title="Redo (Ctrl+Y)">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3L21 13"/></svg>
</button>
</div>
<!-- Headings Dropdown / Buttons -->
<div class="flex items-center border-r border-border/80 pr-1 mr-1 gap-0.5">
<button type="button" onclick="formatBlock('h1')" class="px-2 py-1 rounded-md text-xs font-bold hover:bg-muted text-muted-foreground hover:text-foreground" title="Heading 1">H1</button>
<button type="button" onclick="formatBlock('h2')" class="px-2 py-1 rounded-md text-xs font-bold hover:bg-muted text-muted-foreground hover:text-foreground" title="Heading 2">H2</button>
<button type="button" onclick="formatBlock('h3')" class="px-2 py-1 rounded-md text-xs font-bold hover:bg-muted text-muted-foreground hover:text-foreground" title="Heading 3">H3</button>
<button type="button" onclick="formatBlock('p')" class="px-2 py-1 rounded-md text-xs font-medium hover:bg-muted text-muted-foreground hover:text-foreground" title="Paragraph">¶</button>
</div>
<!-- Inline Styles -->
<div class="flex items-center border-r border-border/80 pr-1 mr-1 gap-0.5">
<button type="button" onclick="execStyle('bold')" class="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground font-bold text-sm w-7 h-7 flex items-center justify-center" title="Bold (Ctrl+B)">
<strong>B</strong>
</button>
<button type="button" onclick="execStyle('italic')" class="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground italic text-sm w-7 h-7 flex items-center justify-center" title="Italic (Ctrl+I)">
<em>I</em>
</button>
<button type="button" onclick="execStyle('underline')" class="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground underline text-sm w-7 h-7 flex items-center justify-center" title="Underline (Ctrl+U)">
<u>U</u>
</button>
<button type="button" onclick="insertCodeInline()" class="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground font-mono text-xs w-7 h-7 flex items-center justify-center" title="Inline Code">
<>
</button>
</div>
<!-- Lists & Interactive Table -->
<div class="flex items-center border-r border-border/80 pr-1 mr-1 gap-0.5">
<button type="button" onclick="execStyle('insertUnorderedList')" class="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground" title="Bullet List">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="4" cy="6" r="1.5" fill="currentColor"/><circle cx="4" cy="12" r="1.5" fill="currentColor"/><circle cx="4" cy="18" r="1.5" fill="currentColor"/></svg>
</button>
<button type="button" onclick="insertInlineHyperSheetTable()" class="px-2 py-1 rounded-md text-xs font-semibold hover:bg-muted text-primary hover:text-primary/80 flex items-center gap-1 border border-primary/20 bg-primary/5" title="Insert Resizable HyperSheet Table (Alt+Arrow navigation)">
<span>📊</span> <span>Table</span>
</button>
<button type="button" onclick="insertCallout('tip')" class="px-2 py-1 rounded-md text-xs font-medium hover:bg-muted text-emerald-500 hover:text-emerald-400 flex items-center gap-1" title="Insert Tip Callout">
💡 Callout
</button>
</div>
<!-- Auto-Fix & Undo -->
<div class="flex items-center gap-1">
<button type="button" onclick="autoFixTypography()" class="px-2 py-1 rounded-md text-xs font-semibold hover:bg-muted text-emerald-600 dark:text-emerald-400 hover:text-emerald-500 flex items-center gap-1.5 transition-colors border border-emerald-500/20 bg-emerald-500/10" title="Auto-Fix: Remove excess spaces, fix spelling mistakes via Levenshtein dictionary, format smart typography without altering structure">
<span>✅</span> <span>Auto-Fix</span>
</button>
<button type="button" id="btn-undo-autofix" onclick="undoAutoFix()" style="display: none;" class="px-2 py-1 rounded-md text-xs font-semibold hover:bg-muted text-amber-600 dark:text-amber-400 hover:text-amber-500 flex items-center gap-1.5 transition-colors border border-amber-500/20 bg-amber-500/10" title="Undo Auto-Fix: Revert back to pre-autofix snapshot">
<span>↩️</span> <span>Undo Fix</span>
</button>
<button type="button" onclick="document.execCommand('removeFormat')" class="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-destructive" title="Clear Formatting">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>
<!-- View Mode Switcher (Visual vs Markdown vs HTML) -->
<div class="flex items-center gap-1 p-0.5 rounded-lg bg-background border border-border text-xs font-medium" id="editor-view-tabs">
<button type="button"
id="tab-btn-visual"
class="px-2.5 py-1 rounded-md transition-all bg-primary text-primary-foreground shadow-sm"
onclick="switchEditorView('visual')">
Visual
</button>
<button type="button"
id="tab-btn-markdown"
class="px-2.5 py-1 rounded-md transition-all text-muted-foreground hover:text-foreground"
onclick="switchEditorView('markdown')">
Markdown
</button>
<button type="button"
id="tab-btn-html"
class="px-2.5 py-1 rounded-md transition-all text-muted-foreground hover:text-foreground"
onclick="switchEditorView('html')">
HTML
</button>
</div>
</div>
<!-- Editor Full-Surface Content Area -->
<div class="relative min-h-[360px] bg-background p-6">
<!-- 1. Visual WYSIWYG Editable Surface -->
<div id="hypertext-canvas"
contenteditable="true"
oninput="onEditorInput(this)"
onkeydown="onEditorKeydown(event, this)"
oncontextmenu="handleCanvasContextMenu(event)"
class="outline-none min-h-[320px] max-w-none text-foreground text-sm sm:text-base leading-relaxed space-y-4 focus:outline-none block">
<h1>Welcome to Hypertext Editor</h1>
<p>
Hypertext Editor is an ultra-lean, <strong>hypermedia-driven rich text suite</strong> designed for modern web applications. It replaces heavy WYSIWYG packages with zero external dependencies.
</p>
<div class="my-4 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-4 text-emerald-500 dark:text-emerald-400 text-sm">
💡 <strong>ProTip:</strong> Type <kbd class="px-1.5 py-0.5 rounded bg-muted text-xs font-mono border border-border">/</kbd> on any empty line for slash commands, or <strong>Right-Click</strong> anywhere for the document context menu!
</div>
<h2>Inline Resizable HyperSheet Table</h2>
<p>
Navigate cells with <kbd class="px-1.5 py-0.2 rounded bg-muted text-xs font-mono">↑ / ↓ / ← / →</kbd>. Press <kbd class="px-1.5 py-0.2 rounded bg-muted text-xs font-mono">Alt + ↓</kbd> to add a row below, or <kbd class="px-1.5 py-0.2 rounded bg-muted text-xs font-mono">Alt + →</kbd> to add a column. Drag column & row border handles to resize.
</p>
<!-- Interactive Resizable HyperSheet Table -->
<div class="my-4 rounded-xl border border-border bg-card p-3 shadow-xs not-prose select-text" contenteditable="false" id="table-demo-container">
<div class="flex items-center justify-between pb-2 mb-2 border-b border-border/60 text-xs text-muted-foreground">
<div class="flex items-center gap-2 font-medium text-foreground">
<span class="w-2 h-2 rounded-full bg-primary inline-block"></span>
<span>HyperSheet Resizable Matrix</span>
</div>
<div class="flex items-center gap-1.5 text-[11px]">
<span class="text-muted-foreground">Shortcuts: <kbd class="px-1 py-0.2 rounded bg-muted font-mono">Alt+↑/↓/←/→</kbd></span>
<button type="button" onclick="tableAddColumn('demo-table')" class="px-2 py-0.5 rounded bg-muted hover:bg-accent text-[11px] font-medium text-foreground transition-colors">+ Add Col</button>
<button type="button" onclick="tableAddRow('demo-table')" class="px-2 py-0.5 rounded bg-muted hover:bg-accent text-[11px] font-medium text-foreground transition-colors">+ Add Row</button>
</div>
</div>
<div class="overflow-x-auto">
<table id="demo-table" class="w-full text-left border-collapse text-xs table-fixed">
<thead>
<tr class="border-b border-border bg-muted/40 text-muted-foreground font-mono">
<th style="width: 44px;" class="p-2 border-r border-border/60 text-center select-none relative">
#
</th>
<th style="width: 220px;" class="p-2 border-r border-border/60 font-semibold text-foreground relative select-none">
<span>Module (A)</span>
<div class="col-resize-handle absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/50 transition-colors"></div>
</th>
<th style="width: 180px;" class="p-2 border-r border-border/60 font-semibold text-foreground relative select-none">
<span>Owner (B)</span>
<div class="col-resize-handle absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/50 transition-colors"></div>
</th>
<th style="width: 140px;" class="p-2 border-r border-border/60 font-semibold text-foreground relative select-none">
<span>Velocity (C)</span>
<div class="col-resize-handle absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/50 transition-colors"></div>
</th>
<th style="width: 60px;" class="p-2 text-center select-none">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-border/40">
<tr class="hover:bg-muted/20 group relative" style="height: 36px;">
<td class="p-2 border-r border-border/60 font-mono text-muted-foreground text-center bg-muted/20 select-none relative">
1
<div class="row-resize-handle absolute left-0 right-0 bottom-0 h-1.5 cursor-row-resize hover:bg-primary/50 transition-colors"></div>
</td>
<td class="p-2 border-r border-border/60" contenteditable="true" onkeydown="handleTableCellKeydown(event, this)">HxBolt Reactivity Engine</td>
<td class="p-2 border-r border-border/60" contenteditable="true" onkeydown="handleTableCellKeydown(event, this)">Core Team</td>
<td class="p-2 border-r border-border/60 font-mono font-semibold text-emerald-500" contenteditable="true" onkeydown="handleTableCellKeydown(event, this)">60 FPS</td>
<td class="p-2 text-center"><button type="button" onclick="tableDeleteRow(this)" class="opacity-0 group-hover:opacity-100 text-destructive text-xs hover:underline" title="Delete Row">🗑</button></td>
</tr>
<tr class="hover:bg-muted/20 group relative" style="height: 36px;">
<td class="p-2 border-r border-border/60 font-mono text-muted-foreground text-center bg-muted/20 select-none relative">
2
<div class="row-resize-handle absolute left-0 right-0 bottom-0 h-1.5 cursor-row-resize hover:bg-primary/50 transition-colors"></div>
</td>
<td class="p-2 border-r border-border/60" contenteditable="true" onkeydown="handleTableCellKeydown(event, this)">HyperSheet 2D Matrix Engine</td>
<td class="p-2 border-r border-border/60" contenteditable="true" onkeydown="handleTableCellKeydown(event, this)">Data Team</td>
<td class="p-2 border-r border-border/60 font-mono font-semibold text-emerald-500" contenteditable="true" onkeydown="handleTableCellKeydown(event, this)">Sub-1ms</td>
<td class="p-2 text-center"><button type="button" onclick="tableDeleteRow(this)" class="opacity-0 group-hover:opacity-100 text-destructive text-xs hover:underline" title="Delete Row">🗑</button></td>
</tr>
<tr class="hover:bg-muted/20 group relative" style="height: 36px;">
<td class="p-2 border-r border-border/60 font-mono text-muted-foreground text-center bg-muted/20 select-none relative">
3
<div class="row-resize-handle absolute left-0 right-0 bottom-0 h-1.5 cursor-row-resize hover:bg-primary/50 transition-colors"></div>
</td>
<td class="p-2 border-r border-border/60" contenteditable="true" onkeydown="handleTableCellKeydown(event, this)">Levenshtein Spellcheck</td>
<td class="p-2 border-r border-border/60" contenteditable="true" onkeydown="handleTableCellKeydown(event, this)">AI Coders</td>
<td class="p-2 border-r border-border/60 font-mono font-semibold text-emerald-500" contenteditable="true" onkeydown="handleTableCellKeydown(event, this)">Zero Bloat</td>
<td class="p-2 text-center"><button type="button" onclick="tableDeleteRow(this)" class="opacity-0 group-hover:opacity-100 text-destructive text-xs hover:underline" title="Delete Row">🗑</button></td>
</tr>
</tbody>
</table>
</div>
</div>
<h2>Designed for Fullstack Developers</h2>
<p>
Everything you type synchronizes instantly into hidden form fields and updates telemetry in real-time.
</p>
<ul class="list-disc list-inside my-2 space-y-1">
<li>Zero React, zero ProseMirror, zero bloat</li>
<li>Sub-millisecond signal updates via <code>htmx-bolt</code></li>
<li>Native 1:1 Go DSL for server-side HTML rendering</li>
</ul>
</div>
<!-- 2. Markdown Full-Surface Editable Area -->
<textarea id="hypertext-markdown"
style="display: none;"
oninput="onMarkdownInput(this)"
class="w-full min-h-[320px] bg-transparent font-mono text-sm text-foreground border-none outline-none resize-y focus:outline-none leading-relaxed"
placeholder="Type Markdown content here..."></textarea>
<!-- 3. HTML Full-Surface Editable Area -->
<textarea id="hypertext-html"
style="display: none;"
oninput="onHtmlInput(this)"
class="w-full min-h-[320px] bg-transparent font-mono text-sm text-foreground border-none outline-none resize-y focus:outline-none leading-relaxed"
placeholder="Type HTML source here..."></textarea>
<!-- Slash Command Menu Popup -->
<div id="slash-menu"
hx-show="slashMenuOpen"
class="absolute z-50 w-64 rounded-xl border border-border bg-card shadow-2xl p-1.5 space-y-1 text-xs"
style="display: none; top: 120px; left: 40px;">
<div class="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground border-b border-border mb-1">
Insert Block (Type to filter)
</div>
<button type="button" onclick="executeSlashCommand('h1')" class="w-full flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-accent text-left transition-colors">
<span class="w-5 h-5 rounded bg-primary/10 text-primary font-bold flex items-center justify-center text-[10px]">H1</span>
<div><div class="font-medium text-foreground">Heading 1</div><div class="text-[10px] text-muted-foreground">Large title block</div></div>
</button>
<button type="button" onclick="executeSlashCommand('h2')" class="w-full flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-accent text-left transition-colors">
<span class="w-5 h-5 rounded bg-primary/10 text-primary font-bold flex items-center justify-center text-[10px]">H2</span>
<div><div class="font-medium text-foreground">Heading 2</div><div class="text-[10px] text-muted-foreground">Medium section header</div></div>
</button>
<button type="button" onclick="executeSlashCommand('table')" class="w-full flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-accent text-left transition-colors">
<span class="w-5 h-5 rounded bg-emerald-500/20 text-emerald-500 font-bold flex items-center justify-center text-[10px]">📊</span>
<div><div class="font-medium text-foreground">HyperSheet Table</div><div class="text-[10px] text-muted-foreground">Resizable 2D editable matrix</div></div>
</button>
<button type="button" onclick="executeSlashCommand('bullet')" class="w-full flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-accent text-left transition-colors">
<span class="w-5 h-5 rounded bg-muted text-foreground flex items-center justify-center text-[10px]">●</span>
<div><div class="font-medium text-foreground">Bullet List</div><div class="text-[10px] text-muted-foreground">Simple bulleted points</div></div>
</button>
<button type="button" onclick="executeSlashCommand('callout')" class="w-full flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-accent text-left transition-colors">
<span class="w-5 h-5 rounded bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-[10px]">💡</span>
<div><div class="font-medium text-foreground">Callout Box</div><div class="text-[10px] text-muted-foreground">Highlight notice or tip</div></div>
</button>
<button type="button" onclick="executeSlashCommand('code')" class="w-full flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-accent text-left transition-colors">
<span class="w-5 h-5 rounded bg-blue-500/20 text-blue-400 font-mono flex items-center justify-center text-[10px]"><></span>
<div><div class="font-medium text-foreground">Code Block</div><div class="text-[10px] text-muted-foreground">Syntax formatted snippet</div></div>
</button>
</div>
<!-- Document & Table Right-Click Context Menu -->
<div id="editor-context-menu"
class="fixed z-50 w-56 rounded-xl border border-border bg-card shadow-2xl p-1 text-xs space-y-0.5 select-none"
style="display: none;">
<!-- Contextual Table Tools Header -->
<div id="ctx-table-group" class="space-y-0.5 pb-1 border-b border-border/60">
<div class="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-primary">Table Actions</div>
<button type="button" onclick="ctxTableAction('row-above')" class="w-full flex items-center justify-between px-2 py-1.5 rounded-md hover:bg-accent text-left text-foreground">
<span>➕ Insert Row Above</span>
<kbd class="text-[10px] text-muted-foreground font-mono">Alt+↑</kbd>
</button>
<button type="button" onclick="ctxTableAction('row-below')" class="w-full flex items-center justify-between px-2 py-1.5 rounded-md hover:bg-accent text-left text-foreground">
<span>➕ Insert Row Below</span>
<kbd class="text-[10px] text-muted-foreground font-mono">Alt+↓</kbd>
</button>
<button type="button" onclick="ctxTableAction('col-left')" class="w-full flex items-center justify-between px-2 py-1.5 rounded-md hover:bg-accent text-left text-foreground">
<span>➕ Insert Col Left</span>
<kbd class="text-[10px] text-muted-foreground font-mono">Alt+←</kbd>
</button>
<button type="button" onclick="ctxTableAction('col-right')" class="w-full flex items-center justify-between px-2 py-1.5 rounded-md hover:bg-accent text-left text-foreground">
<span>➕ Insert Col Right</span>
<kbd class="text-[10px] text-muted-foreground font-mono">Alt+→</kbd>
</button>
<button type="button" onclick="ctxTableAction('del-row')" class="w-full flex items-center justify-between px-2 py-1.5 rounded-md hover:bg-destructive/10 text-destructive text-left">
<span>🗑️ Delete Row</span>
</button>
<button type="button" onclick="ctxTableAction('del-col')" class="w-full flex items-center justify-between px-2 py-1.5 rounded-md hover:bg-destructive/10 text-destructive text-left">
<span>🗑️ Delete Column</span>
</button>
</div>
<!-- Document Formatting Tools -->
<div class="pt-1 space-y-0.5">
<div class="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Document Format</div>
<button type="button" onclick="execStyle('bold'); closeContextMenu();" class="w-full flex items-center justify-between px-2 py-1.5 rounded-md hover:bg-accent text-left text-foreground">
<span><strong>B</strong> Bold</span>
<kbd class="text-[10px] text-muted-foreground font-mono">Ctrl+B</kbd>
</button>
<button type="button" onclick="execStyle('italic'); closeContextMenu();" class="w-full flex items-center justify-between px-2 py-1.5 rounded-md hover:bg-accent text-left text-foreground">
<span><em>I</em> Italic</span>
<kbd class="text-[10px] text-muted-foreground font-mono">Ctrl+I</kbd>
</button>
<button type="button" onclick="insertCallout('tip'); closeContextMenu();" class="w-full flex items-center px-2 py-1.5 rounded-md hover:bg-accent text-left text-foreground">
<span>💡 Insert Callout</span>
</button>
<button type="button" onclick="insertInlineHyperSheetTable(); closeContextMenu();" class="w-full flex items-center px-2 py-1.5 rounded-md hover:bg-accent text-left text-foreground">
<span>📊 Insert Table</span>
</button>
<button type="button" onclick="autoFixTypography(); closeContextMenu();" class="w-full flex items-center px-2 py-1.5 rounded-md hover:bg-accent text-left text-emerald-600 dark:text-emerald-400 font-medium">
<span>✅ Auto-Fix Typography</span>
</button>
</div>
</div>
</div>
<!-- Bottom Telemetry & Status Footer -->
<div class="border-t border-border bg-muted/20 px-4 py-2 flex flex-wrap items-center justify-between text-xs text-muted-foreground font-mono">
<div class="flex items-center gap-4">
<span>Words: <strong class="text-foreground" hx-text="wordCount">142</strong></span>
<span>Chars: <strong class="text-foreground" hx-text="charCount">890</strong></span>
<span>Read time: <strong class="text-foreground" hx-text="readingTime">1 min</strong></span>
</div>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-emerald-500 inline-block"></span>
<span hx-text="saveStatus">All changes saved</span>
</div>
</div>
</div>
<!-- Hidden form input for seamless HTMX form synchronization -->
<input type="hidden" name="content_html" id="hypertext-hidden-input">
</div>
<!-- Scoped Typography & Table Styles -->
<style>
#hypertext-canvas h1 { font-size: 1.65rem; font-weight: 800; line-height: 1.25; margin-top: 1rem; margin-bottom: 0.5rem; letter-spacing: -0.025em; }
#hypertext-canvas h2 { font-size: 1.35rem; font-weight: 700; line-height: 1.3; margin-top: 0.875rem; margin-bottom: 0.375rem; letter-spacing: -0.02em; }
#hypertext-canvas h3 { font-size: 1.15rem; font-weight: 600; line-height: 1.35; margin-top: 0.75rem; margin-bottom: 0.25rem; }
#hypertext-canvas p { margin-bottom: 0.75rem; line-height: 1.6; }
#hypertext-canvas ul { list-style-type: disc; padding-left: 1.5rem; margin-top: 0.5rem; margin-bottom: 0.75rem; }
#hypertext-canvas ol { list-style-type: decimal; padding-left: 1.5rem; margin-top: 0.5rem; margin-bottom: 0.75rem; }
#hypertext-canvas li { margin-bottom: 0.25rem; }
#hypertext-canvas blockquote { border-left: 4px solid hsl(var(--primary)); padding-left: 1rem; margin: 0.75rem 0; font-style: italic; color: hsl(var(--muted-foreground)); }
#hypertext-canvas code { background-color: hsl(var(--muted)); padding: 0.15rem 0.35rem; border-radius: 0.25rem; font-family: monospace; font-size: 0.85em; }
#hypertext-canvas a { color: hsl(var(--primary)); text-decoration: underline; font-weight: 500; }
#hypertext-canvas hr { border-top: 1px solid hsl(var(--border)); margin: 1.5rem 0; }
.col-resize-handle { user-select: none; }
.row-resize-handle { user-select: none; }
</style>
<!-- Client Integration Scripts -->
<script>
// 1,800+ Top Core English & Tech Vocabulary Dictionary
const WORD_LIST_STR = "a ability able about above accept according account across act action actions activity actually add address administration admit adult affect after again against age agency agent agentic agents agree agreement ahead air all allow allows almost alone along already also although always among amount analysis and animal animals another answer any anyone anything appear appearance appearances apply approach area areas argue argument arm arms array arrays article articles artist as ask assume at attack attention attorney audience author authority automatic available avoid away back bad bag balance ball bank bar base be beat beautiful because become bed before begin behavior behind believe benefit best better between beyond big bill billion bit black blood blue board body book born both box boy breaks brief build builder builders building built bullet bunch business busy but button buttons buy by call camera campaign can cancel cancer candidate canvas capital car card cards care career carry case catch cause cell cells center central century certain certainly chair chairman challenge chance change changes character characters check checking child children choice choose church circle citizen city civil claim class classes clear clearly client clients climate close coach code cold collection college color column columns combine come commercial common community company compare computer concern condition conference congress consider consumer contain content continue control controls core corporate cost could council count counter country county couple course court cover create created creates creation crime culture cup current customer cut dark dashboard data database daughter day days dead deal dear death debate decade decide decision declaration deep defense define degree demo describe description design designed designer designers designing desk destroy detail detailed determine develop developer developers development device dialog differ difference different difficult dimension dinner direction director discover discuss disease do doctor document documents dog dollar door double down draw dream dress drink drive drop drug due during each early earth easy eat economic economy edge edition editor editors edit education effect effects effort eight either election electric element elements elite else employee empty enable end energy engine engineer engineers enjoy enough ensure enter enterprise entire entry environment equal error errors especially establish even evening event events ever every everybody everyone everything evidence exact exactly example examples executive exist existence expand expect experience expert explain export extend extension extensions eye face fact factor fail family far fast father fault fear feature features federal feel feeling few field fields fight figure figures file files fill film filter filters final finally finance financial find fine finger finish fire first fish five fix fixes fixed flag flash floor fly focus follow following food foot for force foreign form format formatting forms forward found four frame frameworks framework free friend from front full fully function functional functions fundamental future game garden gas general generation get girl give glass global go goal good government great green ground group grow growth guess guidance guide gun guy hair half hand handle handler happen happy hard have he head header heading headings health hear heart heat heavy help helps helpful her here herself high highlight him himself history hit hold home hope hospital hot hotel hour house how however huge human hundred husband hypertext htmx Hyperstack idea identify if ignore image images imagine impact important improve in include includes including increase indeed indicate individual industry information info initial inject input inputs inside instant instead institution insurance intend interaction interactive interest internal international internet interval into investigate investment invite invoice involve issue item items its itself job join judge jump just keep key keyboard keys kill kind know knowledge land landscape language large last late later laugh launch law lawyer lay layer layers layout layouts lead leader leaders learn leave left legal legacy legend length less letter letters level levels library libraries license life lift light lightning like likely limit line lines link links list lists listen listener listeners literature little live local lock log logic look lose loss lot love low machine macro magazine mail maintain maintainer main major majority make male man manage manager managing modern many market marriage mass master match math matrix matter may maybe me meal mean meaning measurement mechanism media medical meet member memory mention menu message messages method micro middle might military million mind mine minute miss mission mistake mode model modern modify moment money month monthly more morning most mother motion move movement movie much multiple municipal music must mutate my myself name nation national native natural nature near nearby necessary need negative network never new news next nice night nine no nobody normal north not note notes notice novel number numbers observe occur occurred occurrence off offer office officer official often oh oil ok old on once one ongoing online only onto open opening operate operation operations opinion opportunity oppose option options or order ordered organization original other others our ourselves out outcome output outputs over overall owner package packages page pages pain paint panel paper paragraph paragraphs part partial partials participant particular party pass past path pattern pay payment peak peer penalty people perform performance permanent persist personal phone photo phrase physical physics picture piece pill pilot place plan planning platform play player point policy poor pop popup port portfolio position positive possible post potential pound pour power powerful practical practice precise prefer prepare presence president pressure pretty prevent price primitive print prior priority privacy private probability problem process produce product profession program project promise promote proper properly proportion protocol provide province provision public publish pull purpose push put quality quantitative query quick quickly quote quotes radar radius raise range rapid rare rate rather than race radio radius reaction read reading ready real realize really reason receive recent recipe record recover reduce region regular reject relate relative release reliable religion religious rely remain remember remote remove render rendering rendered renders renderer repeat replace report represent require research resolve response rest restaurant result return reveal rich right ring risk road rock role roll room rough round routine row rule run running safe safety salary same sample save saveStatus scan scene schedule schema science score screen script scroll search season seat second secret section security see seed seek seem self sell send sequence serial series server service set settle setup seven several severe shape share shell sheet shoot short should show sibling side sign signal signature significant simple simply since single sink sister sit site situation size skill skills slash slice slide slim small smart snapshot solution solve son soon sound source space spatial speak special species specific speech spend spirit spring staff stage standard start state statement states static status step steps still stimulus stock stone stop store story strategy street strike string structural structure structures student study style styles stylish subject submillisecond subscribe subscription subsequent success successful such sudden suffer suggest suit summarize summary summer super supplement support surface surprise surveillance switch symbol system systems table tables tablet tail take talk target task tasks tax team telemetry template templates temporary ten term test tests text textarea texture thank than that the their them theme themes themselves then theology theory there therefore these they thing think third this those though thought threat three through throughout throw ticket tier time tiny title to today together tolerance tomorrow tonight tool toolbar toolkit tools topic total tough tour toward track tracking trade tradition traffic trail train transaction transcript transition translation transport trash travel treasure treatment tree trigger true truly trust try tube turn tutorial twin two type typed typedarray typical typography ultimate ultra under understand unit unity universe unless until unto update updates upgrade upload upon upper upset urban use used useful user users utility vacation valid validate value values variable variables variation variety various verify version view visual vocabulary volume wait walk wall want war warm warn warning watch water wave way we weapon wear weather web website week weight well west western what whatever wheel when where whereas whether which while white whole whom whose why widget wide wife wild will win window wind wire wish with within without woman wonder word words work worker working workplace workshop workspace world worry worth would write writer writing wrong yard year years yellow yes yesterday yet you young your yourself zone";
const DICTIONARY_WORDS = new Set(WORD_LIST_STR.split(' '));
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz';
const typoDictionary = [
[/\bteh\b/g, 'the'], [/\bTeh\b/g, 'The'],
[/\brecieve\b/g, 'receive'], [/\bRecieve\b/g, 'Receive'],
[/\brecieving\b/g, 'receiving'], [/\bRecieving\b/g, 'Receiving'],
[/\brecieved\b/g, 'received'], [/\bRecieved\b/g, 'Received'],
[/\bseperate\b/g, 'separate'], [/\bSeperate\b/g, 'Separate'],
[/\bseperated\b/g, 'separated'], [/\bSeperated\b/g, 'Separated'],
[/\bdefinately\b/g, 'definitely'], [/\bDefinately\b/g, 'Definitely'],
[/\boccured\b/g, 'occurred'], [/\bOccured\b/g, 'Occurred'],
[/\boccuring\b/g, 'occurring'], [/\bOccuring\b/g, 'Occurring'],
[/\buntill\b/g, 'until'], [/\bUntill\b/g, 'Until'],
[/\bwierd\b/g, 'weird'], [/\bWierd\b/g, 'Weird'],
[/\btruely\b/g, 'truly'], [/\bTruely\b/g, 'Truly'],
[/\baccomodate\b/g, 'accommodate'], [/\bAccomodate\b/g, 'Accommodate'],
[/\bgoverment\b/g, 'government'], [/\bGoverment\b/g, 'Government'],
[/\bacheive\b/g, 'achieve'], [/\bAcheive\b/g, 'Achieve'],
[/\bcalender\b/g, 'calendar'], [/\bCalender\b/g, 'Calendar'],
[/\btommorow\b/g, 'tomorrow'], [/\bTommorow\b/g, 'Tomorrow'],
[/\bneccessary\b/g, 'necessary'], [/\bNeccessary\b/g, 'Necessary'],
[/\bexistense\b/g, 'existence'], [/\bExistense\b/g, 'Existence'],
[/\bappearence\b/g, 'appearance'], [/\bAppearence\b/g, 'Appearance'],
[/\bpossession\b/g, 'possession'],
[/\bpossess\b/g, 'possess'],
[/\bhtmx\b/g, 'HTMX'], [/\bHtmx\b/g, 'HTMX']
];
let lastAutoFixSnapshot = null;
let activeContextTd = null;
// --- 2D Keyboard Navigation & Alt+Arrow Shortcuts for Table ---
function handleTableCellKeydown(e, el) {
const td = el.closest('td');
if (!td) return;
const tr = td.parentElement;
const tbody = tr.parentElement;
const table = tbody.closest('table');
if (!table) return;
const allTrs = Array.from(tbody.children);
const rowIndex = allTrs.indexOf(tr);
const allTdsInRow = Array.from(tr.children).filter(c => c.tagName === 'TD' && c.getAttribute('contenteditable') === 'true');
const colIndex = allTdsInRow.indexOf(td);
// --- Alt + Arrow Key Insertions ---
if (e.altKey) {
if (e.key === 'ArrowDown') {
e.preventDefault();
insertRowAt(table, rowIndex + 1);
focusTableCell(tbody, rowIndex + 1, colIndex);
return;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
insertRowAt(table, rowIndex);
focusTableCell(tbody, rowIndex, colIndex);
return;
} else if (e.key === 'ArrowRight') {
e.preventDefault();
insertColumnAt(table, colIndex + 1);
focusTableCell(tbody, rowIndex, colIndex + 1);
return;
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
insertColumnAt(table, colIndex);
focusTableCell(tbody, rowIndex, colIndex);
return;
}
}
// --- 2D Arrow Navigation ---
if (e.key === 'ArrowDown') {
if (rowIndex < allTrs.length - 1) {
e.preventDefault();
focusTableCell(tbody, rowIndex + 1, colIndex);
}
} else if (e.key === 'ArrowUp') {
if (rowIndex > 0) {
e.preventDefault();
focusTableCell(tbody, rowIndex - 1, colIndex);
}
} else if (e.key === 'Tab') {
if (!e.shiftKey && rowIndex === allTrs.length - 1 && colIndex === allTdsInRow.length - 1) {
e.preventDefault();
tableAddRow(table.id);
focusTableCell(tbody, rowIndex + 1, 0);
}
}
}
function focusTableCell(tbody, rowIdx, colIdx) {
const rows = tbody.querySelectorAll('tr');
if (rowIdx >= 0 && rowIdx < rows.length) {
const editableTds = Array.from(rows[rowIdx].children).filter(c => c.tagName === 'TD' && c.getAttribute('contenteditable') === 'true');
const targetCol = Math.max(0, Math.min(editableTds.length - 1, colIdx));
if (editableTds[targetCol]) {
editableTds[targetCol].focus();
try {
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(editableTds[targetCol]);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
} catch (_) {}
}
}
}
function insertRowAt(table, targetRowIdx) {
const tbody = table.querySelector('tbody');
const colCount = table.querySelectorAll('thead th').length - 2;
const tr = document.createElement('tr');
tr.className = 'hover:bg-muted/20 group relative';
tr.style.height = '36px';
let cells = `<td class="p-2 border-r border-border/60 font-mono text-muted-foreground text-center bg-muted/20 select-none relative">
<span class="row-num"></span>
<div class="row-resize-handle absolute left-0 right-0 bottom-0 h-1.5 cursor-row-resize hover:bg-primary/50 transition-colors"></div>
</td>`;
for (let i = 0; i < colCount; i++) {
cells += `<td class="p-2 border-r border-border/60" contenteditable="true" onkeydown="handleTableCellKeydown(event, this)">-</td>`;
}
cells += `<td class="p-2 text-center"><button type="button" onclick="tableDeleteRow(this)" class="opacity-0 group-hover:opacity-100 text-destructive text-xs hover:underline">🗑</button></td>`;
tr.innerHTML = cells;
const rows = tbody.querySelectorAll('tr');
if (targetRowIdx >= rows.length) {
tbody.appendChild(tr);
} else {
tbody.insertBefore(tr, rows[targetRowIdx]);
}
renumberTableRows(table);
initTableResizers(table);
syncEditorState();
}
function insertColumnAt(table, targetColIdx) {
const theadTr = table.querySelector('thead tr');
const allThs = Array.from(theadTr.children);
const colLetter = String.fromCharCode(65 + Math.min(25, targetColIdx));
const th = document.createElement('th');
th.style.width = '180px';
th.className = 'p-2 border-r border-border/60 font-semibold text-foreground relative select-none';
th.innerHTML = `<span>Col (${colLetter})</span><div class="col-resize-handle absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/50 transition-colors"></div>`;
// Target th index is targetColIdx + 1 (after row index th)
const refTh = allThs[targetColIdx + 1] || theadTr.lastElementChild;
theadTr.insertBefore(th, refTh);
// Insert Td in all rows
const rows = table.querySelectorAll('tbody tr');
rows.forEach(tr => {
const td = document.createElement('td');
td.className = 'p-2 border-r border-border/60';
td.setAttribute('contenteditable', 'true');
td.setAttribute('onkeydown', 'handleTableCellKeydown(event, this)');
td.innerText = '-';
const allTds = Array.from(tr.children);
const refTd = allTds[targetColIdx + 1] || tr.lastElementChild;
tr.insertBefore(td, refTd);
});
initTableResizers(table);
syncEditorState();
}
function renumberTableRows(table) {
const rows = table.querySelectorAll('tbody tr');
rows.forEach((r, i) => {
const numEl = r.querySelector('.row-num') || r.querySelector('td:first-child');
if (numEl) {
if (numEl.querySelector('.row-num')) numEl.querySelector('.row-num').innerText = String(i + 1);
else if (numEl.firstChild) numEl.firstChild.nodeValue = String(i + 1);
}
});
}
function tableAddRow(tableId) {
const table = document.getElementById(tableId);
if (!table) return;
const tbody = table.querySelector('tbody');
insertRowAt(table, tbody.querySelectorAll('tr').length);
}
function tableAddColumn(tableId) {
const table = document.getElementById(tableId);
if (!table) return;
const colCount = table.querySelectorAll('thead th').length - 2;
insertColumnAt(table, colCount);
}
function tableDeleteRow(btn) {
const tr = btn.closest('tr');
const table = tr?.closest('table');
if (tr) {
tr.remove();
if (table) renumberTableRows(table);
syncEditorState();
}
}
function insertInlineHyperSheetTable() {
const canvas = document.getElementById('hypertext-canvas');
if (!canvas) return;
const tableId = `table-${Date.now()}`;
const html = `
<div class="my-4 rounded-xl border border-border bg-card p-3 shadow-xs not-prose select-text" contenteditable="false" id="${tableId}-container">
<div class="flex items-center justify-between pb-2 mb-2 border-b border-border/60 text-xs text-muted-foreground">
<div class="flex items-center gap-2 font-medium text-foreground">
<span class="w-2 h-2 rounded-full bg-primary inline-block"></span>
<span>HyperSheet Matrix</span>
</div>
<div class="flex items-center gap-1.5 text-[11px]">
<span class="text-muted-foreground">Shortcuts: <kbd class="px-1 py-0.2 rounded bg-muted font-mono">Alt+↑/↓/←/→</kbd></span>
<button type="button" onclick="tableAddColumn('${tableId}')" class="px-2 py-0.5 rounded bg-muted hover:bg-accent text-[11px] font-medium text-foreground transition-colors">+ Add Col</button>
<button type="button" onclick="tableAddRow('${tableId}')" class="px-2 py-0.5 rounded bg-muted hover:bg-accent text-[11px] font-medium text-foreground transition-colors">+ Add Row</button>
<button type="button" onclick="this.closest('#${tableId}-container').remove(); syncEditorState();" class="px-2 py-0.5 rounded hover:bg-destructive/10 text-destructive text-[11px]">✕ Delete</button>
</div>
</div>
<div class="overflow-x-auto">
<table id="${tableId}" class="w-full text-left border-collapse text-xs table-fixed">
<thead>
<tr class="border-b border-border bg-muted/40 text-muted-foreground font-mono">
<th style="width: 44px;" class="p-2 border-r border-border/60 text-center select-none relative">#</th>
<th style="width: 200px;" class="p-2 border-r border-border/60 font-semibold text-foreground relative select-none">
<span>Column A</span>
<div class="col-resize-handle absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/50 transition-colors"></div>
</th>
<th style="width: 200px;" class="p-2 border-r border-border/60 font-semibold text-foreground relative select-none">
<span>Column B</span>
<div class="col-resize-handle absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/50 transition-colors"></div>
</th>
<th style="width: 60px;" class="p-2 text-center select-none">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-border/40">
<tr class="hover:bg-muted/20 group relative" style="height: 36px;">
<td class="p-2 border-r border-border/60 font-mono text-muted-foreground text-center bg-muted/20 select-none relative">
1
<div class="row-resize-handle absolute left-0 right-0 bottom-0 h-1.5 cursor-row-resize hover:bg-primary/50 transition-colors"></div>
</td>
<td class="p-2 border-r border-border/60" contenteditable="true" onkeydown="handleTableCellKeydown(event, this)">Data 1</td>
<td class="p-2 border-r border-border/60" contenteditable="true" onkeydown="handleTableCellKeydown(event, this)">Value 1</td>
<td class="p-2 text-center"><button type="button" onclick="tableDeleteRow(this)" class="opacity-0 group-hover:opacity-100 text-destructive text-xs hover:underline">🗑</button></td>
</tr>
<tr class="hover:bg-muted/20 group relative" style="height: 36px;">
<td class="p-2 border-r border-border/60 font-mono text-muted-foreground text-center bg-muted/20 select-none relative">
2
<div class="row-resize-handle absolute left-0 right-0 bottom-0 h-1.5 cursor-row-resize hover:bg-primary/50 transition-colors"></div>
</td>
<td class="p-2 border-r border-border/60" contenteditable="true" onkeydown="handleTableCellKeydown(event, this)">Data 2</td>
<td class="p-2 border-r border-border/60" contenteditable="true" onkeydown="handleTableCellKeydown(event, this)">Value 2</td>
<td class="p-2 text-center"><button type="button" onclick="tableDeleteRow(this)" class="opacity-0 group-hover:opacity-100 text-destructive text-xs hover:underline">🗑</button></td>
</tr>
</tbody>
</table>
</div>
</div>
<p><br></p>
`;
document.execCommand('insertHTML', false, html);
initTableResizers(document.getElementById(tableId));
syncEditorState();
}
// --- Column and Row Drag-to-Resize Engine ---
function initTableResizers(table) {
if (!table) return;
// Column Width Resizing
table.querySelectorAll('.col-resize-handle').forEach(handle => {
handle.onmousedown = (e) => {
e.preventDefault();
e.stopPropagation();
const th = handle.closest('th');
const startX = e.clientX;
const startWidth = th.offsetWidth;
const onMouseMove = (moveEvent) => {
const dx = moveEvent.clientX - startX;
const newWidth = Math.max(60, startWidth + dx);
th.style.width = `${newWidth}px`;
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
syncEditorState();
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
});
// Row Height Resizing
table.querySelectorAll('.row-resize-handle').forEach(handle => {
handle.onmousedown = (e) => {
e.preventDefault();
e.stopPropagation();
const tr = handle.closest('tr');
const startY = e.clientY;
const startHeight = tr.offsetHeight;
const onMouseMove = (moveEvent) => {
const dy = moveEvent.clientY - startY;
const newHeight = Math.max(28, startHeight + dy);
tr.style.height = `${newHeight}px`;
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
syncEditorState();
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
});
}
// --- Right-Click Context Menu Engine ---
function handleCanvasContextMenu(e) {
e.preventDefault();
const menu = document.getElementById('editor-context-menu');
const tableGroup = document.getElementById('ctx-table-group');
if (!menu) return;
activeContextTd = e.target.closest('td') || e.target.closest('th');
if (activeContextTd && activeContextTd.closest('table')) {
tableGroup.style.display = 'block';
} else {
tableGroup.style.display = 'none';
}
// Position context menu
const posX = Math.min(window.innerWidth - 240, e.clientX);
const posY = Math.min(window.innerHeight - 300, e.clientY);
menu.style.left = `${posX}px`;
menu.style.top = `${posY}px`;
menu.style.display = 'block';
}
function closeContextMenu() {
const menu = document.getElementById('editor-context-menu');
if (menu) menu.style.display = 'none';
activeContextTd = null;
}
function ctxTableAction(action) {
if (!activeContextTd) {
closeContextMenu();
return;
}
const cell = activeContextTd;
const tr = cell.closest('tr');
const table = cell.closest('table');
if (!table || !tr) {
closeContextMenu();
return;
}
const theadTr = table.querySelector('thead tr');
const tbody = table.querySelector('tbody');
if (!theadTr || !tbody) {
closeContextMenu();
return;
}
const allTrs = Array.from(tbody.children);
const rowIdx = allTrs.indexOf(tr);
const cellIndex = Array.from(tr.children).indexOf(cell);
const totalCols = theadTr.children.length; // #, data cols..., Action
if (action === 'row-above') {
insertRowAt(table, rowIdx >= 0 ? rowIdx : 0);
} else if (action === 'row-below') {
insertRowAt(table, rowIdx >= 0 ? rowIdx + 1 : allTrs.length);
} else if (action === 'col-left') {
const targetCol = Math.max(0, Math.min(totalCols - 2, cellIndex > 0 ? cellIndex - 1 : 0));
insertColumnAt(table, targetCol);
} else if (action === 'col-right') {
const targetCol = Math.max(0, Math.min(totalCols - 2, cellIndex > 0 ? cellIndex : 1));
insertColumnAt(table, targetCol);
} else if (action === 'del-row') {
if (allTrs.length > 1) {
if (rowIdx >= 0) {
tr.remove();
} else {
allTrs[0].remove();
}
renumberTableRows(table);
syncEditorState();
} else if (allTrs.length === 1) {
allTrs[0].querySelectorAll('td[contenteditable="true"]').forEach(c => c.innerText = '-');
syncEditorState();
}
} else if (action === 'del-col') {
if (totalCols <= 3) {
// Must keep at least 1 data column
closeContextMenu();
return;
}
let colToDelete = cellIndex;
if (colToDelete < 1) colToDelete = 1;
if (colToDelete >= totalCols - 1) colToDelete = totalCols - 2;
if (theadTr.children[colToDelete]) {
theadTr.children[colToDelete].remove();
}
tbody.querySelectorAll('tr').forEach(r => {
if (r.children[colToDelete]) {
r.children[colToDelete].remove();
}
});
initTableResizers(table);
syncEditorState();
}
closeContextMenu();
}
document.addEventListener('click', (e) => {
if (!e.target.closest('#editor-context-menu')) {
closeContextMenu();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeContextMenu();
}
});
// --- Levenshtein Auto-Fix & Spellchecker Engine ---
function matchCasing(original, corrected) {
if (!corrected) return original;
if (original === original.toUpperCase()) return corrected.toUpperCase();
if (original[0] === original[0].toUpperCase() && original.slice(1) === original.slice(1).toLowerCase()) {
return corrected.charAt(0).toUpperCase() + corrected.slice(1).toLowerCase();
}
return corrected.toLowerCase();
}
function correctWord(originalWord) {
if (!originalWord || originalWord.length < 3) return originalWord;
if (originalWord.length <= 5 && originalWord === originalWord.toUpperCase()) return originalWord;
const lower = originalWord.toLowerCase();
if (DICTIONARY_WORDS.has(lower)) return originalWord;
for (let i = 0; i < typoDictionary.length; i++) {
if (typoDictionary[i][0].test(originalWord)) {
return originalWord.replace(typoDictionary[i][0], typoDictionary[i][1]);
}
}
const deVoweled = lower.replace(/([aeiou])\1/g, '$1');
if (DICTIONARY_WORDS.has(deVoweled)) return matchCasing(originalWord, deVoweled);
const singleLetter = lower.replace(/(.)\1+/g, '$1');
if (DICTIONARY_WORDS.has(singleLetter)) return matchCasing(originalWord, singleLetter);
const len = lower.length;
let candidates = [];
for (let i = 0; i < len; i++) {
const del = lower.slice(0, i) + lower.slice(i + 1);
if (DICTIONARY_WORDS.has(del)) candidates.push(del);
}
for (let i = 0; i < len - 1; i++) {
const trans = lower.slice(0, i) + lower[i + 1] + lower[i] + lower.slice(i + 2);
if (DICTIONARY_WORDS.has(trans)) candidates.push(trans);
}
if (candidates.length === 0) {
for (let i = 0; i < len; i++) {
for (let j = 0; j < ALPHABET.length; j++) {
const sub = lower.slice(0, i) + ALPHABET[j] + lower.slice(i + 1);
if (DICTIONARY_WORDS.has(sub)) candidates.push(sub);
}
}
}
if (candidates.length > 0) return matchCasing(originalWord, candidates[0]);
return originalWord;
}
function fixSpellingAndSpacing(text) {
if (!text) return '';
let res = text;
res = res.replace(/[ \t]{2,}/g, ' ');
res = res.replace(/ +([,\.!\?;:])/g, '$1');
res = res.replace(/\(\s+/g, '(').replace(/\s+\)/g, ')');
res = res.replace(/(\w)--(\w)/g, '$1—$2');
res = res.replace(/ -- /g, ' — ');
res = res.replace(/\.{3}/g, '…');
res = res.replace(/\b([a-zA-Z]{3,})\b/g, (match) => correctWord(match));
return res;
}
function formatBlock(tag) {
document.execCommand('formatBlock', false, tag);
syncEditorState();
}
function execStyle(cmd) {
document.execCommand(cmd, false, null);
syncEditorState();
}
function insertCodeInline() {
const selection = window.getSelection();
if (!selection.rangeCount) return;
const text = selection.toString() || 'code';
document.execCommand('insertHTML', false, `<code>${text}</code>`);
syncEditorState();
}
function insertCallout(type) {
const html = `<div class="my-4 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-4 text-emerald-500 dark:text-emerald-400 text-sm">💡 <strong>Callout:</strong> Write your note here...</div><p><br></p>`;
document.execCommand('insertHTML', false, html);
syncEditorState();
}
function onEditorInput(el) {
syncEditorState();
}
function onEditorKeydown(e, el) {
const slashMenu = document.getElementById('slash-menu');
if (e.key === '/') {
const state = HxBolt.getState(document.body);
if (state) state.slashMenuOpen = true;
slashMenu.style.display = 'block';
} else if (e.key === 'Escape') {
const state = HxBolt.getState(document.body);
if (state) state.slashMenuOpen = false;
slashMenu.style.display = 'none';
}
}
function executeSlashCommand(cmd) {
const slashMenu = document.getElementById('slash-menu');
slashMenu.style.display = 'none';
const state = HxBolt.getState(document.body);
if (state) state.slashMenuOpen = false;
document.execCommand('delete', false, null);
if (cmd === 'h1') formatBlock('h1');
else if (cmd === 'h2') formatBlock('h2');
else if (cmd === 'table') insertInlineHyperSheetTable();
else if (cmd === 'bullet') execStyle('insertUnorderedList');
else if (cmd === 'callout') insertCallout('tip');
}
function syncEditorState() {
const canvas = document.getElementById('hypertext-canvas');
if (!canvas) return;
const html = canvas.innerHTML;
const text = canvas.innerText.trim();
const hiddenInput = document.getElementById('hypertext-hidden-input');
if (hiddenInput) hiddenInput.value = html;
const words = text.length > 0 ? text.split(/\s+/).length : 0;
const chars = text.length;
const readMin = Math.max(1, Math.ceil(words / 200));
const state = HxBolt.getState(document.body);
if (state) {
state.wordCount = words;
state.charCount = chars;
state.readingTime = `${readMin} min`;
state.saveStatus = 'Draft updated';
}
}
function autoFixTypography() {
const canvas = document.getElementById('hypertext-canvas');
const mdArea = document.getElementById('hypertext-markdown');
const htmlArea = document.getElementById('hypertext-html');
const undoBtn = document.getElementById('btn-undo-autofix');
let activeMode = 'visual';
if (mdArea && mdArea.style.display !== 'none') activeMode = 'markdown';
else if (htmlArea && htmlArea.style.display !== 'none') activeMode = 'html';
lastAutoFixSnapshot = {
mode: activeMode,
canvasHTML: canvas ? canvas.innerHTML : '',
mdValue: mdArea ? mdArea.value : '',
htmlValue: htmlArea ? htmlArea.value : ''
};
if (undoBtn) undoBtn.style.display = 'inline-flex';
if (activeMode === 'visual' && canvas) {
const walker = document.createTreeWalker(canvas, NodeFilter.SHOW_TEXT, null, false);
let node;
const textNodes = [];
while ((node = walker.nextNode())) {
if (node.parentElement && (node.parentElement.tagName === 'CODE' || node.parentElement.tagName === 'PRE')) continue;
textNodes.push(node);
}
textNodes.forEach(tNode => {
tNode.nodeValue = fixSpellingAndSpacing(tNode.nodeValue);
});
const paragraphs = Array.from(canvas.querySelectorAll('p'));
for (let i = paragraphs.length - 1; i > 0; i--) {
const current = paragraphs[i];
const prev = paragraphs[i - 1];
if (current.textContent.trim() === '' && prev.textContent.trim() === '' && !current.querySelector('img, hr, iframe')) {
current.remove();
}
}
} else if (activeMode === 'markdown' && mdArea) {
let lines = mdArea.value.split('\n');
let inCode = false;
lines = lines.map(line => {
if (line.trim().startsWith('```')) {
inCode = !inCode;
return line;
}
if (inCode) return line;
return fixSpellingAndSpacing(line);
});
mdArea.value = lines.join('\n').replace(/\n{3,}/g, '\n\n');
} else if (activeMode === 'html' && htmlArea) {
const temp = document.createElement('div');
temp.innerHTML = htmlArea.value;
const walker = document.createTreeWalker(temp, NodeFilter.SHOW_TEXT, null, false);
let node;
const textNodes = [];
while ((node = walker.nextNode())) {
if (node.parentElement && (node.parentElement.tagName === 'CODE' || node.parentElement.tagName === 'PRE')) continue;
textNodes.push(node);
}
textNodes.forEach(tNode => {
tNode.nodeValue = fixSpellingAndSpacing(tNode.nodeValue);
});
htmlArea.value = temp.innerHTML;
}
syncEditorState();
const state = HxBolt.getState(document.body);
if (state) {
state.saveStatus = '✨ Auto-fix applied (spacing, spelling, typography)';
setTimeout(() => {
if (state && state.saveStatus && state.saveStatus.includes('Auto-fix applied')) {
state.saveStatus = 'All changes saved';
}
}, 3500);
}
}
function undoAutoFix() {
if (!lastAutoFixSnapshot) return;
const canvas = document.getElementById('hypertext-canvas');
const mdArea = document.getElementById('hypertext-markdown');
const htmlArea = document.getElementById('hypertext-html');
const undoBtn = document.getElementById('btn-undo-autofix');
if (canvas && lastAutoFixSnapshot.canvasHTML !== undefined) canvas.innerHTML = lastAutoFixSnapshot.canvasHTML;
if (mdArea && lastAutoFixSnapshot.mdValue !== undefined) mdArea.value = lastAutoFixSnapshot.mdValue;
if (htmlArea && lastAutoFixSnapshot.htmlValue !== undefined) htmlArea.value = lastAutoFixSnapshot.htmlValue;
lastAutoFixSnapshot = null;
if (undoBtn) undoBtn.style.display = 'none';
syncEditorState();
}
function onMarkdownInput(el) { syncEditorState(); }
function onHtmlInput(el) { syncEditorState(); }
// Bidirectional Markdown <-> HTML
function htmlToMarkdown(html) {
const container = document.createElement('div');
container.innerHTML = html;
function walk(node) {
if (node.nodeType === Node.TEXT_NODE) return node.nodeValue;
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const tag = node.tagName.toLowerCase();
const children = Array.from(node.childNodes).map(walk).join('');
switch (tag) {
case 'h1': return `\n\n# ${children.trim()}\n\n`;
case 'h2': return `\n\n## ${children.trim()}\n\n`;
case 'h3': return `\n\n### ${children.trim()}\n\n`;
case 'p': return `\n\n${children.trim()}\n\n`;
case 'strong':
case 'b': return `**${children}**`;
case 'em':
case 'i': return `*${children}*`;
case 'code':
if (node.parentElement && node.parentElement.tagName.toLowerCase() === 'pre') return children;
return `\`${children}\``;
case 'ul': {
const items = Array.from(node.children).filter(c => c.tagName.toLowerCase() === 'li').map(c => `- ${walk(c).trim()}`).join('\n');
return `\n\n${items}\n\n`;
}
case 'li': return children;
default: return children;
}
}
let res = walk(container);
return res.replace(/\n{3,}/g, '\n\n').trim();
}
function inlineMd(str) {
let res = str;
res = res.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
res = res.replace(/\*([^\*]+)\*/g, '<em>$1</em>');
res = res.replace(/`([^`]+)`/g, '<code>$1</code>');
return res;
}
function markdownToHtml(md) {
if (!md) return '<p><br></p>';
const lines = md.split('\n');
let html = '';
let inList = false;
const flushList = () => {
if (inList) { html += '</ul>'; inList = false; }
};
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
if (!line) { flushList(); continue; }
if (line.startsWith('# ')) { flushList(); html += `<h1>${inlineMd(line.slice(2))}</h1>`; continue; }
if (line.startsWith('## ')) { flushList(); html += `<h2>${inlineMd(line.slice(3))}</h2>`; continue; }
if (line.startsWith('### ')) { flushList(); html += `<h3>${inlineMd(line.slice(4))}</h3>`; continue; }
if (/^[-*]\s+(.*)$/.test(line)) {
const match = line.match(/^[-*]\s+(.*)$/);
if (!inList) { html += '<ul class="list-disc list-inside my-2 space-y-1">'; inList = true; }
html += `<li>${inlineMd(match[1])}</li>`;
continue;
}
flushList();
html += `<p>${inlineMd(line)}</p>`;
}
flushList();
return html;
}
function switchEditorView(mode) {
const canvas = document.getElementById('hypertext-canvas');
const mdArea = document.getElementById('hypertext-markdown');
const htmlArea = document.getElementById('hypertext-html');
const btnVisual = document.getElementById('tab-btn-visual');
const btnMd = document.getElementById('tab-btn-markdown');
const btnHtml = document.getElementById('tab-btn-html');
if (mdArea && mdArea.style.display !== 'none' && mdArea.value !== undefined) {
canvas.innerHTML = markdownToHtml(mdArea.value);
} else if (htmlArea && htmlArea.style.display !== 'none' && htmlArea.value !== undefined) {
canvas.innerHTML = htmlArea.value;
}
if (mode === 'markdown') {
mdArea.value = htmlToMarkdown(canvas.innerHTML);
canvas.style.display = 'none';
htmlArea.style.display = 'none';
mdArea.style.display = 'block';
mdArea.focus();
} else if (mode === 'html') {
htmlArea.value = canvas.innerHTML.trim();
canvas.style.display = 'none';
mdArea.style.display = 'none';
htmlArea.style.display = 'block';
htmlArea.focus();
} else {
mdArea.style.display = 'none';
htmlArea.style.display = 'none';
canvas.style.display = 'block';
canvas.focus();
}
const activeClass = 'bg-primary text-primary-foreground shadow-sm';
const inactiveClass = 'text-muted-foreground hover:text-foreground';
[btnVisual, btnMd, btnHtml].forEach(btn => {
if (btn) btn.className = 'px-2.5 py-1 rounded-md transition-all ' + inactiveClass;
});
if (mode === 'visual' && btnVisual) btnVisual.className = 'px-2.5 py-1 rounded-md transition-all ' + activeClass;
if (mode === 'markdown' && btnMd) btnMd.className = 'px-2.5 py-1 rounded-md transition-all ' + activeClass;
if (mode === 'html' && btnHtml) btnHtml.className = 'px-2.5 py-1 rounded-md transition-all ' + activeClass;
syncEditorState();
}
// Initialize resizers on load
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('table').forEach(t => initTableResizers(t));
});
setTimeout(() => {
document.querySelectorAll('table').forEach(t => initTableResizers(t));
}, 100);
</script>
<!-- Usage & HTMX Integration Section -->
<div class="space-y-6 pt-6 border-t border-border">
<h2 class="text-xl font-bold tracking-tight">HTMX Form Integration</h2>
<p class="text-sm text-muted-foreground leading-relaxed">
Hypertext Editor connects directly to standard HTMX forms. It updates a hidden <code class="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">content_html</code> payload on every change.
</p>
<div class="code-block"><code><pre><!-- HTMX Form Submission -->
<form hx-post="/api/posts/create" hx-target="#post-feedback" class="space-y-4">
<input type="text" name="title" placeholder="Post Title..." class="w-full px-4 py-2 border rounded-lg">
<!-- Drop-in Hypertext Editor -->
<div hx-ext="reactive" ...>
<input type="hidden" name="content_html" id="hypertext-hidden-input">
</div>
<button type="submit" class="px-5 py-2.5 rounded-lg bg-primary text-primary-foreground font-semibold">
Publish Article →
</button>
</form></pre></code></div>
</div>
<!-- Go DSL Usage Section -->
<div class="space-y-4 pt-6 border-t border-border">
<h2 class="text-xl font-bold tracking-tight">Go DSL Usage</h2>
<div class="code-block"><code><pre>import "github.com/hyperlibs/hyperstack/go/components"
// Server-rendered Hypertext Editor with inline HyperSheet table
editor := components.HypertextEditor(components.HypertextEditorProps{
Name: "article_body",
Placeholder: "Write your document...",
InitialHTML: "<h1>My Notes</h1><p>Start writing here...</p>",
MinHeight: "350px",
})</pre></code></div>
</div>
</div>