Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/core/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Annotation, EditorSelection, EditorState } from "@codemirror/state";
// no onChange emission, no AST reparse for the onChange pipeline.
const silentDocChange = Annotation.define<boolean>();
import { EditorView, keymap, dropCursor, lineNumbers, type Direction } from "@codemirror/view";
import { indentWithTab, undo as cmUndo, redo as cmRedo } from "@codemirror/commands";
import { indentWithTab, undo as cmUndo, redo as cmRedo, isolateHistory } from "@codemirror/commands";
import { closeBrackets } from "@codemirror/autocomplete";
import type { Root } from "mdast";
import type { Heading } from "mdast";
Expand Down Expand Up @@ -879,13 +879,17 @@ export function createEditor(config: EditorConfig): EditorAPI {
replaceRange(from, to, insert, selection, opts) {
if (destroyed) return;
const silent = opts?.silent === true;
const annotations = [
...(silent ? [silentDocChange.of(true)] : []),
...(opts?.isolateHistory ? [isolateHistory.of("full")] : []),
];
view.dispatch({
changes: { from, to, insert },
selection: selection
? { anchor: selection.anchor, head: selection.head ?? selection.anchor }
: undefined,
scrollIntoView: true,
annotations: silent ? silentDocChange.of(true) : undefined,
annotations: annotations.length > 0 ? annotations : undefined,
});
if (silent) {
const next = view.state.doc.toString();
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,8 +417,13 @@ export interface EditorAPI {
* The AST is still resynced inline so `getAst()` stays consistent for
* immediate callers. Intended for non-user edits only (file-open, seeding).
* Plugin code should leave `silent` unset.
* - Positions (`from`, `to`, and `selection` offsets) are in pre-edit doc
* coordinates — the same coordinate space as `getSelection()` returns.
* - `isolateHistory` prevents this edit from being merged with adjacent history
* events. Toolbar commands use it so consecutive commands can be undone
* independently; when omitted, it preserves the default CodeMirror grouping.
*
* - `from` and `to` are pre-edit doc coordinates. `selection` offsets are
* post-edit coordinates, matching CodeMirror's transaction contract and
* the final selection returned by `getSelection()`.
* - Bounds: callers are responsible for valid offsets. CM6 throws
* `RangeError` on out-of-bounds values — identical trust model to
* `setSelection`. No double validation is performed in this layer.
Expand All @@ -431,7 +436,7 @@ export interface EditorAPI {
to: number,
insert: string,
selection?: { anchor: number; head?: number },
opts?: { silent?: boolean }
opts?: { silent?: boolean; isolateHistory?: boolean }
): void;
undo(): boolean;
redo(): boolean;
Expand Down
16 changes: 16 additions & 0 deletions packages/core/test/editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,22 @@ describe("createEditor", () => {
editor.destroy();
});

it("replaceRange: isolateHistory keeps adjacent edits undoable separately", () => {
const container = document.createElement("div");
const editor = createEditor({ container, initialValue: "hello world", plugins: [createHistoryPlugin()] });

editor.replaceRange(6, 11, "earth", undefined, { isolateHistory: true });
editor.replaceRange(6, 11, "mars", undefined, { isolateHistory: true });

expect(editor.getDocument()).toBe("hello mars");
expect(editor.undo()).toBe(true);
expect(editor.getDocument()).toBe("hello earth");
expect(editor.undo()).toBe(true);
expect(editor.getDocument()).toBe("hello world");
expect(editor.undo()).toBe(false);
editor.destroy();
});

// ── HTML export ──

it("exports markdown to semantic HTML", () => {
Expand Down
30 changes: 9 additions & 21 deletions packages/plugin-toolbar/src/formatting.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { EditorAPI } from "@floatboat/nexus-core";

const TOOLBAR_REPLACE_OPTIONS = { isolateHistory: true } as const;

interface LineRange {
lineStart: number;
lineEnd: number;
Expand Down Expand Up @@ -55,7 +57,7 @@ function applyLines(editor: EditorAPI, lines: LineRange[], newLines: string[]):
const selection = lines.length === 1
? { anchor: first.lineStart + newLines[0].length }
: { anchor: first.lineStart, head: first.lineStart + newBlock.length };
editor.replaceRange(first.lineStart, last.lineEnd, newBlock, selection);
editor.replaceRange(first.lineStart, last.lineEnd, newBlock, selection, TOOLBAR_REPLACE_OPTIONS);
return true;
}

Expand All @@ -69,9 +71,7 @@ function toggleLinePrefix(editor: EditorAPI, prefix: string): boolean {
const line = doc.slice(lineStart, lineEnd);

const newLine = line.startsWith(prefix) ? line.slice(prefix.length) : prefix + line;
const newDoc = doc.slice(0, lineStart) + newLine + doc.slice(lineEnd);
editor.setDocument(newDoc);
editor.setSelection(lineStart + newLine.length);
editor.replaceRange(lineStart, lineEnd, newLine, { anchor: lineStart + newLine.length }, TOOLBAR_REPLACE_OPTIONS);
return true;
}

Expand Down Expand Up @@ -135,12 +135,9 @@ export function insertCodeBlock(editor: EditorAPI): boolean {
"```\n" + (selected || "") + "\n```" +
(needsTrailingNewline ? "\n" : "");

const newDoc = doc.slice(0, from) + block + doc.slice(to);
editor.setDocument(newDoc);

// Place cursor on the language line (after ```)
const langPos = from + (needsLeadingNewline ? 1 : 0) + 3;
editor.setSelection(langPos);
editor.replaceRange(from, to, block, { anchor: langPos }, TOOLBAR_REPLACE_OPTIONS);
return true;
}

Expand All @@ -153,12 +150,9 @@ export function insertImage(editor: EditorAPI): boolean {

const alt = selected || "alt text";
const md = `![${alt}](url)`;
const newDoc = doc.slice(0, from) + md + doc.slice(to);
editor.setDocument(newDoc);

// Select the "url" part
const urlStart = from + alt.length + 4;
editor.setSelection(urlStart, urlStart + 3);
editor.replaceRange(from, to, md, { anchor: urlStart, head: urlStart + 3 }, TOOLBAR_REPLACE_OPTIONS);
return true;
}

Expand All @@ -172,10 +166,8 @@ export function applyTextColor(editor: EditorAPI, color: string): boolean {
if (from === to) return false;

const wrapped = `<span style="color:${color}">${selected}</span>`;
const newDoc = doc.slice(0, from) + wrapped + doc.slice(to);
editor.setDocument(newDoc);
const innerStart = from + `<span style="color:${color}">`.length;
editor.setSelection(innerStart, innerStart + selected.length);
editor.replaceRange(from, to, wrapped, { anchor: innerStart, head: innerStart + selected.length }, TOOLBAR_REPLACE_OPTIONS);
return true;
}

Expand All @@ -189,10 +181,8 @@ export function applyHighlight(editor: EditorAPI, color: string): boolean {
if (from === to) return false;

const wrapped = `<mark style="background:${color}">${selected}</mark>`;
const newDoc = doc.slice(0, from) + wrapped + doc.slice(to);
editor.setDocument(newDoc);
const innerStart = from + `<mark style="background:${color}">`.length;
editor.setSelection(innerStart, innerStart + selected.length);
editor.replaceRange(from, to, wrapped, { anchor: innerStart, head: innerStart + selected.length }, TOOLBAR_REPLACE_OPTIONS);
return true;
}

Expand All @@ -203,8 +193,6 @@ export function insertHorizontalRule(editor: EditorAPI): boolean {
const needsLeadingNewline = anchor > 0 && doc[anchor - 1] !== "\n";
const hr = (needsLeadingNewline ? "\n" : "") + "---\n";

const newDoc = doc.slice(0, anchor) + hr + doc.slice(anchor);
editor.setDocument(newDoc);
editor.setSelection(anchor + hr.length);
editor.replaceRange(anchor, anchor, hr, { anchor: anchor + hr.length }, TOOLBAR_REPLACE_OPTIONS);
return true;
}
26 changes: 17 additions & 9 deletions packages/plugin-toolbar/src/toolbar-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
toggleUnorderedList,
} from "./formatting";

const TOOLBAR_REPLACE_OPTIONS = { isolateHistory: true } as const;

export function toggleWrap(editor: EditorAPI, marker: string): boolean {
const doc = editor.getDocument();
const { anchor, head } = editor.getSelection();
Expand All @@ -19,15 +21,23 @@ export function toggleWrap(editor: EditorAPI, marker: string): boolean {
const after = doc.slice(to, to + marker.length);

if (before === marker && after === marker) {
editor.setDocument(
doc.slice(0, from - marker.length) + selected + doc.slice(to + marker.length),
editor.replaceRange(
from - marker.length,
to + marker.length,
selected,
{ anchor: from - marker.length, head: to - marker.length },
TOOLBAR_REPLACE_OPTIONS,
);
editor.setSelection(from - marker.length, to - marker.length);
return true;
}

editor.setDocument(doc.slice(0, from) + marker + selected + marker + doc.slice(to));
editor.setSelection(from + marker.length, to + marker.length);
editor.replaceRange(
from,
to,
marker + selected + marker,
{ anchor: from + marker.length, head: to + marker.length },
TOOLBAR_REPLACE_OPTIONS,
);
return true;
}

Expand Down Expand Up @@ -56,9 +66,8 @@ export function insertLink(editor: EditorAPI): boolean {
const linkText = selected || "link text";
const markdown = `[${linkText}](url)`;

editor.setDocument(doc.slice(0, from) + markdown + doc.slice(to));
const urlStart = from + linkText.length + 3;
editor.setSelection(urlStart, urlStart + 3);
editor.replaceRange(from, to, markdown, { anchor: urlStart, head: urlStart + 3 }, TOOLBAR_REPLACE_OPTIONS);
return true;
}

Expand All @@ -75,8 +84,7 @@ export function toggleHeading(editor: EditorAPI, level: number): boolean {
? line.slice(prefix.length)
: prefix + (headingMatch ? line.slice(headingMatch[0].length) : line);

editor.setDocument(doc.slice(0, lineStart) + newLine + doc.slice(end));
editor.setSelection(lineStart + newLine.length);
editor.replaceRange(lineStart, end, newLine, { anchor: lineStart + newLine.length }, TOOLBAR_REPLACE_OPTIONS);
return true;
}

Expand Down
5 changes: 3 additions & 2 deletions packages/plugin-toolbar/src/toolbar-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
iconFullscreen,
} from "./icons";

const TOOLBAR_REPLACE_OPTIONS = { isolateHistory: true } as const;

export interface ToolbarButton {
id: string;
title: string;
Expand Down Expand Up @@ -303,8 +305,7 @@ function showHeadingDropdown(
const m = line.match(/^#{1,6}\s/);
if (m) {
const newLine = line.slice(m[0].length);
editor.setDocument(doc.slice(0, lineStart) + newLine + doc.slice(lineEnd));
editor.setSelection(lineStart + newLine.length);
editor.replaceRange(lineStart, lineEnd, newLine, { anchor: lineStart + newLine.length }, TOOLBAR_REPLACE_OPTIONS);
}
}
editor.focus();
Expand Down
Loading