Show full documents in Editor

Remove heading-based Markdown pagination so source and preview operate on the complete file.

Add support for keyboard undo/redo ops.

Give plain text documents the same preview, formatting, save, and file-action toolbar as Markdown, with regression coverage for both behaviors.
This commit is contained in:
Alessandro 2026-07-17 03:42:27 +02:00
parent 77349bd9a5
commit dbdb77b057
5 changed files with 132 additions and 167 deletions

View file

@ -20,6 +20,7 @@
- Keep Editor Open wired through the File Browser text picker so users can open one or more Markdown or plain text files with an obvious confirmation action.
- Keep Download in the Editor file-actions menu and save dirty text before downloading it.
- Keep Save As distinct from Rename: Save As writes the current editor text to a chosen `.md` or `.txt` path and retargets the active session without removing the original file.
- Keep Markdown and plain text on the same toolbar, with full-document source and preview modes plus shared Undo/Redo buttons and keyboard shortcuts.
- Preserve source chat context ids when opening Markdown files from tool-result canvas handoffs.
## Work Guidance

View file

@ -8,7 +8,7 @@
<div x-data>
<template x-if="$store.editor">
<div class="editor-panel" x-create="$store.editor.onMount($el, xAttrs($el) || {})" x-destroy="$store.editor.cleanup()">
<div class="editor-shell" @keydown="$store.editor.handleEditorKeydown($event)">
<div class="editor-shell" @keydown.capture="$store.editor.handleEditorKeydown($event)">
<div class="editor-tabs" x-show="$store.editor.visibleTabs().length > 0" style="display: none;" role="tablist" aria-label="Open text files">
<template x-for="tab in $store.editor.visibleTabs()" :key="tab.tab_id">
<div
@ -82,7 +82,7 @@
<button
type="button"
class="editor-icon-button editor-mode-toggle"
x-show="$store.editor.isMarkdown()"
x-show="$store.editor.isTextDocument()"
style="display: none;"
:title="$store.editor.viewModeTitle()"
:aria-label="$store.editor.viewModeTitle()"
@ -91,44 +91,40 @@
<span class="material-symbols-outlined" aria-hidden="true" x-text="$store.editor.viewModeIcon()"></span>
</button>
<div class="editor-tool-group editor-source-tools" x-show="$store.editor.isTextDocument() && $store.editor.isSourceMode()" style="display: none;">
<button type="button" class="editor-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.editor.canUndo()" @click="$store.editor.undo()">
<div class="editor-tool-group editor-history-tools" x-show="$store.editor.isTextDocument()" style="display: none;">
<button type="button" class="editor-icon-button" title="Undo" aria-label="Undo" :disabled="$store.editor.previewEditing || !$store.editor.canUndo()" @click="$store.editor.undo()">
<span class="material-symbols-outlined">undo</span>
</button>
<button type="button" class="editor-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.editor.canRedo()" @click="$store.editor.redo()">
<button type="button" class="editor-icon-button" title="Redo" aria-label="Redo" :disabled="$store.editor.previewEditing || !$store.editor.canRedo()" @click="$store.editor.redo()">
<span class="material-symbols-outlined">redo</span>
</button>
<button type="button" class="editor-icon-button" title="Bold" aria-label="Bold" x-show="$store.editor.isMarkdown()" @click="$store.editor.format('bold')">
</div>
<div class="editor-tool-group editor-source-tools" x-show="$store.editor.isTextDocument() && $store.editor.isSourceMode()" style="display: none;">
<button type="button" class="editor-icon-button" title="Bold" aria-label="Bold" @click="$store.editor.format('bold')">
<span class="material-symbols-outlined">format_bold</span>
</button>
<button type="button" class="editor-icon-button" title="Italic" aria-label="Italic" x-show="$store.editor.isMarkdown()" @click="$store.editor.format('italic')">
<button type="button" class="editor-icon-button" title="Italic" aria-label="Italic" @click="$store.editor.format('italic')">
<span class="material-symbols-outlined">format_italic</span>
</button>
<button type="button" class="editor-icon-button" title="List" aria-label="List" x-show="$store.editor.isMarkdown()" @click="$store.editor.format('list')">
<button type="button" class="editor-icon-button" title="List" aria-label="List" @click="$store.editor.format('list')">
<span class="material-symbols-outlined">format_list_bulleted</span>
</button>
<button type="button" class="editor-icon-button" title="Numbered list" aria-label="Numbered list" x-show="$store.editor.isMarkdown()" @click="$store.editor.format('numbered')">
<button type="button" class="editor-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.editor.format('numbered')">
<span class="material-symbols-outlined">format_list_numbered</span>
</button>
<button type="button" class="editor-icon-button" title="Table" aria-label="Table" x-show="$store.editor.isMarkdown()" @click="$store.editor.format('table')">
<button type="button" class="editor-icon-button" title="Table" aria-label="Table" @click="$store.editor.format('table')">
<span class="material-symbols-outlined">table</span>
</button>
</div>
<div class="editor-tool-group editor-preview-tools" x-show="$store.editor.isMarkdown() && $store.editor.isPreviewMode()" style="display: none;">
<button type="button" class="editor-icon-button" title="Previous page" aria-label="Previous page" :disabled="$store.editor.previewEditing || $store.editor.activePageIndex <= 0" @click="$store.editor.previousPage()">
<span class="material-symbols-outlined">chevron_left</span>
</button>
<span class="editor-page-count" x-text="$store.editor.pagePositionLabel()"></span>
<button type="button" class="editor-icon-button" title="Next page" aria-label="Next page" :disabled="$store.editor.previewEditing || $store.editor.activePageIndex >= $store.editor.pages().length - 1" @click="$store.editor.nextPage()">
<span class="material-symbols-outlined">chevron_right</span>
</button>
<button type="button" class="editor-icon-button" title="Edit page" aria-label="Edit page" x-show="!$store.editor.previewEditing" @click="$store.editor.startPreviewEdit()">
<div class="editor-tool-group editor-preview-tools" x-show="$store.editor.isTextDocument() && $store.editor.isPreviewMode()" style="display: none;">
<button type="button" class="editor-icon-button" title="Edit document" aria-label="Edit document" x-show="!$store.editor.previewEditing" @click="$store.editor.startPreviewEdit()">
<span class="material-symbols-outlined">edit_note</span>
</button>
<button type="button" class="editor-icon-button is-primary" title="Apply page edit" aria-label="Apply page edit" x-show="$store.editor.previewEditing" @click="$store.editor.applyPreviewEdit()">
<button type="button" class="editor-icon-button is-primary" title="Apply document edit" aria-label="Apply document edit" x-show="$store.editor.previewEditing" @click="$store.editor.applyPreviewEdit()">
<span class="material-symbols-outlined">check</span>
</button>
<button type="button" class="editor-icon-button" title="Cancel page edit" aria-label="Cancel page edit" x-show="$store.editor.previewEditing" @click="$store.editor.cancelPreviewEdit()">
<button type="button" class="editor-icon-button" title="Cancel document edit" aria-label="Cancel document edit" x-show="$store.editor.previewEditing" @click="$store.editor.cancelPreviewEdit()">
<span class="material-symbols-outlined">close</span>
</button>
<button type="button" class="editor-icon-button" title="Search" aria-label="Search" :disabled="$store.editor.previewEditing" @click="$store.editor.openSearch()">
@ -241,7 +237,7 @@
</div>
</div>
<div class="editor-preview-shell" x-show="$store.editor.session && $store.editor.isMarkdown() && $store.editor.isPreviewMode()" style="display: none;">
<div class="editor-preview-shell" x-show="$store.editor.session && $store.editor.isTextDocument() && $store.editor.isPreviewMode()" style="display: none;">
<div class="editor-preview-title">
<h1 x-text="$store.editor.pageTitle()"></h1>
</div>
@ -249,7 +245,7 @@
<textarea
class="editor-preview-page-editor"
data-editor-preview-source
aria-label="Markdown page source"
aria-label="Document source"
x-model="$store.editor.previewEditText"
@input="$store.editor.onPreviewEditInput()"
@keydown.meta.enter.prevent="$store.editor.applyPreviewEdit()"
@ -263,7 +259,7 @@
data-editor-preview
x-show="!$store.editor.previewEditing"
x-html="$store.editor.previewHtml()"
x-effect="$store.editor.activePageIndex; $store.editor.editorText; $store.editor.searchQuery; $store.editor.searchIndex; $store.editor.schedulePreviewEnhance()"
x-effect="$store.editor.editorText; $store.editor.searchQuery; $store.editor.searchIndex; $store.editor.schedulePreviewEnhance()"
@click="$store.editor.handlePreviewClick($event)"
></div>
</div>
@ -597,7 +593,6 @@
min-width: 8px;
}
.editor-page-count,
.editor-search-count {
min-width: 54px;
color: var(--color-text-secondary);

View file

@ -1,6 +1,5 @@
import { renderSafeMarkdown } from "/js/safe-markdown.js";
const PAGE_HEADING_RE = /^(#{1,2})\s+(.+?)\s*#*\s*$/;
const FOOTNOTE_DEF_RE = /^\[\^([^\]]+)\]:\s*(.*)$/;
export function renderEditorPreviewMarkdown(markdown = "", fullMarkdown = markdown) {
@ -13,68 +12,15 @@ export function renderEditorPreviewMarkdown(markdown = "", fullMarkdown = markdo
export function buildMarkdownPages(markdown = "", fallbackTitle = "Markdown") {
const source = String(markdown || "");
const lines = source.split("\n");
const pages = [];
let current = null;
let intro = [];
let introStart = 0;
let fenced = false;
let offset = 0;
const startPage = (title, level, line, start) => {
if (current) {
current.end = start;
pages.push(finalizePage(current, pages.length, source));
}
current = {
title: cleanHeadingText(title) || fallbackTitle,
level,
lines: [line],
start,
end: source.length,
};
};
for (const [index, line] of lines.entries()) {
const lineStart = offset;
const lineEnd = lineStart + line.length + (index < lines.length - 1 ? 1 : 0);
if (/^\s*```/.test(line)) fenced = !fenced;
const match = !fenced ? line.match(PAGE_HEADING_RE) : null;
if (match) {
if (!current && intro.join("\n").trim()) {
pages.push(finalizePage({
title: fallbackTitle,
level: 0,
lines: intro,
start: introStart,
end: lineStart,
}, pages.length, source));
intro = [];
}
startPage(match[2], match[1].length, line, lineStart);
offset = lineEnd;
continue;
}
if (current) current.lines.push(line);
else intro.push(line);
offset = lineEnd;
}
if (current) {
current.end = source.length;
pages.push(finalizePage(current, pages.length, source));
}
else if (intro.join("\n").trim() || !pages.length) {
pages.push(finalizePage({
title: fallbackTitle,
level: 0,
lines: intro,
start: introStart,
end: source.length,
}, pages.length, source));
}
return pages;
return [{
index: 0,
title: fallbackTitle,
level: 0,
anchor: slugifyHeading(fallbackTitle),
start: 0,
end: source.length,
markdown: source,
}];
}
export function slugifyHeading(text = "", used = new Map()) {
@ -119,28 +65,6 @@ export function isMarkdownPath(path = "") {
return /\.md(?:own)?$/i.test(String(path || "").split(/[?#]/, 1)[0]);
}
function finalizePage(page, index, source = "") {
const start = Math.max(0, Number(page.start || 0));
const end = Math.max(start, Number(page.end ?? String(source || "").length));
const markdown = String(source || "").slice(start, end) || page.lines.join("\n");
return {
index,
title: page.title,
level: page.level,
anchor: slugifyHeading(page.title),
start,
end,
markdown,
};
}
function cleanHeadingText(text = "") {
return String(text || "")
.replace(/\\([\\`*_[\]{}()#+.!-])/g, "$1")
.replace(/[*_`~]/g, "")
.trim();
}
function prepareFootnotes(markdown = "", fullMarkdown = markdown) {
const definitions = [];
const body = [];

View file

@ -251,6 +251,7 @@ const model = {
_inputTimer: null,
_history: [],
_historyIndex: -1,
_historyPushedAt: 0,
_pendingFocus: false,
_pendingFocusEnd: true,
_focusAttempts: 0,
@ -336,7 +337,7 @@ const model = {
},
async setViewMode(mode) {
const next = mode === PREVIEW_MODE && this.isMarkdown() ? PREVIEW_MODE : SOURCE_MODE;
const next = mode === PREVIEW_MODE && this.isTextDocument() ? PREVIEW_MODE : SOURCE_MODE;
if (this.viewMode === next) return;
this.applyPreviewEdit({ silent: true });
this.syncEditorText();
@ -354,7 +355,7 @@ const model = {
},
async toggleViewMode() {
if (!this.isMarkdown()) return;
if (!this.isTextDocument()) return;
await this.setViewMode(this.isPreviewMode() ? SOURCE_MODE : PREVIEW_MODE);
},
@ -367,7 +368,7 @@ const model = {
},
pages() {
if (!this.isMarkdown()) return [];
if (!this.isTextDocument()) return [];
return buildMarkdownPages(this.editorText, this.tabTitle(this.session || {}));
},
@ -381,35 +382,13 @@ const model = {
return this.currentPage().title || this.tabTitle(this.session || {});
},
pagePositionLabel() {
const pages = this.pages();
if (!pages.length) return "";
return `${Math.min(this.activePageIndex + 1, pages.length)} of ${pages.length}`;
},
previewHtml() {
if (!this.isMarkdown()) return "";
if (!this.isTextDocument()) return "";
return renderEditorPreviewMarkdown(this.currentPage().markdown || "", this.editorText);
},
selectPage(index) {
if (this.previewEditing) return;
const pages = this.pages();
if (!pages.length) return;
this.activePageIndex = Math.max(0, Math.min(Number(index) || 0, pages.length - 1));
this.schedulePreviewEnhance();
},
nextPage() {
this.selectPage(this.activePageIndex + 1);
},
previousPage() {
this.selectPage(this.activePageIndex - 1);
},
startPreviewEdit() {
if (!this.session || !this.isMarkdown() || !this.isPreviewMode()) return;
if (!this.session || !this.isTextDocument() || !this.isPreviewMode()) return;
const page = this.currentPage();
this.previewEditing = true;
this.previewEditDirty = false;
@ -457,7 +436,7 @@ const model = {
this.previewEditPageIndex = -1;
return this.replacePageMarkdown(page, replacement, {
message: "Page updated",
message: "Document updated",
silent: options.silent,
});
},
@ -493,7 +472,7 @@ const model = {
},
togglePreviewTask(taskIndex, checked) {
if (!this.session || !this.isMarkdown() || !this.isPreviewMode() || this.previewEditing) return false;
if (!this.session || !this.isTextDocument() || !this.isPreviewMode() || this.previewEditing) return false;
const page = this.currentPage();
const lines = String(page.markdown || "").split("\n");
const indexes = taskLineIndexes(page.markdown || "");
@ -514,7 +493,7 @@ const model = {
},
schedulePreviewEnhance() {
if (!this.isMarkdown() || !this.isPreviewMode()) return;
if (!this.isTextDocument() || !this.isPreviewMode()) return;
if (this._previewEnhanceTimer) globalThis.clearTimeout(this._previewEnhanceTimer);
this._previewEnhanceTimer = globalThis.setTimeout(() => {
this._previewEnhanceTimer = null;
@ -707,7 +686,7 @@ const model = {
},
openSearch() {
if (!this.isMarkdown()) return;
if (!this.isTextDocument()) return;
if (!this.isPreviewMode()) {
this.setViewMode(PREVIEW_MODE);
}
@ -870,8 +849,19 @@ const model = {
},
handleEditorKeydown(event) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") {
if (!this.isMarkdown()) return;
if (!(event.metaKey || event.ctrlKey) || !this.isTextDocument()) return;
const key = event.key.toLowerCase();
const historyAction = key === "y" || (key === "z" && event.shiftKey) ? "redo" : key === "z" ? "undo" : "";
const nativeEditing = event.target?.matches?.("input, textarea, [contenteditable='true']")
&& !event.target.closest?.("[data-editor-ace], [data-editor-source]");
if (nativeEditing) return;
if (historyAction && !this.previewEditing) {
event.preventDefault();
event.stopPropagation();
this[historyAction]();
return;
}
if (key === "f") {
event.preventDefault();
this.openSearch();
}
@ -982,11 +972,6 @@ const model = {
this.activeTabId = tab?.tab_id || "";
this.editorText = String(tab?.text || "");
this.dirty = Boolean(tab?.dirty);
if (!this.isMarkdown(tab) && this.isPreviewMode()) {
this.viewMode = SOURCE_MODE;
this.searchOpen = false;
this.searchQuery = "";
}
if (this.previewEditing) this.cancelPreviewEdit();
if (!options.preservePage) {
this.activePageIndex = 0;
@ -1500,7 +1485,6 @@ const model = {
const wasActive = this.activeTabId === (previous?.tab_id || next.tab_id);
if (wasActive) {
this.session = next;
if (!this.isMarkdown(next) && this.isPreviewMode()) this.viewMode = SOURCE_MODE;
this.updateSourceEditorMode(next);
}
const index = this.tabs.findIndex((tab) => tab.tab_id === (previous?.tab_id || next.tab_id));
@ -1519,37 +1503,41 @@ const model = {
resetHistory(text) {
this._history = [String(text || "")];
this._historyIndex = 0;
this._historyPushedAt = 0;
},
pushHistory(text) {
pushHistory(text, coalesce = false) {
const value = String(text || "");
if (this._history[this._historyIndex] === value) return;
const now = Date.now();
if (
coalesce
&& this._historyPushedAt
&& now - this._historyPushedAt <= INPUT_PUSH_DELAY_MS
&& this._historyIndex === this._history.length - 1
&& this._historyIndex > 0
) {
this._history[this._historyIndex] = value;
this._historyPushedAt = now;
return;
}
this._history = this._history.slice(0, this._historyIndex + 1);
this._history.push(value);
if (this._history.length > MAX_HISTORY) this._history.shift();
this._historyIndex = this._history.length - 1;
this._historyPushedAt = coalesce ? now : 0;
},
undo() {
if (this.sourceEditor && this.isSourceMode()) {
this.sourceEditor.undo();
this.editorText = this.sourceEditor.getValue();
this.syncEditorText();
return;
}
if (this._historyIndex <= 0) return;
this._historyPushedAt = 0;
this._historyIndex -= 1;
this.applyEditorText(this._history[this._historyIndex], true);
},
redo() {
if (this.sourceEditor && this.isSourceMode()) {
this.sourceEditor.redo();
this.editorText = this.sourceEditor.getValue();
this.syncEditorText();
return;
}
if (this._historyIndex >= this._history.length - 1) return;
this._historyPushedAt = 0;
this._historyIndex += 1;
this.applyEditorText(this._history[this._historyIndex], true);
},
@ -1580,7 +1568,7 @@ const model = {
onSourceInput() {
this.markDirty();
this.pushHistory(this.editorText);
this.pushHistory(this.editorText, true);
this.scheduleInputPush();
},
@ -1613,7 +1601,7 @@ const model = {
},
format(command) {
if (!this.session || !this.isMarkdown()) return;
if (!this.session || !this.isTextDocument()) return;
if (this.sourceEditor && this.isSourceMode()) {
const selected = this.sourceEditor.getSelectedText();
const replacement = this.formatReplacement(command, selected);

View file

@ -156,6 +156,8 @@ def test_right_canvas_uses_desktop_surface_id_and_migrates_legacy_office_state()
assert 'input[type="checkbox"]' in editor_store
assert "renderEditorPreviewMarkdown" in editor_store
assert "buildMarkdownPages" in editor_store
assert "PAGE_HEADING_RE" not in editor_preview
assert "markdown: source" in editor_preview
assert "hydrateActiveSession" in editor_store
assert "refreshSourceEditorLayout" in editor_store
assert "editor.resize?.(true)" in editor_store
@ -170,7 +172,6 @@ def test_right_canvas_uses_desktop_surface_id_and_migrates_legacy_office_state()
assert "renderSafeMarkdown" in editor_preview
assert "prepareFootnotes" in editor_preview
assert "resolveDocumentRelativePath" in editor_preview
assert "slice(start, end)" in editor_preview
assert "allowDataImages: true" in editor_preview
assert "allowLatex: true" in editor_preview
assert "html = sanitizeHtml(html, options);" in safe_markdown
@ -564,6 +565,7 @@ def test_editor_toolbar_places_preview_toggle_left_and_save_on_right():
toolbar = editor_panel[toolbar_start:toolbar_end]
mode_toggle = toolbar.index("editor-mode-toggle")
history_tools = toolbar.index("editor-history-tools")
source_tools = toolbar.index("editor-source-tools")
preview_tools = toolbar.index("editor-preview-tools")
spacer = toolbar.index("editor-toolbar-spacer")
@ -572,8 +574,8 @@ def test_editor_toolbar_places_preview_toggle_left_and_save_on_right():
file_actions = toolbar.index("editor-file-actions")
file_menu = toolbar.index("editor-file-menu")
assert mode_toggle < source_tools
assert mode_toggle < preview_tools
assert mode_toggle < history_tools < source_tools
assert history_tools < preview_tools
assert spacer < save_button < save_as_button < file_actions < file_menu
assert "@click=\"$store.editor.save()\"" in toolbar
assert "@click=\"$store.editor.saveAs()\"" in toolbar
@ -589,6 +591,61 @@ def test_editor_toolbar_places_preview_toggle_left_and_save_on_right():
assert "<span>Close File</span>" in file_menu_markup
def test_editor_uses_full_document_preview_and_matching_markdown_text_tools():
editor_panel = read("plugins", "_editor", "webui", "editor-panel.html")
editor_store = read("plugins", "_editor", "webui", "editor-store.js")
assert "Previous page" not in editor_panel
assert "Next page" not in editor_panel
assert "editor-page-count" not in editor_panel
assert "pagePositionLabel" not in editor_store
assert "nextPage()" not in editor_store
assert "previousPage()" not in editor_store
assert 'x-show="$store.editor.isTextDocument()"' in editor_panel
assert '$store.editor.isTextDocument() && $store.editor.isPreviewMode()' in editor_panel
assert 'mode === PREVIEW_MODE && this.isTextDocument()' in editor_store
assert "if (!this.session || !this.isTextDocument()) return;" in editor_store
source_tools_start = editor_panel.index('class="editor-tool-group editor-source-tools"')
preview_tools_start = editor_panel.index('class="editor-tool-group editor-preview-tools"')
source_tools = editor_panel[source_tools_start:preview_tools_start]
for action in ("Bold", "Italic", "List", "Numbered list", "Table"):
assert f'title="{action}"' in source_tools
assert "isMarkdown()" not in source_tools
def test_editor_history_shortcuts_and_toolbar_controls_cover_markdown_and_text():
editor_panel = read("plugins", "_editor", "webui", "editor-panel.html")
editor_store = read("plugins", "_editor", "webui", "editor-store.js")
assert '@keydown.capture="$store.editor.handleEditorKeydown($event)"' in editor_panel
history_start = editor_panel.index('class="editor-tool-group editor-history-tools"')
source_start = editor_panel.index('class="editor-tool-group editor-source-tools"')
history_tools = editor_panel[history_start:source_start]
assert 'x-show="$store.editor.isTextDocument()"' in history_tools
assert 'title="Undo"' in history_tools
assert 'title="Redo"' in history_tools
assert "$store.editor.previewEditing || !$store.editor.canUndo()" in history_tools
assert "$store.editor.previewEditing || !$store.editor.canRedo()" in history_tools
keydown_start = editor_store.index("handleEditorKeydown(event)")
create_start = editor_store.index("\n async create", keydown_start)
keydown = editor_store[keydown_start:create_start]
assert 'key === "z"' in keydown
assert 'key === "y"' in keydown
assert "event.stopPropagation()" in keydown
assert "this[historyAction]()" in keydown
assert "nativeEditing" in keydown
undo_start = editor_store.index("\n undo()")
can_undo_start = editor_store.index("\n canUndo()", undo_start)
history_actions = editor_store[undo_start:can_undo_start]
assert "sourceEditor.undo" not in history_actions
assert "sourceEditor.redo" not in history_actions
assert "pushHistory(text, coalesce = false)" in editor_store
assert "this.pushHistory(this.editorText, true)" in editor_store
def test_desktop_text_open_with_routes_to_editor_surface():
desktop_session = read("plugins", "_desktop", "helpers", "desktop_session.py")
desktop_store = read("plugins", "_desktop", "webui", "desktop-store.js")