feat(editeur): #93 edition inline — Editer/Forge remplacent la vue lecture
CI / lint (push) Successful in 1m26s
CI / security (push) Successful in 1m0s
CI / test (push) Successful in 3m17s
CI / build (push) Successful in 56s
CI / e2e (push) Successful in 10m47s

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:
2026-09-15 23:40:10 -04:00
parent f630771150
commit e3df4bbf00
16 changed files with 920 additions and 33 deletions
+10
View File
@@ -1450,6 +1450,16 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
window.addEventListener('message', function(e) {
if (!e.data || !e.data.type) return;
if (e.data.type === 'parent-save') { forceSave(); }
// #93 — the parent reloads the document after an external write (AI
// assistant edit_file / append_to_file / create_file): re-read from disk
// and drop the stale local buffer that would otherwise be autosaved back
// over the assistant's change.
if (e.data.type === 'parent-reload') {
clearTimeout(saveTimer);
isDirty = false;
loadFile();
updateSaveDot();
}
if (e.data.type === 'parent-delete') {
if (!fileVault || !filePath) return;
if (!confirm('Supprimer definitivement ' + filePath + ' ?')) return;
+11 -3
View File
@@ -1211,7 +1211,13 @@
apercu rapide. Cliquez sur
<strong>Forge</strong> dans la
barre d'outils pour l'editeur
avance.</span
avance. <strong>Editer</strong> et
<strong>Forge</strong> remplacent
le document en mode lecture
(retour par ✓, ✕ ou
<strong>Echap</strong>) : le
panneau de l'assistant IA reste
accessible a cote.</span
>
</div>
</div>
@@ -1370,9 +1376,11 @@
</div>
</div>
<!-- Editor Modal -->
<!-- Editor Modal — the .editor-container is moved into the document
content area (inline mode, #93) when a file is being edited, so the
editor replaces the read view instead of overlaying it. -->
<div class="editor-modal" id="editor-modal">
<div class="editor-container">
<div class="editor-container" id="editor-container">
<div class="editor-header">
<span class="editor-brand" data-i18n="header.logo">ObsiGate</span>
<input
+45
View File
@@ -781,6 +781,12 @@ class BooksLM {
ctx.open_documents = docs.map((d) => ({ vault: d.vault, path: d.path }));
}
// #93 — Live edition session: the document currently replaced by the
// editor/Forge surface. Knowing it lets the assistant target "le document
// que je suis en train d'éditer" and update it in place.
const editing = this._editingDocument();
if (editing) ctx.editing = editing;
const query = state.advancedSearchLastQuery;
if (query) {
ctx.search_query = query;
@@ -803,6 +809,43 @@ class BooksLM {
return ctx;
}
/**
* #93 — The document currently being edited, i.e. the one the editor/Forge
* surface replaced the read view with, or null. Sent as `app_context.editing`
* so the assistant can target "le document que je suis en train d'éditer".
*/
_editingDocument() {
try {
const modal = document.getElementById('editor-modal');
if (!modal || !modal.classList.contains('active')) return null;
const vault = state.editorVault;
const path = state.editorPath;
if (!vault || !path) return null;
return { vault, path, surface: document.getElementById('forge-iframe') ? 'forge' : 'editor' };
} catch (e) {
return null;
}
}
/**
* #93 — A write tool just modified a vault file. Notify the UI so the
* displayed document is reloaded from disk: otherwise the read view keeps the
* stale content, and an open editor buffer autosaves the old text back over
* the assistant's change (see utils.reloadExternalWrite).
*/
_notifyFileWritten(data) {
if (!data || data.ok === false) return;
const WRITE_TOOLS = ['edit_file', 'append_to_file', 'create_file', 'restore_backup'];
if (WRITE_TOOLS.indexOf(data.name) === -1) return;
const args = data.arguments || {};
if (!args.vault || !args.path) return;
window.dispatchEvent(
new CustomEvent('obsigate:file-written', {
detail: { vault: args.vault, path: args.path, tool: data.name },
}),
);
}
// ── Rendering ───────────────────────────────────────────────────────
_render() {
@@ -2575,6 +2618,8 @@ class BooksLM {
step: data.step || null,
sources: Array.isArray(data.sources) ? data.sources : [],
});
// #93 — a vault write must be reflected in the displayed document.
this._notifyFileWritten(data);
this._setActivity('working', t('ai.activity_tool', { name: data.name }));
return;
}
+115
View File
@@ -0,0 +1,115 @@
/**
* ObsiGate — Inline editor host (ROADMAP #93)
*
* The edition surface (`#editor-container`, containing the CodeMirror editor or
* the Forge iframe) used to live inside the full-screen `#editor-modal` overlay,
* which covered both the document read view and the AI assistant sidebar.
*
* This module moves that container **into the content area of the document
* being read** so editing replaces the read view instead of overlaying it:
*
* `#content-area` (or the active pane `.pane-content` in split view)
* └── .editor-container.editor-inline ← the editor, replacing the document
*
* Keeping the same DOM nodes (same ids: `#editor-body`, `#editor-save`, …) means
* CodeMirror, autosave, Yjs collaboration, the AI toolbar and the Forge iframe
* keep working untouched — only their host moves.
*
* Classes:
* `.editor-inline` — set on the container while it hosts an inline session
* `.editor-inline-host` — set on the content area it replaces
* `#editor-modal.editor-inline-mode` — the overlay is neutralised (transparent,
* zero-size, no scroll) but keeps `active`, so the mobile
* ribbon anchored to it (`.me-ribbon`) still works.
*/
export const INLINE_CONTAINER_CLASS = 'editor-inline';
export const INLINE_HOST_CLASS = 'editor-inline-host';
export const INLINE_MODAL_CLASS = 'editor-inline-mode';
/** The editor overlay element (`#editor-modal` when present). */
export function getEditorModal() {
return document.getElementById('editor-modal');
}
/**
* The editor container, wherever it currently lives (modal or content area).
* Falls back to a class lookup so the helper also works on a DOM where the id
* was not set (tests, older markup).
*/
export function getEditorContainer() {
return (
document.getElementById('editor-container') ||
document.querySelector('.editor-container.' + INLINE_CONTAINER_CLASS)
);
}
/**
* Content area that should host the inline editor: the pane currently being
* read. Mirrors viewer.js `getContentArea()` (multi-pane aware).
*/
export function getInlineHostArea() {
if (typeof window !== 'undefined') {
if (window._activePaneContentArea) return window._activePaneContentArea;
if (
window.PaneManager &&
typeof window.PaneManager.isSplit === 'function' &&
window.PaneManager.isSplit() &&
typeof window.getActiveContentArea === 'function'
) {
return window.getActiveContentArea();
}
}
return document.getElementById('content-area');
}
/** True when an inline edition session currently owns a content area. */
export function isInlineEditorActive() {
const container = getEditorContainer();
return !!(container && container.classList.contains(INLINE_CONTAINER_CLASS));
}
/**
* Move the editor container into `area`, replacing the rendered document.
* @returns {boolean} true when the container was mounted inline.
*/
export function mountEditorInline(area) {
const container = getEditorContainer();
const modal = getEditorModal();
if (!container || !modal || !area) return false;
// Already hosted by this very area — nothing to move.
if (container.parentElement === area) {
container.classList.add(INLINE_CONTAINER_CLASS);
area.classList.add(INLINE_HOST_CLASS);
modal.classList.add(INLINE_MODAL_CLASS);
return true;
}
area.classList.add(INLINE_HOST_CLASS);
area.replaceChildren(container);
container.classList.add(INLINE_CONTAINER_CLASS);
modal.classList.add(INLINE_MODAL_CLASS);
return true;
}
/**
* Move the editor container back into the modal and clear the inline classes.
* The host area is emptied (its read view is gone) and returned so the caller
* can re-render the document.
* @returns {HTMLElement|null} the content area that must be re-rendered.
*/
export function unmountEditorInline() {
const container = getEditorContainer();
const modal = getEditorModal();
if (!container || !modal) return null;
const parent = container.parentElement;
const host = parent && parent !== modal ? parent : null;
container.classList.remove(INLINE_CONTAINER_CLASS);
if (host) {
host.classList.remove(INLINE_HOST_CLASS);
if (typeof host.replaceChildren === 'function') host.replaceChildren();
else host.innerHTML = '';
}
modal.classList.remove(INLINE_MODAL_CLASS);
modal.appendChild(container);
return host;
}
+4 -16
View File
@@ -6,6 +6,7 @@ import { api, AuthManager } from './auth.js';
import { loadVaults, loadTags, refreshTagsForContext, refreshSidebarTreePreservingState } from './sidebar.js';
import { loadRecentFiles, initAboutModal } from './config.js';
import { openFile } from './viewer.js';
import { closeEditor } from './utils.js';
import { initOffline, syncFileIndexFromServer } from './offline.js';
import { initThemes } from './themes.js';
@@ -636,22 +637,9 @@ export function init() {
}
}
if (e.data.type === 'forge-close') {
var modal = document.getElementById('editor-modal');
var forgeFrame = document.getElementById('forge-iframe');
if (forgeFrame) forgeFrame.remove();
if (modal) modal.classList.remove('active');
// Restore brand text
var brand = modal.querySelector('.editor-brand');
if (brand) brand.textContent = 'ObsiGate';
// Restore header/footer
var headerEl = modal.querySelector('.editor-header');
var footerEl = modal.querySelector('.editor-footer');
if (headerEl) headerEl.style.display = '';
if (footerEl) footerEl.style.display = '';
// Refresh current file content
if (state.currentVault && state.currentPath) {
import('./viewer.js').then(function(m) { m.openFile(state.currentVault, state.currentPath); });
}
// #93 — shared close path: hands the editor container back to the modal
// (inline mode) and re-renders the document read view.
closeEditor();
}
});
}
+183 -8
View File
@@ -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,
};
+18 -3
View File
@@ -1,7 +1,8 @@
/* ObsiGate — Viewer module */
import { api, AuthManager } from './auth.js';
import { state } from './state.js';
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard } from './utils.js';
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard, activateInlineEditor, detachInlineEditor } from './utils.js';
import { isInlineEditorActive } from './editor-inline.js';
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js';
import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js';
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
@@ -518,6 +519,11 @@ function applyPrettyHighlight(codeEl, lang, text) {
}
export function renderFile(data) {
// #93 — An inline edition session (#editor-container mounted in the content
// area) is destroyed by this very re-render: release it first so the editor
// state (CodeMirror view, Forge iframe, Yjs session) is torn down cleanly
// instead of being wiped mid-session by a tab switch / sidebar click.
if (isInlineEditorActive()) detachInlineEditor();
const area = getContentArea();
// Handle PDF files — render in iframe with TOC sidebar
@@ -749,11 +755,19 @@ export function renderFile(data) {
const forgeBtn = el("button", { class: "btn-action btn-forge", title: t("viewer.forge_title") }, [icon("hammer", 14), document.createTextNode("Forge")]);
forgeBtn.addEventListener("click", () => {
var modal = document.getElementById("editor-modal");
var container = modal.querySelector(".editor-container");
var bodyEl = document.getElementById("editor-body");
// Set editor state so parent buttons work
state.editorVault = data.vault;
state.editorPath = data.path;
// #93 — Forge replaces the document read view when this file is the one
// currently displayed; otherwise it falls back to the overlay.
var inline = activateInlineEditor(data.vault, data.path);
if (!inline) {
// A stale inline session (another document) must release the area first.
detachInlineEditor();
state.editorVault = data.vault;
state.editorPath = data.path;
}
// Update header: show "Forge" brand + filename
var brand = modal.querySelector(".editor-brand");
var titleInp = document.getElementById("editor-title-input");
@@ -766,7 +780,8 @@ export function renderFile(data) {
var iframe = document.createElement("iframe");
iframe.id = "forge-iframe";
iframe.src = "/editor-poc?vault=" + encodeURIComponent(data.vault) + "&path=" + encodeURIComponent(data.path) + "&_ts=" + Date.now();
iframe.style.cssText = "width:100%;height:82vh;border:none;display:block;";
// Inline: the iframe fills the content area; overlay: the original 82vh.
iframe.style.cssText = "width:100%;height:" + (inline ? "100%" : "82vh") + ";border:none;display:block;";
bodyEl.appendChild(iframe);
modal.classList.add("active");
safeCreateIcons();
+41
View File
@@ -2795,6 +2795,47 @@ select {
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
/* --- Inline edition (#93) --------------------------------------------------
When a document is being edited, `#editor-container` is moved into the
document content area: the editor *replaces* the read view instead of
covering it with the overlay, so the AI assistant sidebar stays usable.
The overlay itself is neutralised but kept mounted (and `active`) because
the mobile editing ribbon (`#me-toolbar`, `.me-ribbon`) is anchored to it. */
.editor-modal.editor-inline-mode {
display: block;
background: transparent;
padding: 0;
pointer-events: none;
}
.editor-modal.editor-inline-mode .me-ribbon {
pointer-events: auto;
}
.content-area.editor-inline-host,
.pane-content.editor-inline-host {
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.content-area.editor-inline-host .editor-container.editor-inline,
.pane-content.editor-inline-host .editor-container.editor-inline {
flex: 1;
height: auto;
width: 100%;
min-height: 0;
max-width: none;
max-height: none;
border: none;
border-radius: 0;
box-shadow: none;
background: var(--bg-primary);
}
.editor-container.editor-inline .editor-body,
.editor-container.editor-inline .editor-body-cm {
flex: 1;
min-height: 0;
}
.editor-header {
display: flex;
align-items: center;