feat: finalisation éditeur Forge + mode hors-ligne PWA + consolidation roadmap
- Forge: Tab indent, Ctrl+K, auto-focus, AI status, messages erreur FR - Forge: slash commands clean, cache-bust iframe, preview serveur - Forge: iframe 82vh, hauteur auto, table inline, sélecteur langage - PWA hors-ligne: IndexedDB (offline-db.js + offline.js) - Mermaid: thème dark complet, highlight.js preview - Backend: màj CSP, rename chain, bookmarks/history/shares - Roadmap: consolidation dans docs/ROADMAP.md (75 items) - Infra: docker-compose.podman.yml, suppression test-vault - .gitignore: exclusion odysseus/
This commit is contained in:
+421
-39
@@ -29,6 +29,10 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
.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); }
|
||||
@@ -36,7 +40,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
.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 { 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); }
|
||||
@@ -44,6 +48,13 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
.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; }
|
||||
@@ -55,9 +66,18 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
.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: fixed; top: 56px; left: 48px; 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: 50; transition: all 100ms; }
|
||||
/* 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: 11.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); }
|
||||
.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; }
|
||||
|
||||
/* 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; }
|
||||
@@ -103,7 +123,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
.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 { 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; }
|
||||
@@ -175,6 +195,19 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
.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); } }
|
||||
@@ -191,12 +224,18 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
<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">
|
||||
<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-preview" title="Toggle preview">👁 Preview</button>
|
||||
<button class="btn btn-ai off" id="btn-ai" title="AI Panel (Ctrl+J)">✨ AI</button>
|
||||
<button class="btn" id="btn-help" title="Aide / Raccourcis (F1)">?</button>
|
||||
<button class="btn" id="btn-preview" title="Toggle preview">👁</button>
|
||||
<button class="btn btn-ai off" id="btn-ai" title="AI Panel (Ctrl+J)">✨</button>
|
||||
<div class="save-dot ok" id="save-dot"><span class="dot"></span><span id="save-label">Saved</span></div>
|
||||
<button class="btn btn-square danger" id="forge-delete" title="Supprimer">🗑</button>
|
||||
<button class="btn btn-square" id="forge-cancel" title="Annuler">✕</button>
|
||||
<button class="btn btn-square primary" id="forge-save" title="Sauvegarder (Ctrl+S)">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -209,11 +248,16 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
<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) -->
|
||||
<!-- Editor pane (textarea) with line numbers -->
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Quick Insert menu -->
|
||||
<div class="qi-menu" id="qi-menu">
|
||||
@@ -273,6 +317,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resize handle -->
|
||||
@@ -344,11 +390,57 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
<!-- 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>
|
||||
</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/npm/highlight.js@11/styles/atom-one-dark.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/highlight.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() {
|
||||
@@ -358,6 +450,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
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');
|
||||
@@ -376,6 +470,10 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
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;
|
||||
@@ -503,7 +601,12 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
isDirty = false; originalContent = content;
|
||||
saveDot.className = 'save-dot ok'; saveLabel.textContent = 'Saved';
|
||||
showToast('Saved', 'ok');
|
||||
// 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');
|
||||
@@ -524,13 +627,19 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
return;
|
||||
}
|
||||
statFile.textContent = 'Loading...';
|
||||
fetch('/api/file/' + encodeURIComponent(fileVault) + '/raw?path=' + encodeURIComponent(filePath))
|
||||
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) {
|
||||
ta.value = data.raw || '';
|
||||
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();
|
||||
originalContent = ta.value;
|
||||
// Extract title from first # heading or filename
|
||||
@@ -538,12 +647,14 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
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;
|
||||
@@ -759,27 +870,49 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
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('> '); },
|
||||
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'); },
|
||||
image: function() { insertAtCursor('', 2); },
|
||||
link: function() { insertAtCursor('[text](url)', 1); },
|
||||
link: function() { insertAtCursor('[text](https://)', 7); },
|
||||
image: function() { insertAtCursor('', 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
|
||||
// Position below current line at slash character (viewport-relative)
|
||||
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';
|
||||
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(); }
|
||||
@@ -810,9 +943,9 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
function getVisibleSlash() { return slashItems.filter(function(i) { return i.style.display !== 'none'; }); }
|
||||
|
||||
function selectSlashAction(action) {
|
||||
// Clean up the /command text from the current line
|
||||
// Clean up the / character from the current line, preserving any text after it
|
||||
var line = getLine();
|
||||
var m = line.text.match(/^\/\S*\s*/);
|
||||
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);
|
||||
@@ -1064,6 +1197,59 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
|
||||
// ---- 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();
|
||||
@@ -1084,6 +1270,34 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
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;
|
||||
@@ -1119,14 +1333,6 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
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();
|
||||
autoHeight();
|
||||
@@ -1201,6 +1407,27 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
|
||||
btnPreview.addEventListener('click', togglePreview);
|
||||
|
||||
// Action buttons (delete/cancel/save) — same as basic editor
|
||||
document.getElementById('forge-save').addEventListener('click', function() { forceSave(); });
|
||||
document.getElementById('forge-cancel').addEventListener('click', function() {
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({ type: 'forge-close' }, '*');
|
||||
}
|
||||
});
|
||||
document.getElementById('forge-delete').addEventListener('click', function() {
|
||||
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 ----
|
||||
@@ -1242,7 +1469,162 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
}
|
||||
}
|
||||
|
||||
console.log('ObsiGate Editor v1 ready');
|
||||
// ---- 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;
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
// ---- 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 | Ctrl+J AI | Ctrl+S save');
|
||||
|
||||
Reference in New Issue
Block a user