Files
ObsiGate/desktop/frontend-static/editor-poc.html
T
bruno 301c325af7
CI / lint (push) Successful in 25s
CI / security (push) Successful in 19s
CI / test (push) Successful in 29s
CI / build (push) Successful in 8s
CI / e2e (push) Failing after 21s
Desktop Build / build-windows (push) Has been cancelled
Desktop Build / build-linux (push) Has been cancelled
Refactor frontend into ES modules with modern tooling
2026-07-26 10:29:19 -04:00

1998 lines
93 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-sep { color: var(--text3); font-size: 13px; padding: 0 2px; user-select: none; }
.bar-filename { flex: 0 1 auto; min-width: 100px; max-width: 200px; background: transparent; border: 1px solid transparent; color: var(--text); font-size: 12px; padding: 3px 6px; border-radius: var(--r); outline: none; font-family: var(--mono); }
.bar-filename:hover { border-color: var(--border); }
.bar-filename:focus { border-color: var(--ok); 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; margin-right: 8px; }
.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; } }
/* Square action buttons (delete/cancel/save) — match basic editor */
.btn-square { width: 36px; height: 36px; padding: 0; justify-content: center; font-size: 16px; border-radius: 6px; border: 1px solid var(--border); background: transparent; color: var(--text2); cursor: pointer; transition: all 150ms; display: inline-flex; align-items: center; }
.btn-square:hover { color: var(--accent); border-color: var(--accent); }
.btn-square.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-square.primary:hover { opacity: 0.9; }
.btn-square.danger:hover { color: var(--err); border-color: var(--err); }
/* 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); }
/* Line numbers */
.ln-wrap { position: relative; display: flex; flex: 1; overflow: hidden; }
.ln-gutter { width: 44px; min-width: 44px; background: var(--bg2); border-right: 1px solid var(--border); overflow: hidden; user-select: none; padding-top: 24px; position: relative; z-index: 2; }
.ln-gutter-inner { padding: 0 8px 0 12px; text-align: right; font-family: var(--mono); font-size: 13.5px; line-height: 1.75; color: var(--text3); }
.ln-editor { flex: 1; overflow-y: auto; position: relative; }
.editor-wrap { flex: 1; overflow-y: auto; position: relative; padding: 24px 16px 24px 12px; }
/* Quick insert + — centered in gutter top */
.qi-btn { position: absolute; top: 2px; left: 50%; transform: translateX(-50%); width: 20px; height: 20px; border-radius: 50%; border: 1px solid var(--border); background: var(--bg2); color: var(--text3); cursor: pointer; font-size: 13px; display: flex; align-items: center; justify-content: center; transition: all 100ms; opacity: 0; z-index: 3; }
.ln-gutter:hover .qi-btn, .qi-btn:hover { opacity: 1; }
.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: fixed; 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); }
/* Help panel */
.help-overlay { position: fixed; inset: 0; z-index: 500; display: none; align-items: center; justify-content: center; background: rgba(15,17,23,0.8); }
.help-overlay.on { display: flex; }
.help-panel { background: var(--bg2); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); max-width: 560px; width: 90%; max-height: 75vh; overflow-y: auto; padding: 24px; }
.help-panel h2 { font-size: 18px; margin: 0 0 16px; color: var(--accent); }
.help-panel h3 { font-size: 13px; margin: 16px 0 6px; color: var(--text); text-transform: uppercase; letter-spacing: 0.5px; }
.help-panel table { width: 100%; border-collapse: collapse; font-size: 12px; }
.help-panel td { padding: 4px 8px; border-bottom: 1px solid var(--border); }
.help-panel td:first-child { font-family: var(--mono); color: var(--accent); white-space: nowrap; width: 140px; }
.help-panel td:last-child { color: var(--text2); }
.help-panel .help-close { display: block; margin: 16px auto 0; padding: 6px 20px; border-radius: var(--r); border: 1px solid var(--border); background: var(--bg3); color: var(--text); cursor: pointer; font-size: 12px; font-family: var(--sans); }
.help-panel .help-close:hover { background: var(--bg-hover); }
/* 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); }
/* Autocomplete dropdown */
.ac-dropdown {
position: fixed; z-index: 200;
background: var(--bg2); border: 1px solid var(--border-md);
border-radius: 8px; box-shadow: 0 8px 30px rgba(0,0,0,0.5);
min-width: 220px; max-width: 380px; max-height: 260px;
overflow-y: auto; display: none; padding: 4px;
}
.ac-dropdown.active { display: block; }
.ac-item {
display: flex; align-items: center; gap: 8px;
padding: 5px 10px; border-radius: 4px; cursor: pointer;
font-size: 12px; color: var(--text);
}
.ac-item:hover, .ac-item.active { background: var(--bg-hover); }
.ac-item-label { font-family: var(--mono); font-weight: 500; white-space: nowrap; }
.ac-item-detail { font-size: 10.5px; color: var(--text3); margin-left: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px; }
.ac-item-context { font-size: 9px; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; padding: 2px 10px 4px; }
/* Ghost text overlay */
.ghost-overlay {
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none; z-index: 1;
padding: 24px 16px 24px 12px;
font-family: var(--mono); font-size: 13.5px; line-height: 1.75;
white-space: pre-wrap; word-wrap: break-word;
overflow: hidden; color: transparent;
}
.ghost-prediction {
color: var(--text3); opacity: 0.5;
}
</style>
</head>
<body>
<!-- Autocomplete dropdown -->
<div class="ac-dropdown" id="ac-dropdown"></div>
<!-- Bar -->
<div class="bar">
<div class="bar-left">
<button class="bar-hamburger" title="Menu">&#9776;</button>
<span class="bar-brand">ObsiGate</span>
<input class="bar-title" id="poc-title" placeholder="Titre du document..." spellcheck="false" title="Titre (sync avec # Heading)">
<span class="bar-sep">/</span>
<input class="bar-filename" id="poc-filename" placeholder="nom.md" spellcheck="false" title="Renommer le fichier">
</div>
<div class="bar-right">
<button class="btn" id="btn-help" title="Aide / Raccourcis (F1)">&#63;</button>
<button class="btn" id="btn-preview" title="Toggle preview">&#128065;</button>
<button class="btn btn-ai off" id="btn-ai" title="AI Panel (Ctrl+J)">&#10024;</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">&#128444;</div><div class="drop-title">Drop files here</div><div class="drop-sub">Images, PDFs, documents</div></div>
</div>
<!-- Editor pane (textarea) with line numbers -->
<div class="editor-pane" id="editor-pane">
<div class="ln-wrap">
<div class="ln-gutter" id="ln-gutter">
<button class="qi-btn" id="qi-btn" title="Quick insert (Alt+I)">+</button>
<div class="ln-gutter-inner" id="ln-gutter-inner"></div>
</div>
<div class="ln-editor" id="ln-editor">
<div class="editor-wrap" id="editor-wrap">
<textarea class="textarea" id="ta" placeholder="Type / for commands..." spellcheck="false"></textarea>
<div class="ghost-overlay" id="ghost-overlay"><span class="ghost-prediction" id="ghost-prediction"></span></div>
<!-- Quick Insert menu -->
<div class="qi-menu" id="qi-menu">
<div class="qi-item" data-qi="image">&#127912; Image</div>
<div class="qi-item" data-qi="code">&#60;/&#62; Code Block</div>
<div class="qi-item" data-qi="table">&#128202; Table</div>
<div class="qi-item" data-qi="divider">&#9472; 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">&#128208; Diagram</div>
<div class="qi-item" data-qi="mermaid-flowchart">&#128256; Flowchart</div>
<div class="qi-item" data-qi="mermaid-sequence">&#128257; Sequence</div>
<div class="qi-item" data-qi="mermaid-class">&#128258; Class Diagram</div>
<div class="qi-item" data-qi="mermaid-state">&#128259; State Diagram</div>
<div class="qi-item" data-qi="mermaid-er">&#128260; ER Diagram</div>
<div class="qi-item" data-qi="mermaid-gantt">&#128261; Gantt Chart</div>
<div class="qi-item" data-qi="mermaid-pie">&#128262; 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">&#8226;</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">&#9744;</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">&#10077;</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">&#60;/&#62;</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">&#9472;</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">&#127912;</span><div class="slash-body"><div class="slash-title">Image</div><div class="slash-desc">![alt](url)</div></div></div>
<div class="slash-item" data-action="link"><span class="slash-icon">&#128279;</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">&#128202;</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)">&#10024;</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)">&#10024;</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)">&#10024;</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)">&#10024;</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)">&#10024;</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>
</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">&#60;/&#62;</button>
<button class="bubble-btn" data-action="link" title="Link">&#128279;</button>
<button class="bubble-btn" data-action="highlight" title="Highlight">&#9728;</button>
<span class="bubble-sep"></span>
<button class="bubble-btn ai" id="bubble-more" title="AI Actions">&#8943;</button>
<div class="bubble-more" id="bubble-more-menu">
<div class="bubble-more-item" data-action="ai-improve">&#10024; Improve writing</div>
<div class="bubble-more-item" data-action="ai-fix">&#9989; Fix spelling</div>
<div class="bubble-more-item" data-action="ai-shorter">&#128260; Make shorter</div>
<div class="bubble-more-item" data-action="ai-longer">&#128261; Make longer</div>
<div class="bubble-more-item" data-action="ai-translate">&#127760; Translate to EN</div>
<div class="bubble-more-item" data-action="ai-continue">&#10133; 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">&#10024; AI Assistant</span>
<button class="ai-panel-close" id="ai-close">&times;</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">&#10024; Improve overall style</button>
<button class="ai-sugg">&#10133; Add a conclusion</button>
<button class="ai-sugg">&#128221; 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+\</strong> autocomplete | <strong>Alt+I</strong> Insert</span>
<span>Ctrl+J AI panel</span>
<span>Ctrl+S save</span>
<span>Ctrl+K link</span>
<span class="stat-ai" id="stat-ai">&#10024; AI processing...</span>
<span id="stat-ai-ready" style="display:none;color:var(--ok)">&#10024; AI ready</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>
<!-- Help panel -->
<div class="help-overlay" id="help-overlay">
<div class="help-panel">
<h2>Forge Editor — Aide</h2>
<h3>Navigation</h3>
<table>
<tr><td>/</td><td>Menu de commandes (en debut de ligne)</td></tr>
<tr><td>Alt+I</td><td>Insertion rapide (Quick Insert)</td></tr>
<tr><td>F1</td><td>Ce panneau d'aide</td></tr>
<tr><td>Tab</td><td>Indenter la ligne / element de liste</td></tr>
<tr><td>Shift+Tab</td><td>Desindenter</td></tr>
</table>
<h3>Formatage</h3>
<table>
<tr><td>Ctrl+B</td><td>**Gras**</td></tr>
<tr><td>Ctrl+I</td><td>*Italique*</td></tr>
<tr><td>Ctrl+K</td><td>Inserer un lien [texte](url)</td></tr>
<tr><td>Selection + [</td><td>Envelopper en [lien](https://)</td></tr>
<tr><td>Selection + `</td><td>Envelopper en `code`</td></tr>
</table>
<h3>Brackets & Auto-completion</h3>
<table>
<tr><td>[</td><td>Auto-ferme [] avec curseur au milieu</td></tr>
<tr><td>(</td><td>Auto-ferme () — si apres [texte], insere https://</td></tr>
<tr><td>"</td><td>Auto-ferme ""</td></tr>
<tr><td>`</td><td>Auto-ferme ``</td></tr>
<tr><td>Backspace</td><td>Supprime la paire de brackets d'un coup</td></tr>
</table>
<h3>Fichier</h3>
<table>
<tr><td>Ctrl+S</td><td>Sauvegarder</td></tr>
<tr><td>Clic sur le titre</td><td>Renommer le fichier</td></tr>
<tr><td>Ctrl+J</td><td>Panneau AI</td></tr>
<tr><td>Alt+\</td><td>Autocompletion intelligente</td></tr>
<tr><td>Enter (liste)</td><td>Continue la liste (-, *, 1.) et checkboxes</td></tr>
<tr><td>Enter (liste vide)</td><td>Termine la liste</td></tr>
</table>
<h3>Slash — Commandes</h3>
<table>
<tr><td>/h1 /h2 /h3</td><td>Titres</td></tr>
<tr><td>/bullet /numbered</td><td>Listes</td></tr>
<tr><td>/checklist</td><td>Liste de taches</td></tr>
<tr><td>/quote /code /divider</td><td>Blocs</td></tr>
<tr><td>/link /image /table</td><td>Insertions</td></tr>
</table>
<button class="help-close" id="help-close">Fermer</button>
</div>
</div>
<!-- marked.js + highlight.js + Mermaid CDN for preview rendering -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/styles/atom-one-dark.min.css">
<script src="https://cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/highlight.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 lnGutter = document.getElementById('ln-gutter-inner');
var lnEditor = document.getElementById('ln-editor');
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');
var helpOverlay = document.getElementById('help-overlay');
var btnHelp = document.getElementById('btn-help');
var pocTitle = document.getElementById('poc-title');
var pocFilename = document.getElementById('poc-filename');
// ---- 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*/, ''));
}
// ---- Tab indentation ----
function indentLine() {
var l = getLine();
if (/^(\s*)([-*]|\d+\.)\s/.test(l.text)) { replaceLine(' ' + l.text); }
else { insertAtCursor(' '); }
}
function dedentLine() {
var l = getLine();
if (/^ /.test(l.text)) { replaceLine(l.text.replace(/^ /, '')); }
}
function promptLink() {
var sel = val().substring(ta.selectionStart, ta.selectionEnd);
var url = prompt('URL:', 'https://');
if (url) {
if (sel) { wrapSelection('[', '](' + url + ')'); }
else { insertAtCursor('[text](' + url + ')', 1); }
}
}
// ---- 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 = 'Erreur';
showToast('Echec sauvegarde: ' + e.message, 'err');
});
}
function forceSave() {
if (!fileVault || !filePath) { showToast('Aucun fichier charge.', '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';
// Wait for save-dot to update, then close
setTimeout(function() {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'forge-close' }, '*');
}
}, 300);
}).catch(function(e) {
saveDot.className = 'save-dot err'; saveLabel.textContent = 'Erreur';
showToast('Echec sauvegarde: ' + 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';
autoHeight();
updateLineNumbers();
statFile.textContent = 'Demo mode';
document.title = 'ObsiGate Forge — Demo';
originalContent = ta.value;
updateStats();
schedulePreview();
return;
}
statFile.textContent = 'Loading...';
var url = '/api/file/' + encodeURIComponent(fileVault) + '/raw?path=' + encodeURIComponent(filePath);
fetch(url, { credentials: 'include' })
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function(data) {
var raw = data.raw || data.content || '';
if (!raw && data.raw_size) {
// Raw might not be in the JSON — try reading from the response differently
raw = '# Erreur\n\nDonnees recues mais aucun contenu brut.\n\nChamps: ' + Object.keys(data).join(', ');
}
ta.value = raw;
autoHeight();
updateLineNumbers();
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;
pocFilename.value = filePath.split('/').pop();
statFile.textContent = fileVault + '/' + filePath;
document.title = title + ' — ObsiGate Forge';
updateStats();
schedulePreview();
})
.catch(function(e) {
console.error('[Forge] Load error:', url, e);
ta.value = '# Erreur\n\nImpossible de charger: ' + (fileVault || '?') + '/' + (filePath || '?') + '\n\n' + e.message;
statFile.textContent = 'Erreur chargement';
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 non configuree', 'err'); hideAIProcessing(); }
}).catch(function() { showToast('Verification AI echouee', '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)';
document.getElementById('stat-ai-ready').style.display = 'inline';
} else {
btnAI.className = 'btn btn-ai off'; btnAI.title = 'AI not configured';
document.getElementById('stat-ai-ready').style.display = 'none';
}
}
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 (server-side rendering via ObsiGate API) ----
var previewTimer = null;
var _previewLoading = false;
function schedulePreview() {
if (!previewVisible) return;
clearTimeout(previewTimer);
previewTimer = setTimeout(renderPreview, 500);
}
function renderPreview() {
if (!previewVisible) return;
if (_previewLoading) return;
// Demo mode: fallback to client-side marked
if (!fileVault || !filePath) {
try {
previewContent.innerHTML = marked.parse(val()) || '<div class="preview-loading">Empty document</div>';
} catch(e) {
previewContent.innerHTML = '<div class="preview-loading">Preview error</div>';
}
return;
}
// Save first, then fetch server-rendered HTML
_previewLoading = true;
previewContent.innerHTML = '<div class="preview-loading"><span class="spin"></span> Rendering...</div>';
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('Save failed');
return fetch('/api/file/' + encodeURIComponent(fileVault) + '?path=' + encodeURIComponent(filePath));
}).then(function(r) {
if (!r.ok) throw new Error('Render failed');
return r.json();
}).then(function(data) {
previewContent.innerHTML = data.html || '<div class="preview-loading">Empty document</div>';
processPreviewBlocks();
_previewLoading = false;
}).catch(function(e) {
previewContent.innerHTML = '<div class="preview-loading">Erreur de rendu</div>';
_previewLoading = false;
});
}
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() { prefixLine('# '); },
h2: function() { prefixLine('## '); },
h3: function() { prefixLine('### '); },
bullet: function() { prefixLine('- '); },
numbered: function() { prefixLine('1. '); },
checklist: function() { prefixLine('- [ ] '); },
quote: function() { prefixLine('> '); },
divider: function() { insertAtCursor('\n---\n'); },
link: function() { insertAtCursor('[text](https://)', 7); },
image: function() { insertAtCursor('![alt](https://)', 8); },
};
// prefixLine: replaces line start with prefix, preserving existing text
function prefixLine(prefix) {
var l = getLine();
var existing = l.text.replace(/^\s*\/?\s*/, '');
if (existing) {
replaceLine(prefix + existing);
} else {
replaceLine(prefix);
}
}
function showSlash() {
slashMenu.classList.add('on'); slashVisible = true; slashIdx = -1;
slashSearch.value = ''; slashSearch.focus();
filterSlash('');
// Position below current line at slash character (viewport-relative)
var pos = getPos().s - 1; // position of the / character
var pt = caretToPixel(pos);
var wr = editorWrap.getBoundingClientRect();
var top = wr.top + pt.y + 8;
var left = wr.left + pt.x;
// Clamp to viewport so menu is always fully visible
var menuH = 360; // max-height
if (top + menuH > window.innerHeight - 10) {
top = window.innerHeight - menuH - 10;
}
if (left + 300 > window.innerWidth - 10) {
left = window.innerWidth - 310;
}
slashMenu.style.left = left + 'px';
slashMenu.style.top = top + '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) {
// Clean up the / character from the current line, preserving any text after it
var line = getLine();
var m = line.text.match(/^\/\s*/);
if (m) {
ta.value = val().substring(0, line.start) + val().substring(line.start + m[0].length);
setPos(line.start);
}
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) {
// Auto-close brackets: type [ → [] with cursor inside
// If text is selected, wrap selection instead
if (e.key === '[' && !e.ctrlKey && !e.metaKey) {
var p = getPos();
if (p.s !== p.e) { e.preventDefault(); wrapSelection('[', ']'); return; }
e.preventDefault();
insertAtCursor('[]', 1);
return;
}
if (e.key === '(' && !e.ctrlKey && !e.metaKey) {
var p = getPos();
if (p.s !== p.e) { e.preventDefault(); wrapSelection('(', ')'); return; }
e.preventDefault();
insertAtCursor('()', 1);
return;
}
if (e.key === '"' && !e.ctrlKey && !e.metaKey) {
var p = getPos();
if (p.s !== p.e) { e.preventDefault(); wrapSelection('"', '"'); return; }
// Don't auto-close if next char is already a quote or we're inside a word
var next = val().charAt(p.s);
if (next === '"') { return; } // let default handle — moves cursor past
e.preventDefault();
insertAtCursor('""', 1);
return;
}
if (e.key === '`' && !e.ctrlKey && !e.metaKey) {
var p = getPos();
if (p.s !== p.e) { e.preventDefault(); wrapSelection('`', '`'); return; }
// Don't auto-close if next char is a backtick
var next = val().charAt(p.s);
if (next === '`') { return; }
e.preventDefault();
insertAtCursor('``', 1);
return;
}
// Smart backspace: delete paired brackets together
if (e.key === 'Backspace') {
var p = getPos();
if (p.s === p.e) {
var before = val().charAt(p.s - 1);
var after = val().charAt(p.s);
var pairs = { '[': ']', '(': ')', '"': '"', '`': '`' };
if (pairs[before] && pairs[before] === after) {
e.preventDefault();
ta.value = val().substring(0, p.s - 1) + val().substring(p.s + 1);
setPos(p.s - 1);
markDirty();
return;
}
}
}
// 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('*'); }
if (e.ctrlKey && e.key === 'k') { e.preventDefault(); promptLink(); }
// Tab: indent list items
if (e.key === 'Tab') {
e.preventDefault();
if (e.shiftKey) { dedentLine(); } else { indentLine(); }
}
// Quick Insert: Alt+I
if (e.altKey && e.key === 'i') { e.preventDefault(); showQI(); }
});
// Link auto-complete: when [text]( is typed, insert https:// prefix
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();
}
// Detect [text]( pattern — auto-insert https://
if (e.key === '(') {
var p = getPos();
var v = val();
// Look backward for [text] pattern
var searchEnd = p.s - 1; // position of the (
var bracketStart = v.lastIndexOf('[', searchEnd);
if (bracketStart >= 0) {
var bracketEnd = v.indexOf(']', bracketStart);
if (bracketEnd >= bracketStart && bracketEnd < searchEnd + 2) {
// We have [text] immediately before the (
// Insert https:// after (
var np = p.s; // cursor is after (
ta.value = v.substring(0, np) + 'https://' + v.substring(np);
setPos(np + 8);
markDirty();
}
}
}
});
// 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('input', function() {
updateStats(); markDirty();
autoHeight();
});
// ---- Auto-height textarea (fill available space, grow beyond) ----
function autoHeight() {
var st = ta.scrollTop;
ta.style.height = 'auto';
var contentH = ta.scrollHeight;
// Minimum: fill the editor-wrap's visible area minus padding
var wrapH = editorWrap.clientHeight - 48;
ta.style.height = Math.max(contentH, wrapH) + 'px';
// Restore scroll position: prevent cursor jump on every keystroke
ta.scrollTop = st;
}
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);
// Listen for parent save/delete commands (parent modal buttons)
window.addEventListener('message', function(e) {
if (!e.data || !e.data.type) return;
if (e.data.type === 'parent-save') { forceSave(); }
if (e.data.type === 'parent-delete') {
if (!fileVault || !filePath) return;
if (!confirm('Supprimer definitivement ' + filePath + ' ?')) return;
fetch('/api/file/' + encodeURIComponent(fileVault) + '?path=' + encodeURIComponent(filePath), {
method: 'DELETE', credentials: 'include'
}).then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
showToast('Fichier supprime', 'ok');
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'forge-close' }, '*');
}
}).catch(function(e) { showToast('Echec suppression: ' + e.message, 'err'); });
}
});
document.getElementById('poc-title').addEventListener('input', function() { markDirty(); });
// ---- Init ----
mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' });
initSlashItems();
loadFile();
checkAIStatus();
updateStats();
// Auto-focus after short delay (wait for iframe to fully render)
setTimeout(function() { ta.focus(); }, 200);
// ---- Process preview blocks (Mermaid + highlight.js) ----
function processPreviewBlocks() {
// Render Mermaid diagrams
var mermaidBlocks = previewContent.querySelectorAll('pre code.language-mermaid');
mermaidBlocks.forEach(function(block) {
var code = block.textContent;
var id = 'mermaid-prev-' + Math.random().toString(36).substr(2, 8);
try {
mermaid.render(id, code).then(function(result) {
var container = document.createElement('div');
container.className = 'mermaid-rendered';
container.innerHTML = result.svg;
block.parentElement.replaceWith(container);
}).catch(function() {
// Mermaid render failed, leave code block as-is
});
} catch(e) {
// ignore render errors
}
});
// Syntax highlight code blocks
if (typeof hljs !== 'undefined') {
previewContent.querySelectorAll('pre code').forEach(function(block) {
if (!block.classList.contains('language-mermaid')) {
hljs.highlightElement(block);
}
});
}
}
// ---- Line numbers ----
function updateLineNumbers() {
if (!lnGutter) return;
var lines = val().split('\n');
var html = '';
for (var i = 0; i < lines.length; i++) {
html += (i + 1) + '<br>';
}
lnGutter.innerHTML = html;
}
// Sync scroll between textarea and line numbers gutter
editorWrap.addEventListener('scroll', function() {
if (lnGutter) {
document.getElementById('ln-gutter').scrollTop = editorWrap.scrollTop;
}
// Sync ghost overlay scroll
if (ghostOverlay) {
ghostOverlay.scrollTop = editorWrap.scrollTop;
}
});
// Update line numbers on input
var _lnUpdateTimer = null;
ta.addEventListener('input', function() {
if (_lnUpdateTimer) clearTimeout(_lnUpdateTimer);
_lnUpdateTimer = setTimeout(updateLineNumbers, 100);
});
// ---- Help panel ----
btnHelp.addEventListener('click', function() { helpOverlay.classList.add('on'); });
document.getElementById('help-close').addEventListener('click', function() { helpOverlay.classList.remove('on'); });
helpOverlay.addEventListener('click', function(e) { if (e.target === helpOverlay) helpOverlay.classList.remove('on'); });
document.addEventListener('keydown', function(e) {
if (e.key === 'F1') { e.preventDefault(); helpOverlay.classList.toggle('on'); }
if (e.key === 'Escape' && helpOverlay.classList.contains('on')) { helpOverlay.classList.remove('on'); }
});
// ---- Autocomplete (Tab completion from existing words) ----
var _acTimer = null;
ta.addEventListener('keydown', function(e) {
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && !slashVisible && !qiMenu.classList.contains('on')) {
var p = getPos();
if (p.s !== p.e) return; // Don't autocomplete when selection exists (let indent handle it)
var v = val();
// Find the word fragment before cursor
var start = p.s;
while (start > 0 && /[\w\-\.\/]/.test(v.charAt(start - 1))) start--;
var fragment = v.substring(start, p.s);
if (fragment.length < 2) return; // Need at least 2 chars
// Find matching words in the document
var re = new RegExp('\\b' + fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\w\\-\\.\\/]+', 'gi');
var matches = [];
var m;
while ((m = re.exec(v)) !== null) {
if (matches.indexOf(m[0]) === -1) matches.push(m[0]);
}
if (matches.length === 1) {
e.preventDefault();
insertAtCursor(matches[0].substring(fragment.length), 0);
}
}
});
// ---- Title sync: first heading line <-> title bar (NO rename) ----
function syncTitleFromContent() {
var v = val();
var m = v.match(/^#\s+(.+)/m);
var contentTitle = m ? m[1] : '';
if (contentTitle && pocTitle.value !== contentTitle) {
pocTitle.value = contentTitle;
}
}
function syncContentFromTitle() {
var v = val();
var titleVal = pocTitle.value.trim();
if (!titleVal) return;
var m = v.match(/^#\s+(.+)/m);
if (m) {
// Update existing heading
ta.value = v.substring(0, m.index + 2) + titleVal + v.substring(m.index + 2 + m[1].length);
markDirty();
} else {
// Insert heading at top
ta.value = '# ' + titleVal + '\n' + v;
setPos(0);
markDirty();
}
}
ta.addEventListener('input', function() {
var v = val();
var m = v.match(/^#\s+(.+)/m);
if (m && m.index === 0) {
pocTitle.value = m[1];
}
});
pocTitle.addEventListener('input', function() {
syncContentFromTitle();
});
// ---- File rename (via filename field only, with reindex) ----
var _originalFilename = '';
pocFilename.addEventListener('focus', function() {
_originalFilename = pocFilename.value;
});
pocFilename.addEventListener('blur', function() {
var newName = pocFilename.value.trim();
if (!newName || newName === _originalFilename) return;
if (!fileVault || !filePath) return;
// Ensure .md extension
if (!/\.md$/i.test(newName)) newName += '.md';
fetch('/api/file/' + encodeURIComponent(fileVault), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ path: filePath, new_name: newName })
}).then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}).then(function(data) {
var oldPath = filePath;
filePath = data.new_path || newName;
pocFilename.value = filePath.split('/').pop();
statFile.textContent = fileVault + '/' + filePath;
document.title = (pocTitle.value || filePath) + ' — ObsiGate Forge';
showToast('Fichier renomme en ' + filePath, 'ok');
// Notify parent window to refresh
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'forge-renamed', vault: fileVault, oldPath: oldPath, newPath: filePath }, '*');
}
}).catch(function(e) {
showToast('Echec renommage: ' + e.message, 'err');
pocFilename.value = _originalFilename;
});
});
// Also trigger on Enter
pocFilename.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); pocFilename.blur(); }
});
// Update filename display on file load
var _origLoadFile3 = loadFile;
loadFile = function() {
_origLoadFile3();
setTimeout(function() {
syncTitleFromContent();
updateLineNumbers();
// Set filename field from current path
if (filePath) {
pocFilename.value = filePath.split('/').pop();
}
}, 300);
};
// ---- Autocomplete (Ctrl+Space or smart trigger) ----
var acDropdown = document.getElementById('ac-dropdown');
var acItems = [];
var acIdx = -1;
function showAutocomplete(items, context) {
acItems = items;
acIdx = -1;
if (!items.length) { hideAutocomplete(); return; }
// Position: centered below the textarea (simple, reliable)
var rect = ta.getBoundingClientRect();
acDropdown.style.left = (rect.left + rect.width / 2) + 'px';
acDropdown.style.top = (rect.bottom + 6) + 'px';
acDropdown.style.transform = 'translateX(-50%)';
// Keep dropdown within viewport
acDropdown.style.maxWidth = Math.min(380, rect.width - 32) + 'px';
var html = context ? '<div class="ac-item-context">' + context + '</div>' : '';
items.forEach(function(item, i) {
html += '<div class="ac-item" data-idx="' + i + '">' +
'<span class="ac-item-label">' + escHtml(item.label) + '</span>' +
(item.detail ? '<span class="ac-item-detail">' + escHtml(item.detail) + '</span>' : '') +
'</div>';
});
acDropdown.innerHTML = html;
acDropdown.classList.add('active');
acIdx = 0;
highlightAcItem();
// Wire clicks
acDropdown.querySelectorAll('.ac-item').forEach(function(el) {
el.addEventListener('mousedown', function(e) {
e.preventDefault();
var idx = parseInt(el.dataset.idx);
if (!isNaN(idx) && acItems[idx]) applyAutocomplete(acItems[idx]);
});
});
}
function hideAutocomplete() {
acDropdown.classList.remove('active');
acItems = [];
acIdx = -1;
}
function highlightAcItem() {
acDropdown.querySelectorAll('.ac-item').forEach(function(el, i) {
el.classList.toggle('active', i === acIdx);
});
if (acIdx >= 0) {
var active = acDropdown.querySelector('.ac-item.active');
if (active) active.scrollIntoView({ block: 'nearest' });
}
}
function applyAutocomplete(item) {
if (!item || !item.insert) return;
var start = ta.selectionStart;
var end = ta.selectionEnd;
var before = ta.value.slice(0, start);
var after = ta.value.slice(end);
// Lazy-load detectContext for smart insertion
import('/static/js/autocomplete.js').then(function(m) {
var lineStart = before.lastIndexOf('\n') + 1;
var ctx = m.detectContext(ta.value, start);
if (ctx.type === 'wikilink') {
var bracketPos = before.lastIndexOf('[[');
if (bracketPos >= 0) {
before = before.slice(0, bracketPos) + '[[' + item.insert;
ta.value = before + after;
var newPos = before.length;
ta.setSelectionRange(newPos, newPos);
}
} else if (ctx.type === 'frontmatter') {
before = before.slice(0, lineStart) + item.insert;
ta.value = before + after;
ta.setSelectionRange(before.length, before.length);
} else {
ta.value = before + item.insert + after;
var newPos = start + item.insert.length;
ta.setSelectionRange(newPos, newPos);
}
hideAutocomplete();
ta.focus();
markDirty();
autoHeight();
updateLineNumbers();
});
}
function triggerAutocomplete() {
// Lazy-load the autocomplete module on first use
var loadAndTrigger = function() {
import('/static/js/autocomplete.js').then(function(m) {
var result = m.suggest(ta.value, ta.selectionStart);
if (result.async) {
m.searchWikilinks(result.partial || '', function(items) {
showAutocomplete(items, 'Wikilinks');
});
} else if (result.items && result.items.length > 0) {
var ctxLabels = {
mermaid: 'Mermaid', code: 'Code', fence_open: 'Langage',
frontmatter: 'Frontmatter', markdown: 'Markdown'
};
showAutocomplete(result.items, ctxLabels[result.context] || result.context);
} else {
// Fallback: show markdown formatting suggestions
showAutocomplete(m.MARKDOWN_FORMAT, 'Markdown');
}
}).catch(function(err) {
console.error('[Autocomplete] Failed to load module:', err);
});
};
loadAndTrigger();
}
// ---- Ghost Text (inline AI prediction via Ollama) ----
var ghostOverlay = document.getElementById('ghost-overlay');
var ghostPred = document.getElementById('ghost-prediction');
var _ghostTimer = null;
var _ghostText = '';
function clearGhost() {
_ghostText = '';
ghostOverlay.innerHTML = '';
}
function showGhost(prediction) {
if (!prediction) { clearGhost(); return; }
_ghostText = prediction;
var before = ta.value.slice(0, ta.selectionStart);
// Show: existing text (transparent) + prediction (visible faded)
ghostOverlay.innerHTML = escHtml(before) + '<span class="ghost-prediction">' + escHtml(prediction) + '</span>';
}
function acceptGhost() {
if (!_ghostText) return;
var start = ta.selectionStart;
var before = ta.value.slice(0, start);
var after = ta.value.slice(start);
// Trim to avoid double spaces
var insert = _ghostText.replace(/^\s+/, '');
if (!insert) return;
ta.value = before + insert + after;
ta.setSelectionRange(start + insert.length, start + insert.length);
clearGhost();
ta.focus();
markDirty();
autoHeight();
updateLineNumbers();
}
function requestGhostCompletion() {
var fullText = ta.value.slice(0, ta.selectionStart);
if (fullText.trim().length < 1) return;
var lastChar = fullText.slice(-1);
var midWord = /[a-zA-Z0-9\u00C0-\u024F]$/.test(lastChar);
// Get user's preferred language
var lang = 'fr';
try { lang = localStorage.getItem('obsigate-lang') || 'fr'; } catch(e) {}
var langNames = { fr: 'French', en: 'English', es: 'Spanish', de: 'German' };
var langName = langNames[lang] || 'French';
var prompt, inputText;
if (midWord) {
var wordMatch = fullText.match(/([\w\u00C0-\u024F]+)$/);
var partialWord = wordMatch ? wordMatch[1] : fullText;
inputText = partialWord;
var context = fullText.slice(0, -partialWord.length).trim();
prompt = 'Complete this ' + langName + ' word. The word is: "' + partialWord +
'". Context: "' + (context || '(start of line)') +
'". Return ONLY the remaining letters. Do NOT add spaces. Example: "famil" → "ial" for "familial".';
} else {
inputText = fullText;
prompt = 'Continue this ' + langName + ' text naturally. Return ONLY the new text (do NOT repeat):\n' + fullText;
}
fetch('/api/ai/inline-complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: prompt, provider: 'ollama' })
})
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function(data) {
var raw = (data.result || '').trim();
if (!raw || raw.length < 1) return;
var prediction = raw;
if (midWord && prediction.toLowerCase().startsWith(inputText.toLowerCase())) {
prediction = prediction.slice(inputText.length);
} else if (!midWord && prediction.startsWith(inputText)) {
prediction = prediction.slice(inputText.length);
}
prediction = prediction.trim();
var alreadyThere = ta.value.slice(ta.selectionStart).trimStart();
if (prediction && prediction.length > 0 && !alreadyThere.startsWith(prediction.slice(0, Math.min(6, prediction.length)))) {
showGhost(prediction);
}
})
.catch(function() { /* silent */ });
}
// Trigger after typing pause
ta.addEventListener('input', function() {
clearGhost();
if (_ghostTimer) clearTimeout(_ghostTimer);
_ghostTimer = setTimeout(requestGhostCompletion, 600);
});
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
ta.addEventListener('keydown', function(e) {
// Tab: accept ghost text prediction
if (e.key === 'Tab' && _ghostText && !acDropdown.classList.contains('active')) {
e.preventDefault();
acceptGhost();
return;
}
// Escape: clear ghost text
if (e.key === 'Escape' && _ghostText && !acDropdown.classList.contains('active')) {
clearGhost();
return;
}
if (acDropdown.classList.contains('active')) {
if (e.key === 'ArrowDown') { e.preventDefault(); acIdx = Math.min(acIdx + 1, acItems.length - 1); highlightAcItem(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); acIdx = Math.max(acIdx - 1, 0); highlightAcItem(); }
else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
if (acIdx >= 0 && acItems[acIdx]) applyAutocomplete(acItems[acIdx]);
}
else if (e.key === 'Escape') { e.preventDefault(); hideAutocomplete(); }
return;
}
// Alt+\ : force autocomplete (key may be '\' or 'Backslash' depending on browser)
if (e.altKey && !e.ctrlKey && !e.metaKey && (e.key === '\\' || e.key === 'Backslash')) {
e.preventDefault();
triggerAutocomplete();
return;
}
// Auto-trigger on [[ for wikilinks
if (e.key === '[' && ta.selectionStart > 0 && ta.value[ta.selectionStart - 1] === '[') {
setTimeout(triggerAutocomplete, 50);
}
// Enter: auto-continue lists and checkboxes
if (e.key === 'Enter' && !e.shiftKey) {
var before = ta.value.slice(0, ta.selectionStart);
var after = ta.value.slice(ta.selectionEnd);
var lineStart = before.lastIndexOf('\n') + 1;
// Reconstruct FULL current line (before cursor + after cursor until newline)
var afterNewline = after.indexOf('\n');
var restOfLine = afterNewline >= 0 ? after.slice(0, afterNewline) : after;
var fullLine = before.slice(lineStart) + restOfLine;
var cursorInLine = ta.selectionStart - lineStart;
var indent = (fullLine.match(/^(\s*)/) || [''])[0];
// Ordered list: 1. 2. 3. etc
var olMatch = fullLine.match(/^(\s*)(\d+)\.\s/);
// Unordered list: - * +
var ulMatch = fullLine.match(/^(\s*)([-*+])\s/);
// Task checkbox: - [ ] or - [x]
var taskMatch = fullLine.match(/^(\s*)([-*+])\s+\[([ xX])\]\s/);
if (taskMatch) {
var textAfterMarker = fullLine.slice(taskMatch[0].length);
// Only continue if cursor is at or after the marker text
if (cursorInLine < taskMatch[0].length) return; // cursor in prefix, let browser handle
if (textAfterMarker.trim().length === 0) {
// Empty checkbox: break list
e.preventDefault();
ta.value = before.slice(0, lineStart) + after.slice(restOfLine.length);
ta.setSelectionRange(lineStart, lineStart);
} else {
// Continue checkbox
e.preventDefault();
ta.value = before + '\n' + indent + taskMatch[2] + ' [ ] ' + after;
var newPos = before.length + 1 + indent.length + taskMatch[2].length + 5;
ta.setSelectionRange(newPos, newPos);
}
markDirty(); autoHeight(); updateLineNumbers();
return;
} else if (olMatch) {
if (cursorInLine < olMatch[0].length) return;
// Auto-increment ordered list
e.preventDefault();
var num = parseInt(olMatch[2]) + 1;
ta.value = before + '\n' + olMatch[1] + num + '. ' + after;
var newPos = before.length + 1 + olMatch[1].length + String(num).length + 2;
ta.setSelectionRange(newPos, newPos);
markDirty(); autoHeight(); updateLineNumbers();
return;
} else if (ulMatch) {
if (cursorInLine < ulMatch[0].length) return;
var textAfter = fullLine.slice(ulMatch[0].length);
if (textAfter.trim().length === 0) {
// Empty bullet: break list
e.preventDefault();
ta.value = before.slice(0, lineStart) + after.slice(restOfLine.length);
ta.setSelectionRange(lineStart, lineStart);
} else {
// Continue bullet
e.preventDefault();
ta.value = before + '\n' + ulMatch[1] + ulMatch[2] + ' ' + after;
var newPos = before.length + 1 + ulMatch[1].length + ulMatch[2].length + 1;
ta.setSelectionRange(newPos, newPos);
}
markDirty(); autoHeight(); updateLineNumbers();
return;
}
}
});
// Trigger on triple backtick
ta.addEventListener('input', function() {
var before = ta.value.slice(0, ta.selectionStart);
if (before.endsWith('``')) {
setTimeout(triggerAutocomplete, 50);
}
});
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// ---- Init ----
updateLineNumbers();
console.log('ObsiGate Editor v2 ready');
console.log(' File: ' + (fileVault ? fileVault + '/' + filePath : 'demo mode'));
console.log(' AI: checking...');
console.log(' Type / for commands | Select text for bubble | Alt+\\ autocomplete | Ctrl+J AI | Ctrl+S save');
})();
</script>
</body>
</html>