`, raw HTML — the segment opens in
+raw mode instead. Source that cannot be faithfully round-tripped is never
+silently mangled.
+
+## Raw-mode toggle
+
+`Cmd+/` or the handle-menu "Edit as markdown" item flips the *current* edit
+session to the slim raw textarea. It is momentary: leaving the segment and
+clicking back in returns to WYSIWYG. It is a "show me the source" escape
+hatch, not a sticky per-segment mode. (Could be made sticky later.)
+
+## Floating toolbar
+
+- Floats just above the active segment; follows the user between segments;
+ hides when no segment is active.
+- Global visibility state, **starts hidden** every edit-mode session. Toggled
+ by `Cmd+Shift+F` or the handle-menu item.
+- Buttons: bold, italic, strikethrough, inline code, link, clear-formatting.
+ Each reflects whether the mark is active for the current selection.
+- The link button and `Cmd+K` open a small URL-input popover anchored to the
+ toolbar.
+
+## Keyboard shortcuts (active WYSIWYG segment)
+
+| Shortcut | Action |
+|---|---|
+| `Cmd+B` | Bold |
+| `Cmd+I` | Italic |
+| `Cmd+Shift+X` | Strikethrough |
+| `Cmd+E` | Inline code |
+| `Cmd+K` | Link (opens URL popover) |
+| `Cmd+/` | Toggle raw markdown for the active segment |
+| `Cmd+Shift+F` | Toggle the floating toolbar |
+| `Esc` | Commit and exit the segment (unchanged) |
+
+## Error handling and edge cases
+
+- Empty segment → serializes to an empty string, handled as today.
+- Selection spanning a partial mark → toggle unwraps only the selected run.
+- Switching segments mid-edit → commit the previous segment (existing pattern).
+- Unsupported inline content → segment auto-opens in raw mode (the
+ `canSerialize` guard).
+
+## Testing
+
+- **`inlineMarkdownSerializer`** — the bulk of the coverage, since it is pure:
+ every mark, nested marks, links, text escaping, and `canSerialize` rejecting
+ each unsupported node type. Round-trip tests:
+ `markdown → pluginManager.render → serializeInline → expect original`
+ (modulo normalization).
+- **`InlineEditor`** — mark toggle wraps/unwraps a selection correctly;
+ partial-selection unwrap; shortcuts dispatch the right command.
+- **`EditModeController`** — raw↔WYSIWYG toggle, toolbar toggle, and the commit
+ flow still updates `rawMarkdown` and fires `onContentChange`; a
+ `canSerialize` failure routes to raw mode.
+
+## Out of scope
+
+- Block-level editing (headings, lists, quotes, code blocks) — stays in the
+ existing "Turn into" handle menu.
+- General HTML→markdown conversion — only the five inline marks above.
+- Sticky per-segment raw mode — the toggle is momentary for now.
diff --git a/package.json b/package.json
index d79ed65..fbbc4b6 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "markdown-viewer",
"productName": "Open Markdown",
- "version": "1.3.3",
+ "version": "1.4.0",
"description": "A macOS desktop markdown viewer with GitHub-style rendering and plugin support",
"main": ".vite/build/index.js",
"private": true,
diff --git a/src/index.css b/src/index.css
index 1200c33..7edf5f8 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1702,15 +1702,14 @@ button:focus:not(:focus-visible) {
position: relative;
}
-/* Slice wrapper */
+/* Slice wrapper. Block layout (not flex) so the rendered content inside
+ * behaves identically to view mode — adjacent slices' margins collapse and
+ * each slice's vertical rhythm matches its un-wrapped counterpart. The handle
+ * is positioned absolutely in the left gutter so it doesn't consume flow. */
.slice {
position: relative;
- display: flex;
- align-items: stretch;
border-radius: 4px;
transition: background-color 0.15s ease;
- margin-left: -40px;
- padding-left: 0;
}
.slice:hover {
@@ -1721,21 +1720,38 @@ button:focus:not(:focus-visible) {
opacity: 1;
}
-/* Slice handle (left side, Notion-style grip) */
+/* Slice handle — floats in the negative-x gutter so it sits outside the
+ * content box. Vertical offset depends on the slice's block type because
+ * headings push their content down by margin-top; the per-type overrides
+ * below align the handle with the first line of text. */
.slice-handle {
- position: relative;
- flex-shrink: 0;
+ position: absolute;
+ left: -32px;
+ top: var(--slice-handle-top, -2px);
width: 32px;
display: flex;
align-items: flex-start;
justify-content: center;
- padding-top: 4px;
opacity: 0;
transition: opacity 0.15s ease;
user-select: none;
-webkit-user-select: none;
}
+/* Per-block-type vertical offsets so the 24px handle button centers on the
+ * first text line. Default (0px) targets paragraphs, lists, blockquotes —
+ * their content sits at slice-top with body line-height (~21px); button
+ * center at y=12 ≈ text center at y=10.5. Heading values combine each
+ * heading's margin-top (24px) with the heading's line-height / 2 minus the
+ * button's half-height — i.e. 24 + (line-height-px − 24) / 2. */
+.slice-h1 { --slice-handle-top: 30px; } /* 2em * 1.25 = 35px line, center y=41.5 */
+.slice-h2 { --slice-handle-top: 25px; } /* 1.5em * 1.25 = 26.25, center y=37 */
+.slice-h3 { --slice-handle-top: 23px; } /* 1.25em * 1.25 = 21.9, center y=35 */
+.slice-h4 { --slice-handle-top: 21px; } /* 1em * 1.25 = 17.5, center y=32.75 */
+.slice-h5 { --slice-handle-top: 20px; } /* 0.875em * 1.25 = 15.3, center y=31.65 */
+.slice-h6 { --slice-handle-top: 19px; } /* 0.85em * 1.25 = 14.9, center y=31.45 */
+.slice-code { --slice-handle-top: 13px; } /* padding-top 16 + 85% * 1.45 ≈ 17, center y=24.5 */
+
.slice-handle-btn {
display: flex;
align-items: center;
@@ -1760,37 +1776,47 @@ button:focus:not(:focus-visible) {
cursor: grabbing;
}
-/* Slice content area */
+/* Slice content area. Zero-padding and no margin resets so the inner
+ * markdown layout is byte-identical to view mode — headings keep their
+ * top/bottom margins, paragraphs their bottom margin, lists their padding,
+ * etc. The slice wrapper adds no spacing of its own. */
.slice-content {
- flex: 1;
- min-width: 0;
cursor: text;
border-radius: 4px;
- padding: 2px 8px;
transition: box-shadow 0.15s ease;
}
.slice.slice-editing .slice-content {
- box-shadow: 0 0 0 2px var(--link-color);
background-color: var(--bg-color);
}
-/* Reset margins on rendered content inside slices */
-.slice-content > :first-child {
- margin-top: 0;
+/* Editing affordance: a 2px bar in the gutter, 6px away from the text. Drawn
+ * via a pseudo-element instead of inset box-shadow so it sits outside the
+ * content box and never overlaps the caret when it lands at the line start. */
+/* Editing indicator: a straight 2px bar in the gutter, 6px from the text. */
+.slice.slice-editing .slice-content::before {
+ content: '';
+ position: absolute;
+ left: -8px;
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ background-color: var(--link-color);
+ pointer-events: none;
}
-.slice-content > :last-child {
- margin-bottom: 0;
+.slice-content[contenteditable='true'] {
+ outline: none;
+ cursor: text;
}
-/* Slice editor textarea */
-.slice-editor {
+/* Slim raw-markdown editor (used by the "Edit as markdown" toggle) */
+.slice-raw-editor {
width: 100%;
- min-height: 40px;
- padding: 8px;
+ min-height: 1.6em;
+ padding: 0;
border: none;
- border-radius: 4px;
+ border-radius: 0;
background: transparent;
color: var(--text-color);
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
@@ -1801,7 +1827,7 @@ button:focus:not(:focus-visible) {
overflow: hidden;
}
-.slice-editor:focus {
+.slice-raw-editor:focus {
outline: none;
}
@@ -1920,6 +1946,44 @@ button:focus:not(:focus-visible) {
flex-shrink: 0;
}
+/* Floating inline-format toolbar */
+.inline-format-toolbar {
+ display: inline-flex;
+ gap: 2px;
+ padding: 3px;
+ border-radius: 6px;
+ background: var(--bg-color);
+ border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ z-index: 200;
+}
+
+.inline-format-toolbar[hidden] {
+ display: none;
+}
+
+.inline-format-toolbar button {
+ min-width: 26px;
+ height: 26px;
+ padding: 0 6px;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--text-color);
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.inline-format-toolbar button:hover {
+ background: var(--hover-bg, rgba(0, 0, 0, 0.06));
+}
+
+.inline-format-toolbar button.is-active {
+ background: var(--link-color);
+ color: #fff;
+}
+
/* Edit mode badge in status bar */
.status-edit-mode {
display: flex;
@@ -1929,11 +1993,5 @@ button:focus:not(:focus-visible) {
font-weight: 500;
}
-/* Smooth transition between view and edit modes */
-.markdown-body {
- transition: padding-left 0.2s ease;
-}
-
-.markdown-body.edit-mode {
- padding-left: 8px;
-}
+/* Edit mode adds no padding of its own — the slice wrappers don't shift the
+ * content's horizontal position relative to view mode. */
diff --git a/src/renderer/components/EditModeController.ts b/src/renderer/components/EditModeController.ts
index f3633ac..be41f18 100644
--- a/src/renderer/components/EditModeController.ts
+++ b/src/renderer/components/EditModeController.ts
@@ -7,6 +7,10 @@
*/
import { MarkdownSlicer, type MarkdownSlice } from '../services/MarkdownSlicer';
import type { PluginManager } from '@plugins/core/PluginManager';
+import { InlineEditor } from './InlineEditor';
+import type { InlineMark } from './InlineEditor';
+import { FloatingFormatToolbar, type ToolbarAction } from './FloatingFormatToolbar';
+import { canSerialize } from '../services/inlineMarkdownSerializer';
/**
* Callbacks for EditModeController events
@@ -21,7 +25,9 @@ export interface EditModeCallbacks {
/**
* Actions available from the slice handle menu
*/
-export type SliceAction = 'delete' | 'duplicate' | 'move-up' | 'move-down' | 'add-above' | 'add-below';
+export type SliceAction =
+ | 'delete' | 'duplicate' | 'move-up' | 'move-down' | 'add-above' | 'add-below'
+ | 'edit-as-markdown' | 'toggle-toolbar';
/**
* Block types a slice can be converted to
@@ -39,6 +45,10 @@ export class EditModeController {
private rawMarkdown = '';
private callbacks: EditModeCallbacks = {};
private activeEditIndex: number | null = null;
+ private activeInlineEditor: InlineEditor | null = null;
+ private activeRawTextarea: HTMLTextAreaElement | null = null;
+ private toolbarVisible = false;
+ private toolbar: FloatingFormatToolbar | null = null;
private activeMenu: HTMLElement | null = null;
private sliceElements: Map = new Map();
@@ -63,6 +73,7 @@ export class EditModeController {
this.slices = this.slicer.slice(markdown);
await this.renderSlices();
document.addEventListener('click', this.handleDocumentClick);
+ document.addEventListener('keydown', this.onGlobalKeyDown);
}
/**
@@ -72,6 +83,10 @@ export class EditModeController {
this.commitActiveEdit();
this.closeMenu();
document.removeEventListener('click', this.handleDocumentClick);
+ document.removeEventListener('keydown', this.onGlobalKeyDown);
+ this.toolbar?.getElement().remove();
+ this.toolbar = null;
+ this.toolbarVisible = false;
this.sliceElements.clear();
this.activeEditIndex = null;
return this.rawMarkdown;
@@ -85,10 +100,12 @@ export class EditModeController {
}
/**
- * Render all slices into the container
+ * Synchronously rebuild the slice DOM. Post-render plugin hooks run after,
+ * so callers that need to interact with the DOM (e.g. startEdit on a newly
+ * inserted slice) can do so immediately and not wait for plugin async work.
*/
- private async renderSlices(): Promise {
- this.container.innerHTML = '';
+ private renderSlicesSync(): void {
+ this.container.replaceChildren();
this.container.classList.add('edit-mode');
this.sliceElements.clear();
@@ -97,8 +114,13 @@ export class EditModeController {
this.container.appendChild(el);
this.sliceElements.set(slice.index, el);
}
+ }
- // Run post-render for plugins (e.g., Mermaid diagrams)
+ /**
+ * Render all slices into the container, then run plugin post-render hooks.
+ */
+ private async renderSlices(): Promise {
+ this.renderSlicesSync();
await this.pluginManager.postRender(this.container);
}
@@ -153,37 +175,110 @@ export class EditModeController {
}
/**
- * Start inline editing of a slice
+ * Start inline editing of a slice using the WYSIWYG InlineEditor.
*/
private startEdit(sliceIndex: number): void {
- // Commit any previous edit
this.commitActiveEdit();
- const slice = this.slices.find(s => s.index === sliceIndex);
+ const slice = this.slices.find((s) => s.index === sliceIndex);
const el = this.sliceElements.get(sliceIndex);
if (!slice || !el) return;
+ const contentEl = el.querySelector('.slice-content');
+ if (!contentEl) return;
+
+ // Unsupported inline content is handled by the raw editor (Task 10).
+ if (!canSerialize(contentEl)) {
+ this.startRawEdit(sliceIndex);
+ return;
+ }
+
this.activeEditIndex = sliceIndex;
el.classList.add('slice-editing');
+ this.activeInlineEditor = new InlineEditor(contentEl, {
+ onCommit: (inlineMarkdown) => {
+ this.applyInlineCommit(sliceIndex, inlineMarkdown);
+ },
+ onRequestLink: () => {
+ if (this.activeInlineEditor) this.promptAndApplyLink(this.activeInlineEditor);
+ },
+ onSplit: (beforeMd, afterMd) => {
+ this.splitActiveSlice(sliceIndex, beforeMd, afterMd);
+ },
+ onNavigate: (direction) => {
+ this.navigateFromSlice(sliceIndex, direction);
+ },
+ });
+ this.activeInlineEditor.start();
+ if (this.toolbarVisible) {
+ this.getToolbar().show(contentEl);
+ this.refreshToolbarState();
+ }
+ }
+
+ /**
+ * Apply the markdown produced by an InlineEditor commit: re-attach the
+ * slice's block prefix, push it through the slicer, and re-render the slice.
+ */
+ private applyInlineCommit(sliceIndex: number, inlineMarkdown: string): void {
+ const slice = this.slices.find((s) => s.index === sliceIndex);
+ const el = this.sliceElements.get(sliceIndex);
+ if (!slice || !el) return;
+
+ const blockType = this.detectBlockType(slice.raw);
+ const newRaw = this.applyBlockPrefix(inlineMarkdown, blockType);
+
+ if (newRaw !== slice.raw) {
+ const result = this.slicer.updateSlice(this.slices, sliceIndex, newRaw);
+ this.rawMarkdown = result.markdown;
+ this.slices = result.slices;
+ this.callbacks.onContentChange?.(this.rawMarkdown);
+ }
+
+ el.classList.remove('slice-editing');
const contentEl = el.querySelector('.slice-content');
+ const updatedSlice = this.slices.find((s) => s.index === sliceIndex);
+ if (contentEl) {
+ const html = this.pluginManager.render(updatedSlice?.raw ?? slice.raw);
+ contentEl.replaceChildren();
+ contentEl.insertAdjacentHTML('afterbegin', html);
+ contentEl.addEventListener('click', (e) => {
+ if ((e.target as HTMLElement).closest('a')) return;
+ e.stopPropagation();
+ this.startEdit(sliceIndex);
+ });
+ void this.pluginManager.postRender(contentEl as HTMLElement);
+ }
+ }
+
+ /**
+ * Open a slice in the slim raw-markdown textarea. Used as the fallback for
+ * unsupported inline content and as the target of the Cmd+/ toggle.
+ */
+ private startRawEdit(sliceIndex: number): void {
+ this.commitActiveEdit();
+
+ const slice = this.slices.find((s) => s.index === sliceIndex);
+ const el = this.sliceElements.get(sliceIndex);
+ if (!slice || !el) return;
+
+ const contentEl = el.querySelector('.slice-content');
if (!contentEl) return;
- // Replace rendered HTML with textarea
+ this.activeEditIndex = sliceIndex;
+ el.classList.add('slice-editing');
+
const textarea = document.createElement('textarea');
- textarea.className = 'slice-editor';
+ textarea.className = 'slice-raw-editor';
textarea.value = slice.raw;
textarea.spellcheck = false;
- // Auto-resize
const resize = (): void => {
textarea.style.height = 'auto';
- textarea.style.height = textarea.scrollHeight + 'px';
+ textarea.style.height = `${textarea.scrollHeight}px`;
};
-
textarea.addEventListener('input', resize);
-
- // Handle keyboard shortcuts
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
@@ -191,63 +286,285 @@ export class EditModeController {
}
});
- contentEl.innerHTML = '';
- contentEl.appendChild(textarea);
-
- // Focus and resize
+ contentEl.replaceChildren(textarea);
+ this.activeRawTextarea = textarea;
textarea.focus();
resize();
}
/**
- * Commit the active edit and re-render the slice
+ * Commit a raw-textarea edit: the textarea value is the slice's markdown
+ * verbatim — no block-prefix reconciliation needed.
*/
- private commitActiveEdit(): void {
- if (this.activeEditIndex === null) return;
-
- const sliceIndex = this.activeEditIndex;
- // Clear immediately to prevent race conditions with async postRender
- this.activeEditIndex = null;
+ private commitRawEdit(sliceIndex: number): void {
+ const textarea = this.activeRawTextarea;
+ this.activeRawTextarea = null;
+ if (!textarea) return;
+ const slice = this.slices.find((s) => s.index === sliceIndex);
const el = this.sliceElements.get(sliceIndex);
- if (!el) return;
-
- const textarea = el.querySelector('.slice-editor');
- if (!textarea) return;
+ if (!slice || !el) return;
const newRaw = textarea.value;
- const slice = this.slices.find(s => s.index === sliceIndex);
-
- if (slice && newRaw !== slice.raw) {
- // Update the slice and recalculate
+ if (newRaw !== slice.raw) {
const result = this.slicer.updateSlice(this.slices, sliceIndex, newRaw);
this.rawMarkdown = result.markdown;
this.slices = result.slices;
-
- // Notify of content change
this.callbacks.onContentChange?.(this.rawMarkdown);
}
- // Re-render just this slice's content
el.classList.remove('slice-editing');
const contentEl = el.querySelector('.slice-content');
- if (contentEl && slice) {
- const updatedSlice = this.slices.find(s => s.index === sliceIndex);
+ const updatedSlice = this.slices.find((s) => s.index === sliceIndex);
+ if (contentEl) {
const html = this.pluginManager.render(updatedSlice?.raw ?? slice.raw);
- contentEl.innerHTML = html;
-
- // Re-attach click handler
+ contentEl.replaceChildren();
+ contentEl.insertAdjacentHTML('afterbegin', html);
contentEl.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('a')) return;
e.stopPropagation();
this.startEdit(sliceIndex);
});
-
- // Run post-render asynchronously (non-blocking)
void this.pluginManager.postRender(contentEl as HTMLElement);
}
}
+ /**
+ * Toggle the active slice between WYSIWYG and raw-markdown editing.
+ * Bound to Cmd+/ and the "Edit as markdown" handle-menu item.
+ */
+ toggleRawForActiveSlice(): void {
+ const sliceIndex = this.activeEditIndex;
+ if (sliceIndex === null) return;
+ const wasRaw = this.activeRawTextarea !== null;
+ this.commitActiveEdit();
+ if (wasRaw) {
+ this.startEdit(sliceIndex);
+ } else {
+ this.startRawEdit(sliceIndex);
+ }
+ }
+
+ /**
+ * Split the active slice at the caret. The InlineEditor produced before/after
+ * markdown; here we re-apply the slice's block prefix to the "before" half,
+ * insert a new paragraph slice with the "after" half, and open editing on it.
+ * Mirrors the Notion behaviour of Enter creating a new block below.
+ */
+ private splitActiveSlice(sliceIndex: number, beforeMd: string, afterMd: string): void {
+ const idx = this.slices.findIndex((s) => s.index === sliceIndex);
+ const slice = this.slices[idx];
+ const el = this.sliceElements.get(sliceIndex);
+ if (idx === -1 || !slice || !el) return;
+
+ // The InlineEditor has already torn down its session; clear our refs too.
+ this.activeEditIndex = null;
+ this.activeInlineEditor = null;
+ this.toolbar?.hide();
+ el.classList.remove('slice-editing');
+
+ // Enter at the start of a non-empty slice: insert an empty paragraph
+ // ABOVE without modifying the current slice. Without this branch, the
+ // generic split would rewrite the current slice's raw to '' (losing any
+ // block prefix like '#' for headings, '- ' for lists) and move the
+ // content into a new plain paragraph below.
+ if (beforeMd === '' && afterMd !== '') {
+ const empty: MarkdownSlice = {
+ index: Math.max(...this.slices.map((s) => s.index)) + 1,
+ type: 'paragraph',
+ raw: '',
+ startLine: slice.startLine,
+ endLine: slice.startLine,
+ };
+ this.slices.splice(idx, 0, empty);
+
+ this.rawMarkdown = this.slices.map((s) => s.raw).join('\n\n');
+ this.recomputeLineNumbers();
+ this.callbacks.onContentChange?.(this.rawMarkdown);
+
+ this.renderSlicesSync();
+ this.startEdit(slice.index);
+ void this.pluginManager.postRender(this.container);
+ return;
+ }
+
+ const blockType = this.detectBlockType(slice.raw);
+ slice.raw = this.applyBlockPrefix(beforeMd, blockType);
+
+ const newSlice: MarkdownSlice = {
+ index: Math.max(...this.slices.map((s) => s.index)) + 1,
+ type: 'paragraph',
+ raw: afterMd,
+ startLine: slice.endLine,
+ endLine: slice.endLine,
+ };
+ this.slices.splice(idx + 1, 0, newSlice);
+
+ // Skip slicer.slice() round-trip: an empty afterMd (Enter at end of slice)
+ // is a valid paragraph in our model but markdown can't express an empty
+ // paragraph, so slicer.slice() would collapse it away and startEdit would
+ // land on the next existing slice instead of the new empty one. We rebuild
+ // rawMarkdown with '\n\n' separators (paragraph break — single '\n' would
+ // soft-break) and recompute line numbers manually so reassemble's
+ // startLine-sort still works for later structural actions.
+ this.rawMarkdown = this.slices.map((s) => s.raw).join('\n\n');
+ this.recomputeLineNumbers();
+ this.callbacks.onContentChange?.(this.rawMarkdown);
+
+ this.renderSlicesSync();
+ this.startEdit(newSlice.index);
+ void this.pluginManager.postRender(this.container);
+ }
+
+ /**
+ * Cross-slice arrow navigation. Commits the current edit and opens the
+ * adjacent slice, placing the caret at the end (for 'up') or start (for
+ * 'down') so the cursor lands as close as possible to where it was visually.
+ */
+ private navigateFromSlice(sliceIndex: number, direction: 'up' | 'down'): void {
+ const idx = this.slices.findIndex((s) => s.index === sliceIndex);
+ const targetIdx = direction === 'up' ? idx - 1 : idx + 1;
+ const target = this.slices[targetIdx];
+ if (!target) return;
+
+ this.commitActiveEdit();
+ this.startEdit(target.index);
+ this.placeCaretInActiveSlice(direction === 'up' ? 'end' : 'start');
+ }
+
+ /** Place the caret at the start or end of the active slice's contenteditable. */
+ private placeCaretInActiveSlice(at: 'start' | 'end'): void {
+ if (this.activeEditIndex === null) return;
+ const el = this.sliceElements.get(this.activeEditIndex);
+ const contentEl = el?.querySelector('.slice-content');
+ if (!contentEl) return;
+ const range = document.createRange();
+ range.selectNodeContents(contentEl);
+ range.collapse(at === 'start');
+ const sel = window.getSelection();
+ if (sel) {
+ sel.removeAllRanges();
+ sel.addRange(range);
+ }
+ }
+
+ /**
+ * Walk this.slices in order and assign startLine/endLine values consistent
+ * with a '\n\n'-joined reassembly. Each slice occupies its own line range
+ * plus one blank-line separator before the next.
+ */
+ private recomputeLineNumbers(): void {
+ let line = 0;
+ for (const s of this.slices) {
+ s.startLine = line;
+ const lineCount = s.raw === '' ? 1 : s.raw.split('\n').length;
+ s.endLine = line + lineCount;
+ line = s.endLine + 1; // +1 for the blank separator between slices
+ }
+ }
+
+ /**
+ * Commit the active edit. The InlineEditor's onCommit callback does the
+ * markdown reconciliation; this just triggers it and clears local state.
+ */
+ private commitActiveEdit(): void {
+ if (this.activeEditIndex === null) return;
+ const sliceIndex = this.activeEditIndex;
+ this.activeEditIndex = null;
+
+ if (this.activeRawTextarea) {
+ this.commitRawEdit(sliceIndex);
+ return;
+ }
+ const editor = this.activeInlineEditor;
+ this.activeInlineEditor = null;
+ this.toolbar?.hide();
+ editor?.commit();
+ }
+
+ /** Test-only: deterministically commit the active edit. */
+ commitActiveEditForTest(): void {
+ this.commitActiveEdit();
+ }
+
+ private readonly onGlobalKeyDown = (e: KeyboardEvent): void => {
+ const mod = e.metaKey || e.ctrlKey;
+ if (!mod) return;
+ if (e.key === '/') {
+ e.preventDefault();
+ this.toggleRawForActiveSlice();
+ } else if (e.key.toLowerCase() === 'f' && e.shiftKey) {
+ e.preventDefault();
+ this.setToolbarVisible(!this.toolbarVisible);
+ }
+ };
+
+ /** Whether the floating toolbar is currently enabled (global state). */
+ isToolbarVisible(): boolean {
+ return this.toolbarVisible;
+ }
+
+ /** Enable/disable the floating toolbar. Reflects immediately for the active slice. */
+ setToolbarVisible(visible: boolean): void {
+ this.toolbarVisible = visible;
+ if (!visible) {
+ this.toolbar?.hide();
+ return;
+ }
+ if (this.activeEditIndex !== null && this.activeInlineEditor) {
+ const el = this.sliceElements.get(this.activeEditIndex);
+ const contentEl = el?.querySelector('.slice-content');
+ if (contentEl) {
+ this.getToolbar().show(contentEl);
+ this.refreshToolbarState();
+ }
+ }
+ }
+
+ private getToolbar(): FloatingFormatToolbar {
+ if (!this.toolbar) {
+ this.toolbar = new FloatingFormatToolbar({
+ onAction: (action) => this.handleToolbarAction(action),
+ });
+ this.container.appendChild(this.toolbar.getElement());
+ }
+ return this.toolbar;
+ }
+
+ private handleToolbarAction(action: ToolbarAction): void {
+ const editor = this.activeInlineEditor;
+ if (!editor) return;
+ if (action === 'link') {
+ this.promptAndApplyLink(editor);
+ return;
+ }
+ if (action === 'clear') {
+ (['bold', 'italic', 'strikethrough', 'code'] as InlineMark[]).forEach((m) => {
+ if (editor.isMarkActive(m)) editor.toggleMark(m);
+ });
+ this.refreshToolbarState();
+ return;
+ }
+ editor.toggleMark(action as InlineMark);
+ this.refreshToolbarState();
+ }
+
+ /** Prompt for a URL and apply it as a link on the editor's selection. */
+ private promptAndApplyLink(editor: InlineEditor): void {
+ const href = window.prompt('Link URL') ?? '';
+ editor.applyLink(href.trim());
+ }
+
+ private refreshToolbarState(): void {
+ if (!this.toolbar || !this.activeInlineEditor) return;
+ const editor = this.activeInlineEditor;
+ const active: ToolbarAction[] = [];
+ (['bold', 'italic', 'strikethrough', 'code'] as InlineMark[]).forEach((m) => {
+ if (editor.isMarkActive(m)) active.push(m);
+ });
+ this.toolbar.setActiveMarks(active);
+ }
+
/**
* Toggle the options menu for a slice handle
*/
@@ -329,6 +646,19 @@ export class EditModeController {
Add block below
+
+
+