feat(editeur): #93 edition inline — Editer/Forge remplacent la vue lecture
Le conteneur d'edition (#editor-container, CodeMirror ou iframe Forge) est deplace dans la zone de contenu du document (#content-area ou pane active) au lieu de l'overlay plein ecran : le mode edition remplace la vue lecture. L'overlay reste monte (transparent, pointer-events: none) car le ruban d'edition mobile y est ancre ; il ne sert plus que de repli quand la cible n'est pas le document affiche. Garde-fou dans renderFile() pour liberer proprement la session (destroy CodeMirror/Yjs, retrait de l'iframe Forge) ; forge-close passe par closeEditor() ; cache d'onglet invalide au retour en lecture. Assistant IA : app_context.editing annonce le document en cours d'edition (frontend bookslm.js + backend bookslm.py), et chaque ecriture d'outil (edit_file, append_to_file, create_file, restore_backup) recharge le document affiche (obsigate:file-written) — tampon CodeMirror remplace avec auto-save neutralisee, parent-reload pour l'iframe Forge, re-rendu de la vue lecture sinon. Tests : tests/frontend/editor-inline.test.mjs (19), tests/test_bookslm.py::TestGeneralPrompt (3 nouveaux). Fiche : docs/features/editeur-inline.md.
This commit is contained in:
+183
-8
@@ -4,6 +4,67 @@ import { openFile, showWelcome } from './viewer.js';
|
||||
import { refreshSidebarForContext, refreshTagsForContext } from './sidebar.js';
|
||||
import { createAIToolbar } from './ai.js';
|
||||
import { startCollab, stopCollab } from './collab.js';
|
||||
import {
|
||||
getInlineHostArea,
|
||||
mountEditorInline,
|
||||
unmountEditorInline,
|
||||
isInlineEditorActive,
|
||||
} from './editor-inline.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline edition (#93)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Set just before a programmatic document replacement (external AI write) so
|
||||
* the autosave update listener does not schedule a redundant save of the
|
||||
* freshly reloaded content.
|
||||
*/
|
||||
let suppressAutoSaveOnce = false;
|
||||
|
||||
/**
|
||||
* Inline edition: the editor replaces the document read view in the content
|
||||
* area instead of covering it with the `#editor-modal` overlay. Only possible
|
||||
* when the editor targets the document currently read (the pane area is what
|
||||
* gets taken over); otherwise the overlay is kept as a fallback (e.g. a file
|
||||
* just created from the command palette and not opened in the viewer yet).
|
||||
*
|
||||
* @returns {boolean} true when the editor is hosted inline.
|
||||
*/
|
||||
export function activateInlineEditor(vaultName, filePath) {
|
||||
if (!vaultName || !filePath) return false;
|
||||
if (state.currentVault !== vaultName || state.currentPath !== filePath) return false;
|
||||
const area = getInlineHostArea();
|
||||
if (!area) return false;
|
||||
return mountEditorInline(area);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop an inline edition session without re-rendering the document: used when
|
||||
* the read view is about to be re-rendered for another file (tab switch,
|
||||
* sidebar click, wikilink), which owns the same content area.
|
||||
*/
|
||||
export function detachInlineEditor() {
|
||||
if (!isInlineEditorActive()) return;
|
||||
const modal = document.getElementById("editor-modal");
|
||||
stopCollab();
|
||||
if (state.editorView) {
|
||||
state.editorView.destroy();
|
||||
state.editorView = null;
|
||||
}
|
||||
state.fallbackEditorEl = null;
|
||||
clearTimeout(window._obsigateAutoSaveTimer);
|
||||
var forgeFrame = document.getElementById("forge-iframe");
|
||||
if (forgeFrame) forgeFrame.remove();
|
||||
if (modal) {
|
||||
modal.classList.remove("active");
|
||||
var brand = modal.querySelector('.editor-brand');
|
||||
if (brand) brand.textContent = 'ObsiGate';
|
||||
}
|
||||
unmountEditorInline();
|
||||
state.editorVault = null;
|
||||
state.editorPath = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File extension → Lucide icon mapping
|
||||
@@ -318,6 +379,11 @@ async function openEditor(vaultName, filePath) {
|
||||
if (footerEl) footerEl.style.display = "";
|
||||
if (container) container.style.maxHeight = "";
|
||||
|
||||
// #93 — Inline mode: when the edited file is the document currently read, the
|
||||
// editor container takes the place of the read view (no overlay covering the
|
||||
// document and the AI assistant panel).
|
||||
activateInlineEditor(vaultName, filePath);
|
||||
|
||||
filenameInput.value = filePath.split("/").pop();
|
||||
statFile.textContent = vaultName + "/" + filePath;
|
||||
|
||||
@@ -361,6 +427,12 @@ async function openEditor(vaultName, filePath) {
|
||||
// Auto-save: debounced save after 2s of inactivity
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
// Skip the autosave scheduled by a programmatic reload of the
|
||||
// document (external write by the AI assistant, #93).
|
||||
if (suppressAutoSaveOnce) {
|
||||
suppressAutoSaveOnce = false;
|
||||
return;
|
||||
}
|
||||
clearTimeout(window._obsigateAutoSaveTimer);
|
||||
window._obsigateAutoSaveTimer = setTimeout(() => saveFile(true), 2000);
|
||||
// Mark dirty
|
||||
@@ -506,26 +578,106 @@ function closeEditor() {
|
||||
const vault = state.editorVault;
|
||||
const path = state.editorPath;
|
||||
const modal = document.getElementById("editor-modal");
|
||||
if (!modal) return;
|
||||
modal.classList.remove("active");
|
||||
// Restore brand text
|
||||
var brand = modal.querySelector('.editor-brand');
|
||||
if (brand) brand.textContent = 'ObsiGate';
|
||||
// Restore header/footer (may be hidden by Forge)
|
||||
var headerEl = modal.querySelector(".editor-header");
|
||||
var footerEl = modal.querySelector(".editor-footer");
|
||||
if (headerEl) headerEl.style.display = "";
|
||||
if (footerEl) footerEl.style.display = "";
|
||||
stopCollab();
|
||||
if (state.editorView) {
|
||||
state.editorView.destroy();
|
||||
state.editorView = null;
|
||||
}
|
||||
state.fallbackEditorEl = null;
|
||||
// Remove forge iframe if present + refresh current file
|
||||
clearTimeout(window._obsigateAutoSaveTimer);
|
||||
// Remove forge iframe if present
|
||||
var forgeFrame = document.getElementById("forge-iframe");
|
||||
if (forgeFrame) {
|
||||
forgeFrame.remove();
|
||||
if (state.currentVault && state.currentPath) openFile(state.currentVault, state.currentPath);
|
||||
}
|
||||
if (forgeFrame) forgeFrame.remove();
|
||||
// #93 — give the editor container back to the modal; an inline session also
|
||||
// releases the content area, whose read view must be re-rendered.
|
||||
const host = unmountEditorInline();
|
||||
_invalidateActiveTabCache(vault, path);
|
||||
state.editorVault = null;
|
||||
state.editorPath = null;
|
||||
// Refresh file content if we're viewing the edited file
|
||||
if (vault && path && state.currentVault === vault && state.currentPath === path) {
|
||||
if (host) {
|
||||
const rereadVault = state.currentVault || vault;
|
||||
const rereadPath = state.currentPath || path;
|
||||
if (rereadVault && rereadPath) openFile(rereadVault, rereadPath);
|
||||
} else if (vault && path && state.currentVault === vault && state.currentPath === path) {
|
||||
// Refresh file content if we're viewing the edited file
|
||||
openFile(vault, path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached render of a file in the active tab/panel so that activating
|
||||
* it again re-fetches it from disk instead of restoring the pre-edit HTML.
|
||||
*/
|
||||
function _invalidateActiveTabCache(vault, path) {
|
||||
if (!vault || !path) return;
|
||||
try {
|
||||
const split = window.PaneManager && typeof window.PaneManager.isSplit === 'function' && window.PaneManager.isSplit();
|
||||
const tm = split && typeof window.getActiveTabManager === 'function'
|
||||
? window.getActiveTabManager()
|
||||
: window.TabManager;
|
||||
if (!tm || !tm._tabCache) return;
|
||||
const tab = (tm._tabs || []).find((tb) => tb.vault === vault && tb.path === path);
|
||||
const key = tab ? tab.id : `${vault}::${path}`;
|
||||
if (tm._tabCache[key]) tm._tabCache[key].data = null;
|
||||
} catch (err) {
|
||||
/* best effort — a stale cache only means a stale re-render */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the displayed document after an external write (AI assistant tool
|
||||
* `edit_file` / `append_to_file` / `create_file`, #93).
|
||||
*
|
||||
* Without it the outcome is wrong in two ways: the read view keeps showing the
|
||||
* pre-write content, and — worse — an open editor holds the old text in memory
|
||||
* and its 2s autosave would overwrite the assistant's change with it.
|
||||
*/
|
||||
async function reloadExternalWrite(vault, path) {
|
||||
if (!vault || !path) return;
|
||||
const isEdited = state.editorVault === vault && state.editorPath === path;
|
||||
if (isEdited) {
|
||||
// Forge hosts its own buffer inside an iframe: ask it to reload from disk.
|
||||
var forgeFrame = document.getElementById("forge-iframe");
|
||||
if (forgeFrame && forgeFrame.contentWindow) {
|
||||
forgeFrame.contentWindow.postMessage({ type: 'parent-reload' }, '*');
|
||||
return;
|
||||
}
|
||||
if (!state.editorView) return;
|
||||
try {
|
||||
const rawUrl = `/api/file/${encodeURIComponent(vault)}/raw?path=${encodeURIComponent(path)}`;
|
||||
const rawData = await api(rawUrl);
|
||||
const view = state.editorView;
|
||||
if (!view || !view.state) return;
|
||||
const raw = rawData && rawData.raw != null ? rawData.raw : "";
|
||||
if (view.state.doc.toString() === raw) return;
|
||||
clearTimeout(window._obsigateAutoSaveTimer);
|
||||
suppressAutoSaveOnce = true;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: raw },
|
||||
selection: { anchor: Math.min(view.state.selection.main.anchor, raw.length) },
|
||||
});
|
||||
var sd = document.getElementById("editor-save-dot");
|
||||
var sl = document.getElementById("editor-save-label");
|
||||
if (sd) sd.className = "editor-save-dot ok";
|
||||
if (sl) sl.textContent = "Saved";
|
||||
} catch (err) {
|
||||
console.warn("Inline editor: reload after external write failed", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Not editing: refresh the read view when it shows the written document.
|
||||
if (state.currentVault === vault && state.currentPath === path) {
|
||||
_invalidateActiveTabCache(vault, path);
|
||||
openFile(vault, path);
|
||||
}
|
||||
}
|
||||
@@ -685,6 +837,14 @@ function initEditor() {
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
// #93 — The AI assistant wrote to a vault file: reload the displayed
|
||||
// document (inline editor, Forge iframe or read view) so the change is
|
||||
// visible right away and never overwritten by the editor's autosave.
|
||||
window.addEventListener("obsigate:file-written", (e) => {
|
||||
const detail = (e && e.detail) || {};
|
||||
reloadExternalWrite(detail.vault, detail.path);
|
||||
});
|
||||
}
|
||||
|
||||
/** Copy text to clipboard — works on both HTTP and HTTPS */
|
||||
@@ -713,4 +873,19 @@ function copyToClipboard(text) {
|
||||
}
|
||||
}
|
||||
|
||||
export { getFileIcon, safeCreateIcons, flushIcons, safeHighlight, escapeHtml, EXT_ICONS, openEditor, closeEditor, saveFile, deleteFile, waitForCodeMirror, initEditor, copyToClipboard };
|
||||
export {
|
||||
getFileIcon,
|
||||
safeCreateIcons,
|
||||
flushIcons,
|
||||
safeHighlight,
|
||||
escapeHtml,
|
||||
EXT_ICONS,
|
||||
openEditor,
|
||||
closeEditor,
|
||||
reloadExternalWrite,
|
||||
saveFile,
|
||||
deleteFile,
|
||||
waitForCodeMirror,
|
||||
initEditor,
|
||||
copyToClipboard,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user