1142 lines
58 KiB
HTML
1142 lines
58 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>ObsiGate Editor v1 — POC</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0f1117; --bg2: #161822; --bg3: #1c1f2e; --bg4: #222639;
|
|
--bg-hover: #2a2d3e; --text: #e1e4ed; --text2: #9ca3b8; --text3: #6b7280;
|
|
--border: #2d3148; --border2: #3a3f58;
|
|
--accent: #4a7ab5; --accent-bg: rgba(74,122,181,0.12);
|
|
--ai: #8b5cf6; --ai-bg: rgba(139,92,246,0.12); --ai-glow: rgba(139,92,246,0.4);
|
|
--ok: #34d399; --warn: #fbbf24; --err: #f87171;
|
|
--r: 6px; --R: 8px;
|
|
--shadow: 0 8px 32px rgba(0,0,0,0.4);
|
|
--sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
--mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: var(--sans); background: var(--bg); color: var(--text); height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
|
|
|
/* Top bar */
|
|
.bar { height: 44px; min-height: 44px; display: flex; align-items: center; gap: 8px; padding: 0 12px; background: var(--bg2); border-bottom: 1px solid var(--border); z-index: 100; }
|
|
.bar-left { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
|
|
.bar-hamburger { width: 28px; height: 28px; border-radius: var(--r); border: none; background: transparent; color: var(--text2); cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; }
|
|
.bar-hamburger:hover { background: var(--bg-hover); color: var(--text); }
|
|
.bar-brand { font-weight: 700; font-size: 12px; color: var(--accent); letter-spacing: 0.5px; white-space: nowrap; }
|
|
.bar-title { flex: 1; min-width: 80px; background: transparent; border: 1px solid transparent; color: var(--text); font-size: 13px; padding: 3px 6px; border-radius: var(--r); outline: none; font-family: var(--sans); }
|
|
.bar-title:hover { border-color: var(--border); }
|
|
.bar-title:focus { border-color: var(--accent); background: var(--bg3); }
|
|
.bar-right { display: flex; align-items: center; gap: 4px; }
|
|
.btn { height: 30px; padding: 0 10px; border-radius: var(--r); border: 1px solid transparent; background: transparent; color: var(--text2); cursor: pointer; font-size: 12px; display: flex; align-items: center; gap: 5px; white-space: nowrap; font-family: var(--sans); transition: all 120ms; }
|
|
.btn:hover { background: var(--bg-hover); color: var(--text); }
|
|
.btn-ai { background: var(--ai-bg); color: var(--ai); border-color: rgba(139,92,246,0.25); font-weight: 600; box-shadow: 0 0 10px var(--ai-glow); }
|
|
.btn-ai:hover { background: rgba(139,92,246,0.2); box-shadow: 0 0 18px var(--ai-glow); }
|
|
.btn-ai.active { background: var(--ai); color: #fff; }
|
|
.btn-ai.off { color: var(--text3); border-color: var(--border); box-shadow: none; background: transparent; }
|
|
.save-dot { display: flex; align-items: center; gap: 4px; font-size: 11px; padding: 0 4px; }
|
|
.save-dot .dot { width: 6px; height: 6px; border-radius: 50%; }
|
|
.save-dot.ok { color: var(--ok); } .save-dot.ok .dot { background: var(--ok); }
|
|
.save-dot.dirty { color: var(--warn); } .save-dot.dirty .dot { background: var(--warn); }
|
|
.save-dot.saving { color: var(--ai); animation: pulse 0.8s infinite; } .save-dot.saving .dot { background: var(--ai); }
|
|
.save-dot.err { color: var(--err); } .save-dot.err .dot { background: var(--err); }
|
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
|
|
/* Workspace */
|
|
.ws { display: flex; flex: 1; overflow: hidden; }
|
|
.editor-area { flex: 1; display: flex; overflow: hidden; position: relative; }
|
|
.editor-pane { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
|
|
.editor-pane.hidden { display: none; }
|
|
|
|
/* Textarea pane */
|
|
.editor-wrap { flex: 1; overflow-y: auto; position: relative; padding: 24px 16px 24px 48px; }
|
|
.textarea { width: 100%; height: auto; min-height: 100%; resize: none; overflow: hidden; background: transparent; border: none; outline: none; color: var(--text); font-family: var(--mono); font-size: 13.5px; line-height: 1.75; tab-size: 2; }
|
|
.textarea::placeholder { color: var(--text3); }
|
|
|
|
/* Quick insert + */
|
|
.qi-btn { position: absolute; left: 14px; top: 24px; width: 26px; height: 26px; border-radius: 50%; border: 1px solid var(--border); background: var(--bg2); color: var(--text3); cursor: pointer; font-size: 16px; font-weight: 700; display: flex; align-items: center; justify-content: center; z-index: 10; transition: all 100ms; }
|
|
.qi-btn:hover { color: var(--text); border-color: var(--accent); background: var(--accent-bg); }
|
|
|
|
/* Preview pane */
|
|
.preview-pane { flex: 0.67; overflow-y: auto; padding: 24px 24px 80px; background: var(--bg2); border-left: 1px solid var(--border); display: none; }
|
|
.preview-pane.visible { display: block; }
|
|
.preview-content { max-width: 720px; margin: 0 auto; font-family: var(--sans); font-size: 14px; line-height: 1.75; color: var(--text); }
|
|
.preview-content h1 { font-size: 26px; margin: 20px 0 10px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
|
.preview-content h2 { font-size: 20px; margin: 18px 0 8px; }
|
|
.preview-content h3 { font-size: 16px; margin: 14px 0 6px; }
|
|
.preview-content p { margin: 6px 0; }
|
|
.preview-content ul, .preview-content ol { margin: 6px 0; padding-left: 24px; }
|
|
.preview-content li { margin: 2px 0; }
|
|
.preview-content code { background: var(--bg3); padding: 1px 5px; border-radius: 3px; font-family: var(--mono); font-size: 12px; color: var(--ai); }
|
|
.preview-content pre { background: var(--bg3); padding: 12px 16px; border-radius: var(--R); overflow-x: auto; margin: 10px 0; border: 1px solid var(--border); }
|
|
.preview-content pre code { background: none; padding: 0; color: var(--text); }
|
|
.preview-content blockquote { border-left: 3px solid var(--accent); padding: 4px 14px; margin: 10px 0; color: var(--text2); background: rgba(74,122,181,0.05); border-radius: 0 var(--r) var(--r) 0; }
|
|
.preview-content a { color: var(--accent); }
|
|
.preview-content table { border-collapse: collapse; margin: 10px 0; width: 100%; }
|
|
.preview-content th, .preview-content td { border: 1px solid var(--border); padding: 6px 12px; text-align: left; font-size: 13px; }
|
|
.preview-content th { background: var(--bg3); font-weight: 600; }
|
|
.preview-content hr { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
|
|
.preview-content img { max-width: 100%; border-radius: var(--r); }
|
|
.preview-loading { text-align: center; padding: 40px; color: var(--text3); font-size: 13px; }
|
|
|
|
/* AI Panel (right) */
|
|
.ai-panel { width: 320px; min-width: 320px; background: var(--bg2); border-left: 1px solid var(--border); display: flex; flex-direction: column; transition: transform 200ms, min-width 200ms, width 200ms; overflow: hidden; }
|
|
.ai-panel.hidden { transform: translateX(100%); min-width: 0; width: 0; border-left: none; }
|
|
.ai-panel-head { padding: 12px 14px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
|
|
.ai-panel-title { font-weight: 700; font-size: 13px; color: var(--ai); }
|
|
.ai-panel-close { width: 26px; height: 26px; border-radius: var(--r); border: none; background: transparent; color: var(--text3); cursor: pointer; font-size: 15px; }
|
|
.ai-panel-close:hover { background: var(--bg-hover); color: var(--text); }
|
|
.ai-chat { flex: 1; padding: 14px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; }
|
|
.ai-suggestions { padding: 10px 14px; border-top: 1px solid var(--border); }
|
|
.ai-sugg { display: block; width: 100%; text-align: left; padding: 7px 10px; margin-bottom: 3px; border-radius: var(--r); border: none; background: transparent; color: var(--text2); cursor: pointer; font-size: 12px; font-family: var(--sans); }
|
|
.ai-sugg:hover { background: var(--bg-hover); color: var(--text); }
|
|
.ai-input-row { padding: 8px 14px; border-top: 1px solid var(--border); display: flex; gap: 6px; }
|
|
.ai-input { flex: 1; padding: 7px 10px; border-radius: var(--r); border: 1px solid var(--border); background: var(--bg3); color: var(--text); font-size: 12px; outline: none; font-family: var(--sans); }
|
|
.ai-input:focus { border-color: var(--ai); }
|
|
.ai-send { padding: 7px 12px; border-radius: var(--r); border: none; background: var(--ai); color: #fff; cursor: pointer; font-size: 12px; font-weight: 600; }
|
|
.ai-msg { padding: 8px 11px; border-radius: var(--r); font-size: 12px; line-height: 1.5; max-width: 90%; white-space: pre-wrap; }
|
|
.ai-msg.user { background: var(--bg3); align-self: flex-end; }
|
|
.ai-msg.bot { background: var(--ai-bg); color: var(--ai); align-self: flex-start; }
|
|
.ai-msg.loading { color: var(--text3); font-style: italic; align-self: flex-start; }
|
|
.ai-chat-placeholder { color: var(--text3); font-size: 12px; padding: 10px; }
|
|
|
|
/* Slash menu */
|
|
.slash { position: absolute; z-index: 200; width: 300px; max-height: 360px; overflow-y: auto; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; }
|
|
.slash.on { display: block; animation: fadeIn 120ms; }
|
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
|
.slash-search { padding: 8px 10px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg4); border-radius: var(--R) var(--R) 0 0; }
|
|
.slash-search input { width: 100%; padding: 5px 8px; border-radius: var(--r); border: 1px solid var(--border); background: var(--bg3); color: var(--text); font-size: 12px; outline: none; font-family: var(--sans); }
|
|
.slash-section { padding: 6px 12px 3px; font-size: 10px; font-weight: 600; color: var(--text3); text-transform: uppercase; letter-spacing: 0.4px; }
|
|
.slash-item { display: flex; align-items: center; gap: 8px; padding: 7px 12px; cursor: pointer; font-size: 12px; color: var(--text2); transition: background 60ms; }
|
|
.slash-item:hover, .slash-item.sel { background: var(--bg-hover); color: var(--text); }
|
|
.slash-icon { width: 24px; height: 24px; border-radius: var(--r); background: var(--bg3); display: flex; align-items: center; justify-content: center; font-size: 13px; flex-shrink: 0; }
|
|
.slash-body { flex: 1; min-width: 0; }
|
|
.slash-title { font-weight: 600; font-size: 12px; color: var(--text); }
|
|
.slash-desc { font-size: 10px; color: var(--text3); margin-top: 1px; }
|
|
.slash-div { height: 1px; margin: 3px 12px; background: var(--border); }
|
|
|
|
/* Bubble menu */
|
|
.bubble { position: fixed; z-index: 150; display: none; align-items: center; gap: 1px; padding: 3px 5px; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); }
|
|
.bubble.on { display: flex; animation: fadeIn 100ms; }
|
|
.bubble-btn { width: 28px; height: 28px; border-radius: var(--r); border: none; background: transparent; color: var(--text2); cursor: pointer; font-size: 13px; display: flex; align-items: center; justify-content: center; transition: all 80ms; }
|
|
.bubble-btn:hover { background: var(--bg-hover); color: var(--text); }
|
|
.bubble-sep { width: 1px; height: 18px; background: var(--border); margin: 0 3px; }
|
|
.bubble-btn.ai { color: var(--ai); }
|
|
.bubble-more { position: absolute; top: 100%; right: 0; margin-top: 4px; width: 190px; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; z-index: 160; padding: 3px; }
|
|
.bubble-more.on { display: block; }
|
|
.bubble-more-item { display: flex; align-items: center; gap: 6px; padding: 6px 9px; font-size: 12px; color: var(--text2); cursor: pointer; border-radius: var(--r); }
|
|
.bubble-more-item:hover { background: var(--bg-hover); color: var(--text); }
|
|
|
|
/* Quick Insert menu */
|
|
.qi-menu { position: absolute; z-index: 190; width: 200px; max-height: 380px; overflow-y: auto; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; padding: 3px; }
|
|
.qi-menu.on { display: block; animation: fadeIn 100ms; }
|
|
.qi-item { display: flex; align-items: center; gap: 7px; padding: 6px 9px; font-size: 12px; color: var(--text2); cursor: pointer; border-radius: var(--r); }
|
|
.qi-item:hover { background: var(--bg-hover); color: var(--text); }
|
|
|
|
/* Inline form (table dims, code lang) */
|
|
.inline-form { position: absolute; z-index: 195; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; padding: 8px 10px; min-width: 200px; }
|
|
.inline-form.on { display: block; animation: fadeIn 100ms; }
|
|
.inline-form-label { font-size: 10px; font-weight: 600; color: var(--text3); text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 6px; }
|
|
.inline-form-row { display: flex; gap: 6px; align-items: center; margin-bottom: 5px; }
|
|
.inline-form-row label { font-size: 11px; color: var(--text2); min-width: 55px; }
|
|
.inline-form-row input { flex: 1; padding: 4px 7px; border-radius: var(--r); border: 1px solid var(--border); background: var(--bg3); color: var(--text); font-size: 12px; outline: none; font-family: var(--mono); width: 60px; }
|
|
.inline-form-row input:focus { border-color: var(--accent); }
|
|
.inline-form-actions { display: flex; gap: 4px; justify-content: flex-end; margin-top: 4px; }
|
|
.inline-form-actions button { padding: 4px 12px; border-radius: var(--r); border: 1px solid var(--border); background: var(--bg3); color: var(--text); cursor: pointer; font-size: 11px; font-family: var(--sans); }
|
|
.inline-form-actions button.ok { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
|
|
.inline-form-actions button:hover { background: var(--bg-hover); }
|
|
.inline-form-actions button.ok:hover { background: #5a8fc5; }
|
|
|
|
/* Lang picker (in QI menu style) */
|
|
.lang-picker { position: absolute; z-index: 195; width: 200px; max-height: 320px; overflow-y: auto; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; padding: 3px; }
|
|
.lang-picker.on { display: block; animation: fadeIn 100ms; }
|
|
.lang-item { display: flex; align-items: center; gap: 7px; padding: 6px 9px; font-size: 12px; color: var(--text2); cursor: pointer; border-radius: var(--r); font-family: var(--mono); }
|
|
.lang-item:hover, .lang-item.sel { background: var(--bg-hover); color: var(--text); }
|
|
|
|
/* Drag & Drop */
|
|
.drop-overlay { position: fixed; inset: 0; z-index: 500; display: none; align-items: center; justify-content: center; background: rgba(15,17,23,0.85); border: 3px dashed var(--accent); pointer-events: none; }
|
|
.drop-overlay.on { display: flex; }
|
|
.drop-msg { text-align: center; color: var(--accent); }
|
|
.drop-icon { font-size: 44px; margin-bottom: 10px; }
|
|
.drop-title { font-size: 18px; font-weight: 700; margin-bottom: 4px; }
|
|
.drop-sub { font-size: 13px; color: var(--text2); }
|
|
|
|
/* Status bar */
|
|
.stat { height: 26px; min-height: 26px; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; background: var(--bg2); border-top: 1px solid var(--border); font-size: 11px; color: var(--text3); }
|
|
.stat-left, .stat-right { display: flex; align-items: center; gap: 14px; }
|
|
.stat-ai { color: var(--ai); font-weight: 600; display: none; align-items: center; gap: 4px; }
|
|
.stat-ai.on { display: flex; }
|
|
.stat-file { color: var(--text2); max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
|
/* Toast */
|
|
.toast { position: fixed; bottom: 44px; left: 50%; transform: translateX(-50%); background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); padding: 9px 18px; font-size: 12px; color: var(--text); box-shadow: var(--shadow); z-index: 600; display: none; animation: fadeIn 150ms; }
|
|
.toast.on { display: block; }
|
|
.toast.ok { border-color: var(--ok); } .toast.err { border-color: var(--err); }
|
|
|
|
/* Loading spinner */
|
|
.spin { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--text3); border-top-color: var(--ai); border-radius: 50%; animation: spin 0.6s linear infinite; margin-right: 6px; vertical-align: middle; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
/* Resize handle between panes */
|
|
.resize-handle { width: 4px; cursor: col-resize; background: var(--border); flex-shrink: 0; transition: background 150ms; }
|
|
.resize-handle:hover { background: var(--accent); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- Bar -->
|
|
<div class="bar">
|
|
<div class="bar-left">
|
|
<button class="bar-hamburger" title="Menu">☰</button>
|
|
<span class="bar-brand">ObsiGate</span>
|
|
<input class="bar-title" id="poc-title" placeholder="Titre..." spellcheck="false">
|
|
</div>
|
|
<div class="bar-right">
|
|
<button class="btn" id="btn-preview" title="Toggle preview">👁 Preview</button>
|
|
<button class="btn btn-ai off" id="btn-ai" title="AI Panel (Ctrl+J)">✨ AI</button>
|
|
<div class="save-dot ok" id="save-dot"><span class="dot"></span><span id="save-label">Saved</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Workspace -->
|
|
<div class="ws">
|
|
<div class="editor-area" id="editor-area">
|
|
|
|
<!-- Drop overlay -->
|
|
<div class="drop-overlay" id="drop-overlay">
|
|
<div class="drop-msg"><div class="drop-icon">🖼</div><div class="drop-title">Drop files here</div><div class="drop-sub">Images, PDFs, documents</div></div>
|
|
</div>
|
|
|
|
<!-- Editor pane (textarea) -->
|
|
<div class="editor-pane" id="editor-pane">
|
|
<div class="editor-wrap" id="editor-wrap">
|
|
<button class="qi-btn" id="qi-btn" title="Quick insert">⊕</button>
|
|
<textarea class="textarea" id="ta" placeholder="Type / for commands..." spellcheck="false"></textarea>
|
|
|
|
<!-- Quick Insert menu -->
|
|
<div class="qi-menu" id="qi-menu">
|
|
<div class="qi-item" data-qi="image">🎨 Image</div>
|
|
<div class="qi-item" data-qi="code"></> Code Block</div>
|
|
<div class="qi-item" data-qi="table">📊 Table</div>
|
|
<div class="qi-item" data-qi="divider">─ Divider</div>
|
|
<div style="height:1px;margin:3px 6px;background:var(--border)"></div>
|
|
<div style="font-size:10px;font-weight:600;color:var(--text3);padding:4px 9px 2px;text-transform:uppercase;letter-spacing:0.4px">📐 Diagram</div>
|
|
<div class="qi-item" data-qi="mermaid-flowchart">🔀 Flowchart</div>
|
|
<div class="qi-item" data-qi="mermaid-sequence">🔁 Sequence</div>
|
|
<div class="qi-item" data-qi="mermaid-class">🔂 Class Diagram</div>
|
|
<div class="qi-item" data-qi="mermaid-state">🔃 State Diagram</div>
|
|
<div class="qi-item" data-qi="mermaid-er">🔄 ER Diagram</div>
|
|
<div class="qi-item" data-qi="mermaid-gantt">🔅 Gantt Chart</div>
|
|
<div class="qi-item" data-qi="mermaid-pie">🔆 Pie Chart</div>
|
|
</div>
|
|
|
|
<!-- Inline form (table dims) -->
|
|
<div class="inline-form" id="inline-form">
|
|
<div class="inline-form-label" id="inline-form-label">Table dimensions</div>
|
|
<div class="inline-form-row"><label>Colonnes</label><input type="number" id="if-cols" value="3" min="1" max="12"></div>
|
|
<div class="inline-form-row"><label>Lignes</label><input type="number" id="if-rows" value="3" min="1" max="50"></div>
|
|
<div class="inline-form-actions">
|
|
<button class="ok" id="if-ok">Insérer</button>
|
|
<button id="if-cancel">Annuler</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Language picker (code block) -->
|
|
<div class="lang-picker" id="lang-picker"></div>
|
|
|
|
<!-- Slash menu -->
|
|
<div class="slash" id="slash-menu">
|
|
<div class="slash-search"><input type="text" id="slash-search" placeholder="Filter..." autocomplete="off"></div>
|
|
<div class="slash-section">Formatting</div>
|
|
<div class="slash-item" data-action="h1"><span class="slash-icon">H1</span><div class="slash-body"><div class="slash-title">Heading 1</div><div class="slash-desc">Big section heading</div></div></div>
|
|
<div class="slash-item" data-action="h2"><span class="slash-icon">H2</span><div class="slash-body"><div class="slash-title">Heading 2</div><div class="slash-desc">Medium section heading</div></div></div>
|
|
<div class="slash-item" data-action="h3"><span class="slash-icon">H3</span><div class="slash-body"><div class="slash-title">Heading 3</div><div class="slash-desc">Small section heading</div></div></div>
|
|
<div class="slash-item" data-action="bullet"><span class="slash-icon">•</span><div class="slash-body"><div class="slash-title">Bullet List</div><div class="slash-desc">Unordered list</div></div></div>
|
|
<div class="slash-item" data-action="numbered"><span class="slash-icon">1.</span><div class="slash-body"><div class="slash-title">Numbered List</div><div class="slash-desc">Ordered list</div></div></div>
|
|
<div class="slash-item" data-action="checklist"><span class="slash-icon">☐</span><div class="slash-body"><div class="slash-title">Checklist</div><div class="slash-desc">Task list</div></div></div>
|
|
<div class="slash-item" data-action="quote"><span class="slash-icon">❝</span><div class="slash-body"><div class="slash-title">Quote</div><div class="slash-desc">Block quote</div></div></div>
|
|
<div class="slash-item" data-action="code"><span class="slash-icon"></></span><div class="slash-body"><div class="slash-title">Code Block</div><div class="slash-desc">Syntax-highlighted</div></div></div>
|
|
<div class="slash-item" data-action="divider"><span class="slash-icon">─</span><div class="slash-body"><div class="slash-title">Divider</div><div class="slash-desc">Horizontal rule</div></div></div>
|
|
<div class="slash-div"></div>
|
|
<div class="slash-section">Insert</div>
|
|
<div class="slash-item" data-action="image"><span class="slash-icon">🎨</span><div class="slash-body"><div class="slash-title">Image</div><div class="slash-desc"></div></div></div>
|
|
<div class="slash-item" data-action="link"><span class="slash-icon">🔗</span><div class="slash-body"><div class="slash-title">Link</div><div class="slash-desc">[text](url)</div></div></div>
|
|
<div class="slash-item" data-action="table"><span class="slash-icon">📊</span><div class="slash-body"><div class="slash-title">Table</div><div class="slash-desc">Data table</div></div></div>
|
|
<div class="slash-div"></div>
|
|
<div class="slash-section">AI Actions</div>
|
|
<div class="slash-item" data-action="ai-improve"><span class="slash-icon" style="color:var(--ai)">✨</span><div class="slash-body"><div class="slash-title">AI: Improve writing</div><div class="slash-desc">Enhance style and clarity</div></div></div>
|
|
<div class="slash-item" data-action="ai-fix"><span class="slash-icon" style="color:var(--ai)">✨</span><div class="slash-body"><div class="slash-title">AI: Fix spelling</div><div class="slash-desc">Correct errors</div></div></div>
|
|
<div class="slash-item" data-action="ai-continue"><span class="slash-icon" style="color:var(--ai)">✨</span><div class="slash-body"><div class="slash-title">AI: Continue writing</div><div class="slash-desc">Continue from here</div></div></div>
|
|
<div class="slash-item" data-action="ai-summarize"><span class="slash-icon" style="color:var(--ai)">✨</span><div class="slash-body"><div class="slash-title">AI: Summarize</div><div class="slash-desc">Condense document</div></div></div>
|
|
<div class="slash-item" data-action="ai-translate"><span class="slash-icon" style="color:var(--ai)">✨</span><div class="slash-body"><div class="slash-title">AI: Translate</div><div class="slash-desc">To another language</div></div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Resize handle -->
|
|
<div class="resize-handle" id="resize-handle" title="Drag to resize"></div>
|
|
|
|
<!-- Preview pane -->
|
|
<div class="preview-pane" id="preview-pane">
|
|
<div class="preview-content" id="preview-content"><div class="preview-loading">Preview appears here</div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bubble menu -->
|
|
<div class="bubble" id="bubble">
|
|
<button class="bubble-btn" data-action="bold" title="Bold (Ctrl+B)"><b>B</b></button>
|
|
<button class="bubble-btn" data-action="italic" title="Italic (Ctrl+I)"><i>I</i></button>
|
|
<button class="bubble-btn" data-action="strike" title="Strikethrough"><s>S</s></button>
|
|
<button class="bubble-btn" data-action="code" title="Inline Code"></></button>
|
|
<button class="bubble-btn" data-action="link" title="Link">🔗</button>
|
|
<button class="bubble-btn" data-action="highlight" title="Highlight">☀</button>
|
|
<span class="bubble-sep"></span>
|
|
<button class="bubble-btn ai" id="bubble-more" title="AI Actions">⋯</button>
|
|
<div class="bubble-more" id="bubble-more-menu">
|
|
<div class="bubble-more-item" data-action="ai-improve">✨ Improve writing</div>
|
|
<div class="bubble-more-item" data-action="ai-fix">✅ Fix spelling</div>
|
|
<div class="bubble-more-item" data-action="ai-shorter">🔄 Make shorter</div>
|
|
<div class="bubble-more-item" data-action="ai-longer">🔅 Make longer</div>
|
|
<div class="bubble-more-item" data-action="ai-translate">🌐 Translate to EN</div>
|
|
<div class="bubble-more-item" data-action="ai-continue">➕ Continue writing</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- AI Panel -->
|
|
<div class="ai-panel hidden" id="ai-panel">
|
|
<div class="ai-panel-head">
|
|
<span class="ai-panel-title">✨ AI Assistant</span>
|
|
<button class="ai-panel-close" id="ai-close">×</button>
|
|
</div>
|
|
<div class="ai-chat" id="ai-chat">
|
|
<div class="ai-chat-placeholder">Ask AI about your document. Use slash /ai or select text for AI actions.</div>
|
|
</div>
|
|
<div class="ai-suggestions">
|
|
<button class="ai-sugg">✨ Improve overall style</button>
|
|
<button class="ai-sugg">➕ Add a conclusion</button>
|
|
<button class="ai-sugg">📝 Summarize document</button>
|
|
</div>
|
|
<div class="ai-input-row">
|
|
<input class="ai-input" id="ai-input" placeholder="Ask AI...">
|
|
<button class="ai-send" id="ai-send">Send</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status bar -->
|
|
<div class="stat">
|
|
<div class="stat-left">
|
|
<span>Type <strong>/</strong> for commands | <strong>Alt+I</strong> Insert</span>
|
|
<span>Ctrl+J AI panel</span>
|
|
<span>Ctrl+S save</span>
|
|
<span class="stat-ai" id="stat-ai">✨ AI processing...</span>
|
|
</div>
|
|
<div class="stat-right">
|
|
<span class="stat-file" id="stat-file"></span>
|
|
<span id="stat-words">0 words</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast -->
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<!-- marked.js CDN for markdown rendering -->
|
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
|
|
<script>
|
|
(function() {
|
|
'use strict';
|
|
|
|
// ---- DOM ----
|
|
var ta = document.getElementById('ta');
|
|
var editorWrap = document.getElementById('editor-wrap');
|
|
var editorArea = document.getElementById('editor-area');
|
|
var previewPane = document.getElementById('preview-pane');
|
|
var previewContent = document.getElementById('preview-content');
|
|
var resizeHandle = document.getElementById('resize-handle');
|
|
var slashMenu = document.getElementById('slash-menu');
|
|
var slashSearch = document.getElementById('slash-search');
|
|
var bubble = document.getElementById('bubble');
|
|
var bubbleMore = document.getElementById('bubble-more-menu');
|
|
var qiMenu = document.getElementById('qi-menu');
|
|
var aiPanel = document.getElementById('ai-panel');
|
|
var btnAI = document.getElementById('btn-ai');
|
|
var btnPreview = document.getElementById('btn-preview');
|
|
var saveDot = document.getElementById('save-dot');
|
|
var saveLabel = document.getElementById('save-label');
|
|
var statFile = document.getElementById('stat-file');
|
|
var statWords = document.getElementById('stat-words');
|
|
var statAI = document.getElementById('stat-ai');
|
|
var toast = document.getElementById('toast');
|
|
var dropOverlay = document.getElementById('drop-overlay');
|
|
|
|
// ---- State ----
|
|
var fileVault = null;
|
|
var filePath = null;
|
|
var originalContent = '';
|
|
var isDirty = false;
|
|
var previewVisible = false;
|
|
var slashItems = [];
|
|
var slashVisible = false;
|
|
var slashIdx = -1;
|
|
var saveTimer = null;
|
|
var aiConfigured = null;
|
|
|
|
// Parse URL params
|
|
(function() {
|
|
var p = new URLSearchParams(window.location.search);
|
|
fileVault = p.get('vault');
|
|
filePath = p.get('path');
|
|
})();
|
|
|
|
// ---- Cursor utils ----
|
|
function getPos() { return { s: ta.selectionStart, e: ta.selectionEnd }; }
|
|
function setPos(p) { ta.setSelectionRange(p, p); ta.focus(); }
|
|
function val() { return ta.value; }
|
|
|
|
function insertAtCursor(text, cursorOffset) {
|
|
var p = getPos();
|
|
ta.value = val().substring(0, p.s) + text + val().substring(p.e);
|
|
var np = cursorOffset != null ? p.s + cursorOffset : p.s + text.length;
|
|
setPos(np);
|
|
markDirty();
|
|
}
|
|
|
|
function wrapSelection(before, after) {
|
|
var p = getPos();
|
|
if (p.s === p.e) { insertAtCursor(before + (after || before), before.length); return; }
|
|
var sel = val().substring(p.s, p.e);
|
|
ta.value = val().substring(0, p.s) + before + sel + (after || before) + val().substring(p.e);
|
|
setPos(p.s + before.length + sel.length + (after || before).length);
|
|
markDirty();
|
|
}
|
|
|
|
function getLine() {
|
|
var p = getPos();
|
|
var v = val();
|
|
var ls = v.lastIndexOf('\n', p.s - 1) + 1;
|
|
var le = v.indexOf('\n', p.s); if (le === -1) le = v.length;
|
|
return { text: v.substring(ls, le), start: ls, end: le };
|
|
}
|
|
|
|
function replaceLine(newText) {
|
|
var l = getLine();
|
|
ta.value = val().substring(0, l.start) + newText + val().substring(l.end);
|
|
setPos(l.start + newText.length);
|
|
markDirty();
|
|
}
|
|
|
|
function removeTrailingSlash() {
|
|
var l = getLine();
|
|
replaceLine(l.text.replace(/^\/\s*/, ''));
|
|
}
|
|
|
|
// ---- Dirty / Save ----
|
|
function markDirty() {
|
|
if (!isDirty) { isDirty = true; updateSaveDot(); }
|
|
scheduleAutoSave();
|
|
schedulePreview();
|
|
}
|
|
|
|
function updateSaveDot() {
|
|
if (isDirty) { saveDot.className = 'save-dot dirty'; saveLabel.textContent = 'Unsaved'; }
|
|
else { saveDot.className = 'save-dot ok'; saveLabel.textContent = 'Saved'; }
|
|
}
|
|
|
|
function scheduleAutoSave() {
|
|
clearTimeout(saveTimer);
|
|
saveTimer = setTimeout(autoSave, 2000);
|
|
}
|
|
|
|
function autoSave() {
|
|
if (!isDirty || !fileVault || !filePath) return;
|
|
saveDot.className = 'save-dot saving'; saveLabel.textContent = 'Saving...';
|
|
var content = val();
|
|
fetch('/api/file/' + encodeURIComponent(fileVault) + '/save?path=' + encodeURIComponent(filePath) + '&backup=false', {
|
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content: content })
|
|
}).then(function(r) {
|
|
if (!r.ok) throw new Error(r.status);
|
|
isDirty = false; originalContent = content;
|
|
saveDot.className = 'save-dot ok'; saveLabel.textContent = 'Saved';
|
|
}).catch(function(e) {
|
|
saveDot.className = 'save-dot err'; saveLabel.textContent = 'Error';
|
|
showToast('Save failed: ' + e.message, 'err');
|
|
});
|
|
}
|
|
|
|
function forceSave() {
|
|
if (!fileVault || !filePath) { showToast('No file loaded. Use ?vault=X&path=Y', 'err'); return; }
|
|
clearTimeout(saveTimer);
|
|
saveDot.className = 'save-dot saving'; saveLabel.textContent = 'Saving...';
|
|
var content = val();
|
|
fetch('/api/file/' + encodeURIComponent(fileVault) + '/save?path=' + encodeURIComponent(filePath), {
|
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content: content })
|
|
}).then(function(r) {
|
|
if (!r.ok) throw new Error(r.status);
|
|
isDirty = false; originalContent = content;
|
|
saveDot.className = 'save-dot ok'; saveLabel.textContent = 'Saved';
|
|
showToast('Saved', 'ok');
|
|
}).catch(function(e) {
|
|
saveDot.className = 'save-dot err'; saveLabel.textContent = 'Error';
|
|
showToast('Save failed: ' + e.message, 'err');
|
|
});
|
|
}
|
|
|
|
// ---- File load ----
|
|
function loadFile() {
|
|
if (!fileVault || !filePath) {
|
|
// Demo mode
|
|
ta.value = '# Demo Document\n\nType **/** for commands. Select text for formatting.\n\n## Features\n\n- Slash commands: type / at start of line\n- Bubble menu: select text\n- AI actions: use the sparkle button or slash /ai\n- Preview: click the eye button\n';
|
|
statFile.textContent = 'Demo mode';
|
|
document.title = 'ObsiGate Forge — Demo';
|
|
originalContent = ta.value;
|
|
updateStats();
|
|
schedulePreview();
|
|
return;
|
|
}
|
|
statFile.textContent = 'Loading...';
|
|
fetch('/api/file/' + encodeURIComponent(fileVault) + '/raw?path=' + encodeURIComponent(filePath))
|
|
.then(function(r) {
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return r.json();
|
|
})
|
|
.then(function(data) {
|
|
ta.value = data.raw || '';
|
|
originalContent = ta.value;
|
|
// Extract title from first # heading or filename
|
|
var title = filePath.split('/').pop().replace(/\.md$/, '');
|
|
var m = ta.value.match(/^#\s+(.+)/m);
|
|
if (m) title = m[1];
|
|
document.getElementById('poc-title').value = title;
|
|
statFile.textContent = fileVault + '/' + filePath;
|
|
document.title = title + ' — ObsiGate Forge';
|
|
updateStats();
|
|
schedulePreview();
|
|
})
|
|
.catch(function(e) {
|
|
ta.value = '# Error\n\nCould not load: ' + (fileVault || '?') + '/' + (filePath || '?') + '\n\n' + e.message;
|
|
statFile.textContent = 'Load error';
|
|
originalContent = ta.value;
|
|
updateStats();
|
|
});
|
|
}
|
|
|
|
// ---- AI calls ----
|
|
function aiCall(endpoint, text, extra, callback) {
|
|
if (!aiConfigured) {
|
|
// Check status first, then retry
|
|
fetch('/api/ai/status').then(function(r) { return r.json(); }).then(function(s) {
|
|
aiConfigured = s.configured === true;
|
|
updateAIButton();
|
|
if (aiConfigured) aiCall(endpoint, text, extra, callback);
|
|
else { showToast('AI not configured', 'err'); hideAIProcessing(); }
|
|
}).catch(function() { showToast('AI status check failed', 'err'); hideAIProcessing(); });
|
|
return;
|
|
}
|
|
showAIProcessing();
|
|
var body = { text: text };
|
|
if (extra) Object.keys(extra).forEach(function(k) { body[k] = extra[k]; });
|
|
fetch('/api/ai/' + endpoint, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
}).then(function(r) {
|
|
if (!r.ok) throw new Error('AI error: ' + r.status);
|
|
return r.json();
|
|
}).then(function(data) {
|
|
hideAIProcessing();
|
|
if (callback) callback(data.result || data.response || '');
|
|
else showToast('AI: ' + (data.result || 'done').substring(0, 60) + '...', 'ok');
|
|
}).catch(function(e) {
|
|
hideAIProcessing();
|
|
showToast(e.message, 'err');
|
|
if (callback) callback(null);
|
|
});
|
|
}
|
|
|
|
function checkAIStatus() {
|
|
fetch('/api/ai/status').then(function(r) { return r.json(); }).then(function(s) {
|
|
aiConfigured = s.configured === true;
|
|
updateAIButton();
|
|
}).catch(function() { aiConfigured = false; updateAIButton(); });
|
|
}
|
|
|
|
function updateAIButton() {
|
|
if (aiConfigured) { btnAI.className = 'btn btn-ai'; btnAI.title = 'AI Panel (Ctrl+J)'; }
|
|
else { btnAI.className = 'btn btn-ai off'; btnAI.title = 'AI not configured'; }
|
|
}
|
|
|
|
function showAIProcessing() { statAI.classList.add('on'); }
|
|
function hideAIProcessing() { statAI.classList.remove('on'); }
|
|
|
|
// AI actions mapped to endpoints
|
|
var AI_MAP = {
|
|
'ai-improve': { ep: 'improve', desc: 'improve writing' },
|
|
'ai-fix': { ep: 'fix-spelling', desc: 'fix spelling' },
|
|
'ai-continue': { ep: 'continue', desc: 'continue writing' },
|
|
'ai-summarize': { ep: 'summarize', desc: 'summarize' },
|
|
'ai-translate': { ep: 'translate', extra: { language: 'en' }, desc: 'translate' },
|
|
'ai-shorter': { ep: 'simplify', desc: 'make shorter' },
|
|
'ai-longer': { ep: 'lengthen', desc: 'make longer' },
|
|
};
|
|
|
|
function execAIAction(actionKey) {
|
|
var cfg = AI_MAP[actionKey];
|
|
if (!cfg) return;
|
|
var sel = val().substring(ta.selectionStart, ta.selectionEnd) || val();
|
|
aiCall(cfg.ep, sel, cfg.extra || null, function(result) {
|
|
if (!result) return;
|
|
if (ta.selectionStart !== ta.selectionEnd) {
|
|
// Replace selection
|
|
var p = getPos();
|
|
ta.value = val().substring(0, p.s) + result + val().substring(p.e);
|
|
setPos(p.s + result.length);
|
|
} else {
|
|
// Insert at cursor
|
|
insertAtCursor('\n' + result + '\n');
|
|
}
|
|
markDirty();
|
|
});
|
|
}
|
|
|
|
// ---- Markdown Preview ----
|
|
var previewTimer = null;
|
|
function schedulePreview() {
|
|
if (!previewVisible) return;
|
|
clearTimeout(previewTimer);
|
|
previewTimer = setTimeout(renderPreview, 300);
|
|
}
|
|
|
|
function renderPreview() {
|
|
if (!previewVisible) return;
|
|
try {
|
|
previewContent.innerHTML = marked.parse(val()) || '<div class="preview-loading">Empty document</div>';
|
|
} catch(e) {
|
|
previewContent.innerHTML = '<div class="preview-loading">Preview error</div>';
|
|
}
|
|
}
|
|
|
|
function togglePreview() {
|
|
previewVisible = !previewVisible;
|
|
previewPane.classList.toggle('visible', previewVisible);
|
|
resizeHandle.style.display = previewVisible ? '' : 'none';
|
|
btnPreview.style.color = previewVisible ? 'var(--accent)' : '';
|
|
btnPreview.style.background = previewVisible ? 'var(--accent-bg)' : '';
|
|
if (previewVisible) renderPreview();
|
|
}
|
|
|
|
// ---- Sync scroll between editor and preview ----
|
|
var _syncScrolling = false;
|
|
editorWrap.addEventListener('scroll', function() {
|
|
if (!previewVisible || _syncScrolling) return;
|
|
_syncScrolling = true;
|
|
var pct = editorWrap.scrollTop / (editorWrap.scrollHeight - editorWrap.clientHeight);
|
|
previewPane.scrollTop = pct * (previewPane.scrollHeight - previewPane.clientHeight);
|
|
setTimeout(function() { _syncScrolling = false; }, 50);
|
|
});
|
|
previewPane.addEventListener('scroll', function() {
|
|
if (_syncScrolling) return;
|
|
_syncScrolling = true;
|
|
var pct = previewPane.scrollTop / (previewPane.scrollHeight - previewPane.clientHeight);
|
|
editorWrap.scrollTop = pct * (editorWrap.scrollHeight - editorWrap.clientHeight);
|
|
setTimeout(function() { _syncScrolling = false; }, 50);
|
|
});
|
|
|
|
// ---- Resize handle ----
|
|
(function() {
|
|
var dragging = false;
|
|
resizeHandle.addEventListener('mousedown', function(e) {
|
|
e.preventDefault();
|
|
dragging = true;
|
|
document.body.style.cursor = 'col-resize';
|
|
document.body.style.userSelect = 'none';
|
|
});
|
|
document.addEventListener('mousemove', function(e) {
|
|
if (!dragging) return;
|
|
var areaRect = editorArea.getBoundingClientRect();
|
|
var ratio = (e.clientX - areaRect.left) / areaRect.width;
|
|
ratio = Math.max(0.25, Math.min(0.75, ratio));
|
|
var editorPane = document.getElementById('editor-pane');
|
|
editorPane.style.flex = ratio;
|
|
previewPane.style.flex = (1 - ratio);
|
|
});
|
|
document.addEventListener('mouseup', function() {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
});
|
|
})();
|
|
|
|
// ---- Caret position to pixel (for monospace textarea) ----
|
|
function caretToPixel(pos) {
|
|
if (pos == null) pos = getPos().s;
|
|
var v = val();
|
|
var before = v.substring(0, pos);
|
|
var lineNum = before.split('\n').length - 1;
|
|
var colNum = pos - v.lastIndexOf('\n', pos - 1) - 1;
|
|
if (colNum < 0) colNum = 0;
|
|
var style = getComputedStyle(ta);
|
|
var fontSize = parseFloat(style.fontSize);
|
|
var lineH = parseFloat(style.lineHeight);
|
|
if (isNaN(lineH)) lineH = fontSize * 1.75;
|
|
var charW = fontSize * 0.615;
|
|
var wrapRect = editorWrap.getBoundingClientRect();
|
|
var taRect = ta.getBoundingClientRect();
|
|
// Position relative to editorWrap
|
|
var x = taRect.left - wrapRect.left + 2 + colNum * charW;
|
|
var y = taRect.top - wrapRect.top + (lineNum + 1) * lineH - ta.scrollTop;
|
|
// Clamp
|
|
if (x < 4) x = 4;
|
|
if (x > wrapRect.width - 310) x = wrapRect.width - 310;
|
|
if (y > wrapRect.height - 360) y = wrapRect.height - 360;
|
|
if (y < 20) y = 20;
|
|
return { x: x, y: y };
|
|
}
|
|
|
|
// ---- Slash menu ----
|
|
function initSlashItems() { slashItems = Array.from(slashMenu.querySelectorAll('.slash-item')); }
|
|
|
|
var SLASH_INSERT = {
|
|
h1: function() { replaceLine('# '); },
|
|
h2: function() { replaceLine('## '); },
|
|
h3: function() { replaceLine('### '); },
|
|
bullet: function() { replaceLine('- '); },
|
|
numbered: function() { replaceLine('1. '); },
|
|
checklist: function() { replaceLine('- [ ] '); },
|
|
quote: function() { replaceLine('> '); },
|
|
divider: function() { insertAtCursor('\n---\n'); },
|
|
image: function() { insertAtCursor('', 2); },
|
|
link: function() { insertAtCursor('[text](url)', 1); },
|
|
};
|
|
|
|
function showSlash() {
|
|
slashMenu.classList.add('on'); slashVisible = true; slashIdx = -1;
|
|
slashSearch.value = ''; slashSearch.focus();
|
|
filterSlash('');
|
|
// Position below current line at slash character
|
|
var pos = getPos().s - 1; // position of the / character
|
|
var pt = caretToPixel(pos);
|
|
slashMenu.style.left = pt.x + 'px';
|
|
slashMenu.style.top = pt.y + 'px';
|
|
}
|
|
|
|
function hideSlash() { slashMenu.classList.remove('on'); slashVisible = false; resetSlash(); }
|
|
|
|
function filterSlash(q) {
|
|
q = q.toLowerCase().trim();
|
|
slashItems.forEach(function(item) {
|
|
var t = (item.querySelector('.slash-title') || {}).textContent || '';
|
|
var d = (item.querySelector('.slash-desc') || {}).textContent || '';
|
|
item.style.display = (!q || t.toLowerCase().indexOf(q) >= 0 || d.toLowerCase().indexOf(q) >= 0) ? '' : 'none';
|
|
item.classList.remove('sel');
|
|
});
|
|
updateSlashSections(); slashIdx = -1;
|
|
}
|
|
function updateSlashSections() {
|
|
slashMenu.querySelectorAll('.slash-section').forEach(function(s) {
|
|
var n = s.nextElementSibling; var allHidden = true;
|
|
while (n && !n.classList.contains('slash-section') && !n.classList.contains('slash-div')) {
|
|
if (n.style.display !== 'none') { allHidden = false; break; } n = n.nextElementSibling;
|
|
}
|
|
s.style.display = allHidden ? 'none' : '';
|
|
});
|
|
}
|
|
function resetSlash() {
|
|
slashItems.forEach(function(i) { i.style.display = ''; i.classList.remove('sel'); });
|
|
slashMenu.querySelectorAll('.slash-section').forEach(function(s) { s.style.display = ''; });
|
|
}
|
|
function getVisibleSlash() { return slashItems.filter(function(i) { return i.style.display !== 'none'; }); }
|
|
|
|
function selectSlashAction(action) {
|
|
hideSlash();
|
|
// Handle QI-specific actions
|
|
if (action && action.indexOf('mermaid-') === 0) {
|
|
var type = action.replace('mermaid-', '');
|
|
var templates = {
|
|
flowchart: 'flowchart TD\n A[Start] --> B[Process] --> C[End]',
|
|
sequence: 'sequenceDiagram\n Alice->>Bob: Hello\n Bob-->>Alice: Hi!',
|
|
class: 'classDiagram\n class Animal {\n +name: string\n +eat()\n }\n class Dog {\n +bark()\n }\n Animal <|-- Dog',
|
|
state: 'stateDiagram-v2\n [*] --> Idle\n Idle --> Processing\n Processing --> Done\n Done --> [*]',
|
|
er: 'erDiagram\n CUSTOMER ||--o{ ORDER : places\n ORDER ||--|{ LINE_ITEM : contains',
|
|
gantt: 'gantt\n title Project Plan\n dateFormat YYYY-MM-DD\n section Phase 1\n Task A :a1, 2026-01-01, 30d\n Task B :after a1, 20d',
|
|
pie: 'pie title Distribution\n "Category A" : 45\n "Category B" : 30\n "Category C" : 25'
|
|
};
|
|
var tpl = templates[type] || 'flowchart TD\n A --> B';
|
|
insertAtCursor('\n```mermaid\n' + tpl + '\n```\n', 12);
|
|
ta.focus(); return;
|
|
}
|
|
// Table: inline form (not prompt)
|
|
if (action === 'table') { showTableForm(); return; }
|
|
// Code block: language picker (not bare ```)
|
|
if (action === 'code') { showLangPicker(); return; }
|
|
if (action && action.indexOf('ai-') === 0) { execAIAction(action); ta.focus(); return; }
|
|
var fn = SLASH_INSERT[action];
|
|
if (fn) fn(); else if (action) showToast('Action: ' + action);
|
|
ta.focus();
|
|
}
|
|
|
|
// ---- Bubble menu ----
|
|
function showBubble() {
|
|
if (ta.selectionStart === ta.selectionEnd) { hideBubble(); return; }
|
|
// Position below selection END (where user finished selecting)
|
|
var pt = caretToPixel(ta.selectionEnd);
|
|
var wr = editorWrap.getBoundingClientRect();
|
|
var top = wr.top + pt.y + 4;
|
|
var left = wr.left + pt.x - 10;
|
|
// Clamp to viewport (never use textarea fallback)
|
|
if (top < 60) top = 60;
|
|
if (top + 40 > window.innerHeight - 10) top = window.innerHeight - 50;
|
|
if (left < 6) left = 6;
|
|
if (left + bubble.offsetWidth > window.innerWidth - 6) left = window.innerWidth - bubble.offsetWidth - 6;
|
|
bubble.style.left = left + 'px'; bubble.style.top = top + 'px';
|
|
bubble.classList.add('on'); bubbleMore.classList.remove('on');
|
|
}
|
|
function hideBubble() { bubble.classList.remove('on'); bubbleMore.classList.remove('on'); }
|
|
|
|
function execBubbleAction(action) {
|
|
if (action && action.indexOf('ai-') === 0) {
|
|
execAIAction(action);
|
|
} else {
|
|
switch(action) {
|
|
case 'bold': wrapSelection('**'); break;
|
|
case 'italic': wrapSelection('*'); break;
|
|
case 'strike': wrapSelection('~~'); break;
|
|
case 'code': wrapSelection('`'); break;
|
|
case 'highlight': wrapSelection('=='); break;
|
|
case 'link':
|
|
var sel = val().substring(ta.selectionStart, ta.selectionEnd);
|
|
var url = prompt('URL:', 'https://');
|
|
if (url) wrapSelection('[', '](' + url + ')');
|
|
break;
|
|
}
|
|
}
|
|
hideBubble(); ta.focus();
|
|
}
|
|
|
|
// ---- Table form (inline, no prompt) ----
|
|
var inlineForm = document.getElementById('inline-form');
|
|
var ifCols = document.getElementById('if-cols');
|
|
var ifRows = document.getElementById('if-rows');
|
|
|
|
function showTableForm() {
|
|
hideAllMenus();
|
|
ifCols.value = '3'; ifRows.value = '3';
|
|
var pt = caretToPixel(null);
|
|
var wr = editorWrap.getBoundingClientRect();
|
|
inlineForm.style.left = (wr.left + pt.x) + 'px';
|
|
inlineForm.style.top = (wr.top + pt.y + 8) + 'px';
|
|
inlineForm.classList.add('on');
|
|
ifCols.focus(); ifCols.select();
|
|
}
|
|
|
|
function insertTableFromForm() {
|
|
var cols = parseInt(ifCols.value) || 3;
|
|
var rows = parseInt(ifRows.value) || 3;
|
|
if (cols < 1) cols = 1; if (rows < 1) rows = 1;
|
|
var hdr = '|'; var sep = '|';
|
|
for (var c = 1; c <= cols; c++) { hdr += ' Col ' + c + ' |'; sep += '-------|'; }
|
|
var body = '';
|
|
for (var r = 1; r <= rows; r++) {
|
|
body += '\n|';
|
|
for (var cc = 1; cc <= cols; cc++) body += ' |';
|
|
}
|
|
insertAtCursor('\n' + hdr + '\n' + sep + body + '\n', 2);
|
|
inlineForm.classList.remove('on');
|
|
ta.focus();
|
|
}
|
|
|
|
document.getElementById('if-ok').addEventListener('click', insertTableFromForm);
|
|
document.getElementById('if-cancel').addEventListener('click', function() { inlineForm.classList.remove('on'); ta.focus(); });
|
|
ifCols.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') { e.preventDefault(); insertTableFromForm(); }
|
|
if (e.key === 'Escape') { e.preventDefault(); inlineForm.classList.remove('on'); ta.focus(); }
|
|
});
|
|
ifRows.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') { e.preventDefault(); insertTableFromForm(); }
|
|
if (e.key === 'Escape') { e.preventDefault(); inlineForm.classList.remove('on'); ta.focus(); }
|
|
});
|
|
|
|
// ---- Language picker (code block) ----
|
|
var langPicker = document.getElementById('lang-picker');
|
|
var LANGS = ['bash','javascript','typescript','python','java','rust','go','c','cpp','csharp','html','css','scss','json','yaml','toml','xml','sql','dockerfile','makefile','markdown','php','ruby','swift','kotlin','lua','r','perl','scala','elixir','haskell','clojure','erlang','ocaml','zig','nim','groovy','dart','graphql','protobuf','text'];
|
|
var langIdx = -1;
|
|
|
|
function showLangPicker() {
|
|
hideAllMenus();
|
|
langIdx = -1;
|
|
var html = '<div style="font-size:10px;font-weight:600;color:var(--text3);padding:4px 9px 2px;text-transform:uppercase">Langage</div>';
|
|
LANGS.forEach(function(l, i) {
|
|
html += '<div class="lang-item" data-idx="' + i + '">' + l + '</div>';
|
|
});
|
|
langPicker.innerHTML = html;
|
|
var pt = caretToPixel(null);
|
|
var wr = editorWrap.getBoundingClientRect();
|
|
langPicker.style.left = (wr.left + pt.x) + 'px';
|
|
langPicker.style.top = (wr.top + pt.y + 8) + 'px';
|
|
langPicker.classList.add('on');
|
|
// Click handler
|
|
langPicker.querySelectorAll('.lang-item').forEach(function(item) {
|
|
item.addEventListener('click', function() {
|
|
var idx = parseInt(this.getAttribute('data-idx'));
|
|
insertCodeBlock(LANGS[idx]);
|
|
});
|
|
});
|
|
}
|
|
|
|
function insertCodeBlock(lang) {
|
|
langPicker.classList.remove('on');
|
|
insertAtCursor('\n```' + lang + '\n\n```\n', 4 + lang.length);
|
|
ta.focus();
|
|
}
|
|
|
|
function hideAllMenus() {
|
|
inlineForm.classList.remove('on');
|
|
langPicker.classList.remove('on');
|
|
qiMenu.classList.remove('on');
|
|
qiIdx = -1; qiItems = [];
|
|
}
|
|
|
|
// Lang picker keyboard nav (document-level)
|
|
var _origKeydownQI = null; // will store reference — actually we handle in the existing document keydown
|
|
// Add lang picker nav to the existing document keydown handler
|
|
var _langItems = [];
|
|
document.addEventListener('keydown', function(e) {
|
|
if (!langPicker.classList.contains('on')) return;
|
|
_langItems = Array.from(langPicker.querySelectorAll('.lang-item'));
|
|
if (e.key === 'Escape') { e.preventDefault(); langPicker.classList.remove('on'); ta.focus(); }
|
|
else if (e.key === 'ArrowDown') { e.preventDefault(); langIdx = (langIdx + 1) % _langItems.length; highlightLang(); }
|
|
else if (e.key === 'ArrowUp') { e.preventDefault(); langIdx = (langIdx - 1 + _langItems.length) % _langItems.length; highlightLang(); }
|
|
else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (langIdx >= 0 && langIdx < _langItems.length) {
|
|
var idx = parseInt(_langItems[langIdx].getAttribute('data-idx'));
|
|
insertCodeBlock(LANGS[idx]);
|
|
}
|
|
}
|
|
});
|
|
function highlightLang() {
|
|
_langItems.forEach(function(item, i) { item.classList.toggle('sel', i === langIdx); });
|
|
}
|
|
|
|
// ---- Quick Insert ----
|
|
function showQI() {
|
|
qiMenu.classList.remove('on');
|
|
qiIdx = -1; qiItems = [];
|
|
// Position below current line, left-aligned
|
|
var pt = caretToPixel(null);
|
|
pt.x = Math.max(pt.x - 10, 4);
|
|
pt.y += 2;
|
|
qiMenu.style.left = pt.x + 'px';
|
|
qiMenu.style.top = pt.y + 'px';
|
|
qiMenu.classList.add('on');
|
|
}
|
|
function toggleQI() {
|
|
if (qiMenu.classList.contains('on')) { qiMenu.classList.remove('on'); return; }
|
|
showQI();
|
|
}
|
|
|
|
// ---- AI Panel ----
|
|
function toggleAIPanel() {
|
|
aiPanel.classList.toggle('hidden');
|
|
btnAI.classList.toggle('active', !aiPanel.classList.contains('hidden'));
|
|
}
|
|
|
|
function sendAIChat() {
|
|
var input = document.getElementById('ai-input');
|
|
var chat = document.getElementById('ai-chat');
|
|
var text = input.value.trim();
|
|
if (!text) return;
|
|
input.value = '';
|
|
|
|
var um = document.createElement('div'); um.className = 'ai-msg user'; um.textContent = text;
|
|
chat.appendChild(um);
|
|
|
|
var lm = document.createElement('div'); lm.className = 'ai-msg loading';
|
|
lm.innerHTML = '<span class="spin"></span>Thinking...';
|
|
chat.appendChild(lm);
|
|
chat.scrollTop = chat.scrollHeight;
|
|
|
|
aiCall('improve', text, null, function(result) {
|
|
lm.remove();
|
|
var bm = document.createElement('div'); bm.className = 'ai-msg bot';
|
|
bm.textContent = result || '(no response)';
|
|
chat.appendChild(bm);
|
|
chat.scrollTop = chat.scrollHeight;
|
|
});
|
|
}
|
|
|
|
// ---- Drag & Drop ----
|
|
var dragC = 0;
|
|
document.addEventListener('dragenter', function(e) { e.preventDefault(); dragC++; if (dragC === 1) dropOverlay.classList.add('on'); });
|
|
document.addEventListener('dragleave', function(e) { e.preventDefault(); dragC--; if (dragC === 0) dropOverlay.classList.remove('on'); });
|
|
document.addEventListener('dragover', function(e) { e.preventDefault(); });
|
|
document.addEventListener('drop', function(e) {
|
|
e.preventDefault(); dragC = 0; dropOverlay.classList.remove('on');
|
|
var files = e.dataTransfer.files;
|
|
if (files.length > 0) {
|
|
var names = Array.from(files).map(function(f) { return f.name; }).join(', ');
|
|
showToast('Dropped: ' + names + ' (upload not implemented)', 'ok');
|
|
}
|
|
});
|
|
|
|
// ---- Stats ----
|
|
function updateStats() {
|
|
var v = val(); var words = v.trim() ? v.trim().split(/\s+/).length : 0;
|
|
statWords.textContent = words + ' words | ' + v.length + ' chars';
|
|
}
|
|
|
|
// ---- Toast ----
|
|
var toastTimer = null;
|
|
function showToast(msg, type) {
|
|
toast.textContent = msg; toast.className = 'toast on ' + (type || '');
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(function() { toast.classList.remove('on'); }, 2500);
|
|
}
|
|
|
|
// ---- Event handlers ----
|
|
ta.addEventListener('keydown', function(e) {
|
|
// Slash menu trigger: type / at start of line
|
|
if (e.key === '/') {
|
|
var l = getLine(); var p = getPos();
|
|
var before = l.text.substring(0, p.s - l.start);
|
|
if (before === '' || /^\s*$/.test(before)) setTimeout(showSlash, 20);
|
|
}
|
|
if (e.ctrlKey && e.key === 'j') { e.preventDefault(); toggleAIPanel(); }
|
|
if (e.ctrlKey && e.key === 's') { e.preventDefault(); forceSave(); }
|
|
if (e.ctrlKey && e.key === 'b') { e.preventDefault(); wrapSelection('**'); }
|
|
if (e.ctrlKey && e.key === 'i') { e.preventDefault(); wrapSelection('*'); }
|
|
// Quick Insert: Alt+I
|
|
if (e.altKey && e.key === 'i') { e.preventDefault(); showQI(); }
|
|
});
|
|
|
|
// Slash menu keyboard nav (bound to search input which has focus)
|
|
slashSearch.addEventListener('keydown', function(e) {
|
|
if (!slashVisible) return;
|
|
if (e.key === 'Escape') { e.preventDefault(); hideSlash(); ta.focus(); }
|
|
else if (e.key === 'ArrowDown') { e.preventDefault(); navSlash(1); }
|
|
else if (e.key === 'ArrowUp') { e.preventDefault(); navSlash(-1); }
|
|
else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
var vis = getVisibleSlash();
|
|
if (slashIdx >= 0 && slashIdx < vis.length) selectSlashAction(vis[slashIdx].getAttribute('data-action'));
|
|
}
|
|
});
|
|
|
|
// Quick Insert keyboard nav (document-level since no input to focus)
|
|
var qiIdx = -1;
|
|
var qiItems = [];
|
|
document.addEventListener('keydown', function(e) {
|
|
if (!qiMenu.classList.contains('on')) return;
|
|
qiItems = Array.from(qiMenu.querySelectorAll('.qi-item')); // refresh each time
|
|
if (e.key === 'Escape') { e.preventDefault(); qiMenu.classList.remove('on'); qiIdx = -1; ta.focus(); }
|
|
else if (e.key === 'ArrowDown') { e.preventDefault(); qiIdx = (qiIdx + 1) % qiItems.length; highlightQI(); }
|
|
else if (e.key === 'ArrowUp') { e.preventDefault(); qiIdx = (qiIdx - 1 + qiItems.length) % qiItems.length; highlightQI(); }
|
|
else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (qiIdx >= 0 && qiIdx < qiItems.length) {
|
|
var action = qiItems[qiIdx].getAttribute('data-qi');
|
|
qiMenu.classList.remove('on');
|
|
selectSlashAction(action);
|
|
}
|
|
}
|
|
});
|
|
function highlightQI() {
|
|
qiItems.forEach(function(item, i) { item.style.background = i === qiIdx ? 'var(--bg-hover)' : ''; item.style.color = i === qiIdx ? 'var(--text)' : ''; });
|
|
}
|
|
|
|
ta.addEventListener('keyup', function(e) {
|
|
if (e.key === '/' && slashVisible) {
|
|
var l = getLine(); var p = getPos();
|
|
var before = l.text.substring(0, p.s - l.start);
|
|
if (!/^\/\S*$/.test(before)) hideSlash();
|
|
}
|
|
});
|
|
|
|
ta.addEventListener('input', function() {
|
|
updateStats(); markDirty();
|
|
// Auto-height: grow textarea to fit content (no double scrollbar)
|
|
ta.style.height = 'auto';
|
|
ta.style.height = ta.scrollHeight + 'px';
|
|
});
|
|
ta.addEventListener('mouseup', function() { setTimeout(showBubble, 50); });
|
|
// Bubble follows cursor on any selection change (keyboard too)
|
|
document.addEventListener('selectionchange', function() {
|
|
if (document.activeElement !== ta) return;
|
|
clearTimeout(window._bubbleTimer);
|
|
window._bubbleTimer = setTimeout(showBubble, 80);
|
|
});
|
|
|
|
slashSearch.addEventListener('input', function() { filterSlash(slashSearch.value); });
|
|
slashMenu.addEventListener('click', function(e) {
|
|
var item = e.target.closest('.slash-item');
|
|
if (item) selectSlashAction(item.getAttribute('data-action'));
|
|
});
|
|
function navSlash(dir) {
|
|
var vis = getVisibleSlash(); if (vis.length === 0) return;
|
|
slashIdx += dir;
|
|
if (slashIdx < 0) slashIdx = vis.length - 1;
|
|
if (slashIdx >= vis.length) slashIdx = 0;
|
|
vis.forEach(function(i, idx) { i.classList.toggle('sel', idx === slashIdx); });
|
|
}
|
|
|
|
bubble.addEventListener('click', function(e) {
|
|
var btn = e.target.closest('.bubble-btn'); if (!btn) return;
|
|
e.preventDefault(); e.stopPropagation();
|
|
if (btn.id === 'bubble-more') { bubbleMore.classList.toggle('on'); return; }
|
|
execBubbleAction(btn.getAttribute('data-action'));
|
|
});
|
|
bubbleMore.addEventListener('click', function(e) {
|
|
var item = e.target.closest('.bubble-more-item'); if (!item) return;
|
|
e.stopPropagation(); execBubbleAction(item.getAttribute('data-action'));
|
|
});
|
|
|
|
document.getElementById('qi-btn').addEventListener('click', function(e) { e.stopPropagation(); toggleQI(); });
|
|
qiMenu.addEventListener('click', function(e) {
|
|
var item = e.target.closest('.qi-item'); if (!item) return;
|
|
var action = item.getAttribute('data-qi');
|
|
qiMenu.classList.remove('on');
|
|
selectSlashAction(action);
|
|
});
|
|
|
|
document.addEventListener('click', function(e) {
|
|
if (slashVisible && !slashMenu.contains(e.target) && e.target !== ta) hideSlash();
|
|
if (bubble.classList.contains('on') && !bubble.contains(e.target)) hideBubble();
|
|
if (qiMenu.classList.contains('on') && !qiMenu.contains(e.target) && e.target.id !== 'qi-btn') { qiMenu.classList.remove('on'); qiIdx = -1; qiItems = []; }
|
|
if (inlineForm.classList.contains('on') && !inlineForm.contains(e.target)) { inlineForm.classList.remove('on'); ta.focus(); }
|
|
if (langPicker.classList.contains('on') && !langPicker.contains(e.target)) { langPicker.classList.remove('on'); ta.focus(); }
|
|
});
|
|
|
|
btnAI.addEventListener('click', toggleAIPanel);
|
|
document.getElementById('ai-close').addEventListener('click', toggleAIPanel);
|
|
document.getElementById('ai-send').addEventListener('click', sendAIChat);
|
|
document.getElementById('ai-input').addEventListener('keydown', function(e) { if (e.key === 'Enter') sendAIChat(); });
|
|
document.querySelectorAll('.ai-sugg').forEach(function(b) {
|
|
b.addEventListener('click', function() {
|
|
document.getElementById('ai-input').value = b.textContent.trim();
|
|
sendAIChat();
|
|
});
|
|
});
|
|
|
|
btnPreview.addEventListener('click', togglePreview);
|
|
|
|
document.getElementById('poc-title').addEventListener('input', function() { markDirty(); });
|
|
|
|
// ---- Init ----
|
|
initSlashItems();
|
|
loadFile();
|
|
checkAIStatus();
|
|
updateStats();
|
|
|
|
console.log('ObsiGate Editor v1 ready');
|
|
console.log(' File: ' + (fileVault ? fileVault + '/' + filePath : 'demo mode'));
|
|
console.log(' AI: checking...');
|
|
console.log(' Type / for commands | Select text for bubble | Ctrl+J AI | Ctrl+S save');
|
|
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html> |