This commit is contained in:
pj committed 2026-08-28 09:22:58 +05:30
commit 852cba1840
252 files changed
+55050

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
// The window, and the only file that knows what the whole app looks like at once.
//
// Everything here is wiring: which surface is on screen, which of the backend's events the shell
// listens for, and what happens to an unsaved document when the window is asked to close. No
// business logic and no disk access. The stores hold state, their sibling modules do the work, and
// this file decides what is mounted.
//
// One document at a time and no tab bar, so there is exactly one editor in the tree and it is
// either the WYSIWYG surface or the plain text one, never both. A folder of documents can be open
// with nothing chosen out of it, which is why the empty pane is a state and not an error.
import { useEffect, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Backlinks } from "./components/Backlinks";
import { CommandPalette } from "./components/CommandPalette";
import { ConflictDialog } from "./components/ConflictDialog";
import { FindBar } from "./components/FindBar";
import { FindInFiles } from "./components/FindInFiles";
import { ProofPopover } from "./components/ProofPopover";
import { QuickOpen } from "./components/QuickOpen";
import { Recents } from "./components/Recents";
import { Shortcuts } from "./components/Shortcuts";
import { Sidebar } from "./components/Sidebar";
import { Titlebar } from "./components/Titlebar";
import { Toast } from "./components/Toast";
import { flushPendingSave, keepBuffer } from "./document";
import { DocumentEditor, PlainTextEditor, useDocumentFind } from "./editor";
import { Toolbar, type ToolbarSaveState } from "./editor/Toolbar";
import { MENU_ACTION_EVENT, isDesktop, isTauri } from "./ipc";
import { onCommand, type CommandId } from "./keys/commands";
import { useKeymap } from "./keys/keymap";
import { handleMenuAction } from "./keys/menu";
import { openLink } from "./links";
import { documentKindForPath } from "./model/doc";
import { useDocument } from "./store/useDocument";
import { notify } from "./store/useToast";
import { useWorkspace } from "./store/useWorkspace";
import { useCompact, useTouch } from "./useMedia";
import { applyWidth } from "./width";
import { restoreSession, startWorkspaceEvents } from "./workspace";
/**
* Commands whose whole result is a panel that this milestone does not have. They are bound keys
* and native menu rows already, so pressing one has to say something: a key that silently does
* nothing reads as a broken app rather than as an unfinished one. Each line goes when its panel
* arrives.
*/
const UNBUILT: ReadonlyArray<[CommandId, string]> = [
["settings", "There is no settings panel yet. The theme is in the title bar."],
];
const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
function App() {
const roots = useWorkspace((s) => s.roots);
const path = useDocument((s) => s.path);
const openDocument = useDocument((s) => s.document);
const savePhase = useDocument((s) => s.savePhase);
const externalChange = useDocument((s) => s.externalChange);
const setContent = useDocument((s) => s.setContent);
const reloadFromDisk = useDocument((s) => s.reloadFromDisk);
const find = useDocumentFind();
const [resolving, setResolving] = useState(false);
useKeymap();
useCompact();
useTouch();
useEffect(() => {
void restoreSession();
}, []);
// `watch-event` and `index-progress`, both of them landing in the stores that care. The routing
// itself belongs to src/workspace.ts, which is the module that already knows which root a path
// sits under and whether the open document was the file that moved.
useEffect(() => startWorkspaceEvents(), []);
// The native menu emits a command id, so this is a lookup and not a second dispatch table. A
// phone has a menu bar to emit from too, hence isTauri rather than isDesktop.
useEffect(() => {
if (!isTauri) return;
const pending = listen<string>(MENU_ACTION_EVENT, (event) => handleMenuAction(event.payload));
return () => {
void pending.then((stop) => stop()).catch(() => {});
};
}, []);
// Quitting with an edit half a second old must not lose it. The debounce is cancelled and the
// save run to completion before the window is allowed to go, and the close is only intercepted
// when there is actually something to write.
useEffect(() => {
if (!isDesktop) return;
const win = getCurrentWindow();
const pending = win.onCloseRequested(async (event) => {
if (!useDocument.getState().dirty) return;
event.preventDefault();
await flushPendingSave();
void win.destroy();
});
return () => {
void pending.then((stop) => stop()).catch(() => {});
};
}, []);
useEffect(() => {
const stops = [
onCommand("editor-width-narrow", () => applyWidth("narrow")),
onCommand("editor-width-normal", () => applyWidth("normal")),
onCommand("editor-width-wide", () => applyWidth("wide")),
...UNBUILT.map(([id, message]) => onCommand(id, () => notify(message))),
];
return () => {
for (const stop of stops) stop();
};
}, []);
const conflict = externalChange === "changed-on-disk";
// Asked once, when the conflict appears. Dismissing it leaves the warning in the toolbar to
// reopen rather than asking again on the next keystroke.
useEffect(() => {
if (conflict) setResolving(true);
}, [conflict]);
const kind = path === null ? null : documentKindForPath(path);
const saveState: ToolbarSaveState = conflict
? "conflict"
: savePhase === "saving"
? "saving"
: "idle";
return (
<div className="app">
<Titlebar />
<div className="stage">
{roots.length === 0 ? (
<Recents />
) : (
<>
<Sidebar />
<main className="editor-pane">
{openDocument === null || kind === null ? (
<p className="pane-empty">Choose a document from the sidebar.</p>
) : kind === "markdown" ? (
<>
<article className="sheet">
<DocumentEditor
document={openDocument}
onChange={setContent}
onOpenLink={openLink}
editable={!resolving}
/>
<Backlinks />
</article>
<Toolbar
document={openDocument}
saveState={saveState}
onResolveConflict={() => setResolving(true)}
/>
</>
) : (
<article className="sheet">
<PlainTextEditor
document={openDocument}
onChange={setContent}
editable={!resolving}
/>
</article>
)}
</main>
</>
)}
</div>
<FindBar find={find} />
<QuickOpen />
<FindInFiles />
<CommandPalette />
<ProofPopover />
<Shortcuts />
<Toast />
{resolving && conflict && path !== null && (
<ConflictDialog
name={baseName(path)}
onReload={() => {
setResolving(false);
reloadFromDisk().catch((e) => notify(`Could not reload: ${String(e)}`));
}}
onKeep={() => {
setResolving(false);
keepBuffer();
}}
onDismiss={() => setResolving(false)}
/>
)}
</div>
);
}
export default App;
+36
View File
@@ -0,0 +1,36 @@
import { call, type AssetResult, type FileNode, type ReadResult, type WriteResult } from "../ipc";
export const fileRead = (path: string) => call<ReadResult>("file_read", { path });
/**
* Writes through a temporary file and a rename, so a crash mid-write leaves the old document
* whole. Pass the `modifiedMs` the buffer was read at: if the file has moved on since, nothing is
* written and the result comes back with `conflict`.
*/
export const fileWrite = (path: string, text: string, expectedModifiedMs?: number) =>
call<WriteResult>("file_write", { path, text, expectedModifiedMs });
/** `name` is a suggestion. A taken name gets a suffix, and the node returned carries the real one. */
export const fileCreate = (parentPath: string, name: string) =>
call<FileNode>("file_create", { parentPath, name });
export const fileFolderCreate = (parentPath: string, name: string) =>
call<FileNode>("file_folder_create", { parentPath, name });
export const fileRename = (path: string, name: string) =>
call<FileNode>("file_rename", { path, name });
export const fileMove = (path: string, destDir: string) =>
call<FileNode>("file_move", { path, destDir });
export const fileDuplicate = (path: string) => call<FileNode>("file_duplicate", { path });
/** To the system Trash, never an unlink. Deleting a document is undoable in Finder. */
export const fileTrash = (path: string) => call<void>("file_trash", { path });
/**
* A pasted image, into an `assets/` folder beside the document. `bytes` is the clipboard payload
* and `name` the filename it suggested, which is usually `image.png` and usually already taken.
*/
export const assetWrite = (docPath: string, bytes: number[], name: string) =>
call<AssetResult>("asset_write", { docPath, bytes, name });
+16
View File
@@ -0,0 +1,16 @@
import { call, type Backlink, type IndexStatus, type QuickOpenHit, type SearchHit } from "../ipc";
/** Rescans every open root. Progress arrives on the `index-progress` event. */
export const indexRebuild = () => call<IndexStatus>("index_rebuild");
export const indexStatus = () => call<IndexStatus>("index_status");
/** Fuzzy match over paths relative to their root, across every open root. */
export const searchQuickOpen = (query: string, limit: number) =>
call<QuickOpenHit[]>("search_quick_open", { query, limit });
export const searchText = (query: string, limit: number) =>
call<SearchHit[]>("search_text", { query, limit });
/** Which documents link to this one, for the section at the end of the document. */
export const backlinksFor = (path: string) => call<Backlink[]>("backlinks_for", { path });
+16
View File
@@ -0,0 +1,16 @@
import { call, type FileNode, type RootInfo } from "../ipc";
export const rootsList = () => call<RootInfo[]>("roots_list");
/** `path` comes from the native folder picker. Opening a folder never writes anything into it. */
export const rootOpen = (path: string) => call<RootInfo>("root_open", { path });
export const rootClose = (rootId: string) => call<void>("root_close", { rootId });
/** The whole tree for one root, root node included. */
export const treeRead = (rootId: string) => call<FileNode>("tree_read", { rootId });
export const revealInFinder = (path: string) => call<void>("reveal_in_finder", { path });
/** Hands a file to whatever macOS opens it with. The only way to open a non-editable file. */
export const openExternal = (path: string) => call<void>("open_external", { path });
+35
View File
@@ -0,0 +1,35 @@
// Spelling, which is the system's and not this app's.
//
// Everything here goes to NSSpellChecker, the same checker Mail and Notes correct into, so a word
// learned anywhere on the machine is a word this editor does not underline and the user's own
// languages are already configured. Nothing in this app ships a dictionary or has an opinion about
// English.
//
// The checker is asked about a run of text and answers about that run. It has no idea a document
// exists, which is what keeps the caller free to send it a paragraph, a visible screenful or one
// sentence, and to decide for itself what a stale answer is worth.
import { call, type SpellIssue } from "../ipc";
/**
* Every misspelling in one run of text, with offsets in characters counted from the start of that
* run. The caller adds its own base offset; this never sees a document position.
*/
export const spellCheck = (text: string) => call<SpellIssue[]>("spell_check", { text });
/**
* Teaches the word to the system, for every app on the machine and not only this one. That is the
* honest behaviour for a checker borrowed from the OS, and it is what the "Learn Spelling" item in
* every other Mac app does.
*/
export const spellLearn = (word: string) => call<void>("spell_learn", { word });
/** Undoes a `spellLearn`, for a word taught by a slip of the hand. */
export const spellUnlearn = (word: string) => call<void>("spell_unlearn", { word });
/**
* Whether the machine has a checker at all. False on a build that is not macOS, where the answer
* to every check is an empty list rather than an error, and the UI hides itself rather than
* offering a menu that cannot do anything.
*/
export const spellAvailable = () => call<boolean>("spell_available");
+6
View File
@@ -0,0 +1,6 @@
import { call } from "../ipc";
/** Changes arrive on the `watch-event` event, never as a return value. */
export const watchStart = (rootId: string) => call<void>("watch_start", { rootId });
export const watchStop = (rootId: string) => call<void>("watch_stop", { rootId });
+138
View File
@@ -0,0 +1,138 @@
// The "Linked from" section: the documents elsewhere on disk that point at the one on screen.
//
// Nothing here is content. A backlink exists because of bytes in somebody else's file, so it is
// never in the ProseMirror document, never serialized and never written; it is drawn after the last
// block and that is the whole of its existence. Which is why this is a sibling of the editor inside
// the sheet rather than a node at the end of it: it shares the paper and the measure with the
// document and shares nothing else, and a caret cannot land in a section that was never in the
// editable, nor can a select all inside the editor reach it.
//
// Silence is the default and it is the point. No section under a document nothing links to, and no
// section before the index has finished a pass, because "nothing links here" and "I have not looked
// yet" are different facts and only one of them has earned a heading.
//
// The open document and the index are read from their stores rather than passed in, so the mount in
// App.tsx is a bare tag. This component already has to watch the index to know whether its answer
// means anything, so it is subscribed either way, and a prop would only put half of what it needs
// through the shell while the other half went round it.
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { backlinksFor } from "../api";
import type { Backlink } from "../ipc";
import { useDocument } from "../store/useDocument";
import { useIndex } from "../store/useIndex";
import { notify } from "../store/useToast";
interface Answer {
/** The document these were asked for, kept with them so a slow reply about the file that was open
* a moment ago is never drawn under the file that is open now. */
path: string;
/** Null is "asked, and could not be told". It draws the same nothing an empty list does, and that
* is a decision rather than an accident: a writer cannot act on a failed index lookup, and a
* permanent error line under every document costs more attention than the feature is worth. The
* two are still not the same fact, so they are not the same value here, and this is the one place
* that could ever tell them apart. */
links: Backlink[] | null;
}
const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path;
/**
* A snippet is the source line the link sits on, so the one construct every snippet is guaranteed
* to contain is the link that made it a backlink, and an editor whose whole pitch is that markdown
* syntax is never visible should not be the thing putting `](../thing.md)` on screen. The link is
* unwrapped to its text and nothing else is: everything else a line might hold is not certain to be
* there, and unwrapping it would be a second markdown reader living in a view.
*/
const readableSnippet = (snippet: string): string =>
snippet.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").trim();
export function Backlinks() {
const path = useDocument((s) => s.path);
const open = useDocument((s) => s.open);
const phase = useIndex((s) => s.phase);
const [answer, setAnswer] = useState<Answer | null>(null);
const [active, setActive] = useState(0);
const rows = useRef<(HTMLButtonElement | null)[]>([]);
// Two triggers, both of them in the dependencies: a different document to ask about, and a pass of
// the index finishing. The second is what keeps the section true when somebody edits another file
// and the watcher reindexes it, since `index-progress` lands in useIndex and comes out as a phase.
//
// Anything short of a completed pass is not asked at all, and mid-pass the previous answer is left
// on screen: a reindex is not new information about this document, and blanking the section for
// the duration would be a flicker that says something changed when nothing has.
useEffect(() => {
if (path === null || phase !== "ready") return;
let cancelled = false;
backlinksFor(path)
.then((links) => {
if (!cancelled) setAnswer({ path, links });
})
.catch(() => {
if (!cancelled) setAnswer({ path, links: null });
});
return () => {
cancelled = true;
};
}, [path, phase]);
// Matched against the open path at render rather than cleared in an effect, so switching documents
// cannot paint one frame of the last one's links before the effect catches up.
const links = answer !== null && answer.path === path ? answer.links : null;
if (links === null || links.length === 0) return null;
// Roving focus: the section is one stop in the tab order however many rows it has, and the arrows
// move inside it. A row per tab stop would make tabbing out of a well linked document a chore
// through chrome, and taking the rows out of the tab order entirely would leave them mouse only.
const focused = active < links.length ? active : 0;
const move = (delta: number) => {
const next = Math.min(Math.max(focused + delta, 0), links.length - 1);
setActive(next);
rows.current[next]?.focus();
};
const onKeyDown = (event: KeyboardEvent<HTMLUListElement>) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
move(event.key === "ArrowDown" ? 1 : -1);
};
const go = (target: string) => {
open(target).catch((e) => notify(`Could not open ${baseName(target)}: ${String(e)}`));
};
return (
<nav className="backlinks" aria-labelledby="backlinks-heading">
{/* One document at a time and one of these, so a fixed id cannot collide with a second. */}
<h2 className="nav-label" id="backlinks-heading">
Linked from
</h2>
<ul className="backlinks-list" onKeyDown={onKeyDown}>
{links.map((link, index) => (
<li key={link.path}>
<button
className="backlinks-row"
ref={(el) => {
rows.current[index] = el;
}}
tabIndex={index === focused ? 0 : -1}
// The title is a heading or a filename and two documents are allowed to share one, so
// the path is what settles which of them this row is.
title={link.path}
onFocus={() => setActive(index)}
onClick={() => go(link.path)}
>
{/* The index titles a document by its first heading and falls back to its filename,
so this only catches a row that would otherwise be a blank line to click. */}
<span className="backlinks-title">{link.title || baseName(link.path)}</span>
<span className="backlinks-snippet">{readableSnippet(link.snippet)}</span>
</button>
</li>
))}
</ul>
</nav>
);
}
+78
View File
@@ -0,0 +1,78 @@
// Cmd+K: every command the app has, by name.
//
// This is the one palette with nothing behind it. No index, no IPC, no store: the rows are the
// table in src/keys/commands.ts filtered by a subsequence match, so it answers on a build where
// SQLite has fallen over and on the first frame after launch, before a folder is even open. That
// is why src/keys/bindings.ts binds it in the `global` context with a comment saying an overlay may
// not shadow it: whatever is on screen, this is how you get anywhere from inside it, and something
// that reaches into a search index for its own row list would not be able to make that promise.
//
// It lists commands, not bindings, which is why the keys on the right come from `keysFor` and
// `keyLabel` rather than from `bindingLabel`: that one turns a binding into its words, and a
// command with no key at all still belongs in this list.
import { useEffect, useState } from "react";
import { useEscapeLayer } from "../escape";
import { keyLabel, keysFor } from "../keys/bindings";
import { COMMANDS, commandMatches, onCommand, runCommand } from "../keys/commands";
import { useKeyContext } from "../keys/keymap";
import { Palette, type PaletteRow } from "./Palette";
interface CommandRow extends PaletteRow {
label: string;
keys: readonly string[];
}
export function CommandPalette() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
// A toggle, like the shortcuts sheet: the key that opens it is reachable from inside it, so it
// has to mean something the second time it is pressed.
useEffect(
() =>
onCommand("command-palette", () => {
setOpen((wasOpen) => !wasOpen);
setQuery("");
}),
[],
);
useEscapeLayer(open, () => setOpen(false));
useKeyContext("overlay", open);
if (!open) return null;
const rows: CommandRow[] = COMMANDS.filter(
(command) => command.palette && commandMatches(command.label, query),
).map((command) => ({
key: command.id,
label: command.label,
keys: keysFor(command.id),
run: () => runCommand(command.id),
}));
return (
<Palette
label="Command palette"
placeholder="Run a command"
query={query}
onQuery={setQuery}
rows={rows}
status={{ text: "No command by that name." }}
onClose={() => setOpen(false)}
renderRow={(row) => (
<span className="palette-main">
<span className="palette-name">{row.label}</span>
<span className="palette-keys">
{row.keys.map((combo) => (
<kbd key={combo} className="key-cap">
{keyLabel(combo)}
</kbd>
))}
</span>
</span>
)}
/>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useRef, type ReactNode } from "react";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon";
interface ConfirmDialogProps {
title: string;
message: ReactNode;
confirmLabel?: string;
onConfirm: () => void;
onClose: () => void;
}
export function ConfirmDialog({
title,
message,
confirmLabel = "Delete",
onConfirm,
onClose,
}: ConfirmDialogProps) {
const confirmRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
confirmRef.current?.focus();
}, []);
useEscapeLayer(true, onClose);
return (
<div className="overlay" onClick={onClose}>
<div
className="panel panel-confirm"
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
>
<div className="panel-head">
<h2>{title}</h2>
<button className="icon-button" onClick={onClose} title="Close (⎋)" aria-label="Close">
<Icon d="M6 6l12 12M18 6L6 18" />
</button>
</div>
<div className="panel-body">
<p className="confirm-text">{message}</p>
</div>
<div className="panel-foot">
<button className="btn-ghost" onClick={onClose}>
Cancel
</button>
<button ref={confirmRef} className="btn-danger" onClick={onConfirm}>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
// Something outside the app changed the file that is open, and the buffer has an edit in it that
// is not on disk. Both copies are real work and the app does not get to pick, so it asks.
//
// There is no merge and there will not be one: a three way merge of somebody's prose is a thing
// that looks like it worked. The two answers are the two copies, and dismissing the dialog picks
// neither, which leaves the warning in the toolbar and the buffer exactly as it was.
import { useEffect, useRef } from "react";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon";
interface ConflictDialogProps {
/** The file's name, not its path: the path is already in the title bar. */
name: string;
/** Throws the buffer away and takes what is on disk. */
onReload: () => void;
/** Keeps the buffer and lets the next save write over the copy on disk. */
onKeep: () => void;
/** Neither, for now. The document stays unsaved and the toolbar keeps the warning. */
onDismiss: () => void;
}
export function ConflictDialog({ name, onReload, onKeep, onDismiss }: ConflictDialogProps) {
const keepRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
keepRef.current?.focus();
}, []);
useEscapeLayer(true, onDismiss);
return (
<div className="overlay" onClick={onDismiss}>
<div
className="panel panel-conflict"
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
>
<div className="panel-head">
<h2>Changed on disk</h2>
<button className="icon-button" onClick={onDismiss} title="Close (⎋)" aria-label="Close">
<Icon d="M6 6l12 12M18 6L6 18" />
</button>
</div>
<div className="panel-body">
<p className="confirm-text">
Something outside Margin Docs has changed <strong>{name}</strong>, and you have edits
here that are not on disk. Nothing has been written and nothing has been lost yet.
</p>
</div>
<div className="panel-foot">
<button className="btn-ghost" onClick={onDismiss}>
Decide later
</button>
<button className="btn-danger" onClick={onReload}>
Reload from disk
</button>
<button ref={keepRef} className="btn-primary" onClick={onKeep}>
Keep my version
</button>
</div>
</div>
</div>
);
}
+274
View File
@@ -0,0 +1,274 @@
// The recursive half of the sidebar: rows, twisties, indentation and drop indicators, and nothing
// else. Selection, the keyboard, the drag gesture and every action a row can perform live one
// level up in Sidebar.tsx, because all of those span every open root and a recursive renderer only
// ever sees one subtree.
import {
useEffect,
useRef,
type CSSProperties,
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
} from "react";
import { useEscapeLayer } from "../escape";
import { MARKDOWN_EXTENSIONS } from "../model/doc";
import type { TreeNode } from "../store/useWorkspace";
import { Icon } from "./Icon";
import { RowMenu, type RowMenuEntry } from "./RowMenu";
const FOLDER = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z";
const FOLDER_OPEN = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2H7l-4 8z M3 17V7";
const DOCUMENT = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5";
const FOREIGN = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5 M9 17l2.5-3 2 2.2 1.5-1.7";
/**
* The extension a row hides. Markdown is the app's own format and `.md` on every second row is
* noise, but everything else keeps its extension: two rows both reading "notes", for `notes.md`
* and `notes.txt`, would be a worse lie than the clutter it saved.
*/
export function splitExtension(name: string, isDir = false): { base: string; hidden: string } {
if (isDir) return { base: name, hidden: "" };
const dot = name.lastIndexOf(".");
if (dot <= 0) return { base: name, hidden: "" };
const ext = name.slice(dot + 1).toLowerCase();
if (!(MARKDOWN_EXTENSIONS as readonly string[]).includes(ext)) return { base: name, hidden: "" };
return { base: name.slice(0, dot), hidden: name.slice(dot) };
}
export const prettyName = (node: TreeNode): string => splitExtension(node.name, node.isDir).base;
export interface TreeRow {
node: TreeNode;
depth: number;
/** The directory the row sits in, empty for a root. Where a "drop above this row" resolves to. */
parentPath: string;
}
/** The rows the user can actually see, in the order they appear, which is what the arrow keys walk. */
export function flattenTree(
nodes: readonly TreeNode[],
expanded: ReadonlySet<string>,
depth = 0,
parentPath = "",
): TreeRow[] {
const rows: TreeRow[] = [];
for (const node of nodes) {
rows.push({ node, depth, parentPath });
if (node.isDir && expanded.has(node.path) && node.children?.length)
rows.push(...flattenTree(node.children, expanded, depth + 1, node.path));
}
return rows;
}
/**
* Every drop resolves to exactly one destination directory, because a filesystem has no row order
* to insert into. The mode is only how the pointer said it: "into" is the folder under the cursor,
* "before" and "after" are the folder that row already lives in.
*/
export type DropMode = "before" | "after" | "into";
export interface DropTarget {
dir: string;
mode: DropMode;
/** The row the indicator draws on, which for "into" is the destination folder itself. */
row: string;
}
export interface TreeViewState {
expanded: ReadonlySet<string>;
selectedPath: string | null;
/** The one row in the whole sidebar that is in the tab order. */
tabStopPath: string | null;
draggingPath: string | null;
dropTarget: DropTarget | null;
renamingPath: string | null;
/** The document currently open in the editor, which is a different thing from the selected row. */
openPath: string | null;
}
export interface TreeHandlers {
onActivate: (node: TreeNode) => void;
onToggle: (node: TreeNode) => void;
onKeyDown: (e: KeyboardEvent, row: TreeRow) => void;
onPointerDown: (e: PointerEvent, row: TreeRow) => void;
onContextMenu: (e: MouseEvent, row: TreeRow) => void;
onMenuOpenChange: (path: string, open: boolean) => void;
menuItems: (row: TreeRow) => readonly RowMenuEntry[];
onRenameCommit: (node: TreeNode, base: string) => void;
onRenameCancel: () => void;
}
interface TreeProps {
nodes: readonly TreeNode[];
depth: number;
parentPath: string;
state: TreeViewState;
handlers: TreeHandlers;
}
export function FileTree({ nodes, depth, parentPath, state, handlers }: TreeProps) {
return (
<ul className="tree" role={depth === 0 ? "tree" : "group"}>
{nodes.map((node) => (
<TreeItem
key={node.path}
row={{ node, depth, parentPath }}
state={state}
handlers={handlers}
/>
))}
</ul>
);
}
function TreeItem({
row,
state,
handlers,
}: {
row: TreeRow;
state: TreeViewState;
handlers: TreeHandlers;
}) {
const { node, depth } = row;
const { base, hidden } = splitExtension(node.name, node.isDir);
const open = node.isDir && state.expanded.has(node.path);
const renaming = state.renamingPath === node.path;
const drop = state.dropTarget?.row === node.path ? state.dropTarget.mode : null;
const foreign = !node.isDir && !node.editable;
return (
<li className="tree-item">
<div
className="tree-row"
role="treeitem"
style={{ "--tree-depth": depth } as CSSProperties}
data-path={node.path}
data-parent={row.parentPath}
data-dir={node.isDir}
data-root={depth === 0}
data-foreign={foreign}
data-selected={state.selectedPath === node.path}
data-current={state.openPath === node.path}
data-dragging={state.draggingPath === node.path}
data-drop-before={drop === "before"}
data-drop-after={drop === "after"}
data-drop-into={drop === "into"}
aria-expanded={node.isDir ? open : undefined}
aria-selected={state.selectedPath === node.path}
aria-level={depth + 1}
tabIndex={state.tabStopPath === node.path ? 0 : -1}
onClick={() => handlers.onActivate(node)}
onKeyDown={(e) => handlers.onKeyDown(e, row)}
onPointerDown={(e) => handlers.onPointerDown(e, row)}
onContextMenu={(e) => handlers.onContextMenu(e, row)}
>
{node.isDir ? (
<button
className="tree-twisty"
tabIndex={-1}
title={open ? "Collapse" : "Expand"}
aria-label={open ? `Collapse ${node.name}` : `Expand ${node.name}`}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
handlers.onToggle(node);
}}
>
<Icon d={open ? "M6 9l6 6 6-6" : "M9 6l6 6-6 6"} size={13} />
</button>
) : (
<span className="tree-twisty" aria-hidden="true" />
)}
<span className="tree-glyph" aria-hidden="true">
<Icon d={node.isDir ? (open ? FOLDER_OPEN : FOLDER) : foreign ? FOREIGN : DOCUMENT} size={15} />
</span>
{renaming ? (
<RenameField
value={base}
onCommit={(next) => handlers.onRenameCommit(node, next)}
onCancel={handlers.onRenameCancel}
/>
) : (
<span className="tree-name" title={node.name}>
{base}
{hidden && <span className="tree-ext">{hidden}</span>}
</span>
)}
<RowMenu
label={node.isDir ? "Folder options" : "File options"}
items={() => handlers.menuItems(row)}
onOpenChange={(isOpen) => handlers.onMenuOpenChange(node.path, isOpen)}
/>
</div>
{open && node.children?.length ? (
<FileTree
nodes={node.children}
depth={depth + 1}
parentPath={node.path}
state={state}
handlers={handlers}
/>
) : null}
</li>
);
}
/**
* Blur commits, the way Finder does, so the escape layer lives here rather than in the sidebar:
* unmounting a focused input can fire a blur on the way out, and a cancel that arrived from
* outside would otherwise be overtaken by the commit it was trying to avoid.
*/
function RenameField({
value,
onCommit,
onCancel,
}: {
value: string;
onCommit: (next: string) => void;
onCancel: () => void;
}) {
const ref = useRef<HTMLInputElement>(null);
const settled = useRef(false);
useEffect(() => {
const input = ref.current;
if (!input) return;
input.focus();
input.select();
}, []);
const commit = (next: string) => {
if (settled.current) return;
settled.current = true;
onCommit(next);
};
useEscapeLayer(true, () => {
settled.current = true;
onCancel();
});
return (
<input
ref={ref}
className="tree-rename"
defaultValue={value}
spellCheck={false}
autoComplete="off"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onBlur={(e) => commit(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
commit(e.currentTarget.value);
}}
/>
);
}
+190
View File
@@ -0,0 +1,190 @@
// Find and replace inside the open document. Margin's bar, minus the cross-chapter scope: there
// is one document open at a time here, and searching every file is `find-in-files` against the
// SQLite index, which is a different panel with different results.
//
// The matching itself is the editor's, not this bar's. `EditorHandle` in src/editor/index.ts does
// not carry a search surface, so the shape this bar drives is declared here and handed in: a
// component that draws a text field has no business owning a ProseMirror decoration set, and the
// alternative, walking the contenteditable DOM behind the editor's back, is a second
// implementation of matching that would disagree with the first the day either changed.
import { useEffect, useRef, useState } from "react";
import { useEscapeLayer } from "../escape";
import { onCommand } from "../keys/commands";
import { Icon } from "./Icon";
export interface FindOptions {
caseSensitive: boolean;
wholeWord: boolean;
}
export interface FindState {
count: number;
/** Zero based, so `current + 1` is what the "3 of 12" readout shows. */
current: number;
}
/**
* What the editor lane implements for this bar to be usable.
*
* A new object whenever `state` changes, the way `EditorHandle` already works: this bar draws the
* "3 of 12" readout from a prop and has nothing to subscribe to, so a handle mutated in place
* would leave the count stale until something unrelated re-rendered.
*/
export interface DocumentFind {
state: FindState;
setQuery: (query: string, options: FindOptions) => void;
clear: () => void;
next: () => void;
prev: () => void;
replaceCurrent: (text: string) => void;
replaceAll: (text: string) => void;
/** Puts the cursor back in the document, which every replace has to do to be worth anything. */
focus: () => void;
}
export function FindBar({ find }: { find: DocumentFind | null }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [replacement, setReplacement] = useState("");
const [caseSensitive, setCaseSensitive] = useState(false);
const [wholeWord, setWholeWord] = useState(false);
const [expanded, setExpanded] = useState(false);
const findRef = useRef<HTMLInputElement>(null);
useEffect(
() =>
onCommand("find", () => {
setOpen(true);
const input = findRef.current;
input?.focus();
input?.select();
}),
[],
);
useEffect(() => {
if (!open) return;
const input = findRef.current;
input?.focus();
input?.select();
}, [open]);
useEffect(() => {
if (!find) return;
if (open) find.setQuery(query, { caseSensitive, wholeWord });
else find.clear();
}, [find, open, query, caseSensitive, wholeWord]);
useEscapeLayer(open, () => setOpen(false));
if (!open || !find) return null;
const { count, current } = find.state;
const countLabel = !query ? "" : count === 0 ? "No results" : `${current + 1} of ${count}`;
const onFindKey = (e: React.KeyboardEvent) => {
if (e.key !== "Enter") return;
e.preventDefault();
if (e.shiftKey) find.prev();
else find.next();
};
const replaceOne = () => {
find.replaceCurrent(replacement);
find.focus();
};
const replaceEvery = () => {
find.replaceAll(replacement);
find.focus();
};
return (
<div className="find-bar" role="search">
<button
className="find-expand"
data-on={expanded}
title={expanded ? "Hide replace" : "Show replace"}
onClick={() => setExpanded((v) => !v)}
>
<Icon d={expanded ? "M6 9l6 6 6-6" : "M9 6l6 6-6 6"} size={14} />
</button>
<div className="find-stack">
<div className="find-row">
<input
ref={findRef}
className="find-input"
value={query}
placeholder="Find"
spellCheck={false}
aria-label="Find"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onFindKey}
/>
<span className="find-count">{countLabel}</span>
<button className="find-btn" title="Previous (⇧↩)" disabled={!count} onClick={find.prev}>
<Icon d="M6 15l6-6 6 6" size={14} />
</button>
<button className="find-btn" title="Next (↩)" disabled={!count} onClick={find.next}>
<Icon d="M6 9l6 6 6-6" size={14} />
</button>
<button
className="find-toggle"
data-on={caseSensitive}
title="Match case"
onClick={() => setCaseSensitive((v) => !v)}
>
Aa
</button>
<button
className="find-toggle"
data-on={wholeWord}
title="Whole word"
onClick={() => setWholeWord((v) => !v)}
>
<span className="find-ww">ab</span>
</button>
<button className="find-btn" title="Close (⎋)" onClick={() => setOpen(false)}>
<Icon d="M18 6L6 18M6 6l12 12" size={14} />
</button>
</div>
{expanded && (
<div className="find-row">
<input
className="find-input"
value={replacement}
placeholder="Replace"
spellCheck={false}
aria-label="Replace with"
onChange={(e) => setReplacement(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
replaceOne();
}}
/>
<button
className="find-action"
disabled={!count}
onClick={replaceOne}
title="Replace the current match"
>
Replace
</button>
<button
className="find-action"
disabled={!count}
onClick={replaceEvery}
title="Replace every match in this document"
>
Replace All
</button>
</div>
)}
</div>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
// Cmd+Shift+F: the text inside every file in every open root, which is the one question the file
// tree and the find bar between them cannot answer. The bar in src/components/FindBar.tsx searches
// the one document that is open; this searches the ones that are not.
//
// Same debounce and the same reason as quick open, a little longer because a full text query reads
// the whole corpus rather than one column of paths, and the same deliberate absence of a second
// guard around the race: the sequence number in src/store/useSearch.ts already refuses an answer
// that has been overtaken.
//
// A row opens the document it found the line in, and stops there. Putting the caret on the line
// itself would need a way to say "open this file at line 42", and the editor's public surface in
// src/editor/index.ts has no such thing, so the honest version of this today is the file open at
// the top rather than a jump built out of a DOM query behind the editor's back.
import { useEffect, useState } from "react";
import { useEscapeLayer } from "../escape";
import type { MatchRange } from "../ipc";
import { onCommand } from "../keys/commands";
import { useKeyContext } from "../keys/keymap";
import { useDocument } from "../store/useDocument";
import { useIndex } from "../store/useIndex";
import { useSearch } from "../store/useSearch";
import { notify } from "../store/useToast";
import { useWorkspace } from "../store/useWorkspace";
import { Palette, highlight, type PaletteRow, type PaletteStatus } from "./Palette";
/** Longer than quick open's: this one reads the text of every file rather than their paths. */
const DEBOUNCE_MS = 140;
interface HitRow extends PaletteRow {
title: string;
/** One based, and counted over the file as it sits on disk, frontmatter included. */
line: number;
excerpt: string;
ranges: readonly MatchRange[];
path: string;
}
export function FindInFiles() {
const [open, setOpen] = useState(false);
const query = useSearch((s) => s.fullTextQuery);
const setQuery = useSearch((s) => s.setFullTextQuery);
const runFullText = useSearch((s) => s.runFullText);
const hits = useSearch((s) => s.fullTextHits);
const phase = useSearch((s) => s.fullTextPhase);
const error = useSearch((s) => s.fullTextError);
const indexPhase = useIndex((s) => s.phase);
const roots = useWorkspace((s) => s.roots);
const select = useWorkspace((s) => s.select);
const openDocument = useDocument((s) => s.open);
useEffect(
() =>
onCommand("find-in-files", () => {
// Empty field, and last time's rows cleared through the store so its sequence number moves
// with them. See the same lines in QuickOpen.tsx.
setQuery("");
void runFullText("");
setOpen(true);
}),
[setQuery, runFullText],
);
useEscapeLayer(open, () => setOpen(false));
useKeyContext("overlay", open);
useEffect(() => {
if (!open) return;
const timer = window.setTimeout(() => void runFullText(query), DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [open, query, runFullText]);
if (!open) return null;
const choose = (path: string) => {
select(path);
openDocument(path).catch((e) => notify(`Could not open: ${String(e)}`));
};
const searching = query.trim() !== "";
// A file can answer on several lines, so the path alone is not a key.
const rows: HitRow[] = hits.map((hit, index) => ({
key: `${hit.path}:${hit.line}:${index}`,
title: hit.title,
line: hit.line,
excerpt: hit.excerpt,
ranges: hit.ranges,
path: hit.path,
run: () => choose(hit.path),
}));
const status = (): PaletteStatus => {
if (phase === "error") {
return { text: error ?? "The search index could not be read.", error: true };
}
if (!searching) {
return {
text:
roots.length === 0 ? "Open a folder first." : "Type to search every folder that is open.",
};
}
if (phase === "loading") return { text: "Searching…" };
if (indexPhase === "indexing") return { text: "Still indexing. Try again in a moment." };
return { text: "Nothing in these folders says that." };
};
return (
<Palette
label="Find in files"
placeholder="Search every open folder"
query={query}
onQuery={setQuery}
rows={rows}
status={status()}
onClose={() => setOpen(false)}
renderRow={(row) => (
<span className="palette-stack" title={row.path}>
<span className="palette-main">
<span className="palette-name">{row.title}</span>
<span className="palette-line">{row.line}</span>
</span>
<span className="palette-snippet">{highlight(row.excerpt, row.ranges)}</span>
</span>
)}
/>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
interface IconProps {
d?: string;
size?: number;
children?: ReactNode;
}
export function Icon({ d, size = 16, children }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
{children ?? <path d={d} />}
</svg>
);
}
+191
View File
@@ -0,0 +1,191 @@
// The shell behind all three overlay palettes: the backdrop, the one text field, the list under it
// and the keyboard that drives them.
//
// Three sources, one widget. What differs between quick open, find in files and the command palette
// is where the rows come from and what a row does when it is chosen, and that is the whole of what
// the three concrete palettes hand in. Everything a user would call "how the palette behaves", the
// arrow keys, the wrap at the ends, the selection following the mouse, the row scrolling itself
// into view, lives here once so the three cannot drift into three slightly different lists.
//
// Rendered only while its palette is open, never handed a closed flag: mounting is what opens it.
// That is what keeps the selection, the scroll position and the focus fresh on every open without a
// single reset effect, and it leaves the open flag, the Escape layer and the key context in the
// concrete component beside its `onCommand` subscription, which is the shape Shortcuts.tsx already
// has.
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import type { MatchRange } from "../ipc";
export interface PaletteRow {
/** Identity, not position: a path, a command id. React's key and nothing more. */
key: string;
/** What choosing the row does. The palette is already closed by the time this is called. */
run: () => void;
}
/** The single line shown in place of the list. */
export interface PaletteStatus {
text: string;
/** Something failed and this is its message. An empty result is not a failure. */
error?: boolean;
}
interface PaletteProps<Row extends PaletteRow> {
/** Names the dialog, its field and its list for a screen reader. */
label: string;
placeholder: string;
query: string;
onQuery: (query: string) => void;
rows: readonly Row[];
/** Shown only when there are no rows, so an answer that is still in flight keeps the last rows
* on screen rather than flashing "No results" between two keystrokes. */
status: PaletteStatus | null;
renderRow: (row: Row) => ReactNode;
onClose: () => void;
}
export function Palette<Row extends PaletteRow>({
label,
placeholder,
query,
onQuery,
rows,
status,
renderRow,
onClose,
}: PaletteProps<Row>) {
const [selected, setSelected] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const listId = useId();
// Clamped where it is read rather than corrected in an effect. The row count changes with every
// answer the index gives back, and an effect that put the index right afterwards would render one
// frame with a selection pointing past the end of the list first.
const at = Math.min(selected, rows.length - 1);
const current = at >= 0 ? rows[at] : null;
useEffect(() => inputRef.current?.focus(), []);
// A new query is a new list, so the selection goes back to the top. Keyed on the query rather
// than on `rows`, because a palette that filters as it renders hands over a new array every time
// and this would then undo every arrow key the moment it was pressed.
useEffect(() => setSelected(0), [query]);
useEffect(() => {
listRef.current?.children[at]?.scrollIntoView({ block: "nearest" });
}, [at]);
const choose = (row: Row) => {
// Closed before the row runs. A command palette row can put another overlay on screen, and the
// two would otherwise unwind the Escape stack and the key context stack in the wrong order.
onClose();
row.run();
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
// Without this the caret jumps to one end of the field on every step through the list.
e.preventDefault();
if (rows.length === 0) return;
const next = e.key === "ArrowDown" ? at + 1 : at - 1 + rows.length;
setSelected(next % rows.length);
return;
}
if (e.key === "Enter" && current) {
e.preventDefault();
choose(current);
}
};
return (
// Mousedown rather than click: a click closes on the release, so dragging a selection out of
// the field and letting go over the backdrop would dismiss the palette mid-gesture.
<div className="overlay" data-align="top" onMouseDown={onClose}>
<div
className="panel palette"
role="dialog"
aria-modal="true"
aria-label={label}
onMouseDown={(e) => e.stopPropagation()}
>
<input
ref={inputRef}
className="palette-field"
value={query}
placeholder={placeholder}
spellCheck={false}
autoComplete="off"
role="combobox"
aria-label={label}
aria-expanded={rows.length > 0}
aria-controls={rows.length > 0 ? listId : undefined}
aria-activedescendant={current ? `${listId}-${at}` : undefined}
onChange={(e) => onQuery(e.target.value)}
onKeyDown={onKeyDown}
/>
{rows.length > 0 ? (
<ul ref={listRef} id={listId} className="palette-list" role="listbox" aria-label={label}>
{rows.map((row, index) => (
<li
key={row.key}
id={`${listId}-${index}`}
className="palette-row"
role="option"
aria-selected={index === at}
data-selected={index === at}
// Move, not enter. The list re-renders under a still cursor every time the index
// answers, and `mouseenter` would hand the selection to whichever row happened to
// slide under a pointer nobody had touched.
onMouseMove={() => setSelected(index)}
onClick={() => choose(row)}
>
{renderRow(row)}
</li>
))}
</ul>
) : (
status && (
<p className="palette-status" data-error={status.error === true}>
{status.text}
</p>
)
)}
</div>
</div>
);
}
/**
* The matched characters, marked.
*
* `ranges` are half-open offsets into `text` and they come from whatever did the matching, which is
* the only thing that knows where it landed, so neither search palette runs the match a second time
* to find out. Offsets are clamped and taken in order rather than trusted: they are computed on the
* other side of the IPC boundary against a string this side only has a copy of, and one bad pair
* would otherwise slice a row into nonsense.
*/
export function highlight(text: string, ranges: readonly MatchRange[]): ReactNode {
if (ranges.length === 0) return text;
const parts: ReactNode[] = [];
let at = 0;
const ordered = [...ranges].sort((a, b) => a.start - b.start);
ordered.forEach((range, i) => {
const start = Math.max(at, Math.min(range.start, text.length));
const end = Math.max(start, Math.min(range.end, text.length));
if (end === start) return;
if (start > at) parts.push(text.slice(at, start));
parts.push(
<mark key={i} className="palette-mark">
{text.slice(start, end)}
</mark>,
);
at = end;
});
if (at < text.length) parts.push(text.slice(at));
return parts;
}
+150
View File
@@ -0,0 +1,150 @@
// The menu over a misspelled word: what the system thinks was meant, and the two ways of saying it
// was not a mistake.
//
// Mounted once and drawing nothing until src/editor/proofing.ts puts a word in the store, so App.tsx
// holds one line for it rather than a piece of the feature. It renders into a portal because the
// document scrolls inside its own pane and a menu clipped by the pane it belongs to is no menu at
// all, and it is positioned in viewport coordinates because that is what the editor measured the
// word in.
//
// It never takes focus. A left click on a misspelled word is somebody putting the caret in a word
// they are about to fix by hand as often as it is somebody asking what else it could have been, and
// a menu that steals the caret out of the sentence being typed has broken the more common of the
// two. So the caret stays where the click put it, typing goes on into the document and dismisses the
// menu on the way, and the buttons refuse the focus a mousedown would otherwise give them.
//
// "Learn Spelling" is the item that has to be honest about what it does. The checker is
// NSSpellChecker and the dictionary is the Mac's, so learning a word here teaches Mail, Notes and
// every other app on the machine, which is what makes it useful and also what makes it more than
// this app's business to do quietly. The note under the buttons says so in the menu, where the
// decision is being made, rather than in a tooltip nobody reads first.
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { replaceSpelling } from "../editor/proofing";
import { useEscapeLayer } from "../escape";
import { useProofing, type ProofTarget } from "../store/useProofing";
/** Clearance from the word above and from the edges of the window. */
const GAP = 6;
const MARGIN = 8;
export function ProofPopover() {
const target = useProofing((s) => s.target);
if (target === null) return null;
// Keyed so that opening the menu over a second word rebuilds it rather than sliding the first
// one's measurements across.
return <ProofMenu key={`${target.from}:${target.word}`} target={target} />;
}
function ProofMenu({ target }: { target: ProofTarget }) {
const closeMenu = useProofing((s) => s.closeMenu);
const ignoreWord = useProofing((s) => s.ignoreWord);
const learnWord = useProofing((s) => s.learnWord);
const popRef = useRef<HTMLDivElement>(null);
const [at, setAt] = useState({ left: target.left, top: target.bottom + GAP });
useLayoutEffect(() => {
const el = popRef.current;
if (!el) return;
const box = el.getBoundingClientRect();
const left = Math.max(
MARGIN,
Math.min(target.left - box.width / 2, window.innerWidth - box.width - MARGIN),
);
// Under the word, unless the window has no room under it, in which case above it. Never over it:
// the word is what the menu is about and covering it hides the mistake being corrected.
const below = target.bottom + GAP;
const top =
below + box.height + MARGIN <= window.innerHeight
? below
: Math.max(MARGIN, target.top - GAP - box.height);
setAt({ left, top });
}, [target]);
useEscapeLayer(true, closeMenu);
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (popRef.current?.contains(e.target as Node)) return;
closeMenu();
};
const close = () => closeMenu();
document.addEventListener("mousedown", onDown, true);
document.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("mousedown", onDown, true);
document.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
}, [closeMenu]);
// The caret belongs to the document, not to this menu, so a press on any of these buttons is not
// allowed to move it.
const keepFocus = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
};
return createPortal(
<div
ref={popRef}
className="proof-pop"
role="menu"
aria-label={`Spelling suggestions for ${target.word}`}
style={{ left: at.left, top: at.top }}
onContextMenu={(e) => e.preventDefault()}
>
{target.suggestions.length === 0 ? (
<p className="proof-none">No suggestions</p>
) : (
target.suggestions.map((suggestion) => (
<button
key={suggestion}
role="menuitem"
className="proof-suggestion"
onMouseDown={keepFocus}
onClick={() => {
replaceSpelling(target, suggestion);
closeMenu();
}}
>
{suggestion}
</button>
))
)}
<div className="proof-sep" />
<button
role="menuitem"
className="proof-action"
title={`Adds “${target.word}” to the dictionary every app on this Mac shares.`}
onMouseDown={keepFocus}
onClick={() => {
void learnWord(target.word);
closeMenu();
}}
>
Learn Spelling
</button>
<button
role="menuitem"
className="proof-action"
title="Stops underlining this word until the app is next opened."
onMouseDown={keepFocus}
onClick={() => {
ignoreWord(target.word);
closeMenu();
}}
>
Ignore
</button>
<p className="proof-note">Learning a word teaches this Mac, not only Margin Docs.</p>
</div>,
document.body,
);
}
+174
View File
@@ -0,0 +1,174 @@
// Cmd+P: every file the index knows about, across every open root, matched against the path it
// sits at relative to that root.
//
// The query lives in the store rather than here because the store is also what asks SQLite, and the
// two have to be able to disagree for a moment: the field shows the letter that was just typed
// while the index is still answering the word before it. What sits in between is the debounce
// below, so a typist crosses the IPC boundary once for a word rather than once for a letter.
//
// A slow answer landing after a fast one is already handled on the other side of that boundary, by
// the sequence number in src/store/useSearch.ts, and this file deliberately does not grow a second
// guard for the same race: two of them would have to agree forever, and the day they stopped the
// symptom would be a row from a query nobody can see any more.
//
// An empty field is not an empty palette. Cmd+P with nothing typed offers the documents this
// session has already been in, which is the other half of what people reach for the key for.
import { useEffect, useState } from "react";
import { useEscapeLayer } from "../escape";
import type { MatchRange } from "../ipc";
import { onCommand } from "../keys/commands";
import { useKeyContext } from "../keys/keymap";
import { useDocument } from "../store/useDocument";
import { useIndex } from "../store/useIndex";
import { useSearch } from "../store/useSearch";
import { notify } from "../store/useToast";
import { useWorkspace, type WorkspaceRoot } from "../store/useWorkspace";
import { Palette, highlight, type PaletteRow, type PaletteStatus } from "./Palette";
/** Long enough that a word is one query rather than five, short enough that the pause between two
* words already has an answer waiting in it. */
const DEBOUNCE_MS = 90;
/** How many already-visited documents an empty field offers before it stops being a shortlist. */
const RECENT_LIMIT = 8;
interface FileRow extends PaletteRow {
path: string;
name: string;
/** The path relative to its root, whole, because that is the string the index counted its match
* offsets against and a trimmed version of it would highlight the wrong characters. */
where: string;
ranges: readonly MatchRange[];
/** The root's own name, or empty. Filled in only when more than one folder is open and the
* relative path alone would be ambiguous between them, and empty for a root that was closed
* while its answer was still in flight. */
root: string;
}
const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path;
function relativeTo(path: string, roots: readonly WorkspaceRoot[]): string {
for (const root of roots) {
if (path.startsWith(`${root.path}/`)) return path.slice(root.path.length + 1);
}
return path;
}
export function QuickOpen() {
const [open, setOpen] = useState(false);
const query = useSearch((s) => s.quickOpenQuery);
const setQuery = useSearch((s) => s.setQuickOpenQuery);
const runQuickOpen = useSearch((s) => s.runQuickOpen);
const hits = useSearch((s) => s.quickOpenHits);
const phase = useSearch((s) => s.quickOpenPhase);
const error = useSearch((s) => s.quickOpenError);
const indexPhase = useIndex((s) => s.phase);
const roots = useWorkspace((s) => s.roots);
const select = useWorkspace((s) => s.select);
const history = useDocument((s) => s.history);
const openPath = useDocument((s) => s.path);
const openDocument = useDocument((s) => s.open);
useEffect(
() =>
onCommand("quick-open", () => {
// The field starts empty every time and last time's rows go with it. `runQuickOpen("")` is
// what clears them, and going through the store rather than reaching for the array directly
// is also what bumps its sequence number, so an answer to the query this palette was closed
// on cannot arrive inside the one it was just opened for.
setQuery("");
void runQuickOpen("");
setOpen(true);
}),
[setQuery, runQuickOpen],
);
useEscapeLayer(open, () => setOpen(false));
useKeyContext("overlay", open);
useEffect(() => {
if (!open) return;
const timer = window.setTimeout(() => void runQuickOpen(query), DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [open, query, runQuickOpen]);
if (!open) return null;
const choose = (path: string) => {
// Selected as well as opened, so the tree, and every command that acts on the selection, agree
// with the document that is now on screen. Opening a file from here and renaming it with the
// next key otherwise renames whatever was last clicked in the sidebar.
select(path);
openDocument(path).catch((e) => notify(`Could not open: ${String(e)}`));
};
const searching = query.trim() !== "";
const recent = () => {
const seen = new Set<string>();
const rows: FileRow[] = [];
for (let i = history.length - 1; i >= 0 && rows.length < RECENT_LIMIT; i -= 1) {
const path = history[i];
// The document already on screen is not somewhere to go.
if (path === openPath || seen.has(path)) continue;
seen.add(path);
rows.push({
key: path,
path,
name: baseName(path),
where: relativeTo(path, roots),
ranges: [],
root: "",
run: () => choose(path),
});
}
return rows;
};
const rows: FileRow[] = searching
? hits.map((hit) => ({
key: hit.path,
path: hit.path,
name: hit.name,
where: hit.relPath,
ranges: hit.ranges,
root: roots.length > 1 ? baseName(hit.rootPath) : "",
run: () => choose(hit.path),
}))
: recent();
const status = (): PaletteStatus => {
if (phase === "error") {
return { text: error ?? "The search index could not be read.", error: true };
}
if (!searching) {
return {
text: roots.length === 0 ? "Open a folder first." : "Type to find a file by name or path.",
};
}
if (phase === "loading") return { text: "Searching…" };
if (indexPhase === "indexing") return { text: "Still indexing. Try again in a moment." };
return { text: "No file matches." };
};
return (
<Palette
label="Quick open"
placeholder="Go to file"
query={query}
onQuery={setQuery}
rows={rows}
status={status()}
onClose={() => setOpen(false)}
renderRow={(row) => (
<span className="palette-main" title={row.path}>
<span className="palette-name">{row.name}</span>
<span className="palette-where">{highlight(row.where, row.ranges)}</span>
{row.root !== "" && <span className="palette-root">{row.root}</span>}
</span>
)}
/>
);
}
+67
View File
@@ -0,0 +1,67 @@
// The start screen: what the window shows before any folder is open. A quiet list rather than a
// grid of cards, because a folder has no cover and pretending otherwise would just be a row of
// identical rectangles.
import { commandLabel, runCommand } from "../keys/commands";
import { notify } from "../store/useToast";
import { useWorkspace } from "../store/useWorkspace";
import { addRoot } from "../workspace";
import { Icon } from "./Icon";
import { shortcutTitle } from "./Titlebar";
const FOLDER = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z";
const OPEN_FOLDER = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M12 10v6 M9 13h6";
const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path;
const parentOf = (path: string): string => path.slice(0, path.lastIndexOf("/")) || "/";
export function Recents() {
const recentFolders = useWorkspace((s) => s.recentFolders);
const scanPhase = useWorkspace((s) => s.scanPhase);
// `openFolder` is the picker and takes no path, so a folder that is already known is opened
// through the effects module directly rather than by asking the user to find it again.
const open = (path: string) => {
addRoot(path).catch((e) => notify(`Could not open that folder: ${String(e)}`));
};
return (
<div className="start">
<div className="start-drag" data-tauri-drag-region />
<div className="start-body">
<h1 className="start-title">Margin Docs</h1>
<p className="start-line">
Open a folder of markdown files. Nothing is copied, nothing is imported, and nothing is
written until you make an edit.
</p>
<button
className="start-open"
title={shortcutTitle("open-folder")}
disabled={scanPhase === "scanning"}
onClick={() => runCommand("open-folder")}
>
<Icon d={OPEN_FOLDER} size={18} />
{scanPhase === "scanning" ? "Opening…" : commandLabel("open-folder")}
</button>
{recentFolders.length > 0 && (
<div className="start-recent">
<div className="nav-label">Recent</div>
<ul className="start-list">
{recentFolders.map((path) => (
<li key={path}>
<button className="start-row" onClick={() => open(path)} title={path}>
<Icon d={FOLDER} size={15} />
<span className="start-name">{baseName(path)}</span>
<span className="start-path">{parentOf(path)}</span>
</button>
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
// The sidebar's drag edge. The width it writes is the `--pane-sidebar` token itself, set on the
// root element, so the stylesheet keeps owning the layout and this only moves a number.
import { useLayoutEffect, type PointerEvent } from "react";
const VAR = "--pane-sidebar";
const KEY = "margindocs-pane-sidebar";
const DEFAULT = 248;
const MIN = 200;
const MAX = 460;
const clamp = (px: number): number => Math.round(Math.min(MAX, Math.max(MIN, px)));
function currentWidth(): number {
const raw = getComputedStyle(document.documentElement).getPropertyValue(VAR);
const px = parseInt(raw, 10);
return Number.isFinite(px) && px > 0 ? px : DEFAULT;
}
function applyWidth(px: number): void {
const width = clamp(px);
document.documentElement.style.setProperty(VAR, `${width}px`);
try {
localStorage.setItem(KEY, String(width));
} catch {
// A webview with storage denied still gets a working drag, just not a remembered one.
}
}
function resetWidth(): void {
document.documentElement.style.removeProperty(VAR);
try {
localStorage.removeItem(KEY);
} catch {
// See above.
}
}
export function ResizeHandle() {
useLayoutEffect(() => {
try {
const saved = parseInt(localStorage.getItem(KEY) ?? "", 10);
if (Number.isFinite(saved) && saved > 0)
document.documentElement.style.setProperty(VAR, `${clamp(saved)}px`);
} catch {
// See above.
}
}, []);
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
e.preventDefault();
const handle = e.currentTarget;
const startX = e.clientX;
const startWidth = currentWidth();
handle.setPointerCapture(e.pointerId);
document.documentElement.setAttribute("data-resizing", "");
const onMove = (ev: globalThis.PointerEvent) => applyWidth(startWidth + (ev.clientX - startX));
const onUp = () => {
document.documentElement.removeAttribute("data-resizing");
handle.removeEventListener("pointermove", onMove);
handle.removeEventListener("pointerup", onUp);
};
handle.addEventListener("pointermove", onMove);
handle.addEventListener("pointerup", onUp);
};
return (
<div
className="pane-resizer"
role="separator"
aria-orientation="vertical"
onPointerDown={onPointerDown}
onDoubleClick={resetWidth}
title="Drag to resize, double click to reset"
/>
);
}
+187
View File
@@ -0,0 +1,187 @@
// The popup menu a row offers, in two forms over one body: a "..." button that opens it under
// itself, and a bare popup anchored to the point a right click happened. Both render into a portal
// so a menu is never clipped by the scrolling tree it belongs to.
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon";
export interface RowMenuItem {
id: string;
label: string;
/** A Feather-style 24x24 stroke path, the same shape `<Icon d>` takes everywhere else. */
icon: string;
danger?: boolean;
run: () => void;
}
/** A hairline between groups of items. Written inline in the array so the order stays readable. */
export type RowMenuEntry = RowMenuItem | "sep";
const MENU_ITEM = ".row-menu-item";
interface PopProps {
x: number;
y: number;
/** A thunk, not an array: a tree of a thousand rows should not build a thousand menus it will
* never show, and the items a row offers can depend on state that moved since it was drawn. */
items: () => readonly RowMenuEntry[];
onClose: () => void;
/** Where focus goes when the menu closes, so keyboard use does not land back at the document. */
restoreFocus?: () => void;
}
function MenuPop({ x, y, items, onClose, restoreFocus }: PopProps) {
const popRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ left: x, top: y });
useLayoutEffect(() => {
const el = popRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setPos({
left: Math.max(8, Math.min(x, window.innerWidth - rect.width - 8)),
top: Math.max(8, Math.min(y, window.innerHeight - rect.height - 8)),
});
}, [x, y]);
useEffect(() => {
popRef.current?.querySelector<HTMLElement>(MENU_ITEM)?.focus();
}, []);
useEscapeLayer(true, () => {
onClose();
restoreFocus?.();
});
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (popRef.current?.contains(e.target as Node)) return;
onClose();
};
const close = () => onClose();
document.addEventListener("mousedown", onDown, true);
document.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("mousedown", onDown, true);
document.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
}, [onClose]);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const all = Array.from(popRef.current?.querySelectorAll<HTMLElement>(MENU_ITEM) ?? []);
if (!all.length) return;
const at = all.indexOf(document.activeElement as HTMLElement);
const next = e.key === "ArrowDown" ? (at + 1) % all.length : (at - 1 + all.length) % all.length;
all[next]?.focus();
};
const choose = (e: React.MouseEvent, item: RowMenuItem) => {
e.stopPropagation();
onClose();
item.run();
};
return createPortal(
<div
ref={popRef}
className="row-menu-pop"
role="menu"
style={{ top: pos.top, left: pos.left }}
onKeyDown={onKeyDown}
onContextMenu={(e) => e.preventDefault()}
>
{items().map((item, i) =>
item === "sep" ? (
<div key={`sep-${i}`} className="menu-sep" />
) : (
<button
key={item.id}
role="menuitem"
className={item.danger ? "row-menu-item danger" : "row-menu-item"}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => choose(e, item)}
>
<Icon d={item.icon} size={14} />
{item.label}
</button>
),
)}
</div>,
document.body,
);
}
interface RowMenuProps {
label: string;
items: () => readonly RowMenuEntry[];
onOpenChange?: (open: boolean) => void;
className?: string;
}
/** The "..." trigger, for a row that has room to show one. */
export function RowMenu({ label, items, onOpenChange, className = "" }: RowMenuProps) {
const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const wasOpen = useRef(false);
useEffect(() => {
const open = anchor !== null;
if (open === wasOpen.current) return;
wasOpen.current = open;
onOpenChange?.(open);
}, [anchor, onOpenChange]);
const toggle = (e: React.MouseEvent) => {
e.stopPropagation();
if (anchor) {
setAnchor(null);
return;
}
const rect = btnRef.current?.getBoundingClientRect();
if (rect) setAnchor({ x: rect.left, y: rect.bottom + 4 });
};
return (
<>
<button
ref={btnRef}
className={`row-menu-btn ${className}`}
data-open={anchor !== null}
title={label}
aria-label={label}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={toggle}
>
<Icon d="M12 5h.01M12 12h.01M12 19h.01" />
</button>
{anchor && (
<MenuPop
x={anchor.x}
y={anchor.y}
items={items}
onClose={() => setAnchor(null)}
restoreFocus={() => btnRef.current?.focus()}
/>
)}
</>
);
}
interface RowMenuAtProps {
x: number;
y: number;
items: () => readonly RowMenuEntry[];
onClose: () => void;
}
/** The same menu opened at a point, which is what a right click on a row produces. */
export function RowMenuAt({ x, y, items, onClose }: RowMenuAtProps) {
return <MenuPop x={x} y={y} items={items} onClose={onClose} />;
}
+69
View File
@@ -0,0 +1,69 @@
// Every key the app answers to, generated from src/keys/bindings.ts rather than written out here.
// That is the point of the table over there: a binding that exists is a binding this sheet shows,
// so the two cannot drift and there is no list to remember to update.
import { useEffect, useState } from "react";
import { useEscapeLayer } from "../escape";
import { BINDINGS, GROUPS, bindingLabel, keyLabel } from "../keys/bindings";
import { commandLabel, onCommand } from "../keys/commands";
import { useKeyContext } from "../keys/keymap";
import { Icon } from "./Icon";
export function Shortcuts() {
const [open, setOpen] = useState(false);
useEffect(() => onCommand("shortcuts", () => setOpen((v) => !v)), []);
useEscapeLayer(open, () => setOpen(false));
useKeyContext("overlay", open);
if (!open) return null;
return (
<div className="overlay" onClick={() => setOpen(false)}>
<div
className="panel panel-keys"
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts"
onClick={(e) => e.stopPropagation()}
>
<div className="panel-head">
<h2>Keyboard shortcuts</h2>
<button
className="icon-button"
onClick={() => setOpen(false)}
title="Close (⎋)"
aria-label="Close"
>
<Icon d="M6 6l12 12M18 6L6 18" />
</button>
</div>
<div className="panel-body">
{GROUPS.map((group) => {
const rows = BINDINGS.filter((binding) => binding.group === group);
if (!rows.length) return null;
return (
<section key={group} className="key-group">
<div className="nav-label">{group}</div>
<ul className="key-list">
{rows.map((binding) => (
<li key={binding.keys.join("+")} className="key-row">
<span className="key-what">{bindingLabel(binding, commandLabel)}</span>
<span className="key-combos">
{binding.keys.map((combo) => (
<kbd key={combo} className="key-cap">
{keyLabel(combo)}
</kbd>
))}
</span>
</li>
))}
</ul>
</section>
);
})}
</div>
</div>
</div>
);
}
+513
View File
@@ -0,0 +1,513 @@
// Every open folder, one tree each. This component owns everything that spans roots: which row is
// selected, which row holds the tab stop, the arrow-key walk over the visible rows, the drag
// gesture and the menus. FileTree.tsx below it only draws.
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, type PointerEvent } from "react";
import { openExternal } from "../api/roots";
import { commandLabel, runCommand } from "../keys/commands";
import { useDocument } from "../store/useDocument";
import { notify } from "../store/useToast";
import { useWorkspace, type TreeNode } from "../store/useWorkspace";
import { movePath } from "../workspace";
import { ConfirmDialog } from "./ConfirmDialog";
import {
FileTree,
flattenTree,
splitExtension,
type DropTarget,
type TreeHandlers,
type TreeRow,
type TreeViewState,
} from "./FileTree";
import { Icon } from "./Icon";
import { ResizeHandle } from "./ResizeHandle";
import { RowMenuAt, type RowMenuEntry } from "./RowMenu";
import { shortcutTitle } from "./Titlebar";
const EXPANDED_KEY = "margindocs-expanded";
const ROOTS_SEEN_KEY = "margindocs-roots-seen";
/** How far the pointer travels before a press on a row becomes a drag rather than a click. */
const DRAG_SLOP = 4;
/** How long a drag hovers a closed folder before it springs open, the way Finder does. */
const SPRING_MS = 650;
const OPEN_FOLDER = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M12 10v6 M9 13h6";
const NEW_DOC_ICON = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5 M12 12v5 M9.5 14.5h5";
const NEW_FOLDER_ICON = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M12 11v6 M9 14h6";
const RENAME_ICON = "M4 20h4L20 8l-4-4L4 16z M14 6l4 4";
const DUPLICATE_ICON = "M9 9h11v11H9z M6 15V5h9";
const REVEAL_ICON = "M9 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4 M15 3h6v6 M10 14L21 3";
const COPY_PATH_ICON = "M8 4h8a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z M9 2h6v4H9z";
const TRASH_ICON = "M5 7h14M10 7V5h4v2M7 7l1 13h8l1-13M10 11v6M14 11v6";
const CLOSE_ICON = "M18 6L6 18M6 6l12 12";
function readList(key: string): string[] {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(key) ?? "[]");
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : [];
} catch {
return [];
}
}
function writeList(key: string, values: readonly string[]): void {
try {
localStorage.setItem(key, JSON.stringify(values));
} catch {
// A webview with storage denied still works, it just forgets the shape of the tree.
}
}
/**
* Where the pointer says the dragged row should land.
*
* A filesystem has no row order to insert into, so all three answers are one destination
* directory: the middle of a folder means that folder, and the edges of any row mean the folder
* that row already lives in. Reading the answer off the DOM rather than off the row array is what
* keeps this working while the tree scrolls under the pointer.
*/
function dropAt(x: number, y: number): DropTarget | null {
const el = document.elementFromPoint(x, y) as HTMLElement | null;
if (!el) return null;
const row = el.closest<HTMLElement>(".tree-row");
if (row?.dataset.path) {
const path = row.dataset.path;
const parent = row.dataset.parent ?? "";
const rect = row.getBoundingClientRect();
const at = (y - rect.top) / rect.height;
if (row.dataset.dir === "true") {
if (!parent || (at > 0.25 && at < 0.75)) return { dir: path, mode: "into", row: path };
return { dir: parent, mode: at <= 0.25 ? "before" : "after", row: path };
}
if (!parent) return null;
return { dir: parent, mode: at < 0.5 ? "before" : "after", row: path };
}
// The indent gutter of a nested list belongs to the folder that owns the list, not to the root.
const owner = el
.closest<HTMLElement>(".tree-item")
?.querySelector<HTMLElement>(":scope > .tree-row");
if (owner?.dataset.path) return { dir: owner.dataset.path, mode: "into", row: owner.dataset.path };
const section = el.closest<HTMLElement>(".tree-section");
if (section?.dataset.root) return { dir: section.dataset.root, mode: "into", row: section.dataset.root };
return null;
}
/** A drop that would not move anything, or would move a folder inside itself, is not a drop. */
function usableDrop(target: DropTarget | null, path: string, parent: string): DropTarget | null {
if (!target) return null;
if (target.dir === parent) return null;
if (target.dir === path || target.dir.startsWith(`${path}/`)) return null;
return target;
}
const sameDrop = (a: DropTarget | null, b: DropTarget | null): boolean =>
a?.dir === b?.dir && a?.mode === b?.mode && a?.row === b?.row;
export function Sidebar() {
const roots = useWorkspace((s) => s.roots);
const expanded = useWorkspace((s) => s.expanded);
const selectedPath = useWorkspace((s) => s.selectedPath);
const scanPhase = useWorkspace((s) => s.scanPhase);
const select = useWorkspace((s) => s.select);
const toggleExpanded = useWorkspace((s) => s.toggleExpanded);
const newDocument = useWorkspace((s) => s.newDocument);
const newFolder = useWorkspace((s) => s.newFolder);
const renameEntry = useWorkspace((s) => s.renameEntry);
const duplicateEntry = useWorkspace((s) => s.duplicateEntry);
const deleteEntry = useWorkspace((s) => s.deleteEntry);
const revealInFinder = useWorkspace((s) => s.revealInFinder);
const closeFolder = useWorkspace((s) => s.closeFolder);
const openDocument = useDocument((s) => s.open);
const openPath = useDocument((s) => s.path);
const [hydrated, setHydrated] = useState(false);
const [renamingPath, setRenamingPath] = useState<string | null>(null);
const [menuOpenPath, setMenuOpenPath] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; row: TreeRow } | null>(null);
const [pendingDelete, setPendingDelete] = useState<TreeNode | null>(null);
const [dragPath, setDragPath] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const gesture = useRef<{ path: string; parent: string; x: number; y: number; active: boolean } | null>(null);
const suppressClick = useRef(false);
const dropRef = useRef<DropTarget | null>(null);
const spring = useRef<number | null>(null);
useEffect(() => {
const already = useWorkspace.getState().expanded;
for (const path of readList(EXPANDED_KEY)) if (!already.has(path)) toggleExpanded(path);
setHydrated(true);
}, [toggleExpanded]);
useEffect(() => {
if (!hydrated) return;
writeList(EXPANDED_KEY, [...expanded]);
}, [hydrated, expanded]);
// A folder the user has only just opened should show its contents. One that they opened months
// ago and then collapsed should stay collapsed, which is why "seen" is remembered separately
// rather than inferred from an empty expansion set.
useEffect(() => {
if (!hydrated) return;
const seen = readList(ROOTS_SEEN_KEY);
const fresh = roots.filter((r) => !seen.includes(r.path));
if (!fresh.length) return;
const already = useWorkspace.getState().expanded;
for (const root of fresh) if (!already.has(root.path)) toggleExpanded(root.path);
writeList(ROOTS_SEEN_KEY, [...seen, ...fresh.map((r) => r.path)]);
}, [hydrated, roots, toggleExpanded]);
useEffect(
() => () => {
if (spring.current !== null) clearTimeout(spring.current);
},
[],
);
const rootNodes: TreeNode[] = useMemo(
() =>
roots.map((root) => ({
path: root.path,
name: root.name,
isDir: true,
editable: false,
children: root.tree,
})),
[roots],
);
const rows = useMemo(
() => rootNodes.flatMap((node) => flattenTree([node], expanded, 0, "")),
[rootNodes, expanded],
);
const tabStopPath =
rows.find((r) => r.node.path === selectedPath)?.node.path ?? rows[0]?.node.path ?? null;
const focusRow = (path: string) =>
requestAnimationFrame(() => {
document.querySelector<HTMLElement>(`.tree-row[data-path="${CSS.escape(path)}"]`)?.focus();
});
const moveFocus = (row: TreeRow | undefined) => {
if (!row) return;
select(row.node.path);
focusRow(row.node.path);
};
const activate = (node: TreeNode) => {
if (suppressClick.current) {
suppressClick.current = false;
return;
}
select(node.path);
if (node.isDir) {
toggleExpanded(node.path);
return;
}
if (node.editable) {
openDocument(node.path).catch((e) => notify(`Could not open: ${String(e)}`));
return;
}
openExternal(node.path).catch((e) => notify(`Could not open: ${String(e)}`));
};
const onKeyDown = (e: KeyboardEvent, row: TreeRow) => {
const at = rows.findIndex((r) => r.node.path === row.node.path);
switch (e.key) {
case "Enter":
case " ":
e.preventDefault();
activate(row.node);
break;
case "ArrowDown":
e.preventDefault();
moveFocus(rows[at + 1]);
break;
case "ArrowUp":
e.preventDefault();
moveFocus(rows[at - 1]);
break;
case "Home":
e.preventDefault();
moveFocus(rows[0]);
break;
case "End":
e.preventDefault();
moveFocus(rows[rows.length - 1]);
break;
case "ArrowRight":
e.preventDefault();
if (!row.node.isDir) break;
if (!expanded.has(row.node.path)) toggleExpanded(row.node.path);
else moveFocus(rows[at + 1]);
break;
case "ArrowLeft":
e.preventDefault();
if (row.node.isDir && expanded.has(row.node.path)) toggleExpanded(row.node.path);
else if (row.parentPath) moveFocus(rows.find((r) => r.node.path === row.parentPath));
break;
default:
break;
}
};
const setDrop = (target: DropTarget | null) => {
if (sameDrop(dropRef.current, target)) return;
dropRef.current = target;
setDropTarget(target);
if (spring.current !== null) {
clearTimeout(spring.current);
spring.current = null;
}
if (target?.mode !== "into") return;
const dir = target.dir;
if (useWorkspace.getState().expanded.has(dir)) return;
spring.current = window.setTimeout(() => {
spring.current = null;
if (dropRef.current?.dir === dir) useWorkspace.getState().toggleExpanded(dir);
}, SPRING_MS);
};
const onPointerMove = (e: globalThis.PointerEvent) => {
const g = gesture.current;
if (!g) return;
if (!g.active) {
if (Math.abs(e.clientX - g.x) < DRAG_SLOP && Math.abs(e.clientY - g.y) < DRAG_SLOP) return;
g.active = true;
setDragPath(g.path);
}
setDrop(usableDrop(dropAt(e.clientX, e.clientY), g.path, g.parent));
};
// The disk half of a drop. Everything it does now lives in src/workspace.ts beside renamePath,
// which is where it belonged: refreshing both folders, following the selection, and rewriting the
// relative links the move broke. Calling fileMove from here would move the bytes and leave every
// link pointing at the old path.
const moveInto = async (path: string, dir: string) => {
await movePath(path, dir);
};
const onPointerUp = () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
const g = gesture.current;
gesture.current = null;
const target = dropRef.current;
if (g?.active) {
suppressClick.current = true;
if (target) moveInto(g.path, target.dir).catch((e) => notify(`Could not move: ${String(e)}`));
}
setDragPath(null);
setDrop(null);
};
const onPointerDown = (e: PointerEvent, row: TreeRow) => {
suppressClick.current = false;
if (e.button !== 0 || renamingPath === row.node.path || menuOpenPath === row.node.path) return;
// A root is where its folder lives on disk, not a row inside a tree, so it does not move.
if (!row.parentPath) return;
const target = e.target as HTMLElement;
if (target.closest(".row-menu-btn") || target.closest(".tree-twisty")) return;
gesture.current = { path: row.node.path, parent: row.parentPath, x: e.clientX, y: e.clientY, active: false };
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
};
const onContextMenu = (e: MouseEvent, row: TreeRow) => {
e.preventDefault();
e.stopPropagation();
select(row.node.path);
setContextMenu({ x: e.clientX, y: e.clientY, row });
};
// Open first, then offer the rename: the editor takes focus as it mounts, and a rename field
// that opened before it would be blurred out from under the user mid-word.
const createDocument = (dir: string) => {
newDocument(dir)
.then((path) => {
select(path);
return openDocument(path).then(() => setRenamingPath(path));
})
.catch((e) => notify(`Could not create the document: ${String(e)}`));
};
const createFolder = (dir: string) => {
newFolder(dir)
.then((path) => {
select(path);
setRenamingPath(path);
})
.catch((e) => notify(`Could not create the folder: ${String(e)}`));
};
const copyPath = (path: string) => {
navigator.clipboard
.writeText(path)
.then(() => notify("Path copied"))
.catch(() => notify("Could not copy the path"));
};
const menuItems = (row: TreeRow): readonly RowMenuEntry[] => {
const node = row.node;
const dir = node.isDir ? node.path : row.parentPath;
const isRoot = !row.parentPath;
const items: RowMenuEntry[] = [
{ id: "new-doc", label: "New Document", icon: NEW_DOC_ICON, run: () => createDocument(dir) },
{ id: "new-folder", label: "New Folder", icon: NEW_FOLDER_ICON, run: () => createFolder(dir) },
];
if (!isRoot) {
items.push("sep");
items.push({
id: "rename",
label: "Rename",
icon: RENAME_ICON,
run: () => setRenamingPath(node.path),
});
items.push({
id: "duplicate",
label: "Duplicate",
icon: DUPLICATE_ICON,
run: () =>
duplicateEntry(node.path).catch((e) => notify(`Could not duplicate: ${String(e)}`)),
});
}
items.push("sep");
items.push({
id: "reveal",
label: "Reveal in Finder",
icon: REVEAL_ICON,
run: () =>
revealInFinder(node.path).catch((e) => notify(`Could not reveal in Finder: ${String(e)}`)),
});
items.push({ id: "copy-path", label: "Copy Path", icon: COPY_PATH_ICON, run: () => copyPath(node.path) });
items.push("sep");
if (isRoot)
items.push({
id: "close-folder",
label: "Close Folder",
icon: CLOSE_ICON,
run: () => closeFolder(node.path),
});
else
items.push({
id: "delete",
label: "Delete",
icon: TRASH_ICON,
danger: true,
run: () => setPendingDelete(node),
});
return items;
};
const view: TreeViewState = {
expanded,
selectedPath,
tabStopPath,
draggingPath: dragPath,
dropTarget,
renamingPath,
openPath,
};
const handlers: TreeHandlers = {
onActivate: activate,
onToggle: (node) => {
select(node.path);
toggleExpanded(node.path);
},
onKeyDown,
onPointerDown,
onContextMenu,
onMenuOpenChange: (path, open) =>
setMenuOpenPath((current) => (open ? path : current === path ? null : current)),
menuItems,
// The row hides the extension, so the rename has to put back the one it took away rather than
// quietly turning notes.md into a file with no extension at all.
onRenameCommit: (node, typed) => {
setRenamingPath(null);
const { base, hidden } = splitExtension(node.name, node.isDir);
const trimmed = typed.trim();
if (!trimmed || trimmed === base) return;
renameEntry(node.path, `${trimmed}${hidden}`).catch((e) =>
notify(`Could not rename: ${String(e)}`),
);
},
onRenameCancel: () => setRenamingPath(null),
};
return (
<>
<aside className="sidebar" aria-label="Folders">
<div className="sidebar-head">
<span className="nav-label">Folders</span>
<div className="sidebar-actions">
<button
className="icon-button"
title={shortcutTitle("open-folder")}
aria-label={commandLabel("open-folder")}
onClick={() => runCommand("open-folder")}
>
<Icon d={OPEN_FOLDER} />
</button>
<button
className="icon-button"
title={shortcutTitle("new-doc")}
aria-label={commandLabel("new-doc")}
onClick={() => runCommand("new-doc")}
>
<Icon d={NEW_DOC_ICON} />
</button>
</div>
</div>
<div className="nav-scroll">
{rootNodes.map((node) => (
<div key={node.path} className="tree-section" data-root={node.path}>
<FileTree nodes={[node]} depth={0} parentPath="" state={view} handlers={handlers} />
</div>
))}
{!rootNodes.length && (
<p className="sidebar-empty">
{scanPhase === "scanning" ? "Reading the folder…" : "No folder is open."}
</p>
)}
</div>
</aside>
<ResizeHandle />
{contextMenu && (
<RowMenuAt
x={contextMenu.x}
y={contextMenu.y}
items={() => menuItems(contextMenu.row)}
onClose={() => setContextMenu(null)}
/>
)}
{pendingDelete && (
<ConfirmDialog
title={pendingDelete.isDir ? "Delete folder" : "Delete file"}
message={
<>
Move <strong>{pendingDelete.name}</strong> to the Trash? You can put it back from
Finder.
</>
}
confirmLabel="Move to Trash"
onConfirm={() => {
const path = pendingDelete.path;
setPendingDelete(null);
deleteEntry(path).catch((e) => notify(`Could not delete: ${String(e)}`));
}}
onClose={() => setPendingDelete(null)}
/>
)}
</>
);
}
+227
View File
@@ -0,0 +1,227 @@
// The macOS overlay title bar. The traffic lights float over its left end, which is why the row
// itself carries `data-tauri-drag-region` and every control inside it does not: an interactive
// element that also drags the window swallows its own click.
import { useEffect, useRef, useState } from "react";
import { useEscapeLayer } from "../escape";
import { keyLabel, keysFor } from "../keys/bindings";
import { commandLabel, onCommand, runCommand, type CommandId } from "../keys/commands";
import { useDocument } from "../store/useDocument";
import { useTheme } from "../store/useTheme";
import { notify } from "../store/useToast";
import { useWorkspace } from "../store/useWorkspace";
import { splitExtension } from "./FileTree";
import { Icon } from "./Icon";
import { WidthMenu } from "./WidthMenu";
const SIDEBAR_KEY = "margindocs-sidebar";
const SIDEBAR_ICON = "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z M9 3v18";
const NEW_DOC = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5 M12 12v5 M9.5 14.5h5";
const SEARCH = "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14z M20 20l-3.6-3.6";
const SUN = "M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10z M12 1v2 M12 21v2 M4.2 4.2l1.4 1.4 M18.4 18.4l1.4 1.4 M1 12h2 M21 12h2 M4.2 19.8l1.4-1.4 M18.4 5.6l1.4-1.4";
const MOON = "M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z";
const MORE = "M5 12h.01M12 12h.01M19 12h.01";
/** A tooltip that names the action and prints its key in the glyphs the sheet uses. */
export function shortcutTitle(id: CommandId): string {
const keys = keysFor(id);
return keys.length ? `${commandLabel(id)} (${keyLabel(keys[0])})` : commandLabel(id);
}
export function Titlebar() {
const path = useDocument((s) => s.path);
const dirty = useDocument((s) => s.dirty);
const externalChange = useDocument((s) => s.externalChange);
const renameEntry = useWorkspace((s) => s.renameEntry);
const theme = useTheme((s) => s.theme);
const toggleTheme = useTheme((s) => s.toggle);
const [sidebar, setSidebar] = useState(
() => document.documentElement.getAttribute("data-sidebar") !== "false",
);
const [menu, setMenu] = useState(false);
const [renaming, setRenaming] = useState(false);
useEffect(() => {
document.documentElement.setAttribute("data-sidebar", String(sidebar));
try {
localStorage.setItem(SIDEBAR_KEY, String(sidebar));
} catch {
// A webview with storage denied still toggles, it just forgets between launches.
}
}, [sidebar]);
useEffect(() => onCommand("toggle-sidebar", () => setSidebar((v) => !v)), []);
useEffect(() => setRenaming(false), [path]);
useEscapeLayer(menu, () => setMenu(false));
const fileName = path ? path.slice(path.lastIndexOf("/") + 1) : "";
const { base, hidden } = splitExtension(fileName);
// The filename and the document's H1 are unrelated, and this is the place that promise is
// easiest to break. What the title bar shows is the file on disk, and the only thing that ever
// renames it is the user typing here. Editing a heading is a content edit and nothing else: it
// does not move the file, because a path is what git, every other editor and every relative
// link from another document already agreed on.
const commitRename = (next: string) => {
setRenaming(false);
const trimmed = next.trim();
if (!path || !trimmed || trimmed === base) return;
renameEntry(path, `${trimmed}${hidden}`).catch((e) => notify(`Could not rename: ${String(e)}`));
};
const menuItem = (id: CommandId) => (
<button
key={id}
onClick={() => {
setMenu(false);
runCommand(id);
}}
>
{commandLabel(id)}
</button>
);
return (
<header className="titlebar" data-tauri-drag-region>
<div className="lead">
<button
className="icon-button"
data-active={sidebar}
title={shortcutTitle("toggle-sidebar")}
aria-label={commandLabel("toggle-sidebar")}
aria-pressed={sidebar}
onClick={() => setSidebar((v) => !v)}
>
<Icon d={SIDEBAR_ICON} />
</button>
</div>
{path &&
(renaming ? (
<TitleRename value={base} onCommit={commitRename} onCancel={() => setRenaming(false)} />
) : (
<button
className="doc-title"
data-external={externalChange === "changed-on-disk"}
title={
externalChange === "changed-on-disk"
? `${fileName} (changed on disk). Click to rename.`
: `${fileName}. Click to rename.`
}
onClick={() => setRenaming(true)}
>
{base}
{dirty && <span className="dirty-dot" />}
</button>
))}
<div className="actions">
<button
className="icon-button"
title={shortcutTitle("new-doc")}
aria-label={commandLabel("new-doc")}
onClick={() => runCommand("new-doc")}
>
<Icon d={NEW_DOC} />
</button>
<button
className="icon-button"
title={shortcutTitle("quick-open")}
aria-label={commandLabel("quick-open")}
onClick={() => runCommand("quick-open")}
>
<Icon d={SEARCH} />
</button>
<button
className="icon-button"
title={theme === "dark" ? "Light theme" : "Dark theme"}
aria-label={commandLabel("toggle-theme")}
onClick={toggleTheme}
>
<Icon d={theme === "dark" ? SUN : MOON} />
</button>
<WidthMenu />
<div className="menu-wrap">
<button
className="icon-button"
data-active={menu}
title="More"
aria-label="More"
aria-expanded={menu}
onClick={() => setMenu((v) => !v)}
>
<Icon d={MORE} />
</button>
{menu && (
<>
<div className="menu-backdrop" onClick={() => setMenu(false)} />
<div className="menu" role="menu">
{menuItem("open-folder")}
{menuItem("new-folder")}
<div className="menu-sep" />
{menuItem("find-in-files")}
{menuItem("shortcuts")}
{menuItem("settings")}
<div className="menu-sep" />
{menuItem("check-updates")}
{menuItem("report-issue")}
</div>
</>
)}
</div>
</div>
</header>
);
}
function TitleRename({
value,
onCommit,
onCancel,
}: {
value: string;
onCommit: (next: string) => void;
onCancel: () => void;
}) {
const ref = useRef<HTMLInputElement>(null);
const settled = useRef(false);
useEffect(() => {
const input = ref.current;
if (!input) return;
input.focus();
input.select();
}, []);
const commit = (next: string) => {
if (settled.current) return;
settled.current = true;
onCommit(next);
};
useEscapeLayer(true, () => {
settled.current = true;
onCancel();
});
return (
<input
ref={ref}
className="doc-title title-rename"
defaultValue={value}
spellCheck={false}
autoComplete="off"
aria-label="Rename this file"
onBlur={(e) => commit(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
commit(e.currentTarget.value);
}}
/>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect } from "react";
import { useToast } from "../store/useToast";
const DWELL_MS = 4200;
export function Toast() {
const message = useToast((s) => s.message);
const dismiss = useToast((s) => s.dismiss);
useEffect(() => {
if (!message) return;
const timer = setTimeout(dismiss, DWELL_MS);
return () => clearTimeout(timer);
}, [message, dismiss]);
if (!message) return null;
return (
<div className="toast" role="status" title="Dismiss" onClick={dismiss}>
{message}
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
// The width control there was no way to click: a title bar button that shows the applied width and
// opens the three named steps with the current one marked.
//
// It belongs beside the theme toggle rather than in the editor pill. Everything in the pill edits
// the file; this edits the app's view of it and touches no byte on disk, and the title bar already
// holds the other two of exactly that kind, the sidebar and the theme, both persisted under the
// same `margindocs-` prefix and both restored by the same boot script. The pill is also the wrong
// place mechanically: its tools go dead while a save conflict is open, and being unable to widen
// the page because the file moved on disk is nonsense, and the foot of src/styles/toolbar.css
// records that the row is already four pixels over the pane at the app's minimum window.
//
// The DOM is the source of truth and this component's state is a cache of it. `applyWidth` writes
// `data-width` on the root element and index.html's boot script writes it before React exists,
// while the keyboard commands and the native menu both call `applyWidth` without telling anyone,
// so the attribute is read on mount and watched with a MutationObserver. A component that
// remembered the last width it set itself would open showing the wrong one the first time somebody
// reached for the key instead.
//
// Focus is not taken from the document. A mouse press on any button here is prevented, so the
// caret stays in the sentence somebody is in the middle of; only a keyboard open moves focus into
// the menu, and closing puts it back where it came from.
import { useEffect, useId, useRef, useState } from "react";
import { useEscapeLayer } from "../escape";
import { applyWidth, WIDTHS, type EditorWidth } from "../width";
import { Icon } from "./Icon";
const ITEM = ".width-menu-item";
const CHECK_D = "M20 6L9 17l-5-5";
/** The page's two edges with three lines of text between them, so the button says which width is
* applied without spending a word of the title bar on it. The edges never move and only the
* measure does, which is the whole of what the setting changes. The sibling's glyph for this is a
* double headed arrow, and it is not ported: an arrow six units long is a smudge at 16px, which is
* the only size this is ever drawn at. */
const WIDTH_ICON: Record<EditorWidth, string> = {
narrow: "M3 4v16M21 4v16M9 7h6M9 12h6M9 17h6",
normal: "M3 4v16M21 4v16M7 7h10M7 12h10M7 17h10",
wide: "M3 4v16M21 4v16M5 7h14M5 12h14M5 17h14",
};
function isWidth(value: string | null): value is EditorWidth {
return WIDTHS.includes(value as EditorWidth);
}
/** Capitalised for a menu. The names themselves belong to src/width.ts and the command ids. */
function widthLabel(width: EditorWidth): string {
return width.charAt(0).toUpperCase() + width.slice(1);
}
/** No attribute at all is the default, because sheet.css only writes rules for narrow and wide and
* the boot script only sets the attribute when something was saved. */
function appliedWidth(): EditorWidth {
const value = document.documentElement.getAttribute("data-width");
return isWidth(value) ? value : "normal";
}
function useAppliedWidth(): EditorWidth {
const [width, setWidth] = useState(appliedWidth);
useEffect(() => {
const read = () => setWidth(appliedWidth());
const observer = new MutationObserver(read);
observer.observe(document.documentElement, { attributeFilter: ["data-width"] });
// The attribute can have moved between the first render and this effect running.
read();
return () => observer.disconnect();
}, []);
return width;
}
export function WidthMenu() {
const width = useAppliedWidth();
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
/** Where focus was when a keyboard user opened the menu, and null when a mouse user did, since
* that press never moved it. */
const returnTo = useRef<HTMLElement | null>(null);
const focusOnOpen = useRef(false);
const labelId = useId();
const close = (restoreFocus = true) => {
setOpen(false);
const el = returnTo.current;
returnTo.current = null;
if (restoreFocus && el?.isConnected) el.focus();
};
useEscapeLayer(open, () => close());
useEffect(() => {
if (!open || !focusOnOpen.current) return;
focusOnOpen.current = false;
menuRef.current?.querySelector<HTMLElement>(ITEM)?.focus();
}, [open]);
const toggle = (e: React.MouseEvent) => {
if (open) {
close();
return;
}
// `detail` is 0 when Enter or Space activated the button and 1 when a pointer did. A keyboard
// user cannot reach the items unless focus is moved into the menu; a mouse user is mid
// sentence and would lose their caret to a setting that has nothing to do with the text.
const byKeyboard = e.detail === 0;
returnTo.current = byKeyboard ? (document.activeElement as HTMLElement | null) : null;
focusOnOpen.current = byKeyboard;
setOpen(true);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const all = Array.from(menuRef.current?.querySelectorAll<HTMLElement>(ITEM) ?? []);
const at = all.indexOf(document.activeElement as HTMLElement);
const next = e.key === "ArrowDown" ? (at + 1) % all.length : (at - 1 + all.length) % all.length;
all[next]?.focus();
};
const choose = (next: EditorWidth) => {
applyWidth(next);
close();
};
const name = `Editor width: ${widthLabel(width)}`;
return (
<div
className="menu-wrap"
// Tabbing out of an open menu has to leave the menu behind, since nothing here traps focus,
// and focus that has deliberately gone somewhere else is not dragged back.
onBlur={(e) => {
if (!open || e.currentTarget.contains(e.relatedTarget)) return;
close(false);
}}
>
<button
className="icon-button"
data-active={open}
title={name}
aria-label={name}
aria-haspopup="menu"
aria-expanded={open}
onMouseDown={(e) => e.preventDefault()}
onClick={toggle}
>
<Icon d={WIDTH_ICON[width]} />
</button>
{open && (
<>
<div className="menu-backdrop" onClick={() => close()} />
<div
ref={menuRef}
className="menu"
role="menu"
aria-labelledby={labelId}
onKeyDown={onKeyDown}
>
{/* Three words that mean nothing on their own, so the menu says what they are a width
of and then lends the same line to assistive tech as its own name. Presentational
because a menu's children are meant to be its items, and because being announced as
the menu's name and again as a line inside it is the same sentence twice. */}
<div className="menu-label" id={labelId} role="presentation">
Editor width
</div>
{WIDTHS.map((w) => (
<button
key={w}
className="width-menu-item"
role="menuitemradio"
aria-checked={w === width}
data-on={w === width}
onMouseDown={(e) => e.preventDefault()}
onClick={() => choose(w)}
>
<span className="width-menu-check" aria-hidden="true">
<Icon d={CHECK_D} size={14} />
</span>
{widthLabel(w)}
</button>
))}
</div>
</>
)}
</div>
);
}
+290
View File
@@ -0,0 +1,290 @@
// A dev-only folder of documents, held in memory. It exists so the real UI can be opened in a
// plain browser with no Tauri behind it, by a person or by Playwright, without pointing the app at
// anybody's actual files.
//
// It is shaped like a folder somebody would really have rather than three files called test.md,
// because every interesting case in this app is a case the tree has to render: nesting several
// levels deep, a .txt that is editable, a .png that is not, an assets folder beside a document,
// frontmatter, a callout, a table, a toggle, a code block, and relative links between documents
// so backlinks have something to find.
//
// Anchored to the current time at load, so the tree never shows a modified date from last year.
import type { FileKind, RootInfo } from "../ipc";
const MINUTE = 60_000;
const HOUR = 3_600_000;
const DAY = 86_400_000;
const now = Date.now();
/** One file or directory. `text` is empty for a directory and for anything binary. */
export interface DevEntry {
path: string;
dir: boolean;
text: string;
/** Not a text file. Greyed in the tree, opened by the system, refused by `file_read`. */
binary: boolean;
modifiedMs: number;
}
export const HANDBOOK = "/Users/you/Documents/Handbook";
export const SCRATCH = "/Users/you/Documents/Scratch";
/** Stable for a given path, the way the Rust side derives a root id from the folder it opened. */
export const rootIdFor = (path: string): string =>
path
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
export const devRoots: RootInfo[] = [
{ id: rootIdFor(HANDBOOK), path: HANDBOOK, name: "Handbook", openedMs: now - 6 * DAY },
{ id: rootIdFor(SCRATCH), path: SCRATCH, name: "Scratch", openedMs: now - 2 * HOUR },
];
const readme = `---
title: Handbook
updated: 2026-02-11
tags: [team, reference]
---
# Handbook
Everything the team needs, in one folder, in plain markdown. Nothing here is generated and nothing
here needs an account to read.
Start with [Getting started](guides/getting-started.md). Skim [Writing](guides/writing.md) before
you open your first pull request, and keep the
[keyboard reference](reference/keyboard.md) somewhere you can see it.
## What lives where
Guides are the things you read once. The reference is the thing you come back to. Anything under
\`archive/\` is kept because deleting it would lose the argument, not because it is still true.
`;
const gettingStarted = `---
title: Getting started
tags: [onboarding]
---
# Getting started
Clone the repository and run the app once before you change anything. It is much easier to read a
diff when you have seen the thing the diff is about.
\`\`\`sh
git clone [email protected]:example/handbook.git
cd handbook
pnpm install
pnpm dev
\`\`\`
> [!NOTE]
> The first run builds the search index. On a folder this size it takes a second or two, and quick
> open stays empty until it finishes.
## Your editor
Any editor is fine. Two settings are not optional: trim trailing whitespace, and end every file
with a newline. Without them every pull request carries noise nobody wrote.
> [!WARNING]
> Do not edit anything under \`archive/\`. Those documents are kept as a record and a change there
> will not be reviewed.
When something is bound to a key, [the keyboard reference](../reference/keyboard.md) is the list.
`;
const writing = `---
title: Writing
---
# Writing
Short sentences. Say the thing, then stop. If a paragraph is doing two jobs, it is two paragraphs.
## What the editor does with what you type
| You write | On disk | In the editor |
| --- | --- | --- |
| A note | \`> [!NOTE]\` | a tinted block with a title |
| A toggle | \`<details>\` | a disclosure arrow |
| A link | \`[text](path.md)\` | underlined, click to follow |
| A table | pipes and dashes | a real table with a header row |
The file on disk stays plain markdown. Anything the editor cannot model is left exactly as it was
found and shown as a raw block you can still edit.
<details>
<summary>House style, the short version</summary>
No em dashes. No exclamation marks. Do not start a sentence with "Basically". If you catch
yourself writing "simply", delete it and read the sentence again.
</details>
## Before you open a pull request
Read it out loud once. Then read [Getting started](getting-started.md) if you have not, because
half of what gets flagged in review is covered there already, and check the
[handbook index](../README.md) still points at your new page.
`;
const keyboard = `---
title: Keyboard reference
---
# Keyboard reference
| Key | Does |
| --- | --- |
| \`Cmd P\` | Quick open, fuzzy match on the whole path |
| \`Cmd Shift F\` | Search the text of every open folder |
| \`Cmd S\` | Save |
| \`Cmd N\` | New document, in the selected folder |
| \`Cmd O\` | Open a folder |
| \`Cmd \\\` | Show or hide the sidebar |
| \`Cmd B\` | Bold |
| \`Cmd K\` | Command palette |
Nothing here is configurable yet. If a key is wrong for you, say so and it can move.
`;
const retro = `---
title: 2024 retro
archived: true
---
# 2024 retro
Kept for the record. Most of this is out of date and none of it should be edited.
## What went well
Shipping small and often. The three week gap in July is the only stretch nobody enjoyed, and it
was the week the build broke twice.
## What did not
Documentation drifted from the code for most of the second half of the year, which is the reason
this folder exists at all.
`;
const notes = `Scratch notes, not markdown, still editable.
Ask about the archive folder. Nobody seems to know who owns it.
Chase the design review before Thursday.
The index rebuild takes longer than it should on the big folder.
`;
const inbox = `# Inbox
Things that have not found a home yet.
- Move the keyboard reference into the guides folder, or do not, but decide.
- A callout for "deprecated" would be useful.
- Check whether the .txt files should be indexed too.
`;
const todo = `Buy a new keyboard
Reply to the design review thread
Rebuild the index after the folder move
`;
/**
* A one pixel PNG. The point of it is the tree row, not the image: it proves a file the editor
* will not open is greyed and hands itself to the system instead.
*/
export const devPng =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
const dir = (path: string, modifiedMs: number): DevEntry => ({
path,
dir: true,
text: "",
binary: false,
modifiedMs,
});
const file = (path: string, text: string, modifiedMs: number): DevEntry => ({
path,
dir: false,
text,
binary: false,
modifiedMs,
});
export const devEntries: DevEntry[] = [
dir(HANDBOOK, now - 20 * MINUTE),
file(`${HANDBOOK}/README.md`, readme, now - 20 * MINUTE),
dir(`${HANDBOOK}/guides`, now - 3 * HOUR),
file(`${HANDBOOK}/guides/getting-started.md`, gettingStarted, now - 3 * HOUR),
file(`${HANDBOOK}/guides/writing.md`, writing, now - 2 * DAY),
dir(`${HANDBOOK}/reference`, now - 5 * DAY),
file(`${HANDBOOK}/reference/keyboard.md`, keyboard, now - 5 * DAY),
dir(`${HANDBOOK}/reference/assets`, now - 5 * DAY),
{
path: `${HANDBOOK}/reference/assets/diagram.png`,
dir: false,
text: "",
binary: true,
modifiedMs: now - 5 * DAY,
},
dir(`${HANDBOOK}/archive`, now - 200 * DAY),
dir(`${HANDBOOK}/archive/2024`, now - 200 * DAY),
file(`${HANDBOOK}/archive/2024/retro.md`, retro, now - 200 * DAY),
file(`${HANDBOOK}/notes.txt`, notes, now - 45 * MINUTE),
dir(SCRATCH, now - 90 * MINUTE),
file(`${SCRATCH}/inbox.md`, inbox, now - 90 * MINUTE),
file(`${SCRATCH}/todo.txt`, todo, now - 8 * HOUR),
];
export const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
export const dirName = (path: string): string => path.slice(0, path.lastIndexOf("/")) || "/";
export const joinPath = (parent: string, name: string): string =>
parent.endsWith("/") ? `${parent}${name}` : `${parent}/${name}`;
export const extensionOf = (path: string): string => {
const name = baseName(path);
const dot = name.lastIndexOf(".");
return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
};
export function kindOf(entry: DevEntry): FileKind {
if (entry.dir) return "dir";
const ext = extensionOf(entry.path);
if (ext === "md" || ext === "markdown") return "markdown";
if (ext === "txt") return "text";
return "other";
}
export const editableKind = (kind: FileKind): boolean => kind === "markdown" || kind === "text";
/** Frontmatter title first, then the first heading, then the filename. What the Rust index does. */
export function titleOf(path: string, text: string): string {
const front = /^---\n([\s\S]*?)\n---/.exec(text);
const titled = front && /^title:\s*(.+)$/m.exec(front[1]);
if (titled) return titled[1].trim().replace(/^["']|["']$/g, "");
const heading = /^#\s+(.+)$/m.exec(text);
if (heading) return heading[1].trim();
return baseName(path);
}
/** Resolves `](../thing.md)` against the document that wrote it. Null for anything not local. */
export function resolveRelative(fromFile: string, target: string): string | null {
if (!target || /^[a-z]+:/i.test(target) || target.startsWith("#")) return null;
const clean = target.split("#")[0].split("?")[0];
if (!clean) return null;
const parts = clean.startsWith("/") ? clean.split("/") : `${dirName(fromFile)}/${clean}`.split("/");
const out: string[] = [];
for (const part of parts) {
if (part === "" || part === ".") continue;
if (part === "..") out.pop();
else out.push(part);
}
return `/${out.join("/")}`;
}
+564
View File
@@ -0,0 +1,564 @@
// Serves the IPC surface from the dev fixture when the app is opened in a browser rather than in
// Tauri. This exists so the real UI can be driven and looked at, by a person or by Playwright,
// without a build of the Rust side and without pointing the app at real documents.
//
// It is reachable only when `import.meta.env.DEV` is true and `isTauri` is false, so it is absent
// from a production bundle and can never shadow the real backend inside the app.
//
// Writes mutate the fixture for the session, so creating a document and typing into it behaves
// the way it will on disk.
//
// `external` at the bottom is the other half: the world outside the app, for a test that needs a
// file to change while the app is looking at it. It mutates the fixture the way another program
// would, behind the app's back and without going through `file_write`, and hands back the exact
// `WatchEvent` payloads the Rust watcher would have emitted for what it did. Emitting them is the
// caller's job, because emitting means the Tauri event bus and this module has no opinion about
// where that comes from. src-tauri/tests/watch_payload.rs is what keeps those payloads honest.
import type {
AssetResult,
Backlink,
FileNode,
IndexStatus,
MatchRange,
QuickOpenHit,
ReadResult,
RootInfo,
SearchHit,
SpellIssue,
WatchEvent,
WriteResult,
} from "../ipc";
import {
baseName,
devEntries,
devRoots,
dirName,
editableKind,
extensionOf,
joinPath,
kindOf,
resolveRelative,
rootIdFor,
titleOf,
type DevEntry,
} from "./fixture";
const entries = new Map<string, DevEntry>(devEntries.map((e) => [e.path, { ...e }]));
const roots: RootInfo[] = devRoots.map((r) => ({ ...r }));
/** Roots with a watch running, so `external` can refuse to invent an event nobody subscribed to. */
const watching = new Set<string>();
/**
* A modification time that is always newer than the last one handed out. `Date.now()` twice in the
* same millisecond is two writes the app cannot tell apart, and telling them apart is the entire
* mechanism behind conflict detection and the reload guard.
*/
let lastStamp = 0;
function stamp(): number {
lastStamp = Math.max(Date.now(), lastStamp + 1);
return lastStamp;
}
/** Held writes, for a test that needs a buffer to stay dirty while something else touches disk. */
let writeGate: Promise<void> | null = null;
let openGate: (() => void) | null = null;
/**
* A first launch, which the fixture otherwise has no way to show: it is seeded with two open
* folders, so the empty state somebody new actually opens on was the one screen nobody could look
* at. With this set there are no roots and the tree comes back empty.
*/
const firstRun = (): boolean => {
try {
return localStorage.getItem("margindocs-dev-empty") === "1";
} catch {
return false;
}
};
function entryAt(path: string): DevEntry {
const entry = entries.get(path);
if (!entry) throw new Error(`no such file: ${path}`);
return entry;
}
function rootFor(path: string): RootInfo | undefined {
return roots.find((r) => path === r.path || path.startsWith(`${r.path}/`));
}
const relTo = (root: RootInfo, path: string): string => path.slice(root.path.length + 1);
function childrenOf(path: string): DevEntry[] {
const prefix = `${path}/`;
return [...entries.values()]
.filter((e) => e.path.startsWith(prefix) && !e.path.slice(prefix.length).includes("/"))
.sort((a, b) => {
if (a.dir !== b.dir) return a.dir ? -1 : 1;
return a.path.localeCompare(b.path, undefined, { sensitivity: "base" });
});
}
function nodeFor(entry: DevEntry): FileNode {
const kind = kindOf(entry);
return {
path: entry.path,
name: baseName(entry.path),
kind,
editable: editableKind(kind),
modifiedMs: entry.modifiedMs,
children: entry.dir ? childrenOf(entry.path).map(nodeFor) : [],
};
}
/** Every descendant of a directory, the directory itself included, deepest last. */
function subtree(path: string): DevEntry[] {
const prefix = `${path}/`;
return [...entries.values()].filter((e) => e.path === path || e.path.startsWith(prefix));
}
/** `name` is a suggestion. A taken one gets a numbered suffix, the way the Rust side does it. */
function freePath(parent: string, name: string): string {
const candidate = joinPath(parent, name);
if (!entries.has(candidate)) return candidate;
const dot = name.lastIndexOf(".");
const stem = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : "";
for (let n = 2; ; n += 1) {
const next = joinPath(parent, `${stem} ${n}${ext}`);
if (!entries.has(next)) return next;
}
}
function put(entry: DevEntry): DevEntry {
entries.set(entry.path, entry);
return entry;
}
/** Moves an entry and everything under it, which is the same operation for a rename and a move. */
function relocate(from: string, to: string): DevEntry {
for (const entry of subtree(from)) {
entries.delete(entry.path);
entries.set(entry.path === from ? to : to + entry.path.slice(from.length), {
...entry,
path: entry.path === from ? to : to + entry.path.slice(from.length),
});
}
return entryAt(to);
}
const textFiles = (): DevEntry[] =>
[...entries.values()].filter((e) => !e.dir && !e.binary && editableKind(kindOf(e)));
/** Subsequence match, the cheap kind quick open wants: every query character in order. */
function fuzzy(haystack: string, query: string): { score: number; ranges: MatchRange[] } | null {
const lower = haystack.toLowerCase();
const needle = query.toLowerCase().replace(/\s+/g, "");
if (!needle) return { score: 0, ranges: [] };
const ranges: MatchRange[] = [];
let at = 0;
let score = 0;
let previous = -2;
for (const character of needle) {
const found = lower.indexOf(character, at);
if (found < 0) return null;
// Runs read as a word and score far better than the same letters scattered over a path.
score += found === previous + 1 ? 8 : 1;
if (found > lower.lastIndexOf("/")) score += 4;
const last = ranges[ranges.length - 1];
if (last && last.end === found) last.end = found + 1;
else ranges.push({ start: found, end: found + 1 });
previous = found;
at = found + 1;
}
return { score: score - Math.floor(haystack.length / 10), ranges };
}
/** A window of the line around the first match, so a long line does not fill the results list. */
function snippetAround(line: string, start: number, length: number) {
const from = Math.max(0, start - 32);
const head = from > 0 ? "…" : "";
const body = line.slice(from, from + 160);
const tail = from + 160 < line.length ? "…" : "";
return {
snippet: `${head}${body}${tail}`,
range: { start: head.length + (start - from), end: head.length + (start - from) + length },
};
}
/**
* The whole vocabulary of the dev spell checker. Real spelling comes from the system and this is
* only ever a stand-in for a browser, so the list is short on purpose: it holds the words someone
* exercising the feature is likely to type at it and nothing else.
*/
const DEV_MISSPELLINGS: Record<string, string[]> = {
teh: ["the", "then", "tea"],
recieve: ["receive", "relieve"],
seperate: ["separate", "desperate"],
occured: ["occurred"],
definately: ["definitely", "defiantly"],
accomodate: ["accommodate"],
wierd: ["weird", "wired"],
begining: ["beginning"],
neccessary: ["necessary"],
publically: ["publicly"],
writting: ["writing", "written"],
markdwon: ["markdown"],
};
/** Words `spell_learn` was told about this session. The real checker teaches the whole machine. */
const devLearned = new Set<string>();
export async function mockCall<T>(command: string, args?: Record<string, unknown>): Promise<T> {
const a = (args ?? {}) as Record<string, never>;
switch (command) {
case "roots_list":
return (firstRun() ? [] : roots) as unknown as T;
case "root_open": {
const path = a.path as unknown as string;
const existing = roots.find((r) => r.path === path);
if (existing) return existing as unknown as T;
const opened: RootInfo = {
id: rootIdFor(path),
path,
name: baseName(path),
openedMs: Date.now(),
};
roots.push(opened);
if (!entries.has(path)) {
put({ path, dir: true, text: "", binary: false, modifiedMs: Date.now() });
}
return opened as unknown as T;
}
case "root_close": {
const id = a.rootId as unknown as string;
const at = roots.findIndex((r) => r.id === id);
if (at >= 0) roots.splice(at, 1);
return undefined as T;
}
case "tree_read": {
const root = roots.find((r) => r.id === (a.rootId as unknown as string));
if (!root) throw new Error(`no such root: ${a.rootId as unknown as string}`);
return nodeFor(entryAt(root.path)) as unknown as T;
}
case "reveal_in_finder":
case "open_external":
// Nothing to hand a file to in a browser tab, so say so rather than looking broken.
console.info(`dev mock: ${command} ${a.path as unknown as string}`);
return undefined as T;
case "file_read": {
const entry = entryAt(a.path as unknown as string);
if (entry.dir || entry.binary) throw new Error(`not a text file: ${entry.path}`);
return {
path: entry.path,
text: entry.text,
modifiedMs: entry.modifiedMs,
} satisfies ReadResult as unknown as T;
}
case "file_write": {
// Held only when a test has asked for it, so a buffer can be observed dirty while something
// outside the app changes the same file.
if (writeGate) await writeGate;
const entry = entryAt(a.path as unknown as string);
const expected = a.expectedModifiedMs as unknown as number | undefined;
if (typeof expected === "number" && expected !== entry.modifiedMs) {
return {
path: entry.path,
modifiedMs: entry.modifiedMs,
conflict: true,
} satisfies WriteResult as unknown as T;
}
entry.text = a.text as unknown as string;
entry.modifiedMs = stamp();
return {
path: entry.path,
modifiedMs: entry.modifiedMs,
conflict: false,
} satisfies WriteResult as unknown as T;
}
case "file_create": {
const path = freePath(a.parentPath as unknown as string, a.name as unknown as string);
return nodeFor(
put({ path, dir: false, text: "", binary: false, modifiedMs: stamp() }),
) as unknown as T;
}
case "file_folder_create": {
const path = freePath(a.parentPath as unknown as string, a.name as unknown as string);
return nodeFor(
put({ path, dir: true, text: "", binary: false, modifiedMs: stamp() }),
) as unknown as T;
}
case "file_rename": {
const from = a.path as unknown as string;
return nodeFor(
relocate(from, freePath(dirName(from), a.name as unknown as string)),
) as unknown as T;
}
case "file_move": {
const from = a.path as unknown as string;
const to = freePath(a.destDir as unknown as string, baseName(from));
return nodeFor(relocate(from, to)) as unknown as T;
}
case "file_duplicate": {
const source = entryAt(a.path as unknown as string);
const ext = extensionOf(source.path);
const stem = ext ? baseName(source.path).slice(0, -(ext.length + 1)) : baseName(source.path);
const path = freePath(dirName(source.path), ext ? `${stem} copy.${ext}` : `${stem} copy`);
return nodeFor(put({ ...source, path, modifiedMs: stamp() })) as unknown as T;
}
case "file_trash": {
for (const entry of subtree(a.path as unknown as string)) entries.delete(entry.path);
return undefined as T;
}
case "asset_write": {
const folder = joinPath(dirName(a.docPath as unknown as string), "assets");
if (!entries.has(folder)) {
put({ path: folder, dir: true, text: "", binary: false, modifiedMs: stamp() });
}
const path = freePath(folder, (a.name as unknown as string) || "image.png");
put({ path, dir: false, text: "", binary: true, modifiedMs: stamp() });
return {
path,
relPath: `assets/${baseName(path)}`,
} satisfies AssetResult as unknown as T;
}
case "watch_start":
watching.add(a.rootId as unknown as string);
return undefined as T;
case "watch_stop":
watching.delete(a.rootId as unknown as string);
return undefined as T;
case "index_rebuild":
case "index_status": {
const total = textFiles().length;
return {
phase: "idle",
indexed: total,
total,
lastIndexed: Date.now(),
error: null,
message: null,
} satisfies IndexStatus as unknown as T;
}
case "search_quick_open": {
const query = a.query as unknown as string;
const limit = (a.limit as unknown as number) ?? 30;
const hits: QuickOpenHit[] = [];
for (const entry of textFiles()) {
const root = rootFor(entry.path);
if (!root) continue;
const relPath = relTo(root, entry.path);
const match = fuzzy(relPath, query);
if (!match) continue;
hits.push({
path: entry.path,
name: baseName(entry.path),
root: root.id,
relPath,
score: match.score,
ranges: match.ranges,
});
}
return hits.sort((x, y) => y.score - x.score).slice(0, limit) as unknown as T;
}
case "search_text": {
const query = (a.query as unknown as string) ?? "";
const limit = (a.limit as unknown as number) ?? 100;
const needle = query.toLowerCase();
const hits: SearchHit[] = [];
if (!needle.trim()) return [] as unknown as T;
for (const entry of textFiles()) {
const root = rootFor(entry.path);
if (!root) continue;
const title = titleOf(entry.path, entry.text);
entry.text.split("\n").forEach((line, index) => {
const at = line.toLowerCase().indexOf(needle);
if (at < 0 || hits.length >= limit) return;
const { snippet, range } = snippetAround(line, at, needle.length);
hits.push({
path: entry.path,
root: root.id,
title,
line: index + 1,
snippet,
ranges: [range],
});
});
}
return hits.slice(0, limit) as unknown as T;
}
case "backlinks_for": {
const target = a.path as unknown as string;
const found: Backlink[] = [];
for (const entry of textFiles()) {
if (entry.path === target || kindOf(entry) !== "markdown") continue;
const lines = entry.text.split("\n");
const line = lines.find((text) =>
[...text.matchAll(/\]\(([^)\s]+)\)/g)].some(
(m) => resolveRelative(entry.path, m[1]) === target,
),
);
if (line === undefined) continue;
found.push({
path: entry.path,
title: titleOf(entry.path, entry.text),
snippet: line.trim(),
});
}
return found as unknown as T;
}
// Spelling in a browser is not the system checker and cannot be: NSSpellChecker is not
// reachable from a page. What it is instead is a fixed list of misspellings, which is enough to
// put a real underline under a real word and open a real menu of suggestions over it. A dev
// fixture that flagged every word it did not recognise would need a dictionary, and shipping
// one here to exercise a feature whose whole point is not shipping one would be absurd.
case "spell_available":
return true as unknown as T;
case "spell_check": {
const text = (a.text as unknown as string) ?? "";
const issues: SpellIssue[] = [];
for (const match of text.matchAll(/[\p{L}']+/gu)) {
const word = match[0];
const guesses = DEV_MISSPELLINGS[word.toLowerCase()];
if (guesses === undefined || devLearned.has(word.toLowerCase())) continue;
issues.push({
start: match.index,
end: match.index + word.length,
word,
// Matching the case of what was typed, because a suggestion that comes back lower case
// for a word opening a sentence is a correction the user then has to correct.
suggestions: guesses.map((guess) =>
word[0] === word[0].toUpperCase() ? guess[0].toUpperCase() + guess.slice(1) : guess,
),
});
}
return issues as unknown as T;
}
case "spell_learn":
devLearned.add((a.word as unknown as string).toLowerCase());
return undefined as T;
case "spell_unlearn":
devLearned.delete((a.word as unknown as string).toLowerCase());
return undefined as T;
default:
throw new Error(`dev mock has no handler for ${command}`);
}
}
/**
* The world outside the app: what another program does to the folder while it is open.
*
* Every function here mutates the fixture directly rather than going through `mockCall`, which is
* the point. A change made this way has not been through `file_write`, so nothing has registered a
* self-write against it and nothing has told the open document its file moved on: it is a change
* the app can only find out about from a watch event, exactly like a change made by vim or by a
* git checkout.
*
* The return value is the `WatchEvent` list the Rust watcher would have emitted for that change,
* in the order it would have emitted them. A rename is two events and not one, because FSEvents
* describes the two ends as unrelated and src-tauri/src/watch.rs reports what it is told; see
* `a_rename_is_reported_at_both_ends` in src-tauri/tests/watch.rs.
*
* A change to a root with no watch running is an error rather than an event. Nothing outside a
* watched folder is reported to anybody, so a test that gets an event out of this has also proved
* that opening the folder started the watch.
*/
function watchEventFor(
path: string,
kind: WatchEvent["kind"],
oldPath: string | null = null,
): WatchEvent {
const root = rootFor(path);
if (!root) throw new Error(`no open root owns ${path}`);
if (!watching.has(root.id)) throw new Error(`no watch is running on ${root.path}`);
return { root: root.id, path, kind, oldPath };
}
export const external = {
/** Another program rewrites the file. New bytes, new modification time. */
write(path: string, text: string): WatchEvent[] {
const entry = entryAt(path);
entry.text = text;
entry.modifiedMs = stamp();
return [watchEventFor(path, "modified")];
},
/**
* The same bytes, a newer modification time: `touch`, a git checkout that restores what was
* already there, a backup tool. The watcher cannot tell this from a real edit and reports it as
* one, which is why the app compares bytes and not just timestamps.
*/
touch(path: string): WatchEvent[] {
entryAt(path).modifiedMs = stamp();
return [watchEventFor(path, "modified")];
},
/** An event with nothing behind it, for proving what the app does with a change that is not one. */
signal(path: string, kind: WatchEvent["kind"]): WatchEvent[] {
return [watchEventFor(path, kind)];
},
remove(path: string): WatchEvent[] {
entryAt(path);
const event = watchEventFor(path, "removed");
for (const entry of subtree(path)) entries.delete(entry.path);
return [event];
},
rename(from: string, to: string): WatchEvent[] {
relocate(from, to);
return [watchEventFor(from, "removed"), watchEventFor(to, "created")];
},
/** What is actually on disk now, for asserting that the app has written nothing it should not. */
read(path: string): string | null {
const entry = entries.get(path);
return entry && !entry.dir ? entry.text : null;
},
exists(path: string): boolean {
return entries.has(path);
},
/**
* Holds every `file_write` until `resumeWrites`. A save that cannot land is how a test keeps a
* buffer dirty for as long as it needs to, instead of racing the 500ms autosave.
*/
pauseWrites(): void {
if (writeGate) return;
writeGate = new Promise<void>((resolve) => {
openGate = resolve;
});
},
resumeWrites(): void {
openGate?.();
writeGate = null;
openGate = null;
},
};
+487
View File
@@ -0,0 +1,487 @@
// Everything the open document does to the disk, and the only place that decides when. The store
// next door holds what is on screen and the setters a keystroke can settle on its own; this module
// reads the file, hands it to the markdown bridge, and writes it back 500ms after the last edit.
//
// Opening writes nothing. There is exactly one call to `fileWrite` in this file, it sits inside
// `performSave`, and `performSave` returns before reaching it unless the buffer is dirty, which
// only `setContent` can make it. That is the product's first promise and
// src/store/useDocument.test.ts asserts it rather than trusting this paragraph.
//
// `setContent` marks the buffer dirty through `differsFromDisk` below, which is the second half of
// that promise. That question is asked of the document the file was read from and never of the
// keystroke before, so the answer is "is the buffer different" rather than "did something happen":
// a paragraph typed into and then undone is the document that was opened, and it does not put the
// file on the debounce. A transaction that moved something the markdown has no spelling for gets
// the same answer for the same reason, since the file would not show it either. Dragging a table
// column is the whole of that today.
//
// `performSave` also never runs twice at once for the open document: `saveNow` keeps at most one
// call to it on the wire, folding anything that arrives while one is running into a single next
// lap rather than starting a second write alongside the first.
//
// This module and src/store/useDocument.ts import each other: the store's async actions delegate
// down here, and the work down here lands back in the store. Neither touches the other while its
// own module body is still evaluating, so the cycle resolves. Nothing here runs at import time for
// the same reason: the subscription that drives the debounce is installed by `initDocument`, which
// `loadDocument` calls itself so the shell cannot forget to.
import type { Mark, Node as ProseMirrorNode } from "@tiptap/pm/model";
import { fileRead, fileWrite } from "./api/files";
import type { ReadResult, WriteResult } from "./ipc";
import {
parseMarkdown,
parsePlainText,
serializeMarkdown,
serializePlainText,
} from "./markdown";
import { documentKindForPath, type MarkdownDocument } from "./model/doc";
import { useDocument } from "./store/useDocument";
import { notify } from "./store/useToast";
/** Long enough that a sentence is one save, short enough that Cmd+Tab away is already on disk. */
export const SAVE_DEBOUNCE_MS = 500;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let unsubscribe: (() => void) | null = null;
/**
* The file as this module last saw it, either read or written: its bytes, and the tree those bytes
* are the serialization of.
*
* The bytes are here because a watcher fires on a touch, on a git checkout that restores the same
* content and on this app's own save, and without them the buffer would be thrown away and rebuilt
* for all three. The tree is here because the bytes cannot answer whether the buffer still holds
* the document that was read: a hand written file does not serialize to its own bytes, so from the
* moment it opens the two differ for house style reasons that have nothing to do with any edit.
*
* Either can be null on its own, because they answer different questions. Null bytes mean the file
* no longer holds the bytes this module last saw, so there is nothing a write could be compared
* against and `performSave` reads it as "write". A null document means the file holds a document
* this module has never had, somebody else's copy or none at all, so `differsFromDisk` reads it as
* "dirty". Both say the same thing: the one thing worse than an unnecessary write is a skipped
* necessary one.
*/
let diskText: string | null = null;
let diskDoc: ProseMirrorNode | null = null;
/** The only way either of those moves, so that they cannot drift apart into two answers about the
* same file, one of which sends a write and the other of which holds it back. */
function rememberDisk(text: string | null, doc: ProseMirrorNode | null): void {
diskText = text;
diskDoc = doc;
}
/** Markdown and plain text are two different round trips and picking the wrong one mangles a .txt. */
function bridgeFor(path: string) {
const kind = documentKindForPath(path);
if (kind === null) throw new Error(`${path} is not a document this editor opens`);
return kind === "markdown"
? { parse: parseMarkdown, serialize: serializeMarkdown }
: { parse: parsePlainText, serialize: serializePlainText };
}
/**
* The attributes the serializer never reads, by the node that carries them.
*
* `colwidth` is the whole list, and the list was written by going through src/model/schema.ts
* attribute by attribute against src/markdown/serialize.ts. prosemirror-tables puts a width on
* every cell of a column when its edge is dragged and GFM has no column widths, so that drag is a
* real change to the document and no change at all to the file.
*
* Three others were considered and left off. `colspan` and `rowspan` are unreadable to the
* serializer too, but no op this editor offers can move them, and a table that carried one could
* not be written as a table at all, so calling a change to one insignificant would be hiding the
* one case that needs to be seen. `raw.source` is the file's own bytes and is never written to
* after the parse. And `align` is the near miss: the serializer reads it off the table's first row
* only, so a body cell's copy does not reach the file on its own, but that first row is the
* delimiter row and every align op in tables.ts writes the whole column at once. An alignment
* change is always a change to the file.
*/
const UNWRITTEN_ATTRS: Record<string, readonly string[]> = {
tableHeader: ["colwidth"],
tableCell: ["colwidth"],
};
/**
* Why nothing below compares a NodeType or a MarkType, only its name.
*
* The two documents this comparison is given are never built on the same schema. The one the file
* was read from comes off the bridge, which parses against src/model/schema.ts; the one the editor
* hands back is bound to TipTap's own schema, which src/editor/extensions.ts generates from those
* same specs and which src/editor/Editor.tsx rebinds every opened document on to before it can be
* edited. Two `Schema` instances over one set of specs, so every type object in one is a different
* object from its twin in the other, and `a.type !== b.type` was true of every pair this function
* had ever been handed. Everything behind it, the colwidth exemption included, was unreachable.
*
* The name is also the right thing to compare rather than a way around that. src/markdown/
* serialize.ts dispatches on `node.type.name` and `mark.type.name` and reads nothing else off a
* type, so two nodes agreeing on their name, their attributes, their marks, their text and their
* children are two nodes it writes the same bytes for.
*/
const sameType = (a: { name: string }, b: { name: string }): boolean => a.name === b.name;
/** Two nodes of the same type, agreeing on every attribute the serializer would go looking for. */
function sameAttrs(a: ProseMirrorNode, b: ProseMirrorNode): boolean {
const unwritten = UNWRITTEN_ATTRS[a.type.name];
const names = Object.keys(a.attrs);
// An attribute one side carries and the other does not is not provably nothing, and walking a's
// names only ever shows one of the two directions.
if (names.length !== Object.keys(b.attrs).length) return false;
for (const name of names) {
if (a.attrs[name] === b.attrs[name]) continue;
if (unwritten !== undefined && unwritten.includes(name)) continue;
return false;
}
return true;
}
/**
* The marks on one piece of text, in order.
*
* This replaces `Mark.sameSet`, which compares MarkType by object and so answered "different" for
* every span anybody had ever made bold or turned into a link. Order is compared rather than the
* set treated as unordered because ProseMirror keeps a mark set sorted by the schema's own
* declaration order and both schemas declare the same marks in the same order, so a mismatch is
* either a real difference or the two schemas having drifted apart, and both are worth a write.
*/
function sameMarks(a: readonly Mark[], b: readonly Mark[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const one = a[i];
const other = b[i];
if (one === other) continue;
if (!sameType(one.type, other.type)) return false;
const names = Object.keys(one.attrs);
if (names.length !== Object.keys(other.attrs).length) return false;
for (const name of names) if (one.attrs[name] !== other.attrs[name]) return false;
}
return true;
}
function sameToTheSerializer(a: ProseMirrorNode, b: ProseMirrorNode): boolean {
// The whole reason this is cheap enough to run on every keystroke, even against a tree many
// transactions old. A transaction rebuilds only the spine down to what it touched, so every
// subtree no edit since the read has visited is still the same object it was and the walk stops
// dead at it. Only what has actually been typed into is ever compared node by node.
//
// It buys nothing between an open and the first save, because the tree that was read and the
// editor's rebind of it share no object at all, so every keystroke in that window walks the
// whole document. Measured at 0.08ms on a 57kB file of 984 nodes, against a 500ms debounce.
// Once a save has landed, `diskDoc` is the editor's own tree and the sharing is back.
if (a === b) return true;
if (!sameType(a.type, b.type) || a.text !== b.text || a.childCount !== b.childCount) return false;
if (!sameMarks(a.marks, b.marks)) return false;
if (!sameAttrs(a, b)) return false;
for (let i = 0; i < a.childCount; i += 1) {
if (!sameToTheSerializer(a.child(i), b.child(i))) return false;
}
return true;
}
/**
* Whether a tree differs, anywhere the file would show it, from the document on disk.
*
* This is what the dirty flag is, and the whole of it. Asking it of the document that was read
* rather than of the tree a keystroke ago is what makes it a fact about the file instead of a
* count of transactions: a paragraph typed into and then undone comes back false, because the
* buffer is the file again, and a flag that only ever counted up would have had the whole document
* rewritten in house style for an edit that no longer exists.
*
* It is deliberately lopsided: everything counts as a change to the file unless it is provably not
* one. A change wrongly called insignificant is a keystroke that never reaches the disk, which is
* the worst thing in this module; a change wrongly called significant costs one write that
* `performSave` then finds nothing to do.
*/
export function differsFromDisk(next: ProseMirrorNode): boolean {
return diskDoc === null || !sameToTheSerializer(diskDoc, next);
}
/** The same question, asked of whatever the store is holding now. */
function bufferDiffersFromDisk(): boolean {
const now = useDocument.getState().content;
return now !== null && differsFromDisk(now);
}
function apply(read: ReadResult, document: MarkdownDocument): void {
rememberDisk(read.text, document.doc);
useDocument.setState({
path: read.path,
document,
content: document.doc,
modifiedMs: read.modifiedMs,
frontmatter: document.frontmatter,
dirty: false,
savePhase: "idle",
saveError: null,
externalChange: "synced",
});
}
function scheduleSave(): void {
cancelPendingSave();
saveTimer = setTimeout(() => {
saveTimer = null;
saveNow().catch((e) => notify(`Could not save: ${String(e)}`));
}, SAVE_DEBOUNCE_MS);
}
export function cancelPendingSave(): void {
if (saveTimer !== null) clearTimeout(saveTimer);
saveTimer = null;
}
/**
* Starts the debounce. Idempotent, and returning the teardown rather than keeping it private is
* what lets a test run the lifecycle without leaving a timer behind for the next one.
*/
export function initDocument(): () => void {
if (unsubscribe !== null) return unsubscribe;
const stop = useDocument.subscribe((state, previous) => {
if (state.content === previous.content) return;
// Clean is not just "nothing more to schedule". The edit that armed the timer can have been
// undone while it was still counting down, and letting it run out would put the debounce's
// whole point, one write per burst of typing, behind a document nobody changed.
if (state.dirty) scheduleSave();
else cancelPendingSave();
});
unsubscribe = () => {
stop();
unsubscribe = null;
cancelPendingSave();
};
return unsubscribe;
}
/**
* Reads a file and puts it in the store. Reads only: the bridge is pure, nothing here has a path
* to `fileWrite`, and a document that is opened and closed again leaves the file untouched.
*/
export async function loadDocument(path: string): Promise<void> {
initDocument();
const { parse } = bridgeFor(path);
const read = await fileRead(path);
apply(read, parse(read.text, read.path));
}
/** Throws the buffer away and takes what is on disk. The explicit half of a conflict. */
export async function reloadDocument(): Promise<void> {
const path = useDocument.getState().path;
if (path === null) return;
cancelPendingSave();
await loadDocument(path);
}
/** The single write for the open document that is currently on the wire, if any. */
let writeInFlight: Promise<void> | null = null;
/** Set when a save is requested while `writeInFlight` is already running. One flag, not a queue:
* it can only ever mean "write again after this one", never "write N more times". */
let saveAgainRequested = false;
/**
* Serializes and writes, now. Returns having done nothing when the buffer is clean, which is what
* makes Cmd+S on an untouched document a no-op rather than a reformat.
*
* At most one `fileWrite` for the open document is ever in flight at a time. A call that lands
* while one is already running does not start a second: it flags that another save is wanted and
* folds into a single write that goes out the moment the first one lands, picking up whatever is
* newest in the store by then. That is what keeps the backend from ever seeing two writes of the
* same path race each other, and it is also what keeps the last thing the user typed from being
* the one write that never happened: it is either the content already on the wire, or it is
* exactly what the next lap serializes.
*/
export async function saveNow(): Promise<void> {
cancelPendingSave();
if (writeInFlight !== null) {
saveAgainRequested = true;
return writeInFlight;
}
const inFlight = runSaveLoop();
writeInFlight = inFlight;
try {
await inFlight;
} finally {
if (writeInFlight === inFlight) writeInFlight = null;
}
}
/**
* Runs `performSave` once, then again for every save that arrived while it was on the wire,
* collapsed to the single latest one. Stops the moment a lap does not end in a clean write: a
* conflict or a no-op buffer is not something a stacked-up request should cause to be retried.
*/
async function runSaveLoop(): Promise<void> {
for (;;) {
saveAgainRequested = false;
const outcome = await performSave();
if (outcome !== "wrote" || !saveAgainRequested) return;
}
}
async function performSave(): Promise<"wrote" | "conflict" | "skipped"> {
const { path, document, content, dirty, modifiedMs } = useDocument.getState();
if (path === null || document === null || content === null || !dirty) return "skipped";
const { serialize } = bridgeFor(path);
useDocument.setState({ savePhase: "saving", saveError: null });
let text: string;
try {
text = serialize(document, content);
} catch (e) {
useDocument.setState({ savePhase: "error", saveError: String(e) });
throw e;
}
// The second line, not the first. `differsFromDisk` is what keeps a buffer that is not different
// from the file from being dirty at all, and it has to be, because this comparison only catches
// the case where the serialized bytes already match the file: on a document the editor has
// written before they do, and on a hand written one they differ for house style reasons that have
// nothing to do with any edit, so this would let the write through and the whole file would be
// reformatted for a gesture that moved a line on screen. What is left here is everything else
// that can serialize to the bytes already on disk: two different trees that spell the same
// markdown, and a buffer this module cannot vouch for because the file moved under it.
if (text === diskText) {
// Those bytes are on disk and this is a tree that produces them, which is all `diskDoc` has
// ever claimed to be. Nothing was written, so nothing needs to be.
rememberDisk(text, content);
useDocument.setState({
dirty: bufferDiffersFromDisk(),
savePhase: "idle",
saveError: null,
});
return "skipped";
}
let result: WriteResult;
try {
result = await fileWrite(path, text, modifiedMs ?? undefined);
} catch (e) {
if (useDocument.getState().path === path) {
useDocument.setState({ savePhase: "error", saveError: String(e) });
}
throw e;
}
// The document was switched while the write was in flight, so this result belongs to a buffer
// nobody is looking at any more and applying it would stamp the new one's timestamp.
if (useDocument.getState().path !== path) return "skipped";
if (result.conflict) {
// Not an error and not something to retry. Nothing was written, the edit is still only in the
// buffer, and which copy wins is the user's call. What is on disk is somebody else's copy,
// which this module has not read, so it stops claiming to know either the bytes or the
// document: the buffer stays dirty however much of the edit the user takes back, and the
// decision the UI is now asking for is the only thing that clears it.
rememberDisk(null, null);
useDocument.setState({ savePhase: "idle", externalChange: "changed-on-disk" });
return "conflict";
}
rememberDisk(text, content);
const stillDirty = bufferDiffersFromDisk();
useDocument.setState({
modifiedMs: result.modifiedMs,
dirty: stillDirty,
savePhase: "idle",
saveError: null,
externalChange: "synced",
});
// Typed into, or undone, while that write was on the wire. An undo is the case that needs this:
// it went past the subscription at a moment when the buffer and the file did agree, so nothing
// armed the debounce for it, and this write is what has just made it a difference again.
if (stillDirty) scheduleSave();
else cancelPendingSave();
return "wrote";
}
/**
* Gets an unsaved edit onto disk before something else happens to the document: switching away,
* closing it, quitting. Swallows its own error into a toast, because the caller is on its way
* somewhere else and failing that journey over a failed save helps nobody.
*/
export function flushPendingSave(): Promise<void> {
if (!useDocument.getState().dirty) {
cancelPendingSave();
return Promise.resolve();
}
return saveNow().catch((e) => notify(`Could not save: ${String(e)}`));
}
/**
* Resolves a conflict the other way from `reloadDocument`: the buffer wins and the copy on disk is
* the one that goes.
*
* Dropping `modifiedMs` is what makes the next write land. The backend refuses a write whose
* expected timestamp has moved on, which is the whole conflict mechanism, and there is no way to
* say "yes, I know" other than to stop claiming to know what was there. The write itself is the
* ordinary debounced one, so nothing is put on disk here either.
*/
export function keepBuffer(): void {
if (useDocument.getState().path === null) return;
// What is on disk is whatever the other writer put there, which this module has not read, so the
// last bytes it saw are no longer the file's and neither is the document they came from. Saying
// so is what stops `performSave` from deciding this write is unnecessary and leaving the other
// copy in place, which is the opposite of what the user just asked for, and it is what keeps the
// buffer dirty through an undo taken while the banner is up.
rememberDisk(null, null);
// Dirty even if nothing has been typed: the file moved or went, so the buffer and the disk
// disagree, and that is the only thing the flag has ever meant.
useDocument.setState({ modifiedMs: null, externalChange: "synced", dirty: true });
scheduleSave();
}
/**
* Lets go of the open document without writing it, for when the file it came from has just gone.
* `close` on its own flushes, which for a document that was this second sent to the Trash would
* put the file straight back.
*/
export function abandonDocument(): void {
cancelPendingSave();
useDocument.setState({ dirty: false });
useDocument.getState().close();
}
/**
* Something outside the app touched the open document. Clean buffers take the new bytes silently,
* dirty ones are left exactly as they are and the UI is told there is a choice to make.
*/
export async function documentChangedOnDisk(path: string): Promise<void> {
if (useDocument.getState().path !== path) return;
let read: ReadResult;
try {
read = await fileRead(path);
} catch {
// Deleted, renamed out from under us, or unreadable. The buffer is now the only copy there is,
// so it stays put. The bytes go, because there are none left to hold a write back, and the
// document stays, because it is still the last one this module knew the file to hold and it is
// what keeps a buffer nobody has typed into from turning dirty and putting the file back.
// Resurrecting a file the user deleted is `keepBuffer`, and it is the user's word, not a
// side effect of clicking into the editor afterwards.
rememberDisk(null, diskDoc);
useDocument.setState({ externalChange: "changed-on-disk" });
return;
}
const state = useDocument.getState();
if (state.path !== path) return;
if (read.modifiedMs === state.modifiedMs) return;
if (read.text === diskText) {
useDocument.setState({ modifiedMs: read.modifiedMs });
return;
}
if (state.dirty) {
// The buffer stays, but these are the file's bytes now and this module has just read them, so
// it says so rather than going on remembering the ones the other writer replaced. It does not
// parse them: the document on disk is somebody else's and no tree here is it, so the honest
// answer to "is the buffer different from the file" is that we do not know, which is the answer
// that keeps this dirty until the user picks a side.
rememberDisk(read.text, null);
useDocument.setState({ externalChange: "changed-on-disk" });
return;
}
const { parse } = bridgeFor(path);
apply(read, parse(read.text, read.path));
}
+678
View File
@@ -0,0 +1,678 @@
// The document surface, and the only place in the app that holds a TipTap instance.
//
// The autosave contract lives here as much as it does in the store, and it is a contract about
// what this component does NOT do. Opening a document installs a ProseMirror state and nothing
// else: no serializer runs, no write is scheduled, and `onChange` does not fire, because
// `view.updateState` is not a dispatched transaction and TipTap only emits `update` when a
// transaction actually changed the document. A file that is opened, read and closed is never
// written. Once the user types, `onChange` hands out the live ProseMirror node on every keystroke,
// which is cheap; turning that node into markdown happens once, later, on the shell's debounce.
//
// Ported from margin's editor/Editor.tsx. Margin keeps an EditorState per chapter because a book
// is many documents open at once; here there is one document at a time, so that cache collapses
// into a small path-keyed LRU whose only job is making a return to a recent file instant, with its
// undo history and its caret still where they were.
import { useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from "react";
import type { ReactElement } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import { EditorState, TextSelection } from "@tiptap/pm/state";
import type { EditorProps as ProseMirrorProps, EditorView } from "@tiptap/pm/view";
import type { CalloutKind, HeadingLevel, MarkdownDocument } from "../model/doc";
import { marks as markSpecs } from "../model/schema";
import type { MarkName } from "../model/schema";
import { notify } from "../store/useToast";
import { insertMath, insertMermaid, setCodeLanguage, tableCommand } from "./blocks";
import { createEditorExtensions } from "./extensions";
import { change, markable, place, placeable } from "./fits";
import { loadPosition, savePosition, type DocumentPosition } from "./positions";
import { searchStateOf, type SearchOptions } from "./search";
import type {
BlockCommand,
BlockKind,
DocumentFind,
EditorActiveState,
EditorHandle,
EditorProps,
TableOp,
} from "./index";
// How much of the pane the caret is kept out of when the view scrolls it into sight. The toolbar
// pill is 44px tall (32px controls, 5px of padding, a 1px border) and sticks 22px above the bottom
// of the pane, so it covers the last 66px of it; a line of body prose on top of that means the
// caret's whole line clears the glass instead of sitting against it. Nothing overlaps the top,
// where the titlebar is a sibling above the scroller rather than floating over it, so the number
// there is breathing room and nothing more.
const CARET_KEEPOUT = { top: 24, right: 0, bottom: 98, left: 0 };
/** Enough that going back to what you were just looking at is instant, and not a document store. */
const CACHE_LIMIT = 8;
const EMPTY_CONTENT = { type: "doc", content: [{ type: "paragraph" }] };
const MARK_NAMES = Object.keys(markSpecs) as MarkName[];
/** Blocks a cursor can be inside without them being what the toolbar should report. */
const PASS_THROUGH = new Set([
"paragraph",
"listItem",
"taskItem",
"tableRow",
"tableCell",
"tableHeader",
]);
const REPORTED = new Set<string>([
"bulletList",
"orderedList",
"taskList",
"blockquote",
"codeBlock",
"toggle",
"table",
"mathBlock",
"raw",
]);
interface Cached {
state: EditorState;
document: MarkdownDocument;
scroll: number;
}
const listeners = new Set<() => void>();
let currentHandle: EditorHandle | null = null;
let currentFind: DocumentFind | null = null;
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function announce(): void {
for (const listener of listeners) listener();
}
const handleSnapshot = () => currentHandle;
const findSnapshot = () => currentFind;
/** The handle for the document currently on screen, or null when there is none. */
export function useEditorHandle(): EditorHandle | null {
return useSyncExternalStore(subscribe, handleSnapshot, handleSnapshot);
}
/** Find and replace over the open document, for whatever draws the find bar. */
export function useDocumentFind(): DocumentFind | null {
return useSyncExternalStore(subscribe, findSnapshot, findSnapshot);
}
function reportContentError(error: unknown): void {
notify(`Part of this document could not be read into the editor: ${String(error)}`);
}
function scrollerOf(editor: Editor): HTMLElement | null {
const dom = editor.view.dom as HTMLElement;
const pane = dom.closest(".editor-pane");
if (pane instanceof HTMLElement) return pane;
for (let el = dom.parentElement; el; el = el.parentElement) {
const overflow = getComputedStyle(el).overflowY;
if (overflow === "auto" || overflow === "scroll") return el;
}
return null;
}
/** Ticking a box is an edit like any other, so it goes through the view and dirties the buffer. */
function toggleTask(view: EditorView, item: Element): boolean {
const $pos = view.state.doc.resolve(view.posAtDOM(item, 0));
for (let depth = $pos.depth; depth > 0; depth -= 1) {
const node = $pos.node(depth);
if (node.type.name !== "taskItem") continue;
view.dispatch(
view.state.tr.setNodeMarkup($pos.before(depth), undefined, {
...node.attrs,
checked: !node.attrs.checked,
}),
);
return true;
}
return false;
}
/**
* The ProseMirror props this component installs directly on the view, as opposed to the ones an
* extension contributes through `addProseMirrorPlugins`.
*
* Lifted out of the component and exported so that it can be enumerated. It is a third channel into
* the document, alongside the editor handle and the extensions' own plugins, and it was the one
* src/editor/fits.test.ts could not see: that file reads every extension's plugins and every
* extension's keymap, and `editorProps` is neither. A `handlePaste` or a `handleKeyDown` added here
* would be asked before any of them and answer for the whole document, unenumerated. The click
* handler that is here today only flips a checkbox, which is the harmless case; the enumeration is
* for the next one.
*/
export function createEditorProps(context: {
editable: () => boolean;
onOpenLink: (href: string) => void;
}): ProseMirrorProps {
return {
attributes: { class: "prose" },
scrollThreshold: CARET_KEEPOUT,
scrollMargin: CARET_KEEPOUT,
handleClick: (view, _pos, event) => {
const target = event.target as HTMLElement | null;
// The checkbox is drawn by the list item's own ::before, so a click that lands on the item
// itself rather than on the paragraph inside it is a click on the box.
const item = target?.closest(".task-item");
if (item && item === event.target && toggleTask(view, item)) {
event.preventDefault();
return true;
}
const anchor = target?.closest("a[href]");
const href = anchor?.getAttribute("href");
if (!href) return false;
// A plain click puts the caret in the link text, which is the only way to edit it. Opening
// is the modified click, or any click at all while the document is not editable.
if (context.editable() && !event.metaKey && !event.ctrlKey) return false;
event.preventDefault();
context.onOpenLink(href);
return true;
},
};
}
function enclosing(editor: Editor, names: readonly string[]): { name: string; pos: number } | null {
const { $from } = editor.state.selection;
for (let depth = $from.depth; depth > 0; depth -= 1) {
const name = $from.node(depth).type.name;
if (names.includes(name)) return { name, pos: $from.before(depth) };
}
return null;
}
function activeStateOf(editor: Editor): EditorActiveState {
const marks = MARK_NAMES.filter((mark) => editor.isActive(mark));
const { $from } = editor.state.selection;
// Asked separately from the walk below, because that walk stops at the innermost block it has a
// name for and a cell selection's own position is not inside any of them. isActive answers for a
// cursor in a cell, a selection across cells and the table selected whole, alike.
const inTable = editor.isActive("table");
const base = { marks, inTable, codeLanguage: null };
for (let depth = $from.depth; depth > 0; depth -= 1) {
const node = $from.node(depth);
const name = node.type.name;
if (PASS_THROUGH.has(name)) continue;
if (name === "heading") {
return { ...base, block: "heading", headingLevel: node.attrs.level as HeadingLevel, callout: null };
}
if (name === "callout") {
return { ...base, block: "callout", headingLevel: null, callout: node.attrs.kind as CalloutKind };
}
if (name === "codeBlock") {
const language = node.attrs.language as string | null;
return { ...base, block: "codeBlock", headingLevel: null, callout: null, codeLanguage: language };
}
if (REPORTED.has(name)) {
return { ...base, block: name as BlockKind, headingLevel: null, callout: null };
}
}
return { ...base, block: "paragraph", headingLevel: null, callout: null };
}
function sameActive(a: EditorActiveState, b: EditorActiveState): boolean {
return (
a.block === b.block &&
a.headingLevel === b.headingLevel &&
a.callout === b.callout &&
a.inTable === b.inTable &&
a.codeLanguage === b.codeLanguage &&
a.marks.length === b.marks.length &&
a.marks.every((mark, i) => b.marks[i] === mark)
);
}
/**
* The command half of the handle, built once per editor and shared by every published snapshot.
*
* Exported for src/editor/fits.test.ts and for nothing else: the shell gets the handle through
* `useEditorHandle` and has no business building one. That test enumerates the keys of what this
* returns and refuses to pass unless every insert among them has been proved to keep its hands off
* a document it cannot insert into, which is the only way this file's own insert commands and the
* block lanes' are held to the same rule.
*/
export function createCommands(editor: Editor): Omit<EditorHandle, "active"> {
const setCallout = (kind: CalloutKind | null) => {
const found = enclosing(editor, ["callout", "blockquote"]);
if (kind === null) {
if (found?.name !== "callout") return;
change(editor, "unwrap", (chain) =>
chain.command(({ tr, state, dispatch }) => {
if (dispatch) tr.setNodeMarkup(found.pos, state.schema.nodes.blockquote, {});
return true;
}),
);
return;
}
if (found) {
change(editor, "wrap", (chain) =>
chain.command(({ tr, state, dispatch }) => {
if (dispatch) tr.setNodeMarkup(found.pos, state.schema.nodes.callout, { kind });
return true;
}),
);
return;
}
change(editor, "wrap", (chain) => chain.wrapIn("callout", { kind }));
};
return {
focus: () => {
editor.commands.focus();
},
toggleMark: (mark) => {
editor.chain().focus().toggleMark(mark).run();
},
// With a collapsed caret and no link under it the url goes in as TEXT and is marked
// afterwards, and the text lands whether the mark can or not: in a fence or a raw block, where
// the schema allows no marks at all, that is the url typed into somebody's code. Asked of the
// mark rather than of the block, so it is the same question in both places and in whatever
// block comes next. The two chains below only add a mark, which ProseMirror already declines
// to do where the schema says no.
setLink: (href, title = null) => {
if (href === null) {
editor.chain().focus().extendMarkRange("link").unsetMark("link").run();
return;
}
if (editor.state.selection.empty && !editor.isActive("link")) {
if (!markable(editor.state, editor.schema.marks.link)) return;
if (!placeable(editor.state, editor.schema.nodes.text)) return;
editor
.chain()
.focus()
.extendMarkRange("link")
.insertContent({ type: "text", text: href, marks: [{ type: "link", attrs: { href, title } }] })
.run();
return;
}
editor.chain().focus().extendMarkRange("link").setMark("link", { href, title }).run();
},
// The conversions, and the other half of what fits.ts guards. `place` is for a command that
// adds a node; these change or rewrap the block the caret is already in, which is the question
// `change` answers: a raw block refuses all of them, because a conversion writes its preserved
// source back out as escaped markdown and a wrap writes it back out prefixed, and a conversion
// that would take a callout or a toggle away with it is thrown out rather than dispatched. A
// conversion added here that does not go through `change` fails src/editor/fits.test.ts.
setBlock: (block: BlockCommand) => {
switch (block) {
case "paragraph":
change(editor, "convert", (chain) => chain.clearNodes().setNode("paragraph"));
return;
case "bulletList":
change(editor, "wrap", (chain) => chain.toggleList("bulletList", "listItem"));
return;
case "orderedList":
change(editor, "wrap", (chain) => chain.toggleList("orderedList", "listItem"));
return;
case "taskList":
change(editor, "wrap", (chain) => chain.toggleList("taskList", "taskItem"));
return;
case "blockquote":
change(editor, "wrap", (chain) => chain.toggleWrap("blockquote"));
return;
case "codeBlock":
change(editor, "convert", (chain) => chain.toggleNode("codeBlock", "paragraph"));
return;
case "toggle":
// "unwrap" because this is the toggle's own button: pressed inside one it takes that
// toggle away, summary and all, which is what the user pressed it for.
change(editor, "unwrap", (chain) => chain.toggleWrap("toggle"));
}
},
setHeading: (level) => {
if (level === null) change(editor, "convert", (chain) => chain.setNode("paragraph"));
else change(editor, "convert", (chain) => chain.setNode("heading", { level }));
},
setCallout,
// Every one of these goes through `place`, which is the guard and the insert in one call, for
// the reason written out in fits.ts: with the caret in a table cell an unguarded insert splits
// the table around the new node and leaves a row with no cells in it, which is a table the
// serializer writes back as three blank lines, and in a fence or a raw block it cuts the user's
// own bytes in half and writes the remainder out as prose. An insert added here that does not
// go through `place` fails src/editor/fits.test.ts, which is the point of that file.
insertRule: () => {
place(editor, editor.schema.nodes.horizontalRule, (chain) =>
chain.insertContent({ type: "horizontalRule" }),
);
},
// The same guard the insert below runs, asked on its own, because the Insert image tool has to
// write the picture into the assets folder before it has a path to insert and a refusal after
// that write is an orphan file beside somebody's document. src/editor/paste.ts asks this same
// question before it sends any bytes; the toolbar had no way to.
canInsertImage: () => placeable(editor.state, editor.schema.nodes.image),
// And it still says so when it refuses, for the caller that asks afterwards anyway.
insertImage: (src, alt = null) => {
const placed = place(editor, editor.schema.nodes.image, (chain) =>
chain.insertContent({ type: "image", attrs: { src, alt, title: null } }),
);
if (!placed) notify("An image cannot go where the cursor is.");
},
insertTable: (rows, columns) => {
const table = editor.schema.nodes.table;
const cells = (type: string) =>
Array.from({ length: Math.max(1, columns) }, () => ({ type }));
const body = Array.from({ length: Math.max(0, rows - 1) }, () => ({
type: "tableRow",
content: cells("tableCell"),
}));
const searchFrom = Math.max(0, editor.state.selection.$from.pos - 1);
place(editor, table, (chain) =>
chain
.insertContent({
type: "table",
content: [{ type: "tableRow", content: cells("tableHeader") }, ...body],
})
// The insert leaves the caret past the table, so the first thing typed lands under it
// rather than in it, and the toolbar goes on reading active.inTable as false while a
// table is on screen. Same transaction as the insert, so it is one undo and not two.
.command(({ tr, dispatch }) => {
if (!dispatch) return true;
let found: number | null = null;
tr.doc.nodesBetween(searchFrom, tr.doc.content.size, (node, pos) => {
if (found !== null) return false;
if (node.type === table) found = pos;
return found === null;
});
// A table, its first row and its first cell are one position each, so three in is the
// first place text can go.
if (found !== null) tr.setSelection(TextSelection.create(tr.doc, found + 3));
return true;
}),
);
},
// The four below are the block lanes' own commands, and this is the whole of the wiring: each
// one lives with the extension that gives its block behaviour, in src/editor/blocks/, so that
// tables, code, math and mermaid are worked on without four hands in this file. They return
// false where they have nothing to act on, which is a button pressed in the wrong place and
// means nothing happens.
tableCommand: (op: TableOp) => {
tableCommand(editor, op);
},
insertMath: (display: boolean) => {
insertMath(editor, display);
},
insertMermaid: () => {
insertMermaid(editor);
},
setCodeLanguage: (language: string | null) => {
setCodeLanguage(editor, language);
},
};
}
/**
* The find bar's own handle, which is the second surface this file publishes and the other place a
* command can reach the document from outside the editor layer.
*
* Exported for src/editor/fits.test.ts for the same reason `createCommands` is: two of these seven
* write to the document, and a handle nothing enumerates is a handle a method gets added to without
* anybody saying where it may run.
*/
export function createFind(editor: Editor): Omit<DocumentFind, "state"> {
return {
setQuery: (query: string, options: SearchOptions) => {
editor.commands.setSearch(query, options);
},
clear: () => {
editor.commands.clearSearch();
},
next: () => {
editor.commands.findNext();
},
prev: () => {
editor.commands.findPrev();
},
replaceCurrent: (text: string) => {
editor.commands.replaceCurrent(text);
},
replaceAll: (text: string) => {
editor.commands.replaceAllInDocument(text);
},
focus: () => {
editor.commands.focus();
},
};
}
export function DocumentEditor({
document,
onChange,
onOpenLink,
editable = true,
}: EditorProps): ReactElement {
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onOpenLinkRef = useRef(onOpenLink);
onOpenLinkRef.current = onOpenLink;
const documentRef = useRef(document);
documentRef.current = document;
const editableRef = useRef(editable);
editableRef.current = editable;
const cache = useRef(new Map<string, Cached>());
const host = useRef<Editor | null>(null);
const installed = useRef<MarkdownDocument | null>(null);
const position = useRef<DocumentPosition | null>(null);
const scrollToken = useRef(0);
const publish = useRef<(() => void) | null>(null);
const extensions = useMemo(
() =>
createEditorExtensions({
documentPath: () => documentRef.current.path,
onError: notify,
}),
[],
);
const editor = useEditor({
extensions,
// Empty on purpose: the layout effect below installs the document through the one code path
// that also restores the caret and reports a document the schema cannot hold.
content: EMPTY_CONTENT,
editable,
immediatelyRender: false,
enableContentCheck: true,
editorProps: createEditorProps({
editable: () => editableRef.current,
onOpenLink: (href) => onOpenLinkRef.current(href),
}),
onContentError: ({ error }) => reportContentError(error),
onUpdate: ({ editor }) => onChangeRef.current(editor.state.doc),
});
const applyScroll = (ed: Editor, top: number) => {
const scroller = scrollerOf(ed);
if (!scroller) return;
const token = (scrollToken.current += 1);
const apply = () => {
if (scrollToken.current === token) scroller.scrollTop = top;
};
apply();
requestAnimationFrame(apply);
// Web fonts land after the first paint and change every line's height under the caret with
// them, so the offset that was right a moment ago is wrong once Literata arrives.
window.document.fonts?.ready.then(apply).catch(() => {});
};
const buildState = (ed: Editor, source: MarkdownDocument): EditorState => {
const base = ed.view.state;
try {
// The bridge builds its tree against src/model/schema.ts and TipTap builds an identical one
// of its own from the same specs, so the node has to be rebound before it can be edited.
const doc = ed.schema.nodeFromJSON(source.doc.toJSON());
doc.check();
return EditorState.create({ doc, plugins: base.plugins });
} catch (error) {
reportContentError(error);
return EditorState.create({ schema: base.schema, plugins: base.plugins });
}
};
const remember = (path: string, entry: Cached) => {
cache.current.delete(path);
cache.current.set(path, entry);
for (const stale of Array.from(cache.current.keys()).slice(0, cache.current.size - CACHE_LIMIT)) {
cache.current.delete(stale);
}
};
const stash = (ed: Editor, source: MarkdownDocument) => {
const scroll = scrollerOf(ed)?.scrollTop ?? 0;
const { from, to } = ed.state.selection;
remember(source.path, { state: ed.view.state, document: source, scroll });
savePosition(source.path, { from, to, scroll });
};
const install = (ed: Editor, source: MarkdownDocument, focus: boolean) => {
const cached = cache.current.get(source.path);
// Reusable when it is the same object, or a fresh read of a file whose bytes have not moved
// since that state was built. Anything else and the file is the newer copy, so the cached
// tree goes: an instant reopen is not worth showing somebody yesterday's document.
const entry =
cached && (cached.document === source || cached.document.source === source.source)
? cached
: null;
if (entry) {
ed.view.updateState(entry.state);
if (focus) ed.commands.focus(undefined, { scrollIntoView: false });
applyScroll(ed, entry.scroll);
} else {
ed.view.updateState(buildState(ed, source));
const saved = loadPosition(source.path);
const size = ed.state.doc.content.size;
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
const chain = ed.chain().setTextSelection(selection);
if (focus) chain.focus(undefined, { scrollIntoView: false });
chain.run();
remember(source.path, { state: ed.view.state, document: source, scroll: saved?.scroll ?? 0 });
applyScroll(ed, saved?.scroll ?? 0);
}
position.current = null;
publish.current?.();
};
useLayoutEffect(() => {
if (!editor) return;
// A new editor instance, which React's strict double mount produces, cannot be handed states
// built against the old one's plugins, and it has nothing installed in it yet whatever the
// last document was.
const carried = host.current === editor;
if (!carried) cache.current.clear();
const previous = carried ? installed.current : null;
if (previous === document) return;
if (previous) stash(editor, previous);
install(editor, document, !previous || previous.path !== document.path);
installed.current = document;
host.current = editor;
// The document is installed by identity, not by field: a new object is a different file or a
// reload from disk, and a keystroke is neither.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, document]);
useEffect(() => {
if (!editor) return;
// Not setEditable(value): its default emits an `update` with no transaction behind it, which
// would reach onChange and mark a document dirty that nobody has typed into.
editor.setEditable(editable, false);
}, [editor, editable]);
useEffect(() => {
if (!editor) return;
const commands = createCommands(editor);
const find = createFind(editor);
let active = activeStateOf(editor);
currentHandle = { active, ...commands };
currentFind = { state: { count: 0, current: 0 }, ...find };
const push = () => {
const next = activeStateOf(editor);
if (!sameActive(active, next)) {
active = next;
currentHandle = { active, ...commands };
}
const search = searchStateOf(editor.state);
const count = search?.matches.length ?? 0;
const current = search?.current ?? 0;
if (currentFind === null || currentFind.state.count !== count || currentFind.state.current !== current) {
currentFind = { state: { count, current }, ...find };
}
announce();
};
publish.current = push;
push();
editor.on("transaction", push);
return () => {
editor.off("transaction", push);
publish.current = null;
currentHandle = null;
currentFind = null;
announce();
};
}, [editor]);
useEffect(() => {
if (!editor) return;
const scroller = scrollerOf(editor);
let timer: ReturnType<typeof setTimeout>;
const write = () => {
const source = installed.current;
if (source && position.current) savePosition(source.path, position.current);
};
const persist = () => {
const { from, to } = editor.state.selection;
const scroll = scroller?.scrollTop ?? 0;
position.current = { from, to, scroll };
const entry = installed.current ? cache.current.get(installed.current.path) : undefined;
if (entry) entry.scroll = scroll;
clearTimeout(timer);
timer = setTimeout(write, 400);
};
editor.on("selectionUpdate", persist);
scroller?.addEventListener("scroll", persist, { passive: true });
return () => {
clearTimeout(timer);
editor.off("selectionUpdate", persist);
scroller?.removeEventListener("scroll", persist);
write();
};
}, [editor]);
return <EditorContent editor={editor} className="editor-host" />;
}
+321
View File
@@ -0,0 +1,321 @@
// A .txt file. Not markdown, so nothing here parses any: a line reading "# heading" is that
// literal text on screen and those literal bytes on disk, and the only thing between the two is a
// textarea.
//
// The document shape mirrors the bridge's `parsePlainText` exactly, one line to one paragraph,
// which is what makes splitting and joining on the newline exact inverses and the round trip byte
// identical down to a missing final newline.
//
// Find has no ProseMirror decorations to draw here, since a textarea has no tree to decorate. What
// it has is a native selection, which is the one highlight a textarea can show, so a match is
// found by selecting it and scrolling it into view rather than by painting a span around it. The
// state that search.ts keeps in a plugin lives in a ref instead, published through the same
// `DocumentFind` shape src/editor/index.ts declares for the markdown surface, so FindBar.tsx never
// has to know which editor it is talking to.
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import type { ReactElement } from "react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { schema } from "../model/schema";
import {
EMPTY_PLAIN_SEARCH,
recomputePlainSearch,
replaceAllMatches,
replaceMatch,
sameQuery,
type PlainSearchState,
type SearchMatch,
} from "./plainFind";
import type { DocumentFind, PlainTextProps } from "./index";
function textOf(doc: ProseMirrorNode): string {
const lines: string[] = [];
doc.forEach((block) => lines.push(block.textContent));
return lines.join("\n");
}
function docOf(text: string): ProseMirrorNode {
const lines = text.split("\n");
const blocks = lines.map((line) =>
schema.nodes.paragraph.create(null, line ? schema.text(line) : null),
);
return schema.nodes.doc.create(null, blocks);
}
/** The properties a mirror div needs to copy for its line wrapping, and so the vertical position
* it measures, to match the textarea's own. Only what wrapping and line height depend on: nothing
* about colour or the caret. */
function copyWrappingStyle(mirror: HTMLDivElement, field: HTMLTextAreaElement): void {
const style = getComputedStyle(field);
mirror.style.position = "absolute";
mirror.style.visibility = "hidden";
mirror.style.top = "0";
mirror.style.left = "-9999px";
mirror.style.width = `${field.clientWidth}px`;
mirror.style.fontFamily = style.fontFamily;
mirror.style.fontSize = style.fontSize;
mirror.style.fontWeight = style.fontWeight;
mirror.style.fontStyle = style.fontStyle;
mirror.style.letterSpacing = style.letterSpacing;
mirror.style.lineHeight = style.lineHeight;
mirror.style.textTransform = style.textTransform;
mirror.style.wordSpacing = style.wordSpacing;
mirror.style.tabSize = style.tabSize;
mirror.style.whiteSpace = style.whiteSpace;
mirror.style.wordBreak = style.wordBreak;
mirror.style.overflowWrap = style.overflowWrap;
}
/** Where a character offset lands inside the textarea's own box, found the only way a plain
* textarea allows: rendering the same text in an invisible twin under the same font and width and
* reading back where a marker after it fell. There is no scroll of its own to subtract, since the
* field is always exactly as tall as its text (see the layout effect below). */
function caretOffset(field: HTMLTextAreaElement, index: number): { top: number; height: number } {
const mirror = window.document.createElement("div");
copyWrappingStyle(mirror, field);
mirror.textContent = field.value.slice(0, index);
const marker = window.document.createElement("span");
marker.textContent = field.value.slice(index, index + 1) || ".";
mirror.appendChild(marker);
window.document.body.appendChild(mirror);
const top = marker.offsetTop;
const height = marker.offsetHeight;
window.document.body.removeChild(mirror);
return { top, height };
}
function scrollerOf(field: HTMLTextAreaElement): HTMLElement | null {
for (let node = field.parentElement; node; node = node.parentElement) {
if (node.classList.contains("editor-pane")) return node;
}
return null;
}
/** The textarea has no scrollbar of its own, so bringing a match into view means scrolling the
* pane around it instead, the same "roughly centred" placement search.ts asks the pane for. */
function scrollMatchIntoView(field: HTMLTextAreaElement, match: SearchMatch): void {
const pane = scrollerOf(field);
if (!pane) return;
const { top, height } = caretOffset(field, match.from);
const fieldTop = field.getBoundingClientRect().top - pane.getBoundingClientRect().top + pane.scrollTop;
const target = fieldTop + top - pane.clientHeight / 2 + height / 2;
pane.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
}
const findListeners = new Set<() => void>();
let currentPlainFind: DocumentFind | null = null;
function subscribePlainFind(listener: () => void): () => void {
findListeners.add(listener);
return () => {
findListeners.delete(listener);
};
}
function announcePlainFind(): void {
for (const listener of findListeners) listener();
}
const plainFindSnapshot = () => currentPlainFind;
/**
* Find and replace for the .txt surface, published the same way Editor.tsx publishes the markdown
* one, so src/editor/index.ts can hand `useDocumentFind` whichever of the two is actually on
* screen without either side knowing the other exists.
*/
export function usePlainTextFind(): DocumentFind | null {
return useSyncExternalStore(subscribePlainFind, plainFindSnapshot, plainFindSnapshot);
}
export function PlainTextEditor({
document,
onChange,
editable = true,
}: PlainTextProps): ReactElement {
const field = useRef<HTMLTextAreaElement>(null);
const [text, setText] = useState(() => textOf(document.doc));
const source = useRef(document);
const textRef = useRef(text);
textRef.current = text;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const search = useRef<PlainSearchState>(EMPTY_PLAIN_SEARCH);
const publishRef = useRef<() => void>(() => {});
// Set when the open file is replaced by a newer read of itself, which is what an external edit
// to a clean buffer looks like from here. Editor.tsx stashes and restores the ProseMirror
// selection across the same event; a textarea has no selection of its own to survive a value
// change, so it has to be carried by hand or the caret lands at the end of the file.
const carry = useRef<{ start: number; end: number } | null>(null);
if (source.current !== document) {
const reload = source.current.path === document.path;
const el = field.current;
carry.current = reload && el ? { start: el.selectionStart, end: el.selectionEnd } : null;
source.current = document;
const next = textOf(document.doc);
setText(next);
textRef.current = next;
// A new file has nothing to do with whatever was being searched for in the last one, and its
// matches would point at the wrong offsets anyway. A reload of the same file is the same
// story: the offsets are against text that has just been replaced.
search.current = EMPTY_PLAIN_SEARCH;
}
const methods = useRef<Omit<DocumentFind, "state"> | null>(null);
// Only a new object when the count or the position in it actually moved, the same guard
// Editor.tsx's own `push` keeps: FindBar re-issues `setQuery`/`clear` on every render it is open
// for, and a new object on every one of those, whether anything changed or not, is what a
// `useSyncExternalStore` subscriber reads as new state to render, which is what that repeated
// call turns into an infinite loop rather than the no-op it is meant to be.
const publish = () => {
const count = search.current.matches.length;
const current = search.current.current;
if (!currentPlainFind || currentPlainFind.state.count !== count || currentPlainFind.state.current !== current) {
currentPlainFind = { state: { count, current }, ...methods.current! };
}
announcePlainFind();
};
publishRef.current = publish;
if (!methods.current) {
// Collapses whatever is selected without moving the caret: the closest a textarea has to
// "no decoration", for the moment a query stops matching anything or find closes altogether.
const deselect = () => {
const el = field.current;
if (!el) return;
el.setSelectionRange(el.selectionStart, el.selectionStart);
};
const land = (next: PlainSearchState) => {
search.current = next;
const match = next.matches[next.current];
if (match) {
const el = field.current;
if (el) {
el.setSelectionRange(match.from, match.to);
scrollMatchIntoView(el, match);
}
} else {
deselect();
}
publishRef.current();
};
methods.current = {
setQuery: (query, options) => {
if (sameQuery(search.current, query, options)) return;
land(recomputePlainSearch(textRef.current, query, options, 0));
},
clear: () => {
search.current = EMPTY_PLAIN_SEARCH;
deselect();
publishRef.current();
},
next: () => {
const s = search.current;
if (!s.matches.length) return;
land({ ...s, current: (s.current + 1) % s.matches.length });
},
prev: () => {
const s = search.current;
if (!s.matches.length) return;
land({ ...s, current: (s.current - 1 + s.matches.length) % s.matches.length });
},
replaceCurrent: (replacement) => {
const s = search.current;
if (!s.matches.length) return;
const match = s.matches[s.current];
const nextText = replaceMatch(textRef.current, match, replacement);
textRef.current = nextText;
setText(nextText);
onChangeRef.current(docOf(nextText));
land(recomputePlainSearch(nextText, s.query, s.options, s.current));
},
replaceAll: (replacement) => {
const s = search.current;
if (!s.matches.length) return;
const nextText = replaceAllMatches(textRef.current, s.matches, replacement);
textRef.current = nextText;
setText(nextText);
onChangeRef.current(docOf(nextText));
land(recomputePlainSearch(nextText, s.query, s.options, s.current));
},
focus: () => {
field.current?.focus();
},
};
}
useEffect(() => {
field.current?.focus();
}, [document.path]);
// Republished after every render that changed which document is open, which is what carries the
// reset above (a new file means no active search) out to whatever is drawing the find bar. The
// functions themselves close over refs and read them fresh on every call, so nothing here needs
// rebuilding when only the text or the search changes, just the announcing of it.
useLayoutEffect(() => {
publishRef.current();
}, [document]);
// Unregistering is a real unmount only, not a document change: switching files keeps this
// component and its handle in place, and only leaving the plain text surface entirely (the
// document closes, or a markdown file replaces it) should hand `useDocumentFind` back to null.
useEffect(() => {
return () => {
currentPlainFind = null;
announcePlainFind();
};
}, []);
// The pane is the scroller for every other document, and a textarea with its own scrollbar
// inside that pane would be two of them, one of which puts the last line under the chrome with
// no way to scroll it clear. So the field is always exactly as tall as its text.
useLayoutEffect(() => {
const el = field.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
// Clamped, because the edit that arrived from outside may well be shorter than what was on
// screen. Landing at the end of a file that shrank under you is the same complaint as landing
// at the end of one that grew.
const want = carry.current;
carry.current = null;
if (!want) return;
const end = Math.min(want.end, el.value.length);
el.setSelectionRange(Math.min(want.start, end), end);
}, [text]);
return (
<textarea
ref={field}
className="plain-text"
value={text}
readOnly={!editable}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
onChange={(event) => {
const next = event.target.value;
setText(next);
textRef.current = next;
onChange(docOf(next));
// A search still running when the text under it changes stays running, against the new
// text, the same way search.ts recomputes on every transaction that changes the document.
if (search.current.query) {
search.current = recomputePlainSearch(
next,
search.current.query,
search.current.options,
search.current.current,
);
publishRef.current();
}
}}
/>
);
}
+851
View File
@@ -0,0 +1,851 @@
// The app's only formatting surface: a sticky glass pill at the bottom of the editor pane. There
// is no slash menu and there are no drag handles, by product decision, so every control a document
// can be shaped with lives here.
//
// This component drives itself off `useEditorHandle()`, the declared surface in `./index`, and
// nothing else about the editor: no TipTap instance, no ProseMirror import, no reach into
// extensions.ts. Save state does not come from a store here on purpose: what the pill has to show
// is a conflict, and a conflict is `useDocument`'s `externalChange` rather than its `savePhase`,
// so reading one field would report the wrong thing half the time. `saveState` and `document`
// arrive as props instead, the same way `editor` arrived as a prop in the margin version this is
// ported from, and src/App.tsx is the one place the two fields are folded into one answer.
//
// Ported from ../../../margin/src/editor/FloatingToolbar.tsx: the tool() helper, the onMouseDown
// preventDefault on every button (without it, a click steals the selection before the command that
// reads it runs), the .tool-wrap plus conditional backdrop plus popover idiom, and a
// useEscapeLayer per popover so Escape unwinds them in order. The forceUpdate-on-"transaction"
// subscription from that version is not ported: `useEditorHandle()` is a hook that itself returns
// a new `active` object on every relevant change, per its own doc comment, so calling it is the
// replacement for that subscription, not an addition to it.
//
// M2 brought four block families and one hard constraint: the pill is one row and it has to fit
// the app's 880px minimum window beside a 248px sidebar, which leaves 632px of pane. Three tools
// are added, which is what M1 left room for, and everything else expands out of one of them: the
// table tool's popover is a size picker outside a table and the twelve table ops inside one, the
// callout tool's is the five kinds, the insert tool's is math and mermaid, and the language for a
// fence hangs off the code block tool that was already there, since a control the cursor has to be
// inside a fence to want is a control the pill cannot afford to carry permanently.
//
// The toggle tool is the one added since, and it is a plain button rather than a fourth popover for
// the reason the quote button is: everything a toggle needs beyond existing, its summary and
// whether it is open, is edited on the block itself. It does cost the row its last 32px at the
// minimum window, where the pill was already 4px over and living on the tightened separators in
// toolbar.css.
import { useEffect, useRef, useState, type ReactElement, type ReactNode } from "react";
import { Icon } from "../components/Icon";
import { useEscapeLayer } from "../escape";
import { assetWrite } from "../api/files";
import { notify } from "../store/useToast";
import { CALLOUT_KINDS, type MarkdownDocument } from "../model/doc";
import { HEADING_LEVELS } from "../model/schema";
import { useEditorHandle, type EditorActiveState, type TableOp } from "./index";
const DEFAULT_ACTIVE: EditorActiveState = {
marks: [],
block: "paragraph",
headingLevel: null,
callout: null,
inTable: false,
codeLanguage: null,
};
/** The size picker's ceiling. Anything bigger is a table nobody builds from a grid of squares. */
const PICKER_ROWS = 6;
const PICKER_COLUMNS = 8;
/**
* The languages worth one click. Deliberately short and deliberately not the highlighter's list:
* an info string is free text, so the input beside these takes anything, and a fence the file
* already carried keeps whatever it says whether it appears here or not.
*/
const LANGUAGES = [
"javascript",
"typescript",
"python",
"rust",
"go",
"json",
"yaml",
"bash",
"sql",
"html",
"css",
"markdown",
"mermaid",
];
export type ToolbarSaveState = "idle" | "saving" | "conflict";
export interface ToolbarProps {
/** The open document, needed only for the path a pasted image is written beside. Asset writes
* are disabled while this is null. */
document: MarkdownDocument | null;
/** Idle shows nothing on the right of the pill, saving shows a quiet pulse, conflict shows a
* small clickable warning. Defaults to idle so the pill renders sensibly before whoever owns the
* document store has a real phase to report. */
saveState?: ToolbarSaveState;
/** Only read while saveState is "conflict". */
onResolveConflict?: () => void;
}
function normalizeUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed) return "";
if (/^(https?:\/\/|mailto:|tel:|#|\/)/i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
/**
* A fence's info string is a language and then `meta`, the user's own text after it, which this
* editor has no model for and never invents. Anything typed past the first space would be written
* into the fence and read back as meta, so the picker keeps the first word and leaves the rest of
* the info string to whatever the file already said.
*/
function normalizeLanguage(value: string): string | null {
const first = value.trim().split(/\s+/)[0] ?? "";
return first || null;
}
/** Capitalised for a menu. The label on disk is upper case and belongs to the serializer. */
function calloutLabel(kind: string): string {
return kind.charAt(0).toUpperCase() + kind.slice(1);
}
/**
* Whether the caret is in a toggle's title, which is chrome rather than content.
*
* The title is a node view's own editable island, so ProseMirror's selection stays wherever it was
* in the document the whole time somebody is typing in there. src/editor/blocks/toggle.ts refuses
* every document-changing transaction while that is true, because otherwise a button pressed here
* edits a paragraph the user is not looking at. That refusal is the safety net; this is the half
* that has to agree with it, since a tool that draws itself live and then does nothing is the pill
* lying about what it can do.
*
* Asked of the page rather than of a subscription, because clicking into a title dispatches no
* transaction and there is nothing in the editor's own state to watch. Focus events bubble, so one
* pair on the window covers every toggle on screen and every one added later.
*/
function useCaretInToggleTitle(): boolean {
const [inTitle, setInTitle] = useState(false);
useEffect(() => {
const read = () => {
const active = window.document.activeElement;
setInTitle(active instanceof Element && active.closest("[data-toggle-summary]") !== null);
};
read();
window.addEventListener("focusin", read);
window.addEventListener("focusout", read);
return () => {
window.removeEventListener("focusin", read);
window.removeEventListener("focusout", read);
};
}, []);
return inTitle;
}
function tool(
active: boolean,
onClick: () => void,
title: string,
content: ReactNode,
disabled = false,
): ReactElement {
return (
<button
className="tool"
data-on={active}
title={title}
disabled={disabled}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
>
{content}
</button>
);
}
const BOLD_D = "M7 5v14M7 5h5.5a3.5 3.5 0 0 1 0 7H7M7 12h6a3.5 3.5 0 0 1 0 7H7";
const ITALIC_D = "M10 5h6M6 19h6M13 5l-4 14";
const STRIKETHROUGH_D =
"M5 12h14M8 7.5c0-1.5 1.6-2.5 4-2.5s4 1 4 2.5M8 16.5c0 1.5 1.6 2.5 4 2.5s4-1 4-2.5";
const CODE_D = "M9 6l-5 6 5 6M15 6l5 6-5 6";
const HEADING_D = "M5 5v14M5 12h8M13 5v14";
const BULLET_LIST_D = "M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01";
const ORDERED_LIST_D =
"M10 6h11M10 12h11M10 18h11M4 4v4M3 4h2M4 10.5h1.5a1 1 0 1 1 0 2H4h1.5a1 1 0 1 1 0 2H4M4 20.5l1.4-1.7a1 1 0 1 0-1.4-1.6";
const TASK_LIST_D = "M4 5h4v4H4zM5.5 7l1 1 2-2M4 15h4v4H4zM5 17l1 1 2-2M11 7h9M11 17h9M11 12h9";
const BLOCKQUOTE_D = "M7 8h4v4a4 4 0 0 1-4 4M14 8h4v4a4 4 0 0 1-4 4";
const TOGGLE_D = "M5 7l4 5-4 5M12 9h7M12 15h7";
const CODE_BLOCK_D = "M4 6h16v12H4zM7 10l3 2-3 2";
const LINK_D =
"M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1";
const REMOVE_D = "M18 6L6 18M6 6l12 12";
const HR_D = "M5 12h5M14 12h5";
const IMAGE_D = "M4 5h16v14H4zM4 16l4.5-4.5 3 3L16 10l4 4";
const ALERT_D = "M12 3l10 18H2zM12 9v5M12 17h.01";
const CALLOUT_D = "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M12 11v5M12 8h.01";
const TABLE_D = "M4 5h16v14H4zM4 10h16M10 10v9M15 10v9";
const INSERT_D = "M12 5v14M5 12h14";
export function Toolbar({ document, saveState = "idle", onResolveConflict }: ToolbarProps): ReactElement {
const editor = useEditorHandle();
const active = editor?.active ?? DEFAULT_ACTIVE;
const inTitle = useCaretInToggleTitle();
const disabled = !editor || saveState === "conflict" || inTitle;
const [headingOpen, setHeadingOpen] = useState(false);
const [linkOpen, setLinkOpen] = useState(false);
const [calloutOpen, setCalloutOpen] = useState(false);
const [tableOpen, setTableOpen] = useState(false);
const [insertOpen, setInsertOpen] = useState(false);
const [languageOpen, setLanguageOpen] = useState(false);
const [linkValue, setLinkValue] = useState("");
const [languageValue, setLanguageValue] = useState("");
// What the size grid is hovering over, which is the picker's whole state: nothing is inserted
// until a square is clicked.
const [size, setSize] = useState<{ rows: number; columns: number } | null>(null);
const linkInputRef = useRef<HTMLInputElement>(null);
const languageInputRef = useRef<HTMLInputElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
// Every popover in the pill is mutually exclusive already, since each one lays a fixed backdrop
// over the other tools, so opening one closes the rest rather than letting a second live menu
// exist behind an invisible sheet.
const closePopovers = () => {
setHeadingOpen(false);
setLinkOpen(false);
setCalloutOpen(false);
setTableOpen(false);
setInsertOpen(false);
setLanguageOpen(false);
};
useEffect(() => {
if (linkOpen) linkInputRef.current?.focus();
}, [linkOpen]);
useEffect(() => {
if (languageOpen) languageInputRef.current?.focus();
}, [languageOpen]);
// The backdrop cannot be the whole click-away story here the way it is for the titlebar's menu.
// .editor-toolbar carries a backdrop-filter, and that makes it the containing block for a fixed
// position child, so `inset: 0` on the backdrop resolves to the pill and not to the window: it
// covers the other tools, which is what makes a second press of the open one close it, and
// nothing else. A click in the document went behind it and left the menu standing, which was
// survivable with two small popovers and is not with six. Nothing is prevented, so the click
// still lands where it was aimed.
useEffect(() => {
if (!(headingOpen || linkOpen || calloutOpen || tableOpen || insertOpen || languageOpen)) return;
const onDown = (e: MouseEvent) => {
const target = e.target instanceof Element ? e.target : null;
if (target?.closest(".editor-toolbar")) return;
closePopovers();
};
window.addEventListener("mousedown", onDown, true);
return () => window.removeEventListener("mousedown", onDown, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [headingOpen, linkOpen, calloutOpen, tableOpen, insertOpen, languageOpen]);
// A conflict disables every tool, and a popover left open over a disabled pill would still have
// live items in it. The file on disk has already moved by then, so nothing here gets to write to
// the buffer until the user has said which copy wins. Only the six setState functions are read,
// and those are stable, so the closure this captures is never the stale one.
useEffect(() => {
if (disabled) closePopovers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [disabled]);
// Ported Mod-K handling. Note for whoever wires the pane together: cmd+k is already bound
// globally to "command-palette" in src/keys/bindings.ts with allowInInput: true, and that
// listener is installed at app boot, before this component ever mounts, so it always sees the
// keydown first. The defaultPrevented check below means this never double-fires on top of it,
// but it also means this shortcut is inert until a document-context override for cmd+k exists.
// Clicking the link tool still opens the popover either way.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.isComposing || e.defaultPrevented) return;
if (e.key.toLowerCase() !== "k" || !(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return;
if (!editor && !linkOpen) return;
e.preventDefault();
e.stopPropagation();
if (linkOpen) {
setLinkOpen(false);
} else {
setLinkValue("");
setLinkOpen(true);
}
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [editor, linkOpen]);
useEscapeLayer(linkOpen, () => {
setLinkOpen(false);
editor?.focus();
});
useEscapeLayer(headingOpen, () => setHeadingOpen(false));
useEscapeLayer(calloutOpen, () => setCalloutOpen(false));
useEscapeLayer(tableOpen, () => setTableOpen(false));
useEscapeLayer(insertOpen, () => setInsertOpen(false));
useEscapeLayer(languageOpen, () => {
setLanguageOpen(false);
editor?.focus();
});
const applyLink = () => {
if (!editor) return;
const href = normalizeUrl(linkValue);
editor.setLink(href || null);
setLinkOpen(false);
editor.focus();
};
const removeLink = () => {
if (!editor) return;
editor.setLink(null);
setLinkOpen(false);
editor.focus();
};
const openLink = () => {
const wasOpen = linkOpen;
closePopovers();
if (wasOpen) return;
setLinkValue("");
setLinkOpen(true);
};
// The code block tool converts into a fence from outside one and configures the fence from
// inside it. Turning one back into a paragraph moves into the foot of that popover rather than
// staying on a second press of the tool, because the pill has no room for a language control of
// its own and the tool for the block you are in is where you would look for one anyway.
const onCodeBlock = () => {
if (active.block !== "codeBlock") {
closePopovers();
editor?.setBlock("codeBlock");
return;
}
const wasOpen = languageOpen;
closePopovers();
if (wasOpen) return;
setLanguageValue(active.codeLanguage ?? "");
setLanguageOpen(true);
};
const setLanguage = (language: string | null) => {
if (!editor) return;
editor.setCodeLanguage(language);
setLanguageOpen(false);
editor.focus();
};
// Row and column ops leave the popover up: adding three rows is three clicks in the same place,
// and the command has already put the cursor back in the table by the time the next one runs.
const runTable = (op: TableOp) => {
editor?.tableCommand(op);
};
const onImageChosen = async (file: File) => {
if (!editor || !document) return;
// Asked before the bytes go anywhere. The picture has to be on disk before there is a path to
// put in the document, so a cursor in a table cell or a fenced block, where the insert is
// refused, would otherwise leave a file in the user's assets folder that nothing refers to.
if (!editor.canInsertImage()) {
notify("An image cannot go where the cursor is.");
return;
}
try {
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
const result = await assetWrite(document.path, bytes, file.name || "image.png");
const alt = file.name.replace(/\.[^./]+$/, "") || null;
editor.insertImage(result.relPath, alt);
editor.focus();
} catch (e) {
notify(String(e));
}
};
return (
<div className="editor-toolbar">
{tool(active.marks.includes("strong"), () => editor?.toggleMark("strong"), "Bold", <Icon d={BOLD_D} />, disabled)}
{tool(active.marks.includes("em"), () => editor?.toggleMark("em"), "Italic", <Icon d={ITALIC_D} />, disabled)}
{tool(
active.marks.includes("strikethrough"),
() => editor?.toggleMark("strikethrough"),
"Strikethrough",
<Icon d={STRIKETHROUGH_D} />,
disabled,
)}
{tool(active.marks.includes("code"), () => editor?.toggleMark("code"), "Inline code", <Icon d={CODE_D} />, disabled)}
<span className="tool-sep" />
<span className="tool-wrap">
{tool(
headingOpen || active.block === "heading",
() => {
const wasOpen = headingOpen;
closePopovers();
if (!wasOpen) setHeadingOpen(true);
},
"Heading",
<Icon d={HEADING_D} />,
disabled,
)}
{headingOpen && (
<>
<div className="link-pop-backdrop" onMouseDown={() => setHeadingOpen(false)} />
<div className="heading-pop" onMouseDown={(e) => e.stopPropagation()}>
<button
className="pop-item"
data-on={active.block === "paragraph"}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
editor?.setHeading(null);
setHeadingOpen(false);
editor?.focus();
}}
>
Paragraph
</button>
{HEADING_LEVELS.map((level) => (
<button
key={level}
className="pop-item"
data-on={active.block === "heading" && active.headingLevel === level}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
editor?.setHeading(level);
setHeadingOpen(false);
editor?.focus();
}}
>
{`Heading ${level}`}
</button>
))}
</div>
</>
)}
</span>
<span className="tool-sep" />
{tool(active.block === "bulletList", () => editor?.setBlock("bulletList"), "Bulleted list", <Icon d={BULLET_LIST_D} />, disabled)}
{tool(active.block === "orderedList", () => editor?.setBlock("orderedList"), "Numbered list", <Icon d={ORDERED_LIST_D} />, disabled)}
{tool(active.block === "taskList", () => editor?.setBlock("taskList"), "Task list", <Icon d={TASK_LIST_D} />, disabled)}
{tool(active.block === "blockquote", () => editor?.setBlock("blockquote"), "Quote", <Icon d={BLOCKQUOTE_D} />, disabled)}
{/* The third of the three wrapping commands, beside the two it behaves like: one press puts
the block inside a <details>, a second takes it back out. The summary is typed into the
toggle itself rather than asked for here, because it is the one part of a block in this
pill that is a piece of the document and not a setting. */}
{tool(active.block === "toggle", () => editor?.setBlock("toggle"), "Toggle", <Icon d={TOGGLE_D} />, disabled)}
<span className="tool-wrap">
{tool(
calloutOpen || active.block === "callout",
() => {
const wasOpen = calloutOpen;
closePopovers();
if (!wasOpen) setCalloutOpen(true);
},
"Callout",
<Icon d={CALLOUT_D} />,
disabled,
)}
{calloutOpen && (
<>
<div className="link-pop-backdrop" onMouseDown={() => setCalloutOpen(false)} />
<div className="callout-pop" onMouseDown={(e) => e.stopPropagation()}>
{CALLOUT_KINDS.map((kind) => (
<button
key={kind}
className="pop-item"
data-on={active.callout === kind}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
editor?.setCallout(kind);
setCalloutOpen(false);
editor?.focus();
}}
>
{calloutLabel(kind)}
</button>
))}
{active.block === "callout" && (
<button
className="pop-item"
title="Leave the blockquote it is on disk, without the marker"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
editor?.setCallout(null);
setCalloutOpen(false);
editor?.focus();
}}
>
Plain quote
</button>
)}
</div>
</>
)}
</span>
<span className="tool-wrap">
{tool(
languageOpen || active.block === "codeBlock",
onCodeBlock,
active.block === "codeBlock"
? `Code block: ${active.codeLanguage ?? "no language"}`
: "Code block",
<Icon d={CODE_BLOCK_D} />,
disabled,
)}
{languageOpen && (
<>
<div className="link-pop-backdrop" onMouseDown={() => setLanguageOpen(false)} />
<div className="lang-pop" onMouseDown={(e) => e.stopPropagation()}>
<div className="lang-row">
<input
ref={languageInputRef}
className="link-input"
value={languageValue}
placeholder="Language"
spellCheck={false}
onChange={(e) => setLanguageValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
setLanguage(normalizeLanguage(languageValue));
}
}}
/>
<button
className="link-btn"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setLanguage(normalizeLanguage(languageValue))}
>
Apply
</button>
{active.codeLanguage !== null && (
<button
className="link-btn ghost"
title="Leave a bare fence"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setLanguage(null)}
>
<Icon d={REMOVE_D} size={14} />
</button>
)}
</div>
<div className="lang-chips">
{LANGUAGES.map((name) => (
<button
key={name}
className="lang-chip"
data-on={active.codeLanguage === name}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setLanguage(name)}
>
{name}
</button>
))}
</div>
<button
className="pop-item"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
editor?.setBlock("codeBlock");
setLanguageOpen(false);
editor?.focus();
}}
>
Turn into a paragraph
</button>
</div>
</>
)}
</span>
<span className="tool-sep" />
<span className="tool-wrap">
{tool(active.marks.includes("link") || linkOpen, openLink, "Link (⌘K)", <Icon d={LINK_D} />, disabled)}
{linkOpen && (
<>
<div className="link-pop-backdrop" onMouseDown={() => setLinkOpen(false)} />
<div className="link-pop" onMouseDown={(e) => e.stopPropagation()}>
<input
ref={linkInputRef}
className="link-input"
value={linkValue}
placeholder="https://..."
spellCheck={false}
onChange={(e) => setLinkValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyLink();
}
}}
/>
<button className="link-btn" onMouseDown={(e) => e.preventDefault()} onClick={applyLink}>
Apply
</button>
{active.marks.includes("link") && (
<button
className="link-btn ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={removeLink}
title="Remove link"
>
<Icon d={REMOVE_D} size={14} />
</button>
)}
</div>
</>
)}
</span>
<span className="tool-sep" />
{tool(false, () => editor?.insertRule(), "Horizontal rule", <Icon d={HR_D} />, disabled)}
{tool(false, () => fileRef.current?.click(), "Insert image", <Icon d={IMAGE_D} />, disabled || !document)}
<input
ref={fileRef}
type="file"
accept="image/*"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void onImageChosen(file);
e.currentTarget.value = "";
}}
/>
<span className="tool-sep" />
<span className="tool-wrap">
{tool(
tableOpen || active.inTable,
() => {
const wasOpen = tableOpen;
closePopovers();
if (wasOpen) return;
setSize(null);
setTableOpen(true);
},
active.inTable ? "Table" : "Insert table",
<Icon d={TABLE_D} />,
disabled,
)}
{tableOpen && (
<>
<div className="link-pop-backdrop" onMouseDown={() => setTableOpen(false)} />
<div
className="table-pop"
data-mode={active.inTable ? "edit" : "insert"}
onMouseDown={(e) => e.stopPropagation()}
>
{active.inTable ? (
<>
<span className="pop-label">Row</span>
<div className="pop-row">
<button
className="pop-btn"
title="Insert a row above this one"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("addRowBefore")}
>
Above
</button>
<button
className="pop-btn"
title="Insert a row below this one"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("addRowAfter")}
>
Below
</button>
<button
className="pop-btn danger"
title="Delete this row"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("deleteRow")}
>
Delete
</button>
</div>
<span className="pop-label">Column</span>
<div className="pop-row">
<button
className="pop-btn"
title="Insert a column to the left"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("addColumnBefore")}
>
Left
</button>
<button
className="pop-btn"
title="Insert a column to the right"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("addColumnAfter")}
>
Right
</button>
<button
className="pop-btn danger"
title="Delete this column"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("deleteColumn")}
>
Delete
</button>
</div>
{/* Markdown has no per cell alignment, so these set the whole column the cursor
is in, which is what the delimiter row on disk can say. */}
<span className="pop-label">Align column</span>
<div className="pop-row">
<button
className="pop-btn"
title="Align this column left"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("alignLeft")}
>
Left
</button>
<button
className="pop-btn"
title="Align this column centre"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("alignCenter")}
>
Centre
</button>
<button
className="pop-btn"
title="Align this column right"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("alignRight")}
>
Right
</button>
<button
className="pop-btn"
title="Leave this column unaligned"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("alignClear")}
>
None
</button>
</div>
{/* No header row control. A GFM table has one header row, it is the first one,
and there is no spelling for a table without one, so the button would offer an
edit the file cannot hold. See TableOp in src/editor/index.ts. */}
<span className="pop-label">Table</span>
<button
className="pop-item danger"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
runTable("deleteTable");
setTableOpen(false);
}}
>
Delete table
</button>
</>
) : (
<>
<div className="size-grid" onMouseLeave={() => setSize(null)}>
{Array.from({ length: PICKER_ROWS * PICKER_COLUMNS }, (_, i) => {
const rows = Math.floor(i / PICKER_COLUMNS) + 1;
const columns = (i % PICKER_COLUMNS) + 1;
return (
<button
key={i}
className="size-cell"
data-on={size !== null && rows <= size.rows && columns <= size.columns}
title={`${rows} by ${columns} table`}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setSize({ rows, columns })}
onFocus={() => setSize({ rows, columns })}
onClick={() => {
editor?.insertTable(rows, columns);
setTableOpen(false);
editor?.focus();
}}
/>
);
})}
</div>
<span className="size-label">
{size === null ? "Pick a size" : `${size.rows} x ${size.columns}`}
</span>
</>
)}
</div>
</>
)}
</span>
<span className="tool-wrap">
{tool(
insertOpen,
() => {
const wasOpen = insertOpen;
closePopovers();
if (!wasOpen) setInsertOpen(true);
},
"Insert",
<Icon d={INSERT_D} />,
disabled,
)}
{insertOpen && (
<>
<div className="link-pop-backdrop" onMouseDown={() => setInsertOpen(false)} />
<div className="insert-pop" onMouseDown={(e) => e.stopPropagation()}>
<button
className="pop-item"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
editor?.insertMath(false);
setInsertOpen(false);
editor?.focus();
}}
>
Inline formula
</button>
<button
className="pop-item"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
editor?.insertMath(true);
setInsertOpen(false);
editor?.focus();
}}
>
Display formula
</button>
<button
className="pop-item"
title="A fenced block with mermaid as its language"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
editor?.insertMermaid();
setInsertOpen(false);
editor?.focus();
}}
>
Mermaid diagram
</button>
</div>
</>
)}
</span>
<div className="toolbar-status" data-phase={saveState}>
{saveState === "saving" && <span className="toolbar-status-dot" aria-hidden="true" />}
{saveState === "conflict" && (
<button
className="toolbar-status-conflict"
title="This file changed on disk. Click to resolve."
onMouseDown={(e) => e.preventDefault()}
onClick={() => onResolveConflict?.()}
>
<Icon d={ALERT_D} size={14} />
</button>
)}
</div>
</div>
);
}
+396
View File
@@ -0,0 +1,396 @@
// Highlighting is paint, and the tests that matter are the ones that prove it stayed paint: the
// text of a fence, the attributes on it and the bytes it serializes to are the same whether or not
// a grammar was ever run over it. The rest is about the two ways a highlighter goes wrong in a real
// editor. It can throw or misalign on input it did not expect, which is answered here by fences
// nobody has a grammar for, and it can be slow, which is answered by counting how much of the
// document it walks when one character is typed.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Editor } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { EditorState } from "@tiptap/pm/state";
import type { Plugin } from "@tiptap/pm/state";
import type { Decoration, DecorationSet } from "@tiptap/pm/view";
// The highlighter is private to code.ts, deliberately, so the only place left to watch how often it
// runs is underneath it. Everything the real lowlight does still happens; the wrapper only records
// the text it was handed.
const { highlighted } = vi.hoisted(() => ({ highlighted: [] as string[] }));
vi.mock("lowlight", async (importOriginal) => {
const actual = await importOriginal<typeof import("lowlight")>();
return {
...actual,
createLowlight(...created: Parameters<typeof actual.createLowlight>) {
const instance = actual.createLowlight(...created);
return {
...instance,
highlight(...call: Parameters<typeof instance.highlight>) {
highlighted.push(call[1]);
return instance.highlight(...call);
},
};
},
};
});
const { createEditorExtensions } = await import("../extensions");
const { setCodeLanguage } = await import("./code");
const { parseMarkdown, serializeMarkdown } = await import("../../markdown");
const extensions = () =>
createEditorExtensions({ documentPath: () => "/notes/a.md", onError: () => {} });
function editorFor(source: string): Editor {
return new Editor({
element: null,
injectCSS: false,
extensions: extensions(),
content: parseMarkdown(source, "/notes/a.md").doc.toJSON(),
});
}
/**
* The document with the highlighting plugin over it, and nothing else.
*
* An editor cannot be mounted without a DOM and an unmounted one's state carries no plugins at all,
* so the plugin is lifted out of the extension manager and given a state of its own. What it sees
* there is what it sees in the app: a real state over a real parsed document, and transactions
* applied to it one at a time. Only the view is missing, and a decoration is computed without one.
*/
function stateFor(source: string): EditorState {
const editor = editorFor(source);
const plugin = editor.extensionManager.plugins.find((candidate: Plugin) =>
String((candidate as unknown as { key: string }).key).startsWith("codeHighlighting"),
);
if (!plugin) throw new Error("the code highlighting plugin is not in the extension list");
const state = EditorState.create({ doc: editor.state.doc, plugins: [plugin] });
editor.destroy();
return state;
}
interface Block {
pos: number;
node: ProseMirrorNode;
}
function codeBlocks(doc: ProseMirrorNode): Block[] {
const found: Block[] = [];
doc.descendants((node, pos) => {
if (node.type.name !== "codeBlock") return true;
found.push({ pos, node });
return false;
});
return found;
}
function decorations(state: EditorState): Decoration[] {
for (const plugin of state.plugins) {
const set = plugin.getState(state) as DecorationSet | undefined;
if (set) return set.find();
}
return [];
}
function classOf(decoration: Decoration): string {
return (decoration as unknown as { type: { attrs: { class: string } } }).type.attrs.class;
}
/** The text a span was cut from, which is the only thing that says it landed in the right place. */
function textOf(state: EditorState, decoration: Decoration): string {
return state.doc.textBetween(decoration.from, decoration.to);
}
function spanWith(state: EditorState, className: string): string | undefined {
const found = decorations(state).find((decoration) => classOf(decoration).includes(className));
return found && textOf(state, found);
}
function fence(language: string, ...lines: string[]): string {
return ["```" + language, ...lines, "```", ""].join("\n");
}
beforeEach(() => {
highlighted.length = 0;
});
describe("the highlighter", () => {
it("colours the four languages this repo's own docs are written in", () => {
const sources: Record<string, string> = {
rust: fence("rust", "fn main() {}"),
toml: fence("toml", "[package]", 'name = "margin-docs"'),
swift: fence("swift", "let x = 1"),
kotlin: fence("kotlin", "val x = 1"),
};
for (const [language, source] of Object.entries(sources)) {
const state = stateFor(source);
expect([language, decorations(state).length > 0]).toEqual([language, true]);
}
});
it("puts every span over the characters it was cut from", () => {
const state = stateFor(fence("rust", 'fn main() { let x = "hi"; }', "// a comment"));
const [block] = codeBlocks(state.doc);
const from = block.pos + 1;
const to = from + block.node.content.size;
const found = decorations(state);
expect(found.length).toBeGreaterThan(3);
for (const decoration of found) {
expect(decoration.from).toBeGreaterThanOrEqual(from);
expect(decoration.to).toBeLessThanOrEqual(to);
expect(decoration.from).toBeLessThan(decoration.to);
}
expect(spanWith(state, "hljs-keyword")).toBe("fn");
expect(spanWith(state, "hljs-string")).toBe('"hi"');
expect(spanWith(state, "hljs-comment")).toBe("// a comment");
});
it("keeps its offsets over text that is not one code unit per character", () => {
const state = stateFor(fence("ts", 'const flag = "🇬🇧 ok";', "\tconst tabbed = 1;"));
expect(codeBlocks(state.doc)[0].node.textContent).toBe(
'const flag = "🇬🇧 ok";\n\tconst tabbed = 1;',
);
expect(spanWith(state, "hljs-string")).toBe('"🇬🇧 ok"');
});
it("leaves a fence tagged with a language nobody has plain, and loses nothing", () => {
const source = fence("nosuchlanguage", "this is not code in any language", " indented ");
const parsed = parseMarkdown(source, "/notes/a.md");
const state = stateFor(source);
expect(decorations(state)).toEqual([]);
expect(codeBlocks(state.doc)[0].node.textContent).toBe(
"this is not code in any language\n indented ",
);
expect(serializeMarkdown(parsed, state.doc)).toBe(source);
});
it("leaves a bare fence plain", () => {
const state = stateFor(fence("", "just some text"));
expect(codeBlocks(state.doc)[0].node.attrs.language).toBe(null);
expect(decorations(state)).toEqual([]);
expect(highlighted).toEqual([]);
});
it("leaves a mermaid fence to the lane that draws it", () => {
const state = stateFor(fence("mermaid", "graph TD;", " a-->b;"));
expect(decorations(state)).toEqual([]);
expect(highlighted).toEqual([]);
});
it("does not throw on code that is broken in its own language", () => {
const source = fence("json", "{ this is not, json: ]]", '"neither" is "this"');
const parsed = parseMarkdown(source, "/notes/a.md");
const state = stateFor(source);
expect(codeBlocks(state.doc)[0].node.textContent).toBe(
'{ this is not, json: ]]\n"neither" is "this"',
);
expect(serializeMarkdown(parsed, state.doc)).toBe(source);
for (const decoration of decorations(state)) {
expect(textOf(state, decoration).length).toBeGreaterThan(0);
}
});
it("leaves a fence too long to be read as code plain, and keeps every character", () => {
const line = "const x = 1; // a line of code that is being repeated a great many times\n";
const long = line.repeat(1000);
expect(long.length).toBeGreaterThan(50_000);
const state = stateFor(fence("ts", long.trimEnd()));
expect(decorations(state)).toEqual([]);
expect(highlighted).toEqual([]);
expect(codeBlocks(state.doc)[0].node.textContent).toBe(long.trimEnd());
});
it("does not write to the document it is painting over", () => {
const source = ["# Notes", "", fence("rust twoslash", "fn main() {}"), "Prose.", ""].join("\n");
const parsed = parseMarkdown(source, "/notes/a.md");
const state = stateFor(source);
expect(state.doc.toJSON()).toEqual(parsed.doc.toJSON());
expect(codeBlocks(state.doc)[0].node.attrs).toEqual({ language: "rust", meta: "twoslash" });
expect(serializeMarkdown(parsed, state.doc)).toBe(source);
});
});
describe("what a keystroke costs", () => {
const twoBlocks = () =>
stateFor(
[fence("ts", "const first = 1;"), fence("ts", "const second = 2;"), "Prose.", ""].join("\n"),
);
const endOf = (block: Block) => block.pos + 1 + block.node.content.size;
it("re-highlights only the block the edit landed in", () => {
const state = twoBlocks();
const blocks = codeBlocks(state.doc);
expect(blocks).toHaveLength(2);
expect(highlighted).toEqual(["const first = 1;", "const second = 2;"]);
highlighted.length = 0;
state.apply(state.tr.insertText("2", endOf(blocks[1])));
expect(highlighted).toEqual(["const second = 2;2"]);
});
it("carries the untouched block's spans forward, still over their own characters", () => {
const before = twoBlocks();
const state = before.apply(before.tr.insertText("// ", codeBlocks(before.doc)[0].pos + 1));
const second = codeBlocks(state.doc)[1];
const inSecond = decorations(state).filter((decoration) => decoration.from > second.pos);
expect(inSecond.length).toBeGreaterThan(0);
for (const decoration of inSecond) {
expect("const second = 2;").toContain(textOf(state, decoration));
}
const keyword = decorations(state).find(
(decoration) =>
decoration.from > second.pos && classOf(decoration).includes("hljs-keyword"),
);
expect(keyword && textOf(state, keyword)).toBe("const");
});
it("re-highlights a block whose language changed, and no other", () => {
const before = twoBlocks();
const block = codeBlocks(before.doc)[0];
highlighted.length = 0;
const state = before.apply(
before.tr.setNodeMarkup(block.pos, null, { ...block.node.attrs, language: "rust" }),
);
expect(highlighted).toEqual(["const first = 1;"]);
expect(spanWith(state, "hljs-keyword")).toBe("const");
});
it("drops the spans of a block that was deleted, and highlights nothing again", () => {
const before = twoBlocks();
const block = codeBlocks(before.doc)[1];
highlighted.length = 0;
const state = before.apply(
before.tr.delete(block.pos, block.pos + block.node.nodeSize),
);
expect(highlighted).toEqual([]);
expect(codeBlocks(state.doc)).toHaveLength(1);
for (const decoration of decorations(state)) {
expect("const first = 1;").toContain(textOf(state, decoration));
}
});
it("highlights a block that was inserted after the document loaded, and no other", () => {
const before = twoBlocks();
const at = codeBlocks(before.doc)[1].pos;
highlighted.length = 0;
const state = before.apply(
before.tr.insert(
at,
before.doc.type.schema.nodes.codeBlock.create({ language: "rust", meta: null }, [
before.doc.type.schema.text("fn main() {}"),
]),
),
);
expect(highlighted).toEqual(["fn main() {}"]);
expect(codeBlocks(state.doc)).toHaveLength(3);
expect(spanWith(state, "hljs-keyword")).toBe("const");
});
it("does not run at all for a transaction that changed no text", () => {
const before = twoBlocks();
highlighted.length = 0;
const state = before.apply(before.tr.setMeta("nothing", true));
expect(highlighted).toEqual([]);
expect(decorations(state).length).toBeGreaterThan(0);
});
});
describe("setCodeLanguage", () => {
function editorAtFence(source: string): Editor {
const editor = editorFor(source);
editor.commands.setTextSelection(codeBlocks(editor.state.doc)[0].pos + 1);
return editor;
}
it("writes the language and leaves the meta the user wrote alone", () => {
const source = fence("ts twoslash", "const x = 1;");
const parsed = parseMarkdown(source, "/notes/a.md");
const editor = editorAtFence(source);
expect(setCodeLanguage(editor, "rust")).toBe(true);
expect(codeBlocks(editor.state.doc)[0].node.attrs).toEqual({
language: "rust",
meta: "twoslash",
});
const written = serializeMarkdown(parsed, editor.state.doc);
expect(written).toBe(fence("rust twoslash", "const x = 1;"));
editor.destroy();
});
it("clears the fence back to a bare one", () => {
const source = fence("ts", "const x = 1;");
const parsed = parseMarkdown(source, "/notes/a.md");
const editor = editorAtFence(source);
expect(setCodeLanguage(editor, null)).toBe(true);
expect(codeBlocks(editor.state.doc)[0].node.attrs.language).toBe(null);
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(fence("", "const x = 1;"));
editor.destroy();
});
it("treats a blank language as a bare fence", () => {
const editor = editorAtFence(fence("ts", "const x = 1;"));
expect(setCodeLanguage(editor, " ")).toBe(true);
expect(codeBlocks(editor.state.doc)[0].node.attrs.language).toBe(null);
editor.destroy();
});
it("refuses a language with a space in it, which the fence would read back as two things", () => {
const source = fence("ts", "const x = 1;");
const parsed = parseMarkdown(source, "/notes/a.md");
const editor = editorAtFence(source);
expect(setCodeLanguage(editor, "ts twoslash")).toBe(false);
expect(codeBlocks(editor.state.doc)[0].node.attrs).toEqual({ language: "ts", meta: null });
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(source);
editor.destroy();
});
it("does nothing when the block already says that, so nothing is dirtied", () => {
const editor = editorAtFence(fence("ts", "const x = 1;"));
const before = editor.state.doc.toJSON();
expect(setCodeLanguage(editor, "ts")).toBe(false);
expect(editor.state.doc.toJSON()).toEqual(before);
editor.destroy();
});
it("does nothing outside a code block", () => {
const editor = editorFor(["Just a paragraph.", ""].join("\n"));
const before = editor.state.doc.toJSON();
expect(setCodeLanguage(editor, "rust")).toBe(false);
expect(editor.state.doc.toJSON()).toEqual(before);
editor.destroy();
});
});
+245
View File
@@ -0,0 +1,245 @@
// Code block behaviour: syntax highlighting, and the language on the fence.
//
// Highlighting is decorations over the block's own text and never an edit to it. The spans belong
// to the view, nothing they do reaches the tree, and a highlighted block therefore serializes back
// to exactly the fence it was read from. lowlight rather than shiki, because a decoration set has
// to be rebuilt synchronously inside the plugin and shiki highlights asynchronously.
//
// `language` and `meta` are already attributes on the schema's codeBlock, so setting a language is
// an ordinary attribute edit and does change the document, which is the point: the fence on disk
// changes with it. `meta` is never touched. It is whatever the user wrote after the language on
// their own opening fence, this editor has no model for it, and it rides along untouched.
//
// The decoration set is rebuilt per code block rather than per document. A document is autosaved
// half a second after the last keystroke, so the typing path is the hot one, and re-running a
// grammar over every fence in a long file on every character typed is the obvious way to make this
// editor feel slow. A transaction says which ranges it touched; only the code blocks those ranges
// land in are highlighted again, and the rest are carried over by mapping the old set forward.
//
// A codeBlock whose language is mermaid belongs to the mermaid lane, which draws it as a diagram
// through a node view. Nothing here decorates one.
import { Extension } from "@tiptap/core";
import type { Editor } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { Transaction } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import type { LanguageFn } from "highlight.js";
import ini from "highlight.js/lib/languages/ini";
import kotlin from "highlight.js/lib/languages/kotlin";
import rust from "highlight.js/lib/languages/rust";
import swift from "highlight.js/lib/languages/swift";
import { common, createLowlight } from "lowlight";
const lowlight = createLowlight(common);
/**
* The four languages this app's own docs folder is written in, registered by hand.
*
* lowlight's common set carries all four today, toml as an alias of ini, so the loop below does
* nothing on this version. It is here because "common" is somebody else's list and it has been
* trimmed before: if one of these ever falls out of it, the app's own documentation is the first
* thing that stops highlighting, and that is a silly way to find out.
*/
const REQUIRED: ReadonlyArray<readonly [string, LanguageFn]> = [
["rust", rust],
["toml", ini],
["swift", swift],
["kotlin", kotlin],
];
for (const [name, grammar] of REQUIRED) {
if (!lowlight.registered(name)) lowlight.register(name, grammar);
}
/**
* Past this many characters a fence is left plain.
*
* A block this long is a pasted file rather than code anyone is reading, and a grammar walking it
* again on every keystroke is a stutter the user cannot explain. Nothing is lost by not colouring
* it: the text is the document's, the decorations were only ever paint.
*/
const MAX_HIGHLIGHT_CHARS = 50_000;
type HighlightRoot = ReturnType<ReturnType<typeof createLowlight>["highlight"]>;
type HighlightChild = HighlightRoot["children"][number];
const codeHighlightKey = new PluginKey<DecorationSet>("codeHighlighting");
/** The class names lowlight put on one span, as ProseMirror wants them: one string. */
function classNameOf(properties: Record<string, unknown> | undefined): string {
const value = properties?.className;
if (typeof value === "string") return value;
if (Array.isArray(value)) {
return value.filter((name): name is string => typeof name === "string").join(" ");
}
return "";
}
/**
* The language to highlight this block with, or null to leave it plain.
*
* A fence's info string is the user's text, not a menu selection: it can be blank, it can name a
* language nobody has a grammar for, and it can be a typo. All three are plain text and none of
* them is an error, so an unregistered name is answered here rather than by letting the highlighter
* throw. Guessing is not on the list either: highlightAuto would colour a paragraph of prose as
* whichever language it happened to resemble.
*/
function highlightableLanguage(node: ProseMirrorNode): string | null {
const language = typeof node.attrs.language === "string" ? node.attrs.language.trim() : "";
if (!language) return null;
// Matched loosely, unlike the mermaid lane's own exact test, because the two must not both draw
// the same block and the safe direction to be wrong in is leaving a block plain.
if (language.toLowerCase() === "mermaid") return null;
return lowlight.registered(language) ? language : null;
}
/** `base` is the position of the block's first character, so `pos + 1` for the node at `pos`. */
function decorationsFor(node: ProseMirrorNode, base: number): Decoration[] {
const language = highlightableLanguage(node);
if (!language) return [];
const text = node.textContent;
if (!text || text.length > MAX_HIGHLIGHT_CHARS) return [];
let tree: HighlightRoot;
try {
tree = lowlight.highlight(language, text);
} catch {
// A grammar that throws on somebody's file is a highlighter's problem and never the document's.
return [];
}
const decorations: Decoration[] = [];
let offset = 0;
const walk = (children: readonly HighlightChild[]): void => {
for (const child of children) {
if (child.type === "text") {
offset += child.value.length;
} else if (child.type === "element") {
const from = offset;
walk(child.children);
const className = classNameOf(child.properties);
if (className && offset > from) {
decorations.push(Decoration.inline(base + from, base + offset, { class: className }));
}
}
}
};
walk(tree.children);
// The highlighter is a third party walking the user's text, and a decoration that runs past the
// end of the block throws inside the view rather than merely looking wrong. If what came back
// does not measure the same as what went in, the offsets cannot be trusted and the block stays
// plain.
return offset === text.length ? decorations : [];
}
function highlightWholeDoc(doc: ProseMirrorNode): DecorationSet {
const decorations: Decoration[] = [];
doc.descendants((node, pos) => {
if (node.type.name !== "codeBlock") return true;
decorations.push(...decorationsFor(node, pos + 1));
return false;
});
return DecorationSet.create(doc, decorations);
}
/**
* The code blocks a transaction landed in, by position in the new document.
*
* Every step carries a map of the ranges it replaced. Mapping a step's range through the steps that
* came after it puts it in the final document's coordinates, where the blocks it overlaps are the
* ones whose text or attributes could have changed. An attribute-only edit counts: setting the
* language rewrites the node, which shows up here as a touched range around it, which is what makes
* the fence recolour the moment its language changes.
*/
function touchedCodeBlocks(tr: Transaction, doc: ProseMirrorNode): Map<number, ProseMirrorNode> {
const blocks = new Map<number, ProseMirrorNode>();
const end = doc.content.size;
tr.mapping.maps.forEach((stepMap, index) => {
const rest = tr.mapping.slice(index + 1);
stepMap.forEach((_oldFrom, _oldTo, newFrom, newTo) => {
const from = Math.max(0, Math.min(end, rest.map(newFrom, -1)));
const to = Math.max(from, Math.min(end, rest.map(newTo, 1)));
doc.nodesBetween(from, to, (node, pos) => {
if (node.type.name !== "codeBlock") return true;
blocks.set(pos, node);
return false;
});
});
});
return blocks;
}
export const CodeHighlighting = Extension.create({
name: "codeHighlighting",
addProseMirrorPlugins() {
return [
new Plugin<DecorationSet>({
key: codeHighlightKey,
state: {
init: (_config, state) => highlightWholeDoc(state.doc),
apply(tr, value) {
if (!tr.docChanged) return value;
const doc = tr.doc;
const touched = touchedCodeBlocks(tr, doc);
let next = value.map(tr.mapping, doc);
if (!touched.size) return next;
const added: Decoration[] = [];
for (const [pos, node] of touched) {
const from = pos + 1;
const to = from + node.content.size;
// The spans mapped forward through the edit are the ones this block had before it,
// stretched over text the grammar has not seen. They go before the new ones do.
const stale = next.find(from, to);
if (stale.length) next = next.remove(stale);
added.push(...decorationsFor(node, from));
}
return added.length ? next.add(doc, added) : next;
},
},
props: {
decorations: (state) => codeHighlightKey.getState(state) ?? DecorationSet.empty,
},
}),
];
},
});
/** null clears the fence back to a bare ```. False when the cursor is not in a code block. */
export function setCodeLanguage(editor: Editor, language: string | null): boolean {
if (!editor.isActive("codeBlock")) return false;
const next = language === null ? null : language.trim() || null;
// A fence's info string is one word of language and everything after it is `meta`, so a language
// with a space in it would be read back off disk as a different language plus a meta the user
// never wrote. Refusing leaves the file saying what it already says.
if (next !== null && /\s/.test(next)) return false;
// Setting the language a block already has would dirty the document and spend an autosave
// rewriting the file the user is looking at, for no change at all.
const current = (editor.getAttributes("codeBlock").language as string | null | undefined) ?? null;
if (current === next) return false;
// Read out of the chain rather than off run(), because focus answers a different question, and
// answers it with false whenever there is no view to focus.
let changed = false;
editor
.chain()
.focus()
.command(({ commands }) => {
changed = commands.updateAttributes("codeBlock", { language: next });
return changed;
})
.run();
return changed;
}
+54
View File
@@ -0,0 +1,54 @@
// The seam the block lanes plug into, and the only file that knows all five of them exist.
//
// extensions.ts generates one TipTap extension per entry in the frozen schema, which covers what a
// node IS. What a node DOES, its ProseMirror plugins, its node views, its keymap and its input
// rules, has no place on a mechanically generated extension, and five unrelated blocks sharing one
// file would mean five reasons to edit it and five chances to break somebody else's block while
// doing so. So each lane is one Extension in one file, listed here once. extensions.ts spreads this
// array without knowing what is in it, and nothing else imports the lane files.
//
// A lane may add plugins, node views, keyboard shortcuts and input rules. It may not add, remove or
// alter a node or a mark. The schema is src/model/schema.ts, the markdown bridge is written against
// it, and an editor whose schema has drifted from the contract is an editor that cannot hold a
// document the bridge just parsed, which is somebody's file lost on the next save.
import type { Extensions } from "@tiptap/core";
import { CodeHighlighting, setCodeLanguage } from "./code";
import { MathRendering, insertMath } from "./math";
import { MermaidRendering, insertMermaid } from "./mermaid";
import { Tables, tableCommand } from "./tables";
import { Toggles } from "./toggle";
/**
* In precedence order, lowest first. TipTap reverses the extension list before it collects
* ProseMirror plugins, so the last entry here contributes the first plugin the view asks, and for a
* node view the first plugin asked is the one that gets the node. Mermaid is last because a mermaid
* diagram is a codeBlock: it and the highlighter are looking at the same node type, and the diagram
* is the more specific of the two.
*
* Position is the weaker of the two levers, and it is worth knowing which because the stronger one
* is now in use. TipTap sorts the reversed list by each extension's `priority` before it collects
* anything, so a higher priority beats any position in this array; every lane here leaves it at the
* default and is ordered by position alone. src/editor/paste.ts is the one that does not, and it
* says why: its handlers guard the document against every other plugin's, so being ahead of the
* table plugins below cannot be left to where two arrays happen to put it.
*
* Toggles goes first rather than beside the lane it reads most like. The toggle node is claimed by
* nobody else, so where it sits changes nothing about which plugin gets that node view, and the
* four below it are in an order that was argued over: put anywhere else it would move one of them
* and leave the sentence above no longer true of the array under it.
*/
export const BLOCK_EXTENSIONS: Extensions = [
Toggles,
Tables,
MathRendering,
CodeHighlighting,
MermaidRendering,
];
/**
* What the editor handle's block commands delegate to. Each returns false when it has nothing to
* act on where the cursor is, which is what a toolbar button pressed in the wrong place should do,
* and each is responsible for its own focus the way every other command in the handle is.
*/
export { insertMath, insertMermaid, setCodeLanguage, tableCommand };
+343
View File
@@ -0,0 +1,343 @@
// The math lane's tests, which stop at the edge of the DOM.
//
// vite.config.ts runs vitest in the node environment, so there is no document for a view to mount
// in and the node view in math.ts cannot be built from here. What a formula looks like on screen,
// and the field that opens on it when it is selected, belong to the Playwright suite. What belongs
// here is the half that touches the document, because that is the half that can cost somebody a
// file: the two ways a formula gets made, and the LaTeX already in the document surviving both of
// them character for character.
//
// The last group tests KaTeX rather than this app. It is here because the node view rests on two
// promises the library makes and could quietly stop keeping on an upgrade: that it does not throw
// under these options, and that what it hands back when it cannot parse something still contains
// the source it was given.
import { describe, expect, it } from "vitest";
import { Editor } from "@tiptap/core";
import type { JSONContent } from "@tiptap/core";
import { EditorState, NodeSelection } from "@tiptap/pm/state";
import { CellSelection, TableMap } from "@tiptap/pm/tables";
import katex from "katex";
import { parseMarkdown, serializeMarkdown } from "../../markdown";
import { createEditorExtensions } from "../extensions";
import { insertMath } from "./math";
const extensions = () =>
createEditorExtensions({ documentPath: () => "/notes/a.md", onError: () => {} });
function editorWith(content: JSONContent): Editor {
return new Editor({ element: null, injectCSS: false, extensions: extensions(), content });
}
/**
* The same editor with the extensions' ProseMirror plugins actually installed, which TipTap only
* does when it mounts a view and there is no DOM here to mount into. src/editor/Editor.tsx swaps a
* state built this way in for every document it opens, so this is what the app runs minus the
* screen. Only the table tests need it, because a cell selection is prosemirror-tables' own.
*/
function editorWithPlugins(content: JSONContent): Editor {
const editor = editorWith(content);
editor.view.updateState(
EditorState.create({ doc: editor.state.doc, plugins: editor.extensionManager.plugins }),
);
return editor;
}
/** A file, opened, with the bytes it would be written back as. */
function open(source: string) {
const parsed = parseMarkdown(source, "/notes/a.md");
const editor = editorWithPlugins(parsed.doc.toJSON());
return { editor, written: () => serializeMarkdown(parsed, editor.state.doc) };
}
/** The document position just inside a cell, for a table that is the document's first block. */
function inCell(editor: Editor, row: number, column: number): number {
const table = editor.state.doc.firstChild!;
return 1 + TableMap.get(table).positionAt(row, column, table) + 1;
}
/**
* Enter, through the keymap the extension really installs rather than through a function this file
* reached into. A headless editor has no view to take a key event, so the plugins' own handlers are
* called in the order ProseMirror would call them, with the proxy view TipTap answers with and the
* two things prosemirror-keymap reads off an event: the key name, and the four modifier flags.
*/
function pressEnter(editor: Editor): void {
const event = {
key: "Enter",
keyCode: 13,
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
} as unknown as KeyboardEvent;
for (const plugin of editor.extensionManager.plugins) {
// Called through the plugin, which is the "this" ProseMirror types the prop as wanting.
if (plugin.props.handleKeyDown?.call(plugin, editor.view, event)) return;
}
}
const CODE = {
type: "doc",
content: [
{
type: "codeBlock",
attrs: { language: "ts", meta: null },
content: [{ type: "text", text: "const x = 1;" }],
},
],
};
const RAW = {
type: "doc",
content: [
{
type: "raw",
attrs: { source: "<figure><img src='x.png'></figure>" },
content: [{ type: "text", text: "<figure><img src='x.png'></figure>" }],
},
],
};
describe("insertMath", () => {
it("puts an empty display equation in and selects it", () => {
const editor = editorWith({ type: "doc", content: [{ type: "paragraph" }] });
expect(insertMath(editor, true)).toBe(true);
const block = editor.state.doc.firstChild;
expect(block?.type.name).toBe("mathBlock");
// Not empty. This assertion used to read "" and that was the bug: an empty formula is a box on
// screen the file has no way to spell, and inline it went out as $$$$ and came back as text, so
// the first autosave took it away without telling anybody. A new formula is made with the
// placeholder in it, which is a formula that survives being written.
expect(block?.attrs.latex).toBe("\\square");
// Selected is what opens the field on it, so it is the half of the command that matters.
expect(editor.state.selection instanceof NodeSelection).toBe(true);
expect((editor.state.selection as NodeSelection).node.type.name).toBe("mathBlock");
editor.destroy();
});
// The caret mid paragraph is the case the first version of this got wrong. A display formula
// dropped there splits the paragraph and lands between the halves, so the position the insert was
// asked for is not the position the formula ends up at, and selecting the wrong one means a
// formula on screen with no way into its field. The end of a paragraph is the one place the two
// answers agree, which is why the test above did not catch it.
it("selects the display equation even when the caret was in the middle of a paragraph", () => {
const editor = editorWith({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "before after" }] }],
});
editor.commands.setTextSelection(8);
expect(insertMath(editor, true)).toBe(true);
expect(editor.state.doc.child(0).textContent).toBe("before ");
expect(editor.state.doc.child(1).type.name).toBe("mathBlock");
expect(editor.state.doc.child(2).textContent).toBe("after");
expect(editor.state.selection instanceof NodeSelection).toBe(true);
expect((editor.state.selection as NodeSelection).node.type.name).toBe("mathBlock");
editor.destroy();
});
it("puts an inline formula in without disturbing the text around it", () => {
const editor = editorWith({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "ab" }] }],
});
editor.commands.setTextSelection(2);
expect(insertMath(editor, false)).toBe(true);
const paragraph = editor.state.doc.firstChild;
expect(paragraph?.type.name).toBe("paragraph");
expect([...Array(paragraph?.childCount ?? 0)].map((_, i) => paragraph?.child(i).type.name)).toEqual([
"text",
"mathInline",
"text",
]);
expect(paragraph?.textContent).toBe("ab");
editor.destroy();
});
it("refuses inside a code block, and leaves the fence exactly as it was", () => {
const editor = editorWith(CODE);
const before = editor.state.doc.toJSON();
expect(insertMath(editor, false)).toBe(false);
expect(insertMath(editor, true)).toBe(false);
expect(editor.state.doc.toJSON()).toEqual(before);
editor.destroy();
});
it("refuses inside a raw block, which is somebody's bytes and not a place for a formula", () => {
const editor = editorWith(RAW);
const before = editor.state.doc.toJSON();
expect(insertMath(editor, false)).toBe(false);
expect(insertMath(editor, true)).toBe(false);
expect(editor.state.doc.toJSON()).toEqual(before);
editor.destroy();
});
// The three below are one bug each, reproduced from the bytes they cost. All three were live in a
// build whose table, rule and diagram inserts were already guarded: this command had a private
// copy of the guard that had never been given the isolating rule, and a private guard is a guard
// that is only as good as the last person who remembered it existed. It is gone, and this command
// now asks the one in src/editor/fits.ts that every other insert asks.
it("refuses with the caret in a table cell, and leaves the file byte identical", () => {
const source = "| h1 | h2 |\n| - | - |\n| a | b |\n";
const { editor, written } = open(source);
editor.commands.setTextSelection(inCell(editor, 1, 0));
// What this used to do: split the table around the formula, leave the body row empty and the
// moved cells in a second table with no header, and hand the autosave
// "| h1 | h2 |\n| - | - |\n| | |\n\n$$\n$$\n\n| a | b |\n| - | - |\n" half a second later.
expect(insertMath(editor, true)).toBe(false);
expect(written()).toBe(source);
editor.destroy();
});
it("refuses over a dragged cell selection, and every cell keeps its text", () => {
const source = "| h1 | h2 |\n| - | - |\n| a | b |\n| c | d |\n";
const { editor, written } = open(source);
const map = TableMap.get(editor.state.doc.firstChild!);
const table = editor.state.doc.firstChild!;
editor.view.dispatch(
editor.state.tr.setSelection(
CellSelection.create(editor.state.doc, 1 + map.positionAt(0, 0, table), 1 + map.positionAt(2, 1, table)),
),
);
// An inline formula fits in a cell perfectly well, which is why the position rule alone let
// this through: over a rectangle of cells the insert does not go in a cell, it replaces the
// content of all six of them at once. Six cells of somebody's text for one empty formula.
expect(insertMath(editor, false)).toBe(false);
expect(insertMath(editor, true)).toBe(false);
expect(written()).toBe(source);
editor.destroy();
});
it("makes a formula the save can keep, which an empty one is not", () => {
const { editor, written } = open("hello\n");
editor.commands.setTextSelection(6);
expect(insertMath(editor, false)).toBe(true);
const file = written();
expect(file).toBe("hello$$\\square$$\n");
// The whole point of the placeholder, asserted the only way that means anything: the file goes
// back through the parser and there is still a formula in it. An empty one wrote "hello$$$$",
// which comes back as four dollar signs of literal text, and the box the user was looking at
// was gone with nobody told.
const reopened = parseMarkdown(file, "/notes/a.md");
const found: string[] = [];
reopened.doc.descendants((node) => {
if (node.type.name === "mathInline") found.push(node.attrs.latex as string);
});
expect(found).toEqual(["\\square"]);
editor.destroy();
});
});
describe("the fence rule", () => {
it("turns a paragraph holding just $$ into a math block, selected", () => {
const editor = editorWith({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "$$" }] }],
});
editor.commands.setTextSelection(3);
pressEnter(editor);
expect(editor.state.doc.childCount).toBe(1);
expect(editor.state.doc.firstChild?.type.name).toBe("mathBlock");
// The placeholder here too, for the reason on the insertMath test above: a formula made by
// typing a fence has the same claim to still being there after a save as one made by a button.
expect(editor.state.doc.firstChild?.attrs.latex).toBe("\\square");
expect(editor.state.selection instanceof NodeSelection).toBe(true);
editor.destroy();
});
it("leaves a paragraph that says anything else alone", () => {
for (const text of ["$$x", "a $$", "$", "$5 and $10"]) {
const editor = editorWith({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
});
editor.commands.setTextSelection(text.length + 1);
pressEnter(editor);
// Enter still splits the paragraph, which is the base keymap's business and not this lane's.
// What is asserted is only that no formula was made out of somebody's prose.
let found = false;
editor.state.doc.descendants((node) => {
if (node.type.name === "mathBlock" || node.type.name === "mathInline") found = true;
});
expect([text, found]).toEqual([text, false]);
editor.destroy();
}
});
});
describe("the LaTeX already in the document", () => {
const source = [
"Before.",
"",
"$$",
"\\frac{a}{b} = \\sum_{i=0}^{n} x_i",
"$$",
"",
"After $$x^2$$ here.",
"",
].join("\n");
it("comes back byte for byte after a formula is inserted somewhere else", () => {
const parsed = parseMarkdown(source, "/notes/a.md");
const editor = editorWith(parsed.doc.toJSON());
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(source);
// Into the first paragraph, which is the one place in the file this is allowed to change.
editor.commands.setTextSelection(4);
expect(insertMath(editor, false)).toBe(true);
const written = serializeMarkdown(parsed, editor.state.doc);
expect(written).toContain("$$\n\\frac{a}{b} = \\sum_{i=0}^{n} x_i\n$$");
expect(written).toContain("After $$x^2$$ here.");
editor.destroy();
});
});
describe("KaTeX under the options this lane renders with", () => {
// The same object math.ts builds its render call from. Repeated rather than exported, because
// what is being pinned here is the library's behaviour under them and not their spelling.
const options = {
throwOnError: false,
strict: false,
trust: false,
errorColor: "var(--danger)",
} as const;
it("draws a formula, with the source it was given still in the markup", () => {
const markup = katex.renderToString("\\frac{a}{b}", options);
expect(markup).toContain("katex");
expect(markup).toContain("\\frac{a}{b}");
});
it("does not throw on LaTeX it cannot parse, and shows the source in the error colour", () => {
// Two shapes, both of them the source and the colour. LaTeX KaTeX cannot get through the
// parser at all comes back as one .katex-error span holding the whole expression; a command it
// parses and has never heard of is drawn as the text of the command, in the same colour.
for (const broken of ["\\frac{", "\\notacommand", "^", "\\begin{matrix}", "\\sqrt{}}{"]) {
const markup = katex.renderToString(broken, options);
expect([broken, markup.includes(broken)]).toEqual([broken, true]);
expect([broken, markup.includes("var(--danger)")]).toEqual([broken, true]);
}
});
it("writes the error colour through as the custom property it was handed", () => {
// KaTeX puts the colour in an attribute on the element it draws, where a stylesheet cannot
// reach it, so the token has to survive the trip out through the markup exactly as written.
expect(katex.renderToString("\\frac{", options)).toContain("var(--danger)");
});
});
+473
View File
@@ -0,0 +1,473 @@
// Math: KaTeX over the mathInline and mathBlock nodes the bridge produces.
//
// Both are atoms carrying their LaTeX as an attribute, so rendering one is a node view drawing an
// attribute and editing one is that same node view handing the source back. Nothing here parses,
// normalises or rewrites the LaTeX: what round trips to disk is the attribute exactly as it was
// read, and KaTeX only ever gets a copy of it.
//
// LaTeX KaTeX cannot render is shown as the source with the error beside it, never as an empty
// box and never dropped. A formula this editor fails to draw is still the user's formula, and it
// has to survive being opened and saved by an editor that could not display it.
//
// An atom has no editable text of its own, so the field the source is typed into is this file's to
// draw and this file's to write back. It appears while the node is selected, which is what both a
// click on a formula and an arrow key into one produce, and every keystroke in it is a transaction
// like any other. The document is therefore never holding a formula the field has already moved
// past: an autosave that lands mid edit writes what is on screen, and closing the file does not
// take the last few characters with it.
import { Extension } from "@tiptap/core";
import type { Editor } from "@tiptap/core";
import type { Node as ProseMirrorNode, NodeType } from "@tiptap/pm/model";
import { NodeSelection, Plugin, PluginKey, Selection } from "@tiptap/pm/state";
import type { Command, Transaction } from "@tiptap/pm/state";
import type { EditorView, NodeView } from "@tiptap/pm/view";
import katex from "katex";
import { place } from "../fits";
// KaTeX's stylesheet, and the twenty faces it names, are pulled into the bundle from here rather
// than from main.tsx alongside the app's own sheets, because this is the file that cannot work
// without them. The app runs under a CSP of font-src 'self', so a font fetched from KaTeX's CDN
// never arrives and every formula is drawn in a fallback face at metrics the layout was not
// measured for. Importing the sheet is what makes Vite emit the woff2 files as local assets and
// rewrite the URLs on to them, so this import is load bearing and is not a stray dependency.
import "katex/dist/katex.min.css";
/** A paragraph holding exactly this becomes a math block when Enter is pressed in it. */
const FENCE = "$$";
/**
* What a new formula is made with, since a new formula is never made empty.
*
* An empty formula is a box on screen that the file has no way to spell. Inline, it goes out as
* `$$$$`, which is not math to anything that reads it back, so the box the user is looking at is
* gone the next time the document is opened and they were never told. That is the failure this
* editor exists not to have: something on screen that the save quietly does not keep.
*
* Three ways out of it were on the table. Refusing to insert until there is content cannot work,
* because the insert is how the content gets typed. Keeping the node out of the saved document
* until it has LaTeX is the same disappearance one layer down, since an autosave then writes a file
* without a formula the user can see. So the node is created with content: `\square` is the glyph
* mathematics already uses for the term that has not been written yet, KaTeX draws it, and it round
* trips as `$$\square$$` like any other formula. Nothing vanishes, because there is nothing empty.
*
* The field opens with it selected, so typing over it is the same keystroke it would have been in
* an empty box, and the user who walks away is left with a formula they can see rather than one
* they cannot.
*/
const PLACEHOLDER = "\\square";
/** Past these the field scrolls rather than growing. A formula this long is not being read. */
const MAX_ROWS = 16;
const MAX_COLS = 64;
const MIN_COLS = 4;
/**
* KaTeX is never allowed to throw, and never allowed to be the reason a formula is not on screen.
*
* `throwOnError` false is what turns a parse failure into markup: KaTeX draws the source it could
* not read in the error colour with the reason on the element's title, which is the whole of the
* error state for LaTeX it understands well enough to refuse. `strict` false is the same bargain
* one level down, for the LaTeX it can read and would rather complain about, a unicode letter in
* math mode being the usual one; the alternative is a console full of warnings about somebody's
* own file. `trust` stays off because the markup goes into the page with innerHTML, and it is what
* decides whether \href in a document that arrived from somewhere else becomes a link.
*
* The error colour is a custom property rather than a hex value because KaTeX writes it into a
* style attribute on the element it draws, and an inline style is not something a stylesheet can
* take back.
*/
const KATEX_OPTIONS = {
throwOnError: false,
strict: false,
trust: false,
errorColor: "var(--danger)",
} as const;
/**
* Where a node of this type ended up, looked for in the ranges the steps from `since` on wrote.
*
* Asked this way rather than by mapping the insertion point forward, which is the obvious move and
* is wrong. A block formula dropped into the middle of a paragraph splits it, and the position it
* was asked for stays with the first half, several places short of the formula. Mapping it forward
* then finds no formula there and nothing gets selected, which is a formula on screen with no way
* into its field. The end of a paragraph is the one place the two answers agree, which is why
* every test that put the caret there passed.
*/
function placedAt(tr: Transaction, since: number, type: NodeType): number | null {
let found: number | null = null;
for (let step = since; step < tr.steps.length && found === null; step += 1) {
const forward = tr.mapping.slice(step + 1);
tr.mapping.maps[step].forEach((_from, _to, newFrom, newTo) => {
if (found !== null) return;
const size = tr.doc.content.size;
const from = Math.min(size, Math.max(0, forward.map(newFrom, -1)));
const to = Math.min(size, Math.max(from, forward.map(newTo, 1)));
tr.doc.nodesBetween(from, to, (node, pos) => {
if (found === null && node.type === type) found = pos;
return found === null;
});
});
}
return found;
}
/**
* A placeholder formula where the cursor is, selected so that its field opens on it.
*
* Whether it can go there at all is `place`'s question and is asked before this runs, which is why
* there is no check of its own here. There used to be one, a private copy of the walk in fits.ts
* that had never been given the isolating rule, and it answered yes with the caret in a table cell:
* the insert then split the table around the formula and emptied the row it had been in, and the
* autosave wrote that to the user's file half a second later with no keystroke behind it.
*/
function placeMath(type: NodeType): Command {
return (state, dispatch) => {
if (dispatch) {
const tr = state.tr;
const before = tr.steps.length;
// Marks carry on to an inline formula, since **$x$** is a thing the file can say and the
// bridge already reads and writes. A block one is in a part of the document where no mark
// can go, and handing it the marks under the cursor would make it unplaceable.
tr.replaceSelectionWith(type.create({ latex: PLACEHOLDER }), type.isInline);
// Selecting it is what opens its field, on the placeholder, which the field selects whole so
// the first thing typed replaces it.
const placed = placedAt(tr, before, type);
if (placed !== null) tr.setSelection(NodeSelection.create(tr.doc, placed));
dispatch(tr.scrollIntoView());
}
return true;
};
}
/**
* `$$` alone in a paragraph, then Enter.
*
* Not an input rule, though it reads like one: an input rule fires on text input and Enter is not
* text, so there would be nothing to run it. There is deliberately no rule for `$…$` either. A
* dollar sign is money or a shell prompt far more often than it is mathematics, and turning
* "$5 and $10" into an equation as somebody types is exactly the unasked for rewrite this editor
* does not do. The bridge takes the same line one layer down, where single dollar math is off in
* the parser.
*/
const openMathBlock: Command = (state, dispatch) => {
const { $from, empty } = state.selection;
if (!empty) return false;
if ($from.parent.type.name !== "paragraph" || $from.parent.textContent !== FENCE) return false;
const type = state.schema.nodes.mathBlock;
const depth = $from.depth;
const index = $from.index(depth - 1);
if (!type || !$from.node(depth - 1).canReplaceWith(index, index + 1, type)) return false;
if (dispatch) {
const from = $from.before(depth);
// The placeholder, for the reason written on it: this is the other way a formula is made, and
// a formula made by typing a fence has the same claim to still being there after a save as one
// made from the toolbar.
const tr = state.tr.replaceWith(from, $from.after(depth), type.create({ latex: PLACEHOLDER }));
tr.setSelection(NodeSelection.create(tr.doc, from));
dispatch(tr.scrollIntoView());
}
return true;
};
/**
* One formula: what KaTeX drew, and the field the LaTeX behind it is typed into.
*
* The two are siblings inside the element the node's own toDOM describes, and which of them is on
* screen is a data attribute the stylesheet reads. Neither is content in ProseMirror's sense: the
* node is an atom, there is no contentDOM, and every mutation inside here is declared to be this
* file's own so that nothing KaTeX draws can be read back into the document.
*/
class MathView implements NodeView {
readonly dom: HTMLElement;
private readonly view: EditorView;
private readonly getPos: () => number | undefined;
private readonly display: boolean;
private readonly render: HTMLElement;
private readonly field: HTMLTextAreaElement;
private node: ProseMirrorNode;
private editing = false;
constructor(
node: ProseMirrorNode,
view: EditorView,
getPos: () => number | undefined,
display: boolean,
) {
this.node = node;
this.view = view;
this.getPos = getPos;
this.display = display;
const owner = view.dom.ownerDocument;
this.dom = owner.createElement(display ? "div" : "span");
this.dom.className = display ? "math-block" : "math-inline";
if (display) this.dom.setAttribute("data-math-block", "");
this.render = owner.createElement(display ? "div" : "span");
this.render.className = "math-render";
this.dom.appendChild(this.render);
this.field = owner.createElement("textarea");
this.field.className = "math-source";
this.field.spellcheck = false;
this.field.setAttribute("aria-label", display ? "Display equation source" : "Inline math source");
this.field.addEventListener("input", this.onInput);
this.field.addEventListener("keydown", this.onKeyDown);
this.dom.appendChild(this.field);
this.draw();
}
private get latex(): string {
const value = this.node.attrs.latex;
return typeof value === "string" ? value : "";
}
update(node: ProseMirrorNode): boolean {
// A node of another type is another node view; ProseMirror builds a fresh one rather than
// asking this one to become something it was not written to be.
if (node.type !== this.node.type) return false;
this.node = node;
this.draw();
return true;
}
selectNode(): void {
// A document being looked at rather than edited gets no field, so it gets the outline
// ProseMirror would have drawn on its own: it says the formula is selected without offering to
// change it. Node views that define this one are asked instead of that outline, not as well.
if (!this.view.editable) {
this.dom.classList.add("ProseMirror-selectednode");
return;
}
if (this.editing) return;
this.editing = true;
this.draw();
this.take();
}
deselectNode(): void {
this.dom.classList.remove("ProseMirror-selectednode");
if (!this.editing) return;
this.editing = false;
this.draw();
}
/**
* Everything that lands inside the field is the field's own. ProseMirror handling the mousedown
* that opens it would put a node selection where the caret was going, and the field would never
* take focus at all.
*/
stopEvent(event: Event): boolean {
const target = event.target;
return target instanceof HTMLElement && this.field.contains(target);
}
/** The element is this file's from end to end, so nothing read off it is news to the document. */
ignoreMutation(): boolean {
return true;
}
destroy(): void {
// Also what cancels the frame `take` queued: the element is on its way out, and focusing it
// then would put the caret at a position the document no longer has.
this.editing = false;
this.field.removeEventListener("input", this.onInput);
this.field.removeEventListener("keydown", this.onKeyDown);
}
/** Everything on the element that depends on the node or on whether it is being edited. */
private draw(): void {
const latex = this.latex;
// Mirrored on to the element the way the node's own toDOM writes it, so that anything reading
// the page back, a copy, a drag, a mutation ProseMirror decides to re-parse after all, takes
// the source out of the attribute the parse rule names rather than out of what KaTeX drew.
this.dom.setAttribute("data-latex", latex);
// The empty string and nothing else, because this flag is what tells the user the formula has
// no spelling and will not be saved, and a formula of one space is saved: the writer drops an
// equation only when its latex is empty. `paint` below asks a different question, which is
// whether KaTeX has anything to draw, and whitespace is a fair no to that one.
this.flag("data-math-empty", latex === "");
this.flag("data-editing", this.editing);
// The field is the source of truth while it is being typed in. Writing to it here would take
// the caret to the end of a formula the user is in the middle of.
if (!this.editing) {
this.field.value = latex;
this.size();
}
this.paint(latex);
}
private flag(name: string, on: boolean): void {
if (on) this.dom.setAttribute(name, "");
else this.dom.removeAttribute(name);
}
private paint(latex: string): void {
if (latex.trim() === "") {
this.render.textContent = "";
this.render.removeAttribute("data-math-error");
this.render.removeAttribute("title");
return;
}
try {
this.render.innerHTML = katex.renderToString(latex, {
...KATEX_OPTIONS,
displayMode: this.display,
});
this.render.removeAttribute("data-math-error");
this.render.removeAttribute("title");
} catch (error) {
// throwOnError covers the LaTeX KaTeX parses and then refuses to typeset. This is the rest of
// it: input it never expected, on which it throws something that is not a parse error. What
// goes on the page is the source as it stands, because that is what the file holds and what
// there is to fix.
this.render.textContent = latex;
this.render.setAttribute("data-math-error", "");
this.render.title = String(error);
}
}
/** Sized by the textarea's own rows and cols, so nothing here measures anything or sets a style. */
private size(): void {
const lines = this.field.value.split("\n");
const widest = lines.reduce((most, line) => Math.max(most, line.length), 0);
this.field.rows = Math.min(MAX_ROWS, lines.length);
this.field.cols = Math.min(MAX_COLS, Math.max(MIN_COLS, widest + 1));
}
/**
* Focus, on the next frame rather than now.
*
* ProseMirror is part way through drawing the selection this call came from and finishes it by
* putting the document's own selection around the node, and TipTap's focus command may have a
* frame of its own already queued in front of that. Either would take the caret straight back
* out of the field.
*/
private take(): void {
requestAnimationFrame(() => {
if (!this.editing) return;
// Already in it, which is what a keystroke that rewrote the node looks like from here. Moving
// the caret then would jump it to the end of a formula being edited in the middle.
if (this.field.ownerDocument.activeElement === this.field) return;
this.field.focus({ preventScroll: true });
const end = this.field.value.length;
// A formula that is still nothing but the placeholder is one nobody has typed into yet, so
// the placeholder is selected and the first keystroke replaces it. Anything else gets the
// caret at the end, because it is somebody's formula and a keystroke must not wipe it.
const start = this.field.value === PLACEHOLDER ? 0 : end;
this.field.setSelectionRange(start, end);
});
}
private readonly onInput = (): void => {
this.size();
this.commit(this.field.value);
};
private readonly onKeyDown = (event: KeyboardEvent): void => {
// A display equation is written over several lines often enough that Enter has to be a newline
// inside one, so it is inline math that Enter leaves and a block that needs the modifier.
const leaving =
event.key === "Escape" ||
(event.key === "Enter" && (!this.display || event.metaKey || event.ctrlKey));
if (leaving) {
event.preventDefault();
this.leave();
return;
}
if ((event.key === "Backspace" || event.key === "Delete") && this.field.value === "") {
event.preventDefault();
this.discard();
}
};
/**
* The field's text on to the node, as a transaction like any other keystroke in the document.
*
* Not held back until the field is left. A formula the field is holding and the document is not
* is one an autosave writes the previous version of and a switch to another file loses outright,
* and neither is worth the tidier undo history that batching it would buy.
*/
private commit(latex: string): void {
const pos = this.getPos();
if (pos === undefined) return;
const { state } = this.view;
const node = state.doc.nodeAt(pos);
if (!node || node.type !== this.node.type || node.attrs.latex === latex) return;
// Null for the type and nothing for the marks, so an inline formula inside a bold run comes
// back out of this still bold. setNodeMarkup keeps the marks it was not given new ones for.
this.view.dispatch(state.tr.setNodeMarkup(pos, null, { ...node.attrs, latex }));
}
/** Puts the caret back in the document just past the node, which is what re-renders it. */
private leave(): void {
const pos = this.getPos();
const { state } = this.view;
if (pos !== undefined) {
const after = Math.min(pos + this.node.nodeSize, state.doc.content.size);
this.view.dispatch(state.tr.setSelection(Selection.near(state.doc.resolve(after), 1)));
}
this.view.focus();
}
/** Backspace in an empty field takes the formula with it, the field being all there is of it. */
private discard(): void {
const pos = this.getPos();
const { state } = this.view;
if (pos === undefined) return;
if (!(state.selection instanceof NodeSelection) || state.selection.from !== pos) return;
this.view.dispatch(state.tr.deleteSelection().scrollIntoView());
this.view.focus();
}
}
export const MathRendering = Extension.create({
name: "mathRendering",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("mathViews"),
props: {
nodeViews: {
mathInline: (node, view, getPos) => new MathView(node, view, getPos, false),
mathBlock: (node, view, getPos) => new MathView(node, view, getPos, true),
},
},
}),
];
},
addKeyboardShortcuts() {
const editor = this.editor;
// ProseMirror's own calling convention rather than editor.commands.command, which dispatches
// its transaction whatever the command answered. Enter is pressed everywhere in the document
// and a key that did nothing here has to leave nothing at all behind it.
return {
Enter: () => openMathBlock(editor.state, editor.view.dispatch),
};
},
});
/**
* `display` picks mathBlock over mathInline. False where neither can be placed, which is a toolbar
* button pressed somewhere a formula cannot go and means nothing happens.
*
* The guard is `place`'s and is the same one every other insert in the editor asks, deliberately:
* this command had a private one and it was the private one that was missing a rule.
*/
export function insertMath(editor: Editor, display: boolean): boolean {
const type = editor.schema.nodes[display ? "mathBlock" : "mathInline"];
if (!type) return false;
return place(editor, type, (chain) =>
chain.command(({ state, dispatch }) => placeMath(type)(state, dispatch)),
);
}
+324
View File
@@ -0,0 +1,324 @@
// What can be asserted about a mermaid block without a browser, which is most of what matters.
//
// The node view itself needs a DOM and a real ProseMirror view, and this suite runs in node, so the
// drawing is not what is tested here. What is tested is everything the drawing is not allowed to
// disturb: that the extension adds nothing to the schema, that a ```mermaid fence is still an
// ordinary code block that round trips byte for byte through the editor, that exactly one plugin in
// the whole build claims the code block node view, and that the decoration telling a block the caret
// is inside it lands on the right blocks and only those.
import { describe, expect, it } from "vitest";
import { Editor } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
import type { Plugin } from "@tiptap/pm/state";
import type { DecorationSet } from "@tiptap/pm/view";
import { createEditorExtensions } from "../extensions";
import { parseMarkdown, serializeMarkdown } from "../../markdown";
import { MermaidRendering, insertMermaid } from "./mermaid";
const PATH = "/notes/diagrams.md";
const FENCE = "```";
const extensions = () => createEditorExtensions({ documentPath: () => PATH, onError: () => {} });
function makeEditor(content?: object): Editor {
return new Editor({
element: null,
injectCSS: false,
extensions: extensions(),
content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
});
}
/** An editor holding what the bridge made of `source`, which is how a document really arrives. */
function editorFor(source: string): Editor {
return makeEditor(parseMarkdown(source, PATH).doc.toJSON());
}
function blockAt(editor: Editor, index: number): { node: ProseMirrorNode; pos: number } {
const doc = editor.state.doc;
let pos = 0;
for (let i = 0; i < index; i += 1) pos += doc.child(i).nodeSize;
return { node: doc.child(index), pos };
}
/**
* The one plugin that draws diagrams, found the way the view finds it: by what it offers.
*
* The plugins are asked of the extensions rather than of the state, because TipTap only installs
* them when it mounts a view and there is no DOM here to mount one in.
*/
function nodeViewPlugins(editor: Editor): Plugin[] {
return editor.extensionManager.plugins.filter(
(plugin) => plugin.props.nodeViews?.codeBlock !== undefined,
);
}
function cursorDecorations(editor: Editor): DecorationSet | null {
const [plugin] = nodeViewPlugins(editor);
// `this` matters: ProseMirror calls a props function with the plugin as its receiver.
const found = plugin.props.decorations?.call(plugin, editor.state);
return (found as DecorationSet | null | undefined) ?? null;
}
function decoratedRanges(editor: Editor): Array<[number, number]> {
const set = cursorDecorations(editor);
if (!set) return [];
return set.find().map((decoration) => [decoration.from, decoration.to]);
}
const DIAGRAM = [
"# Diagrams",
"",
`${FENCE}mermaid`,
"graph TD;",
" A-->B;",
FENCE,
"",
`${FENCE}ts`,
"const x = 1;",
FENCE,
"",
"After.",
"",
].join("\n");
describe("the mermaid extension", () => {
it("is the one the registry names", () => {
expect(MermaidRendering.name).toBe("mermaidRendering");
});
it("adds no node and no mark, so the bridge and the editor still agree", () => {
const plain = new Editor({
element: null,
injectCSS: false,
extensions: extensions().filter((extension) => extension.name !== "mermaidRendering"),
content: { type: "doc", content: [{ type: "paragraph" }] },
});
const withMermaid = makeEditor();
expect(Object.keys(withMermaid.schema.nodes)).toEqual(Object.keys(plain.schema.nodes));
expect(Object.keys(withMermaid.schema.marks)).toEqual(Object.keys(plain.schema.marks));
plain.destroy();
withMermaid.destroy();
});
it("is the only plugin in the build that claims the code block node view", () => {
const editor = makeEditor();
// Two plugins offering a node view for one node is a silent bug: ProseMirror takes the first
// one asked and the other never runs. The code lane leaves this to mermaid on purpose.
expect(nodeViewPlugins(editor)).toHaveLength(1);
editor.destroy();
});
});
describe("a ```mermaid fence in a document", () => {
it("is an ordinary code block carrying its own language", () => {
const editor = editorFor(DIAGRAM);
const { node } = blockAt(editor, 1);
expect(node.type.name).toBe("codeBlock");
expect(node.attrs.language).toBe("mermaid");
expect(node.textContent).toBe("graph TD;\n A-->B;");
editor.destroy();
});
it("round trips byte for byte through the editor", () => {
const parsed = parseMarkdown(DIAGRAM, PATH);
const editor = makeEditor(parsed.doc.toJSON());
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(DIAGRAM);
editor.destroy();
});
it("keeps its indentation, its blank lines and its meta on the way back", () => {
const source = [
`${FENCE}mermaid theme=forest`,
"sequenceDiagram",
" Alice->>John: Hello",
"",
" John-->>Alice: Hi",
FENCE,
"",
].join("\n");
const parsed = parseMarkdown(source, PATH);
const editor = makeEditor(parsed.doc.toJSON());
const { node } = blockAt(editor, 0);
expect(node.attrs.meta).toBe("theme=forest");
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(source);
editor.destroy();
});
});
describe("the decoration that says the caret is inside", () => {
it("is absent while the cursor is somewhere else", () => {
const editor = editorFor(DIAGRAM);
editor.commands.setTextSelection(1);
expect(decoratedRanges(editor)).toEqual([]);
editor.destroy();
});
it("covers the block the cursor is in, and nothing else", () => {
const editor = editorFor(DIAGRAM);
const { node, pos } = blockAt(editor, 1);
editor.commands.setTextSelection(pos + 1);
expect(decoratedRanges(editor)).toEqual([[pos, pos + node.nodeSize]]);
editor.destroy();
});
it("ignores a code block that is not a diagram", () => {
const editor = editorFor(DIAGRAM);
const { pos } = blockAt(editor, 2);
editor.commands.setTextSelection(pos + 1);
expect(decoratedRanges(editor)).toEqual([]);
editor.destroy();
});
it("does not fire on the paragraph that follows the fence", () => {
const editor = editorFor(DIAGRAM);
const { pos } = blockAt(editor, 3);
editor.commands.setTextSelection(pos + 1);
expect(decoratedRanges(editor)).toEqual([]);
editor.destroy();
});
it("covers the block when it is selected whole rather than typed in", () => {
const editor = editorFor(DIAGRAM);
const { node, pos } = blockAt(editor, 1);
editor.view.dispatch(
editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, pos)),
);
expect(decoratedRanges(editor)).toEqual([[pos, pos + node.nodeSize]]);
editor.destroy();
});
it("covers every diagram a whole document selection touches", () => {
const source = [
`${FENCE}mermaid`,
"graph TD;",
FENCE,
"",
"Between.",
"",
`${FENCE}mermaid`,
"graph LR;",
FENCE,
"",
].join("\n");
const editor = editorFor(source);
editor.commands.selectAll();
expect(decoratedRanges(editor)).toHaveLength(2);
editor.destroy();
});
it("leaves a fence whose language only looks like mermaid alone", () => {
// The code lane leaves this block plain too, so a capitalised info string is neither drawn nor
// coloured. It stays the text the user wrote, which is the safe way for the two to disagree.
const source = [`${FENCE}Mermaid`, "graph TD;", FENCE, ""].join("\n");
const editor = editorFor(source);
editor.commands.setTextSelection(1);
expect(blockAt(editor, 0).node.attrs.language).toBe("Mermaid");
expect(decoratedRanges(editor)).toEqual([]);
editor.destroy();
});
});
describe("insertMermaid", () => {
it("turns the empty paragraph the cursor is on into an empty fence", () => {
const editor = makeEditor();
expect(insertMermaid(editor)).toBe(true);
expect(editor.state.doc.childCount).toBe(1);
const { node } = blockAt(editor, 0);
expect(node.type.name).toBe("codeBlock");
expect(node.attrs.language).toBe("mermaid");
expect(node.attrs.meta).toBe(null);
expect(node.textContent).toBe("");
editor.destroy();
});
it("writes a ```mermaid fence and nothing else", () => {
const parsed = parseMarkdown("", PATH);
const editor = makeEditor(parsed.doc.toJSON());
insertMermaid(editor);
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(`${FENCE}mermaid\n${FENCE}\n`);
editor.destroy();
});
it("splits the paragraph it was called from without losing a character of it", () => {
// The same thing the toolbar's rule and table buttons have always done with a caret in the
// middle of a line. What matters is that the words are all still there, on both sides of it.
const editor = editorFor("Some prose.\n");
editor.commands.setTextSelection(3);
expect(insertMermaid(editor)).toBe(true);
expect(editor.state.doc.childCount).toBe(3);
expect(editor.state.doc.child(0).textContent).toBe("So");
expect(editor.state.doc.child(1).attrs.language).toBe("mermaid");
expect(editor.state.doc.child(2).textContent).toBe("me prose.");
editor.destroy();
});
it("writes something the bridge can read back, even inside a list", () => {
const parsed = parseMarkdown("- one\n- two\n", PATH);
const editor = makeEditor(parsed.doc.toJSON());
editor.commands.setTextSelection(4);
insertMermaid(editor);
const written = serializeMarkdown(parsed, editor.state.doc);
const reread = parseMarkdown(written, PATH);
expect(written).toContain(`${FENCE}mermaid`);
expect(serializeMarkdown(reread, reread.doc)).toBe(written);
editor.destroy();
});
it("does nothing, and says so, in a table cell", () => {
// Left to itself ProseMirror would split the table in two around the block and leave a row with
// no cells in it, which is a table the serializer has nothing to write.
const editor = editorFor("| a | b |\n| - | - |\n| 1 | 2 |\n");
expect(editor.state.doc.child(0).type.name).toBe("table");
editor.view.dispatch(
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3)),
);
const before = editor.state.doc;
expect(insertMermaid(editor)).toBe(false);
expect(editor.state.doc).toBe(before);
editor.destroy();
});
it("does nothing, and says so, inside another fence", () => {
const editor = editorFor(`${FENCE}ts\nconst x = 1;\n${FENCE}\n`);
editor.commands.setTextSelection(3);
const before = editor.state.doc;
expect(insertMermaid(editor)).toBe(false);
expect(editor.state.doc).toBe(before);
editor.destroy();
});
it("does nothing, and says so, inside a raw block", () => {
const editor = editorFor("<figure><img src='x.png'></figure>\n");
expect(editor.state.doc.child(0).type.name).toBe("raw");
editor.commands.setTextSelection(3);
const before = editor.state.doc;
expect(insertMermaid(editor)).toBe(false);
expect(editor.state.doc).toBe(before);
editor.destroy();
});
});
+519
View File
@@ -0,0 +1,519 @@
// Mermaid diagrams, which are a fenced code block whose language is `mermaid` and nothing else.
//
// There is no mermaid node in the schema and there will not be one. On disk a diagram is ```mermaid
// and the bridge reads it as a codeBlock like any other fence, so every byte of it round trips as
// that block's text whether or not it draws. This lane only changes how such a block is shown.
//
// Mermaid renders asynchronously, which a ProseMirror view update is not, so the node view draws
// the fence first and swaps the SVG in when it arrives. A diagram that fails to parse stays as the
// code the user wrote, with the error beside it: a broken diagram is a typo to fix, not a block to
// hide.
//
// Nothing below ever writes to the document. The one transaction this file dispatches sets a text
// selection, which is a caret move and not an edit, and it is what makes clicking a drawn diagram
// put the cursor in the source that drew it. A render result is painted into DOM that sits outside
// contentDOM and is declared to ProseMirror as not the document's, so a picture mermaid hands back
// can never be read into the tree and saved over somebody's fence.
//
// ProseMirror resolves node views by node name and the first plugin asked wins, so this file is
// handed every code block in the document, not only the mermaid ones. The other kind gets a node
// view built from the schema's own toDOM, which is the same `pre > code` a code block had before
// this lane existed: the same element prose.css styles and the same one the code lane's decorations
// land on.
import { Extension } from "@tiptap/core";
import type { Editor } from "@tiptap/core";
import { DOMSerializer } from "@tiptap/pm/model";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import type { EditorState } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import type { EditorView, NodeView, ViewMutationRecord } from "@tiptap/pm/view";
import type { Mermaid, MermaidConfig } from "mermaid";
import { place } from "../fits";
/**
* The one info string that draws. Matched exactly, case included.
*
* The code lane matches the same word case insensitively when it decides what to leave plain, so
* ```Mermaid is highlighted by nobody and drawn by nobody: it stays the fence the user typed. That
* is the safe direction for the two lanes to disagree in. Both drawing it and colouring it would
* mean two plugins fighting over one block.
*/
const LANGUAGE = "mermaid";
const DRAWING = "Drawing diagram…";
const FAILED = "Mermaid could not draw this diagram.";
const mermaidKey = new PluginKey("mermaidRendering");
/** On a node decoration, this marks the code block the selection is currently inside. */
const CURSOR_INSIDE = { mermaidCursor: true };
function isDiagram(node: ProseMirrorNode): boolean {
return node.type.name === "codeBlock" && node.attrs.language === LANGUAGE;
}
// ------------------------------------------------------------------------------------------------
// The library, loaded once and only if a diagram is ever drawn
// ------------------------------------------------------------------------------------------------
let loading: Promise<Mermaid> | null = null;
let configured: string | null = null;
let drawings = 0;
/**
* Mermaid is several megabytes and a dependency graph to match, so this is the only place it is
* mentioned outside a type position and the import is dynamic. The bundler gives it a chunk of its
* own, and a user who never writes a diagram never fetches it.
*
* A failed load clears the promise rather than keeping it, so a chunk that did not arrive once is
* asked for again by the next block instead of poisoning every diagram in the app.
*/
function load(): Promise<Mermaid> {
if (!loading) {
loading = import("mermaid")
.then((module) => module.default)
.catch((error) => {
loading = null;
throw error;
});
}
return loading;
}
function themeName(): string {
return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
}
/**
* The app's palette, handed to mermaid as its own theme variables.
*
* `base` is the one mermaid theme meant to be recoloured; the others are fixed palettes that would
* put somebody else's lavender and yellow in the middle of this page. The values are read off the
* token layer at render time rather than named here, so a diagram is drawn in the same ink as the
* document around it and follows tokens.css when that changes.
*/
function themeVariables(): Record<string, string | boolean> {
const style = getComputedStyle(document.documentElement);
const variables: Record<string, string | boolean> = {
darkMode: themeName() === "dark",
fontFamily: "var(--font-ui)",
};
const palette: ReadonlyArray<readonly [string, string]> = [
["background", "--paper"],
["primaryColor", "--code-surface"],
["primaryTextColor", "--ink"],
["primaryBorderColor", "--doc-rule-strong"],
["secondaryColor", "--shell"],
["tertiaryColor", "--raised"],
["lineColor", "--ink-soft"],
["textColor", "--ink"],
// The card a label on an arrow sits on. Left to itself the base theme picks near black for it
// in dark mode, which puts a hole in the middle of the diagram.
["edgeLabelBackground", "--code-surface"],
];
for (const [variable, token] of palette) {
const value = style.getPropertyValue(token).trim();
// An empty custom property means the stylesheet is not loaded yet. Mermaid derives its shades
// from these by colour arithmetic, and "" is not a colour, so a missing token is left to the
// theme's own default rather than passed on.
if (value) variables[variable] = value;
}
return variables;
}
function configFor(): MermaidConfig {
return {
// The whole point of this file: nothing scans the page for diagrams, every render is asked for
// by a node view that knows which block it belongs to.
startOnLoad: false,
// Strict is mermaid's own default and the right one here. The text being drawn came out of a
// file on disk, so it is sanitised and its click handlers are dropped.
securityLevel: "strict",
// Without this a parse failure leaves mermaid's own error diagram behind in the page and a
// stray temporary div in the body. The error belongs in this block, drawn by the code below.
suppressErrorRendering: true,
theme: "base",
fontFamily: "var(--font-ui)",
themeVariables: themeVariables(),
};
}
/**
* One diagram, as an SVG string. Throws whatever mermaid threw.
*
* The id has to be unique per diagram: mermaid scopes the stylesheet it puts inside each SVG with
* `#id`, so two diagrams sharing one would style each other.
*/
async function toSvg(text: string): Promise<string> {
const mermaid = await load();
const theme = themeName();
if (theme !== configured) {
mermaid.initialize(configFor());
configured = theme;
}
// Parsing first keeps a syntax error away from the renderer entirely, which is the difference
// between an error this file can show and a half drawn diagram.
await mermaid.parse(text);
const { svg } = await mermaid.render(`mermaid-diagram-${(drawings += 1)}`, text);
return svg;
}
function messageOf(error: unknown): string {
if (error instanceof Error) return error.message;
if (error && typeof error === "object") {
// Mermaid's parse errors are plain objects carrying the offending line under `str`.
const detail = error as { str?: unknown; message?: unknown };
if (typeof detail.str === "string") return detail.str;
if (typeof detail.message === "string") return detail.message;
}
return String(error);
}
// ------------------------------------------------------------------------------------------------
// The theme watch
// ------------------------------------------------------------------------------------------------
const live = new Set<DiagramView>();
let watcher: MutationObserver | null = null;
let watched: string | null = null;
/**
* A drawn diagram is a picture with the palette baked into it, so the theme changing under it is
* the one event that invalidates a render nothing else touched. One observer serves every block,
* and it exists only while there is a diagram on screen to redraw.
*/
function watchTheme(): void {
if (watcher) return;
watched = themeName();
watcher = new MutationObserver(() => {
const theme = themeName();
if (theme === watched) return;
watched = theme;
configured = null;
for (const view of live) view.redraw();
});
watcher.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
}
function unwatchTheme(): void {
if (!watcher || live.size > 0) return;
watcher.disconnect();
watcher = null;
}
// ------------------------------------------------------------------------------------------------
// The node views
// ------------------------------------------------------------------------------------------------
/**
* Every code block that is not a diagram, rendered by the schema rather than by hand.
*
* Going through the node's own serializer is what makes this a no-op: the DOM here is the DOM
* ProseMirror would have built for a code block if this file did not exist, down to whether
* `data-language` is written at all, so nothing about an ordinary fence changes because the mermaid
* lane happens to be installed.
*/
class SourceView implements NodeView {
readonly dom: HTMLElement;
readonly contentDOM: HTMLElement | null;
private node: ProseMirrorNode;
constructor(node: ProseMirrorNode) {
const serializer = DOMSerializer.fromSchema(node.type.schema);
const rendered = DOMSerializer.renderSpec(document, serializer.nodes[node.type.name](node));
this.dom = rendered.dom;
this.contentDOM = rendered.contentDOM ?? null;
this.node = node;
}
update(next: ProseMirrorNode): boolean {
// Same markup means the same element, so ProseMirror updates the text inside contentDOM and
// this view stands. Anything else is a rebuild, the language crossing into mermaid included,
// and a rebuild is what the standard node view would have done with the same change.
if (!next.sameMarkup(this.node)) return false;
this.node = next;
return true;
}
}
type DiagramState = "empty" | "source" | "pending" | "diagram" | "error";
/**
* One mermaid block: the picture, and the source that made it.
*
* Both are always in the DOM and which one is shown is a CSS state, because the source is the
* document's own content and hiding it by removing it would be an edit. The cursor being inside
* the block arrives as a node decoration from the plugin below rather than being asked for here,
* since a node view is only told about the selection when something else redraws it.
*/
class DiagramView implements NodeView {
readonly dom: HTMLElement;
readonly contentDOM: HTMLElement;
private readonly figure: HTMLElement;
private readonly drawing: HTMLElement;
private readonly note: HTMLElement;
private readonly view: EditorView;
private readonly getPos: () => number | undefined;
private node: ProseMirrorNode;
private inside: boolean;
/** Bumped by anything that makes a render in flight the answer to a question nobody asked. */
private token = 0;
/** The text the picture on screen was drawn from, or null when there is no picture. */
private drawn: string | null = null;
private failure: string | null = null;
private gone = false;
constructor(
node: ProseMirrorNode,
view: EditorView,
getPos: () => number | undefined,
decorations: readonly Decoration[],
) {
this.node = node;
this.view = view;
this.getPos = getPos;
this.inside = hasCursor(decorations);
this.dom = document.createElement("div");
this.dom.className = "mermaid-block";
this.figure = document.createElement("div");
this.figure.className = "mermaid-figure";
// Not part of the document, so the caret has no business in it and ProseMirror is told as much
// here as well as through ignoreMutation below.
this.figure.contentEditable = "false";
this.drawing = document.createElement("div");
this.drawing.className = "mermaid-drawing";
this.note = document.createElement("div");
this.note.className = "mermaid-note";
this.figure.append(this.drawing, this.note);
const source = document.createElement("pre");
source.className = "mermaid-source";
source.setAttribute("data-language", LANGUAGE);
this.contentDOM = document.createElement("code");
source.appendChild(this.contentDOM);
this.dom.append(this.figure, source);
this.figure.addEventListener("mousedown", this.enter);
live.add(this);
watchTheme();
this.apply();
}
update(next: ProseMirrorNode, decorations: readonly Decoration[]): boolean {
// The language leaving mermaid is a different kind of block with different DOM, so this view is
// finished and ProseMirror builds the plain one in its place.
if (!isDiagram(next)) return false;
const edited = next.textContent !== this.node.textContent;
this.node = next;
this.inside = hasCursor(decorations);
if (edited) {
// Whatever is being drawn was drawn from text that is no longer in this block, and the error
// on screen, if there is one, is about a line the user may have just fixed.
this.token += 1;
this.failure = null;
}
this.apply();
return true;
}
/** The theme changed, so the picture is right about the diagram and wrong about the ink. */
redraw(): void {
this.failure = null;
this.drawn = null;
this.apply();
}
destroy(): void {
this.gone = true;
this.figure.removeEventListener("mousedown", this.enter);
live.delete(this);
unwatchTheme();
}
/**
* The figure is the view's own drawing, not the document. Reading an SVG mermaid just handed over
* back into the tree would replace the user's fence with a transcription of its own picture, so
* every mutation outside contentDOM is none of ProseMirror's business.
*/
ignoreMutation(mutation: ViewMutationRecord): boolean {
return !this.contentDOM.contains(mutation.target);
}
stopEvent(event: Event): boolean {
const target = event.target;
return target instanceof Node ? !this.contentDOM.contains(target) : false;
}
/** Clicking the picture puts the caret in the source that drew it, which is how a diagram is edited. */
private enter = (event: MouseEvent): void => {
const pos = this.getPos();
if (pos === undefined) return;
event.preventDefault();
const { state } = this.view;
const inside = Math.min(pos + 1, state.doc.content.size);
this.view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, inside)));
this.view.focus();
};
/** What should be on screen for the block as it is now, and a render if that is not known yet. */
private apply(): void {
const text = this.node.textContent;
if (!text.trim()) {
this.show("empty");
return;
}
// Shown whether or not the caret is in the block, because a diagram that will not draw is a
// line to go and fix and the message is how anybody knows which line.
if (this.failure !== null) {
this.note.textContent = `${FAILED}\n\n${this.failure}`;
this.show("error");
return;
}
if (this.inside) {
this.show("source");
return;
}
if (this.drawn === text) {
this.show("diagram");
return;
}
this.draw(text);
}
private draw(text: string): void {
const token = (this.token += 1);
// A diagram already on screen stays there while the next one is drawn, so a theme change or a
// finished edit does not blink the block out of the page and back into it.
if (this.drawing.firstChild) {
this.show("diagram");
this.dom.setAttribute("data-busy", "");
} else {
this.note.textContent = DRAWING;
this.show("pending");
}
toSvg(text).then(
(svg) => {
if (this.stale(token)) return;
this.dom.removeAttribute("data-busy");
// Mermaid sanitises what it returns, and a script arriving through innerHTML does not run
// in any case, so the SVG goes in as markup and the error below never does.
this.drawing.innerHTML = svg;
this.drawn = text;
this.failure = null;
this.apply();
},
(error: unknown) => {
if (this.stale(token)) return;
this.dom.removeAttribute("data-busy");
this.drawing.textContent = "";
this.drawn = null;
this.failure = messageOf(error);
this.apply();
},
);
}
/**
* Mermaid answers whenever it answers, and by then the block may have been edited, the document
* may have been closed and this view may have been thrown away. The token covers every one of
* those: it is bumped by an edit, by a theme change and by destroy, so an answer to a question
* nobody is asking any more is dropped rather than painted somewhere it no longer belongs.
*/
private stale(token: number): boolean {
return this.gone || token !== this.token || this.view.isDestroyed;
}
private show(state: DiagramState): void {
this.dom.setAttribute("data-state", state);
}
}
function hasCursor(decorations: readonly Decoration[]): boolean {
return decorations.some((decoration) => decoration.spec?.mermaidCursor === true);
}
// ------------------------------------------------------------------------------------------------
// The plugin
// ------------------------------------------------------------------------------------------------
/**
* A node decoration on every mermaid block the selection touches.
*
* This is how a node view is told the caret is inside it. A decoration changing is one of the two
* things that make ProseMirror ask a node view to update, and the selection moving on its own is
* not the other, so without this a block would keep drawing the diagram with the cursor in it.
*/
function cursorDecorations(state: EditorState): DecorationSet | null {
const { from, to } = state.selection;
const found: Decoration[] = [];
state.doc.nodesBetween(from, to, (node, pos) => {
if (node.type.name !== "codeBlock") return true;
if (isDiagram(node)) found.push(Decoration.node(pos, pos + node.nodeSize, {}, CURSOR_INSIDE));
return false;
});
return found.length > 0 ? DecorationSet.create(state.doc, found) : null;
}
export const MermaidRendering = Extension.create({
name: "mermaidRendering",
addProseMirrorPlugins() {
return [
new Plugin({
key: mermaidKey,
props: {
nodeViews: {
codeBlock: (node, view, getPos, decorations) =>
isDiagram(node)
? new DiagramView(node, view, getPos, decorations)
: new SourceView(node),
},
decorations: cursorDecorations,
},
}),
];
},
});
/**
* Inserts an empty ```mermaid fence. False where a code block cannot go.
*
* Through `place` rather than asking `fits` about each end itself, which is what this did while it
* was the only insert in its own file. Both spellings refuse the same things today, but only one of
* them refuses the next thing the guard learns: `fits` gained a cell selection rule after a drag
* across a table lost six cells to an insert that had asked it the older way, and a caller holding
* its own copy of the question is a caller that does not get told. There is one gate and every
* insert goes through it.
*/
export function insertMermaid(editor: Editor): boolean {
return place(editor, editor.schema.nodes.codeBlock, (chain) =>
chain.insertContent({ type: "codeBlock", attrs: { language: LANGUAGE, meta: null } }),
);
}
+755
View File
@@ -0,0 +1,755 @@
// A table is the one block in this editor whose behaviour is a library's rather than this app's,
// which makes it the one block where "it works" is easy to assume and easy to be wrong about. Two
// things are asserted here that a passing prosemirror-tables would not give for free.
//
// The first is alignment, which is this file's own and not the library's. GFM keeps alignment in
// the delimiter row, one entry per column, so the test that matters is not what the attribute says
// but what the serializer writes: a column whose cells disagree is a table the file cannot hold.
//
// The second is the header row, for the same reason from the other end. GFM has exactly one and it
// is the first row, so an edit that leaves body cells in row zero is an edit whose result the file
// cannot spell, and the screen would go on showing it until the file was next opened.
import { describe, expect, it } from "vitest";
import { Editor } from "@tiptap/core";
import type { JSONContent } from "@tiptap/core";
import { EditorState } from "@tiptap/pm/state";
import type { Transaction } from "@tiptap/pm/state";
import { Fragment, Slice } from "@tiptap/pm/model";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { EditorView } from "@tiptap/pm/view";
import { CellSelection, TableMap } from "@tiptap/pm/tables";
import { createEditorExtensions } from "../extensions";
import { serializeMarkdown } from "../../markdown";
import { tableCommand, typingKey } from "./tables";
const EMPTY: JSONContent = { type: "doc", content: [{ type: "paragraph" }] };
function makeEditor(content: JSONContent = EMPTY): Editor {
const editor = new Editor({
element: null,
injectCSS: false,
extensions: createEditorExtensions({ documentPath: () => "/notes/a.md", onError: () => {} }),
content,
});
// TipTap only installs the extensions' ProseMirror plugins when it mounts a view, and there is no
// DOM here to mount into. Swapping in a state built with them is what src/editor/Editor.tsx does
// on every document it installs, so this is the same editor the app runs, minus the screen.
editor.view.updateState(
EditorState.create({ doc: editor.state.doc, plugins: editor.extensionManager.plugins }),
);
return editor;
}
/**
* One key, offered to the plugins in the order the view would offer it and stopping at the first
* that claims it. Which plugin answered is the whole question in half these tests, so the walk is
* the real one rather than a call into the binding this file happens to be about.
*/
function press(editor: Editor, key: string, shift = false): boolean {
const event = {
key,
keyCode: key === "Tab" ? 9 : 8,
shiftKey: shift,
ctrlKey: false,
altKey: false,
metaKey: false,
preventDefault: () => {},
} as unknown as KeyboardEvent;
const view = editor.view as unknown as EditorView;
for (const plugin of editor.state.plugins) {
const handler = plugin.props?.handleKeyDown;
if (handler && handler.call(plugin, view, event)) return true;
}
return false;
}
/**
* One printable character, offered to the plugins the way the view offers one and, when nobody
* claims it, put in the way the view would put it. True when a plugin claimed it.
*
* `tr.insertText` with no range is `Selection.replace`, and over a cell selection that replaces
* every range in the selection: the character goes into the last cell of the rectangle and the
* rest are emptied. That fallback is the bug, so it is run here rather than described.
*/
function type(editor: Editor, character: string): boolean {
const view = editor.view as unknown as EditorView;
const { $from, $to } = editor.state.selection;
const deflt = () => editor.state.tr.insertText(character).scrollIntoView();
for (const plugin of editor.state.plugins) {
const handler = plugin.props?.handleTextInput;
if (handler && handler.call(plugin, view, $from.pos, $to.pos, character, deflt)) return true;
}
editor.view.dispatch(deflt());
return false;
}
/** A table alone in a document. The first row is header cells, as every GFM table's is. */
function tableDoc(rows: string[][]): JSONContent {
return {
type: "doc",
content: [
{
type: "table",
content: rows.map((cells, row) => ({
type: "tableRow",
content: cells.map((text) => ({
type: row === 0 ? "tableHeader" : "tableCell",
...(text ? { content: [{ type: "text", text }] } : {}),
})),
})),
},
],
};
}
/** The document's first table and where it starts, wherever in the tree it happens to sit. */
function tableAt(editor: Editor): { node: ProseMirrorNode; pos: number } {
let found: { node: ProseMirrorNode; pos: number } | null = null;
editor.state.doc.descendants((node, pos) => {
if (found !== null) return false;
if (node.type.name === "table") found = { node, pos };
return found === null;
});
if (found === null) throw new Error("this document has no table in it");
return found;
}
const table = (editor: Editor) => tableAt(editor).node;
/** The document position just inside a cell. */
function inCell(editor: Editor, row: number, column: number): number {
const { node, pos } = tableAt(editor);
return pos + 1 + TableMap.get(node).positionAt(row, column, node) + 1;
}
function cursorIn(editor: Editor, row: number, column: number): void {
editor.commands.setTextSelection(inCell(editor, row, column));
}
function selectCells(editor: Editor, from: [number, number], to: [number, number]): void {
const anchor = inCell(editor, from[0], from[1]) - 1;
const head = inCell(editor, to[0], to[1]) - 1;
editor.view.dispatch(
editor.state.tr.setSelection(CellSelection.create(editor.state.doc, anchor, head)),
);
}
/** The table as a grid of whatever `of` reads off a cell. */
function grid<T>(editor: Editor, of: (cell: ProseMirrorNode) => T): T[][] {
const out: T[][] = [];
table(editor).forEach((row) => {
const cells: T[] = [];
row.forEach((cell) => cells.push(of(cell)));
out.push(cells);
});
return out;
}
const shape = (editor: Editor) => grid(editor, (cell) => cell.textContent);
const kinds = (editor: Editor) => grid(editor, (cell) => cell.type.name);
const aligns = (editor: Editor) => grid(editor, (cell) => cell.attrs.align as string | null);
/** A clipboard carrying one word of plain text, which is what a slice with something in it is. */
function textSlice(editor: Editor, text: string): Slice {
return new Slice(Fragment.from(editor.schema.text(text)), 0, 0);
}
/** What the bridge would write for this document, in a file that holds nothing else. */
function written(editor: Editor): string {
const doc = editor.state.doc;
return serializeMarkdown({ frontmatter: null, doc, source: "", path: "/notes/a.md" }, doc);
}
const GRID = [
["a", "b", "c"],
["1", "2", "3"],
["4", "5", "6"],
];
describe("Tab in a table", () => {
it("moves to the next cell and wraps on to the next row", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 0, 0);
expect(press(editor, "Tab")).toBe(true);
expect(editor.state.selection.from).toBe(inCell(editor, 0, 1));
expect(press(editor, "Tab")).toBe(true);
expect(press(editor, "Tab")).toBe(true);
expect(editor.state.selection.from).toBe(inCell(editor, 1, 0));
editor.destroy();
});
it("moves back on Shift-Tab, and stops at the first cell", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 1, 0);
expect(press(editor, "Tab", true)).toBe(true);
expect(editor.state.selection.from).toBe(inCell(editor, 0, 2));
// Nowhere to go, so the binding declines and the key falls through untouched by it.
cursorIn(editor, 0, 0);
press(editor, "Tab", true);
expect(shape(editor)).toEqual(GRID);
expect(kinds(editor)[0]).toEqual(["tableHeader", "tableHeader", "tableHeader"]);
editor.destroy();
});
// The precedence assertion. shortcuts.ts also binds Tab, for sinking a list item, and it is the
// only other binding on the key: a fourth row here is proof that this lane's is asked first and
// that the list one does not answer inside a table.
it("grows the table out of the last cell, into a body row", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 2, 2);
expect(press(editor, "Tab")).toBe(true);
expect(shape(editor)).toEqual([...GRID, ["", "", ""]]);
expect(kinds(editor)[3]).toEqual(["tableCell", "tableCell", "tableCell"]);
expect(editor.state.selection.from).toBe(inCell(editor, 3, 0));
editor.destroy();
});
it("grows a table that is nothing but its header row", () => {
const editor = makeEditor(tableDoc([["a", "b"]]));
cursorIn(editor, 0, 1);
expect(press(editor, "Tab")).toBe(true);
expect(kinds(editor)).toEqual([
["tableHeader", "tableHeader"],
["tableCell", "tableCell"],
]);
editor.destroy();
});
it("leaves Tab alone outside a table", () => {
const editor = makeEditor({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "x" }] }],
});
editor.commands.setTextSelection(2);
expect(press(editor, "Tab")).toBe(false);
expect(editor.state.doc.textContent).toBe("x");
editor.destroy();
});
});
describe("Backspace over a cell selection", () => {
it("empties the cells and keeps the table the shape it was", () => {
const editor = makeEditor(tableDoc(GRID));
selectCells(editor, [1, 0], [2, 1]);
expect(press(editor, "Backspace")).toBe(true);
expect(shape(editor)).toEqual([
["a", "b", "c"],
["", "", "3"],
["", "", "6"],
]);
editor.destroy();
});
});
// A printable character over the same rectangle, which until this lane claimed it did what
// Backspace does and then wrote the character into the corner of the wreckage.
//
// Reported and reproduced in Chromium: a real mouse drag from (0,0) to (1,1) of a 2x2 body and the
// three keystrokes "zqx" took "| 1 | 2 |\n| 3 | 4 |" to four empty cells with "zqx" in the last
// one. Four cells of somebody's table for three letters, none of them the cell the drag started
// in. It is the destruction src/editor/paste.ts refuses for a Cmd+V that lands on the same
// selection, arriving by the one route with no guard on it.
describe("typing over a cell selection", () => {
it("leaves every cell in the rectangle exactly as it was", () => {
const editor = makeEditor(tableDoc(GRID));
const before = written(editor);
selectCells(editor, [1, 0], [2, 1]);
expect(type(editor, "z")).toBe(true);
expect(shape(editor)).toEqual(GRID);
expect(written(editor)).toBe(before);
// And the rectangle is still selected, so Backspace, the toolbar and a click into one cell are
// all still where the user left them.
expect(editor.state.selection instanceof CellSelection).toBe(true);
editor.destroy();
});
// The other half, twice, because a guard that claimed every character everywhere would pass the
// test above and would be an editor nobody can type in.
it("still types into a single cell, and still types outside a table", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 1, 0);
expect(type(editor, "z")).toBe(false);
expect(shape(editor)[1][0]).toBe("z1");
editor.destroy();
const prose = makeEditor();
expect(type(prose, "z")).toBe(false);
expect(prose.state.doc.textContent).toBe("z");
prose.destroy();
});
// And the half this project keeps getting wrong: an answer nothing asks for is not an answer.
// ProseMirror stops at the first plugin that claims a character, so a guard behind the plugin
// that would have destroyed the cells is a guard the running editor never reaches. TipTap's own
// input rules claim handleTextInput too, which is what this is measured against.
it("is the first plugin in the list that claims a typed character", () => {
const editor = makeEditor(tableDoc(GRID));
const claimants = editor.state.plugins.flatMap((plugin, index) =>
plugin.props?.handleTextInput ? [index] : [],
);
const guard = editor.state.plugins.findIndex((plugin) => plugin.spec.key === typingKey);
expect(guard).toBeGreaterThanOrEqual(0);
expect(claimants[0]).toBe(guard);
expect(claimants.length).toBeGreaterThan(1);
editor.destroy();
});
});
describe("the row and column ops", () => {
it("adds and removes rows where the cursor is", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 1, 0);
expect(tableCommand(editor, "addRowAfter")).toBe(true);
expect(shape(editor)).toEqual([["a", "b", "c"], ["1", "2", "3"], ["", "", ""], ["4", "5", "6"]]);
cursorIn(editor, 2, 0);
expect(tableCommand(editor, "deleteRow")).toBe(true);
expect(shape(editor)).toEqual(GRID);
cursorIn(editor, 1, 0);
expect(tableCommand(editor, "addRowBefore")).toBe(true);
expect(shape(editor)).toEqual([["a", "b", "c"], ["", "", ""], ["1", "2", "3"], ["4", "5", "6"]]);
editor.destroy();
});
it("adds and removes columns where the cursor is", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 0, 1);
expect(tableCommand(editor, "addColumnAfter")).toBe(true);
expect(shape(editor)[0]).toEqual(["a", "b", "", "c"]);
expect(kinds(editor)[0]).toEqual(["tableHeader", "tableHeader", "tableHeader", "tableHeader"]);
cursorIn(editor, 0, 2);
expect(tableCommand(editor, "deleteColumn")).toBe(true);
expect(shape(editor)).toEqual(GRID);
cursorIn(editor, 0, 0);
expect(tableCommand(editor, "addColumnBefore")).toBe(true);
expect(shape(editor)[1]).toEqual(["", "1", "2", "3"]);
editor.destroy();
});
it("takes the whole table out, leaving a document behind", () => {
const editor = makeEditor({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "before" }] }, ...tableDoc(GRID).content!],
});
editor.commands.setTextSelection(editor.state.doc.content.size - 4);
expect(tableCommand(editor, "deleteTable")).toBe(true);
expect(editor.state.doc.childCount).toBe(1);
expect(editor.state.doc.textContent).toBe("before");
editor.destroy();
});
it("takes a table that is the whole document out, leaving something behind", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 0, 0);
expect(tableCommand(editor, "deleteTable")).toBe(true);
expect(editor.state.doc.childCount).toBe(1);
expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
expect(editor.state.doc.textContent).toBe("");
editor.destroy();
});
// prosemirror-tables builds a new cell from the one it is standing beside, so all three of these
// leave a table whose first row is not the row the serializer will write as the header. What is
// on screen after the edit has to be what the file says, or the edit is taken back the next time
// the document is opened.
it("keeps the header first when a row goes in above it", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 0, 0);
expect(tableCommand(editor, "addRowBefore")).toBe(true);
expect(kinds(editor)).toEqual([
["tableHeader", "tableHeader", "tableHeader"],
["tableCell", "tableCell", "tableCell"],
["tableCell", "tableCell", "tableCell"],
["tableCell", "tableCell", "tableCell"],
]);
editor.destroy();
});
it("keeps the header first when a column goes in in front of it", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 0, 0);
expect(tableCommand(editor, "addColumnBefore")).toBe(true);
expect(kinds(editor)[0]).toEqual(["tableHeader", "tableHeader", "tableHeader", "tableHeader"]);
expect(kinds(editor)[1]).toEqual(["tableCell", "tableCell", "tableCell", "tableCell"]);
editor.destroy();
});
// The last row and the last column decline rather than emptying the table out, which is
// prosemirror-tables' own answer and the right one: a table with no rows is not something GFM can
// write, and Delete table is the op that means what this would have meant.
it("will not take the last row or the last column", () => {
for (const op of ["deleteRow", "deleteColumn"] as const) {
const editor = makeEditor(tableDoc([["a"]]));
cursorIn(editor, 0, 0);
const before = editor.state.doc.toJSON();
expect([op, tableCommand(editor, op)]).toEqual([op, false]);
expect([op, editor.state.doc.toJSON()]).toEqual([op, before]);
editor.destroy();
}
});
it("keeps a header row when the header row is the one deleted", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 0, 0);
expect(tableCommand(editor, "deleteRow")).toBe(true);
expect(shape(editor)).toEqual([GRID[1], GRID[2]]);
expect(kinds(editor)[0]).toEqual(["tableHeader", "tableHeader", "tableHeader"]);
expect(kinds(editor)[1]).toEqual(["tableCell", "tableCell", "tableCell"]);
editor.destroy();
});
it("does nothing at all with the cursor outside a table", () => {
const editor = makeEditor({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "x" }] }],
});
editor.commands.setTextSelection(2);
const before = editor.state.doc.toJSON();
for (const op of ["addRowAfter", "deleteRow", "addColumnAfter", "deleteColumn", "deleteTable", "alignCenter"] as const) {
expect([op, tableCommand(editor, op)]).toEqual([op, false]);
}
expect(editor.state.doc.toJSON()).toEqual(before);
editor.destroy();
});
});
describe("alignment", () => {
it("writes the whole column, header included, and nothing beside it", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 2, 1);
expect(tableCommand(editor, "alignCenter")).toBe(true);
expect(aligns(editor)).toEqual([
[null, "center", null],
[null, "center", null],
[null, "center", null],
]);
editor.destroy();
});
it("reaches the serializer's delimiter row, and comes back off it", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 1, 0);
tableCommand(editor, "alignRight");
cursorIn(editor, 1, 2);
tableCommand(editor, "alignCenter");
expect(written(editor)).toBe(
["| a | b | c |", "| -: | - | :-: |", "| 1 | 2 | 3 |", "| 4 | 5 | 6 |", ""].join("\n"),
);
cursorIn(editor, 1, 0);
expect(tableCommand(editor, "alignClear")).toBe(true);
cursorIn(editor, 1, 2);
expect(tableCommand(editor, "alignClear")).toBe(true);
expect(written(editor)).toBe(
["| a | b | c |", "| - | - | - |", "| 1 | 2 | 3 |", "| 4 | 5 | 6 |", ""].join("\n"),
);
editor.destroy();
});
it("covers every column a cell selection touches", () => {
const editor = makeEditor(tableDoc(GRID));
selectCells(editor, [0, 0], [1, 1]);
expect(tableCommand(editor, "alignLeft")).toBe(true);
expect(aligns(editor)).toEqual([
["left", "left", null],
["left", "left", null],
["left", "left", null],
]);
editor.destroy();
});
it("declines a column that already reads that way", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 0, 0);
expect(tableCommand(editor, "alignLeft")).toBe(true);
expect(tableCommand(editor, "alignLeft")).toBe(false);
expect(tableCommand(editor, "alignClear")).toBe(true);
expect(tableCommand(editor, "alignClear")).toBe(false);
editor.destroy();
});
it("survives a row added above the header row", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 1, 2);
tableCommand(editor, "alignRight");
cursorIn(editor, 0, 0);
expect(tableCommand(editor, "addRowBefore")).toBe(true);
expect(aligns(editor)).toEqual([
[null, null, "right"],
[null, null, "right"],
[null, null, "right"],
[null, null, "right"],
]);
expect(written(editor).split("\n")[1]).toBe("| - | - | -: |");
editor.destroy();
});
it("stays with its own column when a column beside it goes", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 1, 2);
tableCommand(editor, "alignCenter");
cursorIn(editor, 1, 0);
expect(tableCommand(editor, "deleteColumn")).toBe(true);
expect(aligns(editor)[0]).toEqual([null, "center"]);
expect(written(editor).split("\n")[1]).toBe("| - | :-: |");
editor.destroy();
});
it("does not follow a new column in beside it", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 1, 0);
tableCommand(editor, "alignCenter");
expect(tableCommand(editor, "addColumnAfter")).toBe(true);
expect(aligns(editor)[0]).toEqual(["center", null, null, null]);
editor.destroy();
});
// prosemirror-tables builds a new cell from the attribute's default, so every one of these would
// leave a column disagreeing with itself. The delimiter row is written off the first row, which
// makes the row added above it the one that would take the whole table's alignment off.
it("survives a row added under it", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 1, 1);
tableCommand(editor, "alignCenter");
cursorIn(editor, 2, 2);
press(editor, "Tab");
expect(aligns(editor)[3]).toEqual([null, "center", null]);
editor.destroy();
});
});
// Dragging a column edge is the one edit the file has nowhere to put. It is asserted rather than
// assumed because the failure is invisible: the markdown is identical, so a resize looks saved and
// is gone the next time the document is opened. See the note at the top of tables.ts.
describe("a resized column", () => {
it("changes the document without changing a byte of the markdown", () => {
const editor = makeEditor(tableDoc(GRID));
const before = written(editor);
const pos = inCell(editor, 0, 0) - 1;
const cell = editor.state.doc.nodeAt(pos)!;
editor.view.dispatch(
editor.state.tr.setNodeMarkup(pos, null, { ...cell.attrs, colwidth: [180] }),
);
expect(table(editor).firstChild!.firstChild!.attrs.colwidth).toEqual([180]);
expect(written(editor)).toBe(before);
editor.destroy();
});
});
// A table indented under a bullet, which is the one place a table has structure around it that
// another extension's keys will act on. Every key this lane binds is asked here, because what is
// behind each of them is a list command that reshapes the list rather than the table: a key that
// falls through from inside a cell can take the bullet out from under the table the cursor is in.
const NESTED: JSONContent = {
type: "doc",
content: [
{ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "T" }] },
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "item" }] },
...tableDoc([["a", "b"], ["c", "d"]]).content!,
],
},
{
type: "listItem",
content: [{ type: "paragraph", content: [{ type: "text", text: "next" }] }],
},
],
},
],
};
/** The node names from the document down to the cursor, which is what a lifted list item loses. */
function path(editor: Editor): string[] {
const { $from } = editor.state.selection;
const names: string[] = [];
for (let depth = 1; depth <= $from.depth; depth += 1) names.push($from.node(depth).type.name);
return names;
}
describe("a table nested in a list item", () => {
it("stays where it is when Shift-Tab is pressed in the first cell", () => {
const editor = makeEditor(NESTED);
cursorIn(editor, 0, 0);
const before = editor.state.doc.toJSON();
const at = editor.state.selection.from;
expect(path(editor)).toEqual(["bulletList", "listItem", "table", "tableRow", "tableHeader"]);
press(editor, "Tab", true);
// Nowhere to go, so nothing moves. What must not happen is the key reaching the list command
// behind this lane's binding, which lifts the item and dissolves the list around the table.
expect(editor.state.doc.toJSON()).toEqual(before);
expect(editor.state.selection.from).toBe(at);
expect(path(editor)).toEqual(["bulletList", "listItem", "table", "tableRow", "tableHeader"]);
editor.destroy();
});
it("keeps its list when Backspace is pressed at the start of the first cell", () => {
const editor = makeEditor(NESTED);
cursorIn(editor, 0, 0);
const before = editor.state.doc.toJSON();
press(editor, "Backspace");
expect(editor.state.doc.toJSON()).toEqual(before);
editor.destroy();
});
it("keeps its list when Delete is pressed at the end of the last cell", () => {
const editor = makeEditor(NESTED);
const map = TableMap.get(table(editor));
const row = map.height - 1;
const column = map.width - 1;
const cell = table(editor).nodeAt(map.map[row * map.width + column])!;
editor.commands.setTextSelection(inCell(editor, row, column) + cell.content.size);
const before = editor.state.doc.toJSON();
press(editor, "Delete");
expect(editor.state.doc.toJSON()).toEqual(before);
editor.destroy();
});
});
// A paste is not this lane's feature and prosemirror-tables claims one, which is exactly why the
// assertion is here: this file installs that plugin, so what it does with a paste is this file's
// answer to give. Over a rectangle of dragged cells the library replaces the content of every cell
// in the rectangle with the slice, whatever the slice is. One word replaced six cells. An image on
// the clipboard carries no HTML and no text, so the slice ProseMirror hands along is the empty one
// and six cells were emptied by a paste that put nothing anywhere.
//
// Both were reproduced against the plugin order this file used to run: pasting the word `z` over
// a two by three rectangle gave [["z","z","z"],["z","z","z"],["4","5","6"]], and Slice.empty over
// the same rectangle gave [["","",""],["","",""],["4","5","6"]]. The app's clipboard plugin now
// sits in front of the library's and refuses both, and stands aside for the one paste the library
// does better, which is cells over cells.
//
// The walk below is EditorView.prototype.someProp rather than a loop over `editor.state.plugins`
// written here. They agree today, and the point is that this asks the question the running editor
// asks instead of a modelled one: the props the component puts on the view come first, then the
// direct plugins, then the state's, and a guard written in the wrong one of those three answers
// nothing. Four guards in this project have been shipped and never reached, and every test that
// missed one was a test that called the handler itself.
describe("a paste over a dragged rectangle of cells", () => {
const paste = (editor: Editor, slice: Slice): boolean => {
const event = { preventDefault: () => {} } as unknown as ClipboardEvent;
const view = {
get state() {
return editor.state;
},
dispatch: (tr: Transaction) => editor.view.dispatch(tr),
directPlugins: [],
_props: {},
someProp: EditorView.prototype.someProp,
focus: () => {},
dom: null,
composing: false,
dragging: null,
editable: true,
} as unknown as EditorView;
return view.someProp("handlePaste", (f) => f(view, event, slice)) === true;
};
/** The clipboard a real copy out of a table puts there, which is the one shape to stand aside for. */
const cellSlice = (editor: Editor, from: [number, number], to: [number, number]): Slice => {
selectCells(editor, from, to);
return editor.state.selection.content();
};
it("leaves every cell alone when the clipboard carried nothing", () => {
const editor = makeEditor(tableDoc(GRID));
const before = written(editor);
selectCells(editor, [0, 0], [1, 2]);
expect(paste(editor, Slice.empty)).toBe(true);
expect(shape(editor)).toEqual(GRID);
expect(written(editor)).toBe(before);
editor.destroy();
});
it("leaves every cell alone when the clipboard carried a word", () => {
const editor = makeEditor(tableDoc(GRID));
const before = written(editor);
selectCells(editor, [0, 0], [1, 2]);
expect(paste(editor, textSlice(editor, "z"))).toBe(true);
// Six cells for one word is not a paste anybody meant, and it is not undoable in the file: the
// save lands half a second later whether or not the user has noticed yet.
expect(shape(editor)).toEqual(GRID);
expect(written(editor)).toBe(before);
editor.destroy();
});
it("still lays out a rectangle of cells copied out of a table", () => {
const editor = makeEditor(tableDoc(GRID));
const copied = cellSlice(editor, [1, 0], [1, 1]);
selectCells(editor, [2, 0], [2, 1]);
expect(paste(editor, copied)).toBe(true);
// The library's own edit, kept because it is better than anything this app would do with it: it
// lays the cells out over the rectangle and keeps every boundary the user copied. Refusing this
// would be the guard destroying a paste in order to guard it.
expect(shape(editor)).toEqual([
["a", "b", "c"],
["1", "2", "3"],
["1", "2", "6"],
]);
editor.destroy();
});
it("puts a word into the one cell the caret is in", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 1, 1);
editor.commands.setTextSelection(inCell(editor, 1, 1) + 1);
// Nobody claims it, so ProseMirror's own handler runs and does the ordinary thing. Refusing a
// paste at a caret in a cell would have been this guard overreaching in the other direction.
expect(paste(editor, textSlice(editor, "z"))).toBe(false);
editor.destroy();
});
});
+385
View File
@@ -0,0 +1,385 @@
// Table behaviour: everything about editing a GFM table that is not its shape.
//
// The shape is already in src/model/schema.ts and generated into an extension by extensions.ts, so
// nothing here declares a node. What is missing is the behaviour prosemirror-tables carries: the
// cell selection, Tab between cells, and the row and column edits a toolbar asks for by name. That
// library ships inside @tiptap/pm/tables and reads the `tableRole` extensions.ts already puts on
// each spec, so it plugs in whole rather than being reimplemented.
//
// Alignment is the one thing the library has no idea about, and it runs through everything below.
// `align` is a cell attribute the bridge reads back out of the GFM delimiter row, and that row is
// per column: markdown cannot say that one cell is centred and the rest of its column is not. So an
// align op writes the whole column, and a row added into a column has to be told what that column
// says, because prosemirror-tables builds its new cells from the attribute's default. The
// serializer reads the delimiter row off the table's first row, which makes a row added above the
// first one the worst case: left alone it would take the whole table's alignment off the next time
// the file was written.
//
// The other thing markdown cannot follow is the shape of the header. A GFM table has exactly one
// header row, it is the first one, and there is no spelling for a table without one, so the ops
// here keep the document to that shape rather than offering edits the file cannot hold. That is
// also why there is no header row toggle: both directions of it are a change the next open of the
// file silently takes back.
//
// One thing the library does that the file cannot follow either: dragging a column edge writes
// `colwidth` on to every cell in that column, and GFM has no column widths for the serializer to
// put them in. The drag is a real document change all the same, and src/document.ts is where it
// stops being one: a transaction that only moved something the markdown cannot spell does not mark
// the buffer dirty, so the drag never reaches the debounce and no save is scheduled behind it.
import { Extension } from "@tiptap/core";
import type { Editor } from "@tiptap/core";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import type { Command, Transaction } from "@tiptap/pm/state";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import {
TableMap,
addColumnAfter,
addColumnBefore,
addRow,
columnResizing,
deleteCellSelection,
deleteColumn,
deleteRow,
deleteTable,
goToNextCell,
isInTable,
selectedRect,
tableEditing,
} from "@tiptap/pm/tables";
import type { TableRect } from "@tiptap/pm/tables";
import type { ColumnAlign } from "../../model/doc";
import { overCells } from "../fits";
import type { TableOp } from "../index";
/**
* The typing guard, named so that a test can find it in the plugin list and say where in that list
* it sits. Being right about a rectangle of cells is worth nothing if something else is asked
* first, which is the mistake this lane has already made once with a paste.
*/
export const typingKey = new PluginKey("tableTyping");
/** What each column says, read where the serializer reads it: the table's first row. */
function columnAlignments(table: ProseMirrorNode): ColumnAlign[] {
const map = TableMap.get(table);
return Array.from(
{ length: map.width },
(_unused, column) => (table.nodeAt(map.map[column])?.attrs.align ?? null) as ColumnAlign,
);
}
/** Every cell of every column made to agree with `alignment`, whatever the edit left behind. */
function restoreAlignments(tr: Transaction, tablePos: number, alignment: ColumnAlign[]): void {
const table = tr.doc.nodeAt(tablePos);
if (!table) return;
const map = TableMap.get(table);
const columns = Math.min(map.width, alignment.length);
const done = new Set<number>();
for (let row = 0; row < map.height; row += 1) {
for (let column = 0; column < columns; column += 1) {
const pos = map.map[row * map.width + column];
if (done.has(pos)) continue;
done.add(pos);
const cell = table.nodeAt(pos);
// Null for the type: a header cell that is centred is still a header cell, and setNodeMarkup
// is the only way to change an attribute and keep both the type and the content.
if (cell && cell.attrs.align !== alignment[column]) {
tr.setNodeMarkup(tablePos + 1 + pos, null, { ...cell.attrs, align: alignment[column] });
}
}
}
}
/**
* Row zero holding header cells and every other row holding body cells, whatever the edit left.
*
* GFM has one shape for a table: the first row is the header and the delimiter row under it is what
* makes the block a table at all. serialize.ts writes the first row as the header whichever kind of
* cell it is holding, so a table that says otherwise on screen is a table that comes back different
* the next time the file is opened. prosemirror-tables copies the type of the cell it is building
* beside, which is how a row added above the header, or a column added in front of it, leaves body
* cells in row zero.
*/
function normaliseHeaderRow(tr: Transaction, tablePos: number): void {
const table = tr.doc.nodeAt(tablePos);
if (!table || table.type.name !== "table") return;
const map = TableMap.get(table);
const types = table.type.schema.nodes;
const done = new Set<number>();
for (let row = 0; row < map.height; row += 1) {
const want = row === 0 ? types.tableHeader : types.tableCell;
for (let column = 0; column < map.width; column += 1) {
const pos = map.map[row * map.width + column];
if (done.has(pos)) continue;
done.add(pos);
const cell = table.nodeAt(pos);
// Both cell types hold inline content, so this changes the type and keeps the text and the
// alignment that were in it.
if (cell && cell.type !== want) tr.setNodeMarkup(tablePos + 1 + pos, want, cell.attrs);
}
}
}
/** Tab out of the last cell should land in the row it has just made, not stay where it was. */
function cursorIntoLastRow(tr: Transaction, tablePos: number): void {
const table = tr.doc.nodeAt(tablePos);
if (!table) return;
const map = TableMap.get(table);
const cell = tablePos + 1 + map.positionAt(map.height - 1, 0, table);
tr.setSelection(TextSelection.near(tr.doc.resolve(cell + 1))).scrollIntoView();
}
/**
* A row added where `at` says, with the alignments the table already had put back over it.
*
* One transaction rather than a command each, so that Tab out of the last cell is one Cmd+Z rather
* than two, and so that the document is never momentarily a table whose column disagrees with its
* own delimiter row.
*/
function insertRow(
at: (rect: TableRect) => number,
then?: (tr: Transaction, tablePos: number) => void,
): Command {
return (state, dispatch) => {
if (!isInTable(state)) return false;
if (dispatch) {
const rect = selectedRect(state);
const alignment = columnAlignments(rect.table);
const tr = addRow(state.tr, rect, at(rect));
// tableStart is the position just inside the table, so one before it is the table itself,
// and every row went in after that point rather than before it.
const tablePos = rect.tableStart - 1;
restoreAlignments(tr, tablePos, alignment);
normaliseHeaderRow(tr, tablePos);
if (then) then(tr, tablePos);
dispatch(tr);
}
return true;
};
}
const addRowAbove = insertRow((rect) => rect.top);
const addRowBelow = insertRow((rect) => rect.bottom);
const addRowAtEnd = insertRow((rect) => rect.map.height, cursorIntoLastRow);
/** Tab: the next cell along, or the row that has to be made first when there is no next cell. */
const nextCellOrNewRow: Command = (state, dispatch) =>
goToNextCell(1)(state, dispatch) || addRowAtEnd(state, dispatch);
/**
* One of prosemirror-tables' own structural commands, with the header row put back over whatever it
* produced, in the transaction the command built rather than a second one behind it.
*
* The library is asked with a dispatch that only catches the transaction, so a command that answers
* false still leaves nothing behind, and a table this edit removed outright is a table
* `normaliseHeaderRow` declines to find.
*/
function normalising(command: Command): Command {
return (state, dispatch, view) => {
if (!isInTable(state)) return false;
if (!dispatch) return command(state, undefined, view);
const tablePos = selectedRect(state).tableStart - 1;
let caught: Transaction | null = null;
const acted = command(
state,
(tr) => {
caught = tr;
},
view,
);
if (!acted || caught === null) return acted;
normaliseHeaderRow(caught, tablePos);
dispatch(caught);
return true;
};
}
/**
* The whole column the selection covers, header cell included.
*
* Setting only the cell under the cursor would show an alignment on screen that the next save
* silently takes back off, and leaving the header out would lose the alignment outright, since the
* first row is the one the delimiter row is written from.
*/
function alignColumn(align: ColumnAlign): Command {
return (state, dispatch) => {
if (!isInTable(state)) return false;
const { left, right, map, table, tableStart } = selectedRect(state);
// Positions relative to the table, and a set because a cell that spans columns appears in the
// map once per column it covers.
const cells = new Set<number>();
for (let row = 0; row < map.height; row += 1) {
for (let column = left; column < right; column += 1) {
const pos = map.map[row * map.width + column];
if (table.nodeAt(pos)?.attrs.align !== align) cells.add(pos);
}
}
if (cells.size === 0) return false;
if (dispatch) {
const tr = state.tr;
for (const pos of cells) {
const cell = table.nodeAt(pos);
if (cell) tr.setNodeMarkup(tableStart + pos, null, { ...cell.attrs, align });
}
dispatch(tr);
}
return true;
};
}
/**
* The command, with the key claimed for as long as the cursor is in a table, whether or not the
* command found anything to do with it.
*
* A binding that answers false hands the key on to whatever is bound behind it, and behind this
* lane's Tab and Shift-Tab is shortcuts.ts's list pair, which reshapes the list around the table
* rather than anything inside it. A table indented under a bullet is an ordinary thing to write,
* and Shift-Tab in its first cell has nowhere to go: the answer to that is the cursor staying where
* it is, not the item being lifted out and the list dissolved by a key pressed to move back a cell.
*/
function claimedInTable(command: Command): Command {
return (state, dispatch, view) => {
if (!isInTable(state)) return false;
command(state, dispatch, view);
return true;
};
}
/**
* Every op the handle can name, as the ProseMirror command that performs it.
*
* There is no header row op. GFM writes the first row of a table as its header and has no spelling
* for a table without one or for a second one, so both directions of a toggle are an edit the
* serializer cannot carry and the next open of the file does not show. An op the file cannot hold
* is an op that is not offered.
*/
const TABLE_OPS: { [op in TableOp]: Command } = {
addRowBefore: addRowAbove,
addRowAfter: addRowBelow,
deleteRow: normalising(deleteRow),
addColumnBefore: normalising(addColumnBefore),
addColumnAfter: normalising(addColumnAfter),
deleteColumn: normalising(deleteColumn),
deleteTable,
alignLeft: alignColumn("left"),
alignCenter: alignColumn("center"),
alignRight: alignColumn("right"),
alignClear: alignColumn(null),
};
/**
* A printable character typed over a rectangle of dragged cells, which does nothing.
*
* ProseMirror offers a character to `handleTextInput` whenever the selection is not an ordinary one
* inside a single textblock, and when nobody claims it the character goes in through
* `tr.insertText`, which is `Selection.replace`. A cell selection replaces every range it holds:
* the character lands in the LAST cell of the rectangle and the other cells are emptied. Measured,
* in a browser, on the table this lane was reported against: a drag across a 2x2 body and the three
* keystrokes "zqx" took "| 1 | 2 |\n| 3 | 4 |" to four empty cells with "zqx" sitting in the last
* of them. Four cells of somebody's table for three characters, and none of them the cell the drag
* started in.
*
* That is the destruction src/editor/paste.ts refuses for a Cmd+V arriving at the same selection,
* and it arrived here by the one route with no guard on it at all.
*
* Emptying the cells is what Backspace over a rectangle does, in the keymap at the end of this
* file, and that is right: delete is the verb that was pressed and the rows and the columns survive
* it. A letter is not that verb. A rectangle is a selection of whole cells rather than of text, so
* there is no text for a character to replace and no one cell it belongs in: putting it in the
* first cell or the last one both throw away cells the user never aimed at, and neither is what
* they asked for. So nothing happens, the key is claimed so that nothing else does it either, and
* the rectangle stays selected, which leaves Backspace, the toolbar and a click into one cell all
* exactly where they were.
*/
const typing = new Plugin({
key: typingKey,
props: {
handleTextInput: (view) => overCells(view.state),
},
});
export const Tables = Extension.create({
name: "tables",
addProseMirrorPlugins() {
// There was a third plugin in front of these two once, and it answered one paste: an empty
// slice over a rectangle of cells, which is what a clipboard holding only an image looks like
// by the time it reaches a handler. `tableEditing` takes that empty slice and empties every
// cell in the rectangle, so a PNG pasted over a dragged table deleted the table's text.
//
// It was here because src/editor/paste.ts already refused exactly that and was never asked:
// these plugins came ninth in the list and that one came fifteenth. The guard sitting in front
// of the plugin it guards was the right instinct and the wrong fix, because it only covered
// the empty slice, and the same ordering handed the library every non-empty paste over a cell
// selection too. One word pasted over four dragged cells replaced all four.
//
// So the paste ordering is fixed instead: paste.ts asks for the highest priority in the editor,
// is asked first for every paste, and stands aside only for cells pasted into a table, which is
// the one paste those two are better at. `typing` below is not a second copy of that question,
// it is a different event: nothing in this editor claimed a typed character, and a typed
// character over a rectangle is the same destruction arriving by the one route nobody guarded.
return [
typing,
// columnResizing before tableEditing, which takes mousedown for the cell selection drag: a
// press on a column edge is a resize, and the plugin that decides that has to be asked first.
columnResizing(),
tableEditing(),
];
},
addKeyboardShortcuts() {
const editor = this.editor;
// ProseMirror's own calling convention rather than editor.commands.command, which dispatches
// its transaction whatever the command answered. These four are asked on every Tab and every
// Backspace in the document, and a key pressed outside a table has to leave nothing behind.
const run = (command: Command) => () =>
command(editor.state, editor.view.dispatch, editor.view);
return {
// Tab out of the last cell grows the table, which is the only way to add a row without
// reaching for the toolbar. Shift-Tab has no matching gesture: there is no row before the
// first one to make, so in the first cell it moves nothing and answers for the key anyway.
// Both are claimed the same way so that neither can be handed on to a list command; see
// claimedInTable above for what that costs the document when it is.
Tab: run(claimedInTable(nextCellOrNewRow)),
"Shift-Tab": run(claimedInTable(goToNextCell(-1))),
// Said here rather than left to tableEditing's own binding further down the plugin list.
// A cell selection has to be emptied and not removed: deleting it as a selection would take
// the rows and columns the cells were in along with the text that was in them.
//
// Not claimed the way the two above are: with a plain cursor in a cell there is nothing to
// empty, and a Backspace that stopped here would be a Backspace that never deletes a
// character. What is behind these is StarterKit's list keymap, which reads the cursor's
// parent as its list item and finds a table row instead, so it declines from inside a cell
// and the key reaches the editing it was pressed for. src/editor/blocks/tables.test.ts holds
// that assertion, since it is the library's behaviour rather than this file's.
Backspace: run(deleteCellSelection),
Delete: run(deleteCellSelection),
};
},
});
/** False when the cursor is not in a table, or the op has nothing to act on where it is. */
export function tableCommand(editor: Editor, op: TableOp): boolean {
const command = TABLE_OPS[op];
// Read out of the chain rather than off the chain's own result, because focus is in the chain too
// and answers a different question, with a false of its own whenever there is no view to focus.
let acted = false;
editor
.chain()
.focus()
.command(({ state, dispatch }) => {
acted = command(state, dispatch);
return acted;
})
.run();
return acted;
}
+773
View File
@@ -0,0 +1,773 @@
// What can be asserted about a toggle without a browser, which is the half that costs somebody a
// file.
//
// vite.config.ts runs vitest in the node environment, so there is no page here and nothing a
// browser does with one is on trial: the arrow's drawing, the caret's travel through a title and
// the placeholder on an untitled one belong to the Playwright suite. What belongs here is
// everything that reaches disk. The two attributes a toggle carries are the two things this lane
// writes, and both of them go out through an html block that the bridge will only read back if it
// is spelled one exact way, so a title the editor mangles on the way in is a `<details>` that opens
// as a raw block the next time the file is looked at.
//
// The node view is built here all the same, against the page written out at the bottom of this
// file, because two of the things it decides reach disk as surely as the attributes do: which
// clicks on the summary write `open`, and which commands are allowed to run while the caret is
// somewhere the document's selection is not. Both were bugs a green suite did not see, and neither
// is a question about a browser. They are questions about what these handlers do with what a
// browser sends them.
//
// The entity group is the one to keep. A summary holding `&`, `<` or `>` is escaped by the
// serializer and unescaped by the parser, and the parser refuses to model any toggle those two do
// not agree about character for character. An extra round of escaping would not throw, would not
// fail a type check and would not lose the document: it would grow another `amp;` in somebody's
// heading on every save.
import { describe, expect, it } from "vitest";
import { Editor } from "@tiptap/core";
import type { JSONContent } from "@tiptap/core";
import { EditorState, TextSelection } from "@tiptap/pm/state";
import type { Plugin, Transaction } from "@tiptap/pm/state";
import { DecorationSet } from "@tiptap/pm/view";
import type { EditorView } from "@tiptap/pm/view";
import { createEditorExtensions } from "../extensions";
import { parseMarkdown, serializeMarkdown } from "../../markdown";
import { Toggles, setToggleOpen, setToggleSummary } from "./toggle";
const PATH = "/notes/writing.md";
const extensions = () => createEditorExtensions({ documentPath: () => PATH, onError: () => {} });
const EMPTY: JSONContent = { type: "doc", content: [{ type: "paragraph" }] };
/**
* An editor with the lanes' plugins in its state.
*
* TipTap only installs them when it mounts a view and there is no DOM here to mount into, so the
* state is rebuilt with them the way src/editor/Editor.tsx installs every document it opens. It
* matters more here than it does for a keymap: this lane's plugin also appends a transaction, and a
* plugin that is not in the state is never asked to.
*/
function makeEditor(content: JSONContent = EMPTY): Editor {
const editor = new Editor({ element: null, injectCSS: false, extensions: extensions(), content });
editor.view.updateState(
EditorState.create({ doc: editor.state.doc, plugins: editor.extensionManager.plugins }),
);
return editor;
}
function editorFor(source: string): Editor {
return makeEditor(parseMarkdown(source, PATH).doc.toJSON());
}
/** What the bridge would write for the document as it stands now. */
function written(source: string, editor: Editor): string {
return serializeMarkdown(parseMarkdown(source, PATH), editor.state.doc);
}
/** Where the first toggle in the document is, and what it holds. */
function toggleIn(editor: Editor): { pos: number; open: boolean; summary: string; body: string } {
let found: { pos: number; open: boolean; summary: string; body: string } | null = null;
editor.state.doc.descendants((node, pos) => {
if (found || node.type.name !== "toggle") return !found;
found = {
pos,
open: node.attrs.open as boolean,
summary: node.attrs.summary as string,
body: node.textContent,
};
return false;
});
if (!found) throw new Error("no toggle in the document");
return found;
}
/** The plugins offering a node view for the toggle node, found the way the view finds them. */
function nodeViewPlugins(editor: Editor): Plugin[] {
return editor.extensionManager.plugins.filter(
(plugin) => plugin.props.nodeViews?.toggle !== undefined,
);
}
const SUMMARY = "Tom &amp; Jerry &lt;3&gt;";
const PLAIN = "Tom & Jerry <3>";
const DOC = [
"# Writing",
"",
"<details>",
`<summary>${SUMMARY}</summary>`,
"",
"No em dashes.",
"",
"</details>",
"",
"After.",
"",
].join("\n");
describe("the toggle extension", () => {
it("is the one the registry names", () => {
expect(Toggles.name).toBe("toggles");
});
it("adds no node and no mark, so the bridge and the editor still agree", () => {
const plain = new Editor({
element: null,
injectCSS: false,
extensions: extensions().filter((extension) => extension.name !== "toggles"),
content: EMPTY,
});
const withToggles = makeEditor();
expect(Object.keys(withToggles.schema.nodes)).toEqual(Object.keys(plain.schema.nodes));
expect(Object.keys(withToggles.schema.marks)).toEqual(Object.keys(plain.schema.marks));
plain.destroy();
withToggles.destroy();
});
// The node view is the whole feature: without one the browser's own disclosure takes the click,
// the open attribute never moves and a keystroke aimed at the title lands in the body. Two
// plugins claiming the node would be the same bug from the other end, since ProseMirror takes the
// first one asked and the other never runs.
it("is the only plugin in the build that claims the toggle node view", () => {
const editor = makeEditor();
expect(nodeViewPlugins(editor)).toHaveLength(1);
editor.destroy();
});
});
describe("a <details> read off disk", () => {
it("arrives as a toggle carrying its summary as text, not as entities", () => {
const editor = editorFor(DOC);
const toggle = toggleIn(editor);
expect(toggle.summary).toBe(PLAIN);
expect(toggle.open).toBe(false);
expect(toggle.body).toBe("No em dashes.");
editor.destroy();
});
it("is written back byte for byte when nothing was edited", () => {
const editor = editorFor(DOC);
expect(written(DOC, editor)).toBe(DOC);
editor.destroy();
});
it("keeps the whole file byte identical when only the summary is edited", () => {
const editor = editorFor(DOC);
const { pos } = toggleIn(editor);
expect(setToggleSummary(pos, "Tom & Jerry <4>")(editor.state, editor.view.dispatch)).toBe(true);
expect(written(DOC, editor)).toBe(DOC.replace("&lt;3&gt;", "&lt;4&gt;"));
editor.destroy();
});
// The failure this is shaped to catch is invisible: an editor that put the escaped form on the
// node would write `&amp;amp;` here, the file would still parse, and the title would grow a word
// every time the document was saved.
it("does not escape the ampersand it already escaped once", () => {
const editor = editorFor(DOC);
const { pos } = toggleIn(editor);
// The one keystroke: a character on the end of a title that already holds all three of the
// characters the serializer has to spell as entities.
setToggleSummary(pos, `${PLAIN}!`)(editor.state, editor.view.dispatch);
const once = written(DOC, editor);
expect(once).toBe(DOC.replace(SUMMARY, `${SUMMARY}!`));
// And back through the bridge, which is where a second round of escaping would show up.
const again = makeEditor(parseMarkdown(once, PATH).doc.toJSON());
expect(toggleIn(again).summary).toBe(`${PLAIN}!`);
expect(written(once, again)).toBe(once);
again.destroy();
editor.destroy();
});
it("writes the open marker on to the tag, and takes it off again", () => {
const editor = editorFor(DOC);
const { pos } = toggleIn(editor);
expect(setToggleOpen(pos, true)(editor.state, editor.view.dispatch)).toBe(true);
expect(written(DOC, editor)).toBe(DOC.replace("<details>", "<details open>"));
expect(setToggleOpen(pos, false)(editor.state, editor.view.dispatch)).toBe(true);
expect(written(DOC, editor)).toBe(DOC);
editor.destroy();
});
it("leaves the body alone whatever happens to the two attributes", () => {
const editor = editorFor(DOC);
const { pos, body } = toggleIn(editor);
setToggleSummary(pos, "Something else")(editor.state, editor.view.dispatch);
setToggleOpen(pos, true)(editor.state, editor.view.dispatch);
expect(toggleIn(editor).body).toBe(body);
expect(editor.state.doc.textContent).toBe(editorFor(DOC).state.doc.textContent);
editor.destroy();
});
});
describe("the two commands", () => {
it("decline a position that is not a toggle, and one that already reads that way", () => {
const editor = editorFor(DOC);
const { pos, summary } = toggleIn(editor);
const before = editor.state.doc.toJSON();
expect(setToggleOpen(0, true)(editor.state, editor.view.dispatch)).toBe(false);
expect(setToggleSummary(0, "x")(editor.state, editor.view.dispatch)).toBe(false);
expect(setToggleOpen(pos, false)(editor.state, editor.view.dispatch)).toBe(false);
expect(setToggleSummary(pos, summary)(editor.state, editor.view.dispatch)).toBe(false);
expect(editor.state.doc.toJSON()).toEqual(before);
editor.destroy();
});
// A closed <details> does not draw its children, so a caret left in one is a caret nobody can see
// and the next keystroke goes somewhere invisible.
it("bring the caret out of a body that is being closed", () => {
const editor = editorFor(DOC);
const { pos } = toggleIn(editor);
setToggleOpen(pos, true)(editor.state, editor.view.dispatch);
const inside = pos + 2;
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, inside)));
expect(editor.state.selection.from).toBe(inside);
setToggleOpen(pos, false)(editor.state, editor.view.dispatch);
const node = editor.state.doc.nodeAt(pos)!;
expect(editor.state.selection.from >= pos + node.nodeSize).toBe(true);
editor.destroy();
});
it("leave the caret where it is when it was never inside", () => {
const editor = editorFor(DOC);
const { pos } = toggleIn(editor);
setToggleOpen(pos, true)(editor.state, editor.view.dispatch);
editor.commands.setTextSelection(2);
setToggleOpen(pos, false)(editor.state, editor.view.dispatch);
expect(editor.state.selection.from).toBe(2);
editor.destroy();
});
});
// What the pill's Toggle button runs, which is `toggleWrap("toggle")` in setBlock in
// src/editor/Editor.tsx. A toggle takes the attribute's own default, which is closed, so without
// the plugin's appended transaction the paragraph the user just wrapped is behind an arrow and
// reads as a deletion.
describe("wrapping a block in a toggle", () => {
const PROSE: JSONContent = {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "Keep this visible." }] }],
};
it("leaves it open, with the block still on screen inside it", () => {
const editor = makeEditor(PROSE);
editor.commands.setTextSelection(2);
expect(editor.chain().toggleWrap("toggle").run()).toBe(true);
const toggle = toggleIn(editor);
expect(toggle.open).toBe(true);
expect(toggle.summary).toBe("");
expect(toggle.body).toBe("Keep this visible.");
editor.destroy();
});
it("makes something the bridge writes and reads back as the same toggle", () => {
const editor = makeEditor(PROSE);
editor.commands.setTextSelection(2);
editor.chain().toggleWrap("toggle").run();
setToggleSummary(toggleIn(editor).pos, "House style")(editor.state, editor.view.dispatch);
const source = written("Keep this visible.\n", editor);
expect(source).toBe(
["<details open>", "<summary>House style</summary>", "", "Keep this visible.", "", "</details>", ""].join("\n"),
);
const reopened = makeEditor(parseMarkdown(source, PATH).doc.toJSON());
const toggle = toggleIn(reopened);
expect([toggle.open, toggle.summary, toggle.body]).toEqual([true, "House style", "Keep this visible."]);
reopened.destroy();
editor.destroy();
});
it("takes it back out on a second press, which is what the same button means", () => {
const editor = makeEditor(PROSE);
editor.commands.setTextSelection(2);
editor.chain().toggleWrap("toggle").run();
expect(editor.chain().toggleWrap("toggle").run()).toBe(true);
expect(editor.state.doc.toJSON()).toEqual(PROSE);
editor.destroy();
});
});
// The guard on the appended transaction, and the reason it is written the way it is. Opening a
// document restores the caret it was last left at, and that is a selection with no edit behind it:
// a toggle opened by one would be a byte written into a file nobody has touched.
describe("a caret that moves without an edit", () => {
it("never opens the toggle it lands in, and never dirties the document", () => {
const editor = editorFor(DOC);
const { pos } = toggleIn(editor);
const before = editor.state.doc.toJSON();
editor.view.dispatch(
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos + 2)),
);
expect(toggleIn(editor).open).toBe(false);
expect(editor.state.doc.toJSON()).toEqual(before);
expect(written(DOC, editor)).toBe(DOC);
editor.destroy();
});
});
/**
* The few parts of a page the node view reaches for, written out rather than depended on.
*
* A DOM implementation is not a dependency this project has, and the handful of calls the node view
* makes into one does not earn it: it creates elements, hangs listeners on them, asks which one the
* page thinks has the caret, and asks for a frame. What a browser does with an element is
* Playwright's question. What the handlers in toggle.ts do with what a browser sends them is this
* file's, and that is the whole of what these model.
*/
interface PageEvent {
type: string;
target: PageElement;
key?: string;
isComposing?: boolean;
defaultPrevented: boolean;
preventDefault: () => void;
}
class PageElement {
className = "";
contentEditable = "inherit";
textContent = "";
parent: PageElement | null = null;
private readonly attributes = new Set<string>();
private readonly listeners = new Map<string, ((event: PageEvent) => void)[]>();
constructor(
readonly tagName: string,
readonly ownerDocument: Page,
) {}
/** Only ever asked whether there is one, which is how a leftover <br> in a title is found. */
get firstChild(): object | null {
return this.textContent === "" ? null : { nodeName: "#text" };
}
setAttribute(name: string, _value: string): void {
this.attributes.add(name);
}
hasAttribute(name: string): boolean {
return this.attributes.has(name);
}
toggleAttribute(name: string, on: boolean): void {
if (on) this.attributes.add(name);
else this.attributes.delete(name);
}
appendChild(child: PageElement): void {
child.parent = this;
}
append(...children: PageElement[]): void {
for (const child of children) this.appendChild(child);
}
contains(other: PageElement | null): boolean {
for (let element = other; element; element = element.parent) if (element === this) return true;
return false;
}
focus(): void {
this.ownerDocument.activeElement = this;
this.fire("focus");
}
blur(): void {
if (this.ownerDocument.activeElement === this) this.ownerDocument.activeElement = null;
this.fire("blur");
}
addEventListener(type: string, listener: (event: PageEvent) => void): void {
const list = this.listeners.get(type) ?? [];
list.push(listener);
this.listeners.set(type, list);
}
removeEventListener(type: string, listener: (event: PageEvent) => void): void {
this.listeners.set(type, (this.listeners.get(type) ?? []).filter((one) => one !== listener));
}
/** Bubbling, because the row's listeners are what an event on the title inside it reaches. */
fire(type: string, extra: Partial<PageEvent> = {}): PageEvent {
const event: PageEvent = {
type,
target: this,
defaultPrevented: false,
preventDefault: () => {
event.defaultPrevented = true;
},
...extra,
};
for (let element: PageElement | null = this; element; element = element.parent) {
for (const listener of element.listeners.get(type) ?? []) listener(event);
}
return event;
}
}
class Page {
activeElement: PageElement | null = null;
readonly created: PageElement[] = [];
private readonly frames: (() => void)[] = [];
readonly defaultView = {
requestAnimationFrame: (run: () => void): number => this.frames.push(run),
};
createElement(tagName: string): PageElement {
const element = new PageElement(tagName, this);
this.created.push(element);
return element;
}
/** Nothing here models a caret inside a title, only which element holds one. */
getSelection(): null {
return null;
}
/** The frame a browser would run next, which is also where TipTap's own focus call lands. */
runFrames(): number {
const queued = this.frames.splice(0);
for (const run of queued) run();
return queued.length;
}
}
interface Mounted {
page: Page;
details: PageElement;
summary: PageElement;
title: PageElement;
destroy: () => void;
}
/** The node view for the toggle at `pos`, built the way the editor's view builds one. */
function mount(editor: Editor, pos: number): Mounted {
const page = new Page();
const view = {
dom: page.createElement("div"),
get state(): EditorState {
return editor.state;
},
dispatch: (tr: Transaction) => editor.view.dispatch(tr),
editable: true,
focus: () => {},
} as unknown as EditorView;
const build = nodeViewPlugins(editor)[0]?.props.nodeViews?.toggle;
const node = editor.state.doc.nodeAt(pos);
if (!build || !node) throw new Error("nothing to build a node view from");
const nodeView = build(node, view, () => pos, [], DecorationSet.empty);
const find = (match: (element: PageElement) => boolean): PageElement => {
const element = page.created.find(match);
if (!element) throw new Error("the node view did not build that element");
return element;
};
return {
page,
details: find((element) => element.tagName === "details"),
summary: find((element) => element.tagName === "summary"),
title: find((element) => element.hasAttribute("data-toggle-summary")),
destroy: () => nodeView.destroy?.(),
};
}
/** Where the text of this block ends, which is where a click in it leaves the caret. */
function endOf(editor: Editor, text: string): number {
let found: number | null = null;
editor.state.doc.descendants((node, pos) => {
if (found !== null) return false;
if (node.isTextblock && node.textContent === text) found = pos + 1 + node.content.size;
return found === null;
});
if (found === null) throw new Error(`no block reading ${text}`);
return found;
}
const PAGE_DOC = [
"# Doc",
"",
"First paragraph.",
"",
"Second paragraph.",
"",
"<details open>",
"<summary>My title</summary>",
"",
"Toggle body.",
"",
"</details>",
"",
].join("\n");
// A `<details>` is a disclosure control and the browser works one from the keyboard whether this
// file wants it to or not: a space typed anywhere inside the summary sends the row a click of its
// own. Taken as a press, that closed the toggle under the caret on every other space of a title and
// wrote the flip to the file each time.
describe("a space typed in a title", () => {
it("leaves the toggle as it was, and the space in the title", () => {
const editor = editorFor(PAGE_DOC);
const ui = mount(editor, toggleIn(editor).pos);
ui.title.focus();
ui.title.fire("keydown", { key: " " });
// The character the browser puts in the element, and then the click it sends the row after it.
ui.title.textContent = "My title ";
ui.title.fire("input");
ui.summary.fire("click");
const toggle = toggleIn(editor);
expect(toggle.open).toBe(true);
expect(toggle.summary).toBe("My title ");
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC.replace("My title", "My title "));
ui.destroy();
editor.destroy();
});
it("does not find a press that never became a click waiting for it", () => {
const editor = editorFor(PAGE_DOC);
const ui = mount(editor, toggleIn(editor).pos);
// Pressed on the row, and then the pointer left and no click ever came of it.
ui.summary.fire("mousedown");
ui.title.focus();
ui.title.fire("keydown", { key: " " });
ui.summary.fire("click");
expect(toggleIn(editor).open).toBe(true);
ui.destroy();
editor.destroy();
});
});
describe("a press on the summary row", () => {
it("still flips the toggle and writes it, which is what the arrow is for", () => {
const editor = editorFor(PAGE_DOC);
const ui = mount(editor, toggleIn(editor).pos);
ui.summary.fire("mousedown");
ui.summary.fire("click");
expect(toggleIn(editor).open).toBe(false);
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC.replace("<details open>", "<details>"));
ui.destroy();
editor.destroy();
});
it("flips it from the keyboard when the row itself is what has focus", () => {
const editor = editorFor(PAGE_DOC);
const ui = mount(editor, toggleIn(editor).pos);
ui.page.activeElement = ui.summary;
ui.summary.fire("click");
expect(toggleIn(editor).open).toBe(false);
ui.destroy();
editor.destroy();
});
});
// The caret in a title is not in the document: ProseMirror's selection is still wherever it was
// when the caret went in there, so a toolbar button pressed while a title is being typed runs its
// command against a paragraph the user is not looking at.
describe("a command aimed at the document while the caret is in a title", () => {
it("changes nothing, and the file with it", () => {
const editor = editorFor(PAGE_DOC);
const ui = mount(editor, toggleIn(editor).pos);
editor.commands.setTextSelection(endOf(editor, "Second paragraph."));
ui.title.focus();
const before = editor.state.doc.toJSON();
// What the pill's Horizontal rule runs, less the focus() in front of it, which wants a browser.
editor.commands.insertContent({ type: "horizontalRule" });
expect(editor.state.doc.toJSON()).toEqual(before);
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC);
ui.destroy();
editor.destroy();
});
it("leaves the caret in the title, so a second press is refused like the first", () => {
const editor = editorFor(PAGE_DOC);
const ui = mount(editor, toggleIn(editor).pos);
editor.commands.setTextSelection(endOf(editor, "Second paragraph."));
ui.title.focus();
const before = editor.state.doc.toJSON();
editor.commands.insertContent({ type: "horizontalRule" });
// TipTap's focus command queues a view.focus() for the next frame, and it lands behind the
// refusal: without the caret being put back, the second press of the same button has a caret in
// the document again and edits the place the first press was refused for.
ui.title.blur();
expect(ui.page.runFrames()).toBe(1);
expect(ui.page.activeElement).toBe(ui.title);
editor.commands.insertContent({ type: "horizontalRule" });
expect(editor.state.doc.toJSON()).toEqual(before);
ui.destroy();
editor.destroy();
});
it("is taken back the moment the caret leaves the title", () => {
const editor = editorFor(PAGE_DOC);
const ui = mount(editor, toggleIn(editor).pos);
editor.commands.setTextSelection(endOf(editor, "Second paragraph."));
ui.title.focus();
ui.title.blur();
const before = editor.state.doc.toJSON();
editor.commands.insertContent({ type: "horizontalRule" });
expect(editor.state.doc.toJSON()).not.toEqual(before);
ui.destroy();
editor.destroy();
});
it("is never the title's own writing, which is the one edit that is where the caret is", () => {
const editor = editorFor(PAGE_DOC);
const ui = mount(editor, toggleIn(editor).pos);
ui.title.focus();
ui.title.textContent = "Renamed";
ui.title.fire("input");
expect(toggleIn(editor).summary).toBe("Renamed");
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC.replace("My title", "Renamed"));
ui.destroy();
editor.destroy();
});
});
const TWO_TOGGLES = [
"<details>",
"<summary>Closed</summary>",
"",
"Hidden body.",
"",
"</details>",
"",
"<details open>",
"<summary>My title</summary>",
"",
"Toggle body.",
"",
"</details>",
"",
].join("\n");
/** Every toggle in the document, in the order they are written. */
function togglesIn(editor: Editor): { pos: number; open: boolean; summary: string }[] {
const found: { pos: number; open: boolean; summary: string }[] = [];
editor.state.doc.descendants((node, pos) => {
if (node.type.name !== "toggle") return true;
found.push({ pos, open: node.attrs.open as boolean, summary: node.attrs.summary as string });
return true;
});
return found;
}
// The plugin opens the collapsed toggle the caret ends an edit inside, because a caret behind a
// closed arrow is one nobody can see. The caret in a title is not in the document at all, so the
// selection that rule reads is stale and the toggle it names is one nobody is in.
describe("typing in a title while the selection is left inside another toggle", () => {
it("does not open the toggle it is left in, and writes no byte into that one", () => {
const editor = editorFor(TWO_TOGGLES);
const [closed, titled] = togglesIn(editor);
const ui = mount(editor, titled.pos);
editor.view.dispatch(
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, closed.pos + 3)),
);
ui.title.focus();
ui.title.textContent = "Renamed";
ui.title.fire("input");
expect(togglesIn(editor)[0].open).toBe(false);
expect(written(TWO_TOGGLES, editor)).toBe(TWO_TOGGLES.replace("My title", "Renamed"));
ui.destroy();
editor.destroy();
});
});
// src/markdown/parse.ts pairs `<details>` among the root's own children and nowhere else, so a
// toggle anywhere but the top level of the document goes to disk as something the next open of the
// file reads back as one raw block: the bytes are kept and both constructs stop being editable.
describe("a toggle the bridge could not read back", () => {
const CALLOUT = "> [!NOTE]\n> Callout body.\n";
it("is not made inside a callout", () => {
const editor = editorFor(CALLOUT);
editor.commands.setTextSelection(endOf(editor, "Callout body."));
const before = editor.state.doc.toJSON();
editor.chain().toggleWrap("toggle").run();
expect(editor.state.doc.toJSON()).toEqual(before);
expect(written(CALLOUT, editor)).toBe(CALLOUT);
editor.destroy();
});
it("is not made inside another toggle", () => {
const editor = editorFor(PAGE_DOC);
editor.commands.setTextSelection(endOf(editor, "Toggle body."));
const before = editor.state.doc.toJSON();
// Attributes the toggle already there does not carry, because that is the call that nests:
// TipTap's isNodeActive wants them to match before it takes a second press as taking one out,
// so anything else wraps a second toggle around the inside of the first.
editor.chain().toggleWrap("toggle", { open: false }).run();
expect(editor.state.doc.toJSON()).toEqual(before);
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC);
editor.destroy();
});
// The rule is about a toggle being put somewhere, and an edit inside one that is already there
// reaches into the same node without moving it. Reading that as a toggle being nested would
// refuse every keystroke in every toggle body in the document.
it("does not stand in the way of an edit inside a toggle that is already there", () => {
const editor = editorFor(PAGE_DOC);
editor.commands.setTextSelection(endOf(editor, "Toggle body."));
editor.commands.insertContent({ type: "text", text: " More." });
expect(toggleIn(editor).body).toBe("Toggle body. More.");
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC.replace("Toggle body.", "Toggle body. More."));
editor.destroy();
});
it("is still made at the top level, where it is read back as itself", () => {
const editor = editorFor(PAGE_DOC);
editor.commands.setTextSelection(endOf(editor, "First paragraph."));
expect(editor.chain().toggleWrap("toggle").run()).toBe(true);
expect(togglesIn(editor)).toHaveLength(2);
const source = written(PAGE_DOC, editor);
expect(parseMarkdown(source, PATH).doc.childCount).toBe(editor.state.doc.childCount);
editor.destroy();
});
});
+540
View File
@@ -0,0 +1,540 @@
// Toggles: the `<details>` the bridge reads off disk, made into something that can be opened,
// closed and named.
//
// The schema keeps a toggle's summary and its open state as attributes rather than as child nodes,
// because a `<summary>` carrying markup is not modellable and a toggle whose body is content while
// its title is not would be half a node. That decision is what makes this file necessary. An
// attribute is not editable content, so without a node view three things happen and all of them are
// wrong: the browser's own disclosure takes the click and moves the element out of step with the
// node behind it, the `open` the document holds never changes and so never reaches the file, and a
// keystroke aimed at the title lands in the first paragraph of the body instead, which is somebody
// else's sentence quietly rewritten.
//
// So the element built below is the same `<details>` the schema's toDOM describes, and everything
// in it that is not the body is this file's own. ProseMirror is told as much through stopEvent and
// ignoreMutation: nothing outside the body is the document's, nothing typed in the title can be
// read back into the tree, and the two attributes move only through the two commands here.
//
// Native disclosure is cancelled rather than leant on. A `<details>` opens and closes itself on a
// click anywhere in its summary, and the element doing that on its own is the element disagreeing
// with the node, which is the half that gets saved. Cancelling the click is not the whole of it: a
// disclosure is a control, and the browser works one from the keyboard too, so a space or an Enter
// typed anywhere inside the summary arrives here as a click on the row with no press behind it.
// That was every other space in a title flipping the toggle and writing `open` into the file, so
// the flip below asks for a press, or for the row itself holding the keyboard, and takes nothing
// else as consent.
//
// The caret in a title is not in the document. ProseMirror's selection stays wherever it was when
// the caret went in there, so a toolbar button pressed while a title is being typed runs its
// command against a place the user is not looking at, which is somebody else's paragraph edited out
// of sight. A transaction that changes the document is therefore refused while a title holds the
// caret, bar the two this file's own surface makes, and the caret is put back where it was: the
// tools are inert while a title is being typed, which is what they would be if the pill drew them
// disabled. Drawing them disabled is the half of this that belongs to src/editor/Toolbar.tsx.
//
// And a toggle only ever sits among the document's own children. src/markdown/parse.ts pairs
// `<details>` at the top level of a file and nowhere else, so a toggle wrapped around a paragraph
// inside a quote goes to disk as a `<details>` inside a `>` block and comes back as one raw block:
// the bytes are kept, and both constructs stop being editable. An edit whose result this editor
// could not read back is refused rather than offered.
//
// Nothing here escapes anything. The summary attribute holds the title as plain text and the
// serializer turns `&`, `<` and `>` into entities on the way to disk, with the parser undoing
// exactly that on the way back; a node view that wrote markup into the attribute, or read the
// element back with innerHTML, would put an `&amp;` in a title that said `&` and grow another one
// on every save.
import { Extension } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { Plugin, PluginKey, Selection } from "@tiptap/pm/state";
import type { Command, EditorState, Transaction } from "@tiptap/pm/state";
import type { EditorView, NodeView, ViewMutationRecord } from "@tiptap/pm/view";
const NAME = "toggle";
/**
* On the transactions the title and the arrow make, which are the two edits a caret sitting outside
* the document is allowed to produce. A key rather than a string so that nothing else can spell it
* by accident.
*/
const OWN_EDIT = new PluginKey<boolean>("toggleOwnEdit");
/**
* The title the caret is in, or null when it is in the document like any other.
*
* Module level because a transaction is filtered against a state, and a state cannot be asked where
* the caret is when the caret is not in the document. There is one editor and one document, per
* src/editor/index.ts, and every answer taken from this is checked against the editor it came from
* and against the page before it is acted on.
*/
let focused: ToggleView | null = null;
/** The toggle at this position, or null when the document has something else there. */
function toggleAt(state: EditorState, pos: number): ProseMirrorNode | null {
const node = pos >= 0 && pos < state.doc.content.size ? state.doc.nodeAt(pos) : null;
return node && node.type.name === NAME ? node : null;
}
function summaryOf(node: ProseMirrorNode): string {
const value = node.attrs.summary;
return typeof value === "string" ? value : "";
}
/**
* Opens or closes the toggle at `pos`. False when there is no toggle there, or when it already
* reads that way, which is what a second press of a control that is already in that state means.
*
* Closing takes the caret out of the body first. A closed `<details>` does not draw its children,
* so a selection left in there is a caret nobody can see and every keystroke after it goes
* somewhere invisible. It comes out into the same transaction as the close, so the two cannot be
* undone separately. A toggle with nothing either side of it has nowhere to send it, and the node
* view puts the focus in the title instead.
*/
export function setToggleOpen(pos: number, open: boolean): Command {
return (state, dispatch) => {
const node = toggleAt(state, pos);
if (!node || node.attrs.open === open) return false;
if (dispatch) {
const end = pos + node.nodeSize;
const tr = state.tr.setNodeMarkup(pos, null, { ...node.attrs, open });
tr.setMeta(OWN_EDIT, true);
if (!open && state.selection.from > pos && state.selection.from < end) {
// findFrom rather than Selection.near, which falls back to searching the other way when it
// finds nothing and would put the caret back inside the toggle that was just closed.
const out =
Selection.findFrom(tr.doc.resolve(end), 1) ?? Selection.findFrom(tr.doc.resolve(pos), -1);
if (out) tr.setSelection(out);
}
dispatch(tr);
}
return true;
};
}
/**
* Writes the title of the toggle at `pos`, as the plain text it is on the node.
*
* One line, always: the parser reads the opening tag and the title as a two line html block, so a
* newline in the middle of one is a toggle that comes back from disk as unmodellable source. The
* node view keeps Enter and paste from putting one there rather than repairing it here, because a
* title the user can see and the attribute cannot hold is the same disagreement one layer up.
*/
export function setToggleSummary(pos: number, summary: string): Command {
return (state, dispatch) => {
const node = toggleAt(state, pos);
if (!node || summaryOf(node) === summary) return false;
if (dispatch) {
dispatch(state.tr.setNodeMarkup(pos, null, { ...node.attrs, summary }).setMeta(OWN_EDIT, true));
}
return true;
};
}
/**
* Whether this transaction puts a toggle anywhere but among the document's own children.
*
* Asked of the ranges the steps wrote rather than of the whole document, the way math.ts asks where
* a formula ended up, so that the answer costs a keystroke nothing.
*
* Each range is then widened to the whole top level block it lands in, and that is the half this
* needs rather than an optimisation given up. A wrap is a ReplaceAroundStep, and the only bytes it
* rewrites are the two markers it puts either side of the content: the gap between them, which is
* everything it moved a level deeper, is not in the step's map at all. So a toggle dragged over and
* given to the Quote button went a level down inside a range that said nothing had happened to it,
* and the file got a `<details>` inside a `>` block that the next open reads as one raw block.
*
* Widening is cheap because the widened range is the block the caret is in: a keystroke walks its
* own paragraph. A toggle at the top level is visited at depth 0 and is the ordinary case, so
* typing inside one costs a walk of it and nothing else.
*/
function nestsToggle(tr: Transaction): boolean {
let found = false;
for (let step = 0; step < tr.steps.length && !found; step += 1) {
const forward = tr.mapping.slice(step + 1);
tr.mapping.maps[step].forEach((_from, _to, newFrom, newTo) => {
if (found) return;
const size = tr.doc.content.size;
const from = Math.min(size, Math.max(0, forward.map(newFrom, -1)));
const to = Math.min(size, Math.max(from, forward.map(newTo, 1)));
const $from = tr.doc.resolve(from);
const $to = tr.doc.resolve(to);
const start = $from.depth > 0 ? $from.before(1) : from;
const end = $to.depth > 0 ? $to.after(1) : to;
tr.doc.nodesBetween(start, end, (node, pos) => {
if (found) return false;
if (node.type.name === NAME) found = tr.doc.resolve(pos).depth > 0;
return !found;
});
});
}
return found;
}
/**
* The transactions a document is allowed to take, which is all of them bar two kinds of edit that
* would land somewhere nobody aimed at.
*
* The first is anything but this file's own while a title holds the caret, for the reason at the
* top: the selection those commands read is stale by then. The second is a toggle put anywhere the
* bridge could not read one back from.
*
* Both answers are checked against the page and against the editor the transaction is for, so a
* focus event that never got its blur, or a second editor that never existed, can cost this file a
* refusal it should have made and never one it should not have. Letting an edit through is the
* failure that is survivable.
*/
function allowTransaction(tr: Transaction, state: EditorState): boolean {
if (!tr.docChanged) return true;
if (nestsToggle(tr)) return false;
if (focused === null || tr.getMeta(OWN_EDIT) === true) return true;
if (!focused.isFor(state) || !focused.holdsCaret()) return true;
focused.reclaim();
return false;
}
/**
* An edit that leaves the caret inside a collapsed toggle opens it.
*
* The toolbar's Toggle button is why this exists. Wrapping a block makes a toggle with the
* attribute's own default, which is closed, so the paragraph the user just wrapped would vanish
* behind an arrow and read as a deletion. The rule is more general than that one button though: a
* closed toggle draws nothing of its body, and an edit that puts the caret somewhere the user
* cannot see is an edit whose next keystroke disappears.
*
* Only for a transaction that changed the document, and that guard is load bearing rather than an
* optimisation. A selection cannot walk into content the browser is not drawing on its own, and a
* document being installed restores the caret it was last left at: a toggle opened by that would
* be a byte written into a file nobody has edited.
*/
function openAroundSelection(
transactions: readonly Transaction[],
old: EditorState,
state: EditorState,
): Transaction | null {
if (!transactions.some((tr) => tr.docChanged)) return null;
// A title being typed into leaves the selection wherever it was, so the toggle around it is one
// nobody is inside. Opening it would write `open` into the file for a keystroke aimed elsewhere.
if (focused !== null && focused.isFor(old) && focused.holdsCaret()) return null;
const { $from } = state.selection;
let tr: Transaction | null = null;
// Outwards, so a toggle inside a toggle opens along with the one holding it. setNodeMarkup keeps
// a node the size it was, which is what lets these positions stay right across the whole walk.
for (let depth = $from.depth; depth > 0; depth -= 1) {
const node = $from.node(depth);
if (node.type.name !== NAME || node.attrs.open === true) continue;
tr = (tr ?? state.tr).setNodeMarkup($from.before(depth), null, { ...node.attrs, open: true });
}
return tr;
}
/**
* One toggle: the row that opens it and names it, and the body, which is the only part of it that
* is the document.
*
* The title is an editable island. The summary row is declared not editable so that ProseMirror and
* the browser both leave it alone, and the title inside it is declared editable again, which is
* what makes a caret possible there without the text ever being content. Every keystroke in it is a
* transaction like any other, for the reason math.ts gives about its own field: a title the element
* is holding and the document is not is one an autosave writes the previous version of.
*/
class ToggleView implements NodeView {
readonly dom: HTMLElement;
readonly contentDOM: HTMLElement;
private readonly summary: HTMLElement;
private readonly title: HTMLElement;
private readonly view: EditorView;
private readonly getPos: () => number | undefined;
private node: ProseMirrorNode;
/** A press on the row waiting for the click it will become. */
private pressed = false;
constructor(node: ProseMirrorNode, view: EditorView, getPos: () => number | undefined) {
this.node = node;
this.view = view;
this.getPos = getPos;
const owner = view.dom.ownerDocument;
this.dom = owner.createElement("details");
this.dom.className = "toggle";
this.summary = owner.createElement("summary");
this.summary.contentEditable = "false";
this.title = owner.createElement("span");
this.title.setAttribute("data-toggle-summary", "");
this.summary.appendChild(this.title);
this.contentDOM = owner.createElement("div");
this.contentDOM.setAttribute("data-toggle-body", "");
this.dom.append(this.summary, this.contentDOM);
this.summary.addEventListener("mousedown", this.onMouseDown);
this.summary.addEventListener("click", this.onClick);
this.title.addEventListener("focus", this.onFocus);
this.title.addEventListener("blur", this.onBlur);
this.title.addEventListener("beforeinput", this.onBeforeInput);
this.title.addEventListener("input", this.onInput);
this.title.addEventListener("keydown", this.onKeyDown);
this.title.addEventListener("paste", this.onPaste);
this.draw();
}
update(next: ProseMirrorNode): boolean {
if (next.type !== this.node.type) return false;
this.node = next;
this.draw();
return true;
}
/**
* The summary row is this file's, from the arrow to the title. ProseMirror seeing the mousedown
* that puts the caret in the title would put a text selection in the body where the caret was
* going, and the keydowns after it would run the document's commands against that selection.
*/
stopEvent(event: Event): boolean {
const target = event.target;
return target instanceof Node && this.summary.contains(target);
}
/** Nothing outside the body is the document's, so nothing read off it is news to the tree. */
ignoreMutation(mutation: ViewMutationRecord): boolean {
return !this.contentDOM.contains(mutation.target);
}
/** Whether the editor this title is in is the one a transaction is being applied to. */
isFor(state: EditorState): boolean {
return this.view.state === state;
}
/**
* Whether the caret really is in this title, asked of the page rather than of the flag that says
* so. A focus event whose blur never came would otherwise refuse edits nobody was making.
*/
holdsCaret(): boolean {
const active = this.title.ownerDocument.activeElement;
return active !== null && this.title.contains(active);
}
destroy(): void {
if (focused === this) focused = null;
this.summary.removeEventListener("mousedown", this.onMouseDown);
this.summary.removeEventListener("click", this.onClick);
this.title.removeEventListener("focus", this.onFocus);
this.title.removeEventListener("blur", this.onBlur);
this.title.removeEventListener("beforeinput", this.onBeforeInput);
this.title.removeEventListener("input", this.onInput);
this.title.removeEventListener("keydown", this.onKeyDown);
this.title.removeEventListener("paste", this.onPaste);
}
/**
* The caret back in the title, a frame from now, after a command was refused because it was in
* there.
*
* A toolbar button keeps the caret where it is with a preventDefault on its own mousedown and
* then asks the editor to focus, and TipTap's focus command queues that focus for the next frame.
* It lands after the refusal, so without this the caret is dragged out of the title and into the
* selection the refusal was there to protect, and the second press of the same button edits it.
* The range is carried over by hand because focusing an element the caret has left does not put
* it back where it was in the word being typed.
*/
reclaim(): void {
const owner = this.title.ownerDocument;
const win = owner.defaultView;
if (!win) return;
const selection = owner.getSelection();
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
const saved = range && this.title.contains(range.commonAncestorContainer) ? range.cloneRange() : null;
win.requestAnimationFrame(() => {
if (owner.activeElement === this.title) return;
this.title.focus();
if (!saved) return;
const live = owner.getSelection();
if (!live) return;
live.removeAllRanges();
live.addRange(saved);
});
}
/** Everything on the element that comes off the node. */
private draw(): void {
const summary = summaryOf(this.node);
// textContent rather than innerHTML: a title reading "<b>" is those three characters of
// somebody's heading and not the start of a bold run. Written only when it differs, because
// writing it while the caret is in it sends the caret to the end of a word being edited in the
// middle.
if (summary !== this.title.textContent) this.title.textContent = summary;
// A browser leaves a <br> behind when the last character of an editable element goes, which is
// a blank line standing where the placeholder should be.
else if (summary === "" && this.title.firstChild) this.title.textContent = "";
// An empty inline element is nothing to aim at, and a toggle made from the toolbar starts
// without a title. What the prompt says is prose.css's, the way the callout labels are.
this.title.toggleAttribute("data-empty", summary === "");
// Only when it changes. draw runs on every keystroke in the title, and rewriting the attribute
// that makes an element editable under a caret that is already in it is not something to ask a
// browser to do sixty times a sentence.
const editable = this.view.editable ? "true" : "false";
if (this.title.contentEditable !== editable) this.title.contentEditable = editable;
this.dom.toggleAttribute("open", this.node.attrs.open === true);
}
private readonly onFocus = (): void => {
focused = this;
};
private readonly onBlur = (): void => {
if (focused === this) focused = null;
};
/**
* The arrow is the summary's own ::before, so a press that lands on the row itself rather than on
* the title is a press on the chrome. Prevented so it neither focuses the row nor takes the caret
* out of wherever it was in the document; the flip happens on the click, which is also what the
* keyboard sends when the row itself has focus. Remembered, because a press is what tells that
* click apart from the one the browser sends of its own accord.
*/
private readonly onMouseDown = (event: MouseEvent): void => {
this.pressed = event.target === this.summary;
if (this.pressed) event.preventDefault();
};
/**
* A click on the row flips it, when there is a press or a keyboard behind the click.
*
* The browser sends the summary a click of its own every time a space or an Enter is typed inside
* it, because that is how a disclosure is activated from the keyboard, and the caret being in the
* title makes no difference to it. Flipping on one of those closed the toggle under the caret on
* every other space of a title and wrote the new `open` to the file each time.
*/
private readonly onClick = (event: MouseEvent): void => {
event.preventDefault();
const pressed = this.pressed;
this.pressed = false;
if (event.target !== this.summary) return;
if (pressed || this.title.ownerDocument.activeElement === this.summary) this.flip();
};
private flip(): void {
const pos = this.getPos();
// Opening and closing writes `open` to the file, so it is an edit and is refused for the same
// reason typing is while a conflict is being resolved.
if (pos === undefined || !this.view.editable) return;
const open = this.node.attrs.open !== true;
setToggleOpen(pos, open)(this.view.state, this.view.dispatch);
// A toggle that is the whole document has nowhere outside itself to send the caret, so the
// command left it where it was. The title is the one part of a closed toggle still on screen.
if (!open && this.holdsSelection()) this.title.focus();
}
private holdsSelection(): boolean {
const pos = this.getPos();
if (pos === undefined) return false;
const { from } = this.view.state.selection;
return from > pos && from < pos + this.node.nodeSize;
}
/**
* ProseMirror does not rebuild a node view when the editor stops being editable, so this is what
* keeps the title out of a buffer that is not allowed to drift: a document waiting on a conflict
* has already moved on disk, and nothing may be typed into it until the user has said which copy
* wins. `commit` refuses as well, in case the browser declines to cancel the input.
*/
private readonly onBeforeInput = (event: Event): void => {
if (!this.view.editable) event.preventDefault();
};
private readonly onInput = (): void => {
this.commit();
};
private commit(): void {
const pos = this.getPos();
if (pos === undefined || !this.view.editable) return;
setToggleSummary(pos, this.title.textContent ?? "")(this.view.state, this.view.dispatch);
}
private readonly onKeyDown = (event: KeyboardEvent): void => {
// A press on the row that never became a click is stale the moment something is typed, and the
// click the browser sends on a space must not find it waiting.
this.pressed = false;
if (event.key !== "Enter" || event.isComposing) return;
// A newline in a title is a toggle the bridge will not recognise the next time the file is
// opened, so Enter leaves the title for the body, which is where the next thing typed belongs.
event.preventDefault();
this.enter();
};
private enter(): void {
const pos = this.getPos();
if (pos === undefined) return;
setToggleOpen(pos, true)(this.view.state, this.view.dispatch);
const { state } = this.view;
const inside = Selection.findFrom(state.doc.resolve(Math.min(pos + 1, state.doc.content.size)), 1);
if (inside) this.view.dispatch(state.tr.setSelection(inside));
this.view.focus();
}
/**
* A paste goes in as one line of plain text and nothing else.
*
* Left to itself the browser puts the clipboard's own markup in here, and a title holding a bold
* run or a line break is a title whose text content, which is what reaches the attribute, is not
* what is on screen. The two would disagree until the next save decided between them.
*/
private readonly onPaste = (event: ClipboardEvent): void => {
event.preventDefault();
if (!this.view.editable) return;
const text = (event.clipboardData?.getData("text/plain") ?? "").replace(/\s*[\r\n]+\s*/g, " ");
if (!text) return;
const selection = this.title.ownerDocument.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
if (!this.title.contains(range.commonAncestorContainer)) return;
range.deleteContents();
const inserted = this.title.ownerDocument.createTextNode(text);
range.insertNode(inserted);
range.setStartAfter(inserted);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
this.commit();
};
}
export const Toggles = Extension.create({
name: "toggles",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("toggleViews"),
props: {
nodeViews: {
toggle: (node, view, getPos) => new ToggleView(node, view, getPos),
},
},
filterTransaction: allowTransaction,
appendTransaction: openAroundSelection,
}),
];
},
});
+230
View File
@@ -0,0 +1,230 @@
// extensions.ts claims the editor's schema is the contract's schema, node for node. That claim is
// the reason a document the bridge parsed can be edited at all, and it is exactly the kind of
// thing that rots quietly when either side changes, so it is asserted here rather than believed.
import { describe, expect, it } from "vitest";
import { Editor, getSchema } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { createEditorExtensions } from "./extensions";
import { schema as contract } from "../model/schema";
import { parseMarkdown, serializeMarkdown } from "../markdown";
const extensions = () =>
createEditorExtensions({ documentPath: () => "/notes/a.md", onError: () => {} });
const built = getSchema(extensions());
function makeEditor(content: object = { type: "doc", content: [{ type: "paragraph" }] }): Editor {
return new Editor({
element: null,
injectCSS: false,
extensions: extensions(),
content,
});
}
function count(doc: ProseMirrorNode, name: string): number {
let total = 0;
doc.descendants((node) => {
if (node.type.name === name) total += 1;
});
return total;
}
describe("the generated schema", () => {
it("has the contract's nodes and marks, in the contract's order", () => {
expect(Object.keys(built.nodes)).toEqual(Object.keys(contract.nodes));
expect(Object.keys(built.marks)).toEqual(Object.keys(contract.marks));
expect(built.topNodeType.name).toBe("doc");
});
it("carries every node spec field the contract sets", () => {
for (const name of Object.keys(contract.nodes)) {
const want = contract.nodes[name];
const got = built.nodes[name];
expect([name, got.spec.content]).toEqual([name, want.spec.content]);
expect([name, got.spec.group]).toEqual([name, want.spec.group]);
expect([name, got.spec.marks]).toEqual([name, want.spec.marks]);
expect([name, got.isInline]).toEqual([name, want.isInline]);
expect([name, got.isAtom]).toEqual([name, want.isAtom]);
expect([name, got.spec.code]).toEqual([name, want.spec.code]);
expect([name, got.spec.defining]).toEqual([name, want.spec.defining]);
expect([name, got.spec.isolating]).toEqual([name, want.spec.isolating]);
expect([name, got.spec.whitespace]).toEqual([name, want.spec.whitespace]);
expect([name, got.spec.draggable]).toEqual([name, want.spec.draggable]);
expect([name, got.spec.selectable]).toEqual([name, want.spec.selectable]);
expect([name, got.spec.linebreakReplacement]).toEqual([
name,
want.spec.linebreakReplacement,
]);
expect([name, got.spec.tableRole]).toEqual([name, want.spec.tableRole]);
expect([name, Object.keys(got.spec.attrs ?? {})]).toEqual([
name,
Object.keys(want.spec.attrs ?? {}),
]);
for (const attr of Object.keys(want.spec.attrs ?? {})) {
expect([name, attr, got.spec.attrs?.[attr].default]).toEqual([
name,
attr,
want.spec.attrs?.[attr].default,
]);
}
}
});
it("carries every mark spec field the contract sets", () => {
for (const name of Object.keys(contract.marks)) {
const want = contract.marks[name];
const got = built.marks[name];
expect([name, got.spec.inclusive]).toEqual([name, want.spec.inclusive]);
expect([name, got.spec.excludes]).toEqual([name, want.spec.excludes]);
expect([name, got.spec.group]).toEqual([name, want.spec.group]);
expect([name, got.spec.spanning]).toEqual([name, want.spec.spanning]);
expect([name, got.spec.code]).toEqual([name, want.spec.code]);
expect([name, Object.keys(got.spec.attrs ?? {})]).toEqual([
name,
Object.keys(want.spec.attrs ?? {}),
]);
}
});
// A newline inside a paragraph is the author's own line wrap, and the two libraries under this
// editor both have to be told so. prosemirror-view reads this field to decide how to parse the
// editor's own DOM back after a keystroke, and prosemirror-transform reads it before it joins two
// textblocks; with it left at the default both of them take a newline for a line break and put a
// `hardBreak` in its place, which is a backslash per wrap point written into a file whose author
// changed one character. There is no DOM in this suite, so the keystroke half is proved in a
// browser and the join half is proved below; what is here is the field itself, which is the thing
// both halves turn on and the thing a tidy up would take back out.
it("declares a paragraph preformatted, which is what keeps a hand wrap a hand wrap", () => {
expect(built.nodes.paragraph.whitespace).toBe("pre");
expect(built.nodes.paragraph.spec.whitespace).toBe("pre");
// And says the opposite in its parse rule, because a rule's own answer outranks the node's and
// the newlines in a `<p>` off somebody else's page are that html source's indentation. Without
// this, pasting `<p>alpha\n beta</p>` put the newline and the three spaces in the document.
const rules = built.nodes.paragraph.spec.parseDOM ?? [];
expect(rules.map((rule) => [rule.tag, rule.preserveWhitespace])).toEqual([["p", false]]);
});
it("holds a parsed document unchanged, callouts, tasks, tables and raw blocks included", () => {
const source = [
"# Title",
"",
"Some **bold** and `code` and ~~gone~~ and [a link](./other.md 'why').",
"",
"- [ ] a task",
"- [x] a done task",
"",
"> [!WARNING]",
"> careful",
"",
"```ts twoslash",
"const x = 1;",
"```",
"",
"| a | b |",
"| - | -: |",
"| 1 | 2 |",
"",
"<figure><img src='x.png'></figure>",
"",
"<details><summary>More</summary>",
"",
"hidden",
"",
"</details>",
"",
].join("\n");
const parsed = parseMarkdown(source, "/notes/a.md");
const rebound = built.nodeFromJSON(parsed.doc.toJSON());
rebound.check();
expect(rebound.toJSON()).toEqual(parsed.doc.toJSON());
// What the editor hands back to be saved is a node of its own schema, never the bridge's, so
// the serializer has to read node names rather than node types. This is that, asserted.
expect(serializeMarkdown(parsed, rebound)).toBe(serializeMarkdown(parsed, parsed.doc));
});
});
describe("the editor built from them", () => {
it("constructs, with the nodes only this schema has", () => {
const editor = makeEditor();
expect(editor.schema.nodes.callout).toBeTruthy();
expect(editor.schema.nodes.raw).toBeTruthy();
expect(editor.schema.marks.strong).toBeTruthy();
editor.destroy();
});
it("answers every command the toolbar handle is built from", () => {
const editor = makeEditor();
editor.commands.insertContent({ type: "text", text: "hello" });
editor.commands.selectAll();
expect(editor.commands.toggleMark("strong")).toBe(true);
expect(editor.isActive("strong")).toBe(true);
expect(editor.commands.setNode("heading", { level: 3 })).toBe(true);
expect(editor.state.doc.firstChild?.attrs.level).toBe(3);
expect(editor.commands.setNode("paragraph")).toBe(true);
expect(editor.commands.toggleList("taskList", "taskItem")).toBe(true);
expect(editor.state.doc.firstChild?.type.name).toBe("taskList");
expect(editor.commands.toggleList("taskList", "taskItem")).toBe(true);
expect(editor.commands.toggleWrap("blockquote")).toBe(true);
expect(editor.state.doc.firstChild?.type.name).toBe("blockquote");
expect(editor.commands.clearNodes()).toBe(true);
expect(editor.commands.insertContent({ type: "horizontalRule" })).toBe(true);
expect(
editor.commands.insertContent({
type: "table",
content: [
{ type: "tableRow", content: [{ type: "tableHeader" }, { type: "tableHeader" }] },
{ type: "tableRow", content: [{ type: "tableCell" }, { type: "tableCell" }] },
],
}),
).toBe(true);
editor.destroy();
});
it("splits, sinks and lifts the list items Enter and Tab name", () => {
const editor = makeEditor();
editor.commands.insertContent({ type: "text", text: "one" });
expect(editor.commands.toggleList("bulletList", "listItem")).toBe(true);
expect(editor.commands.splitListItem("listItem")).toBe(true);
expect(editor.state.doc.firstChild?.childCount).toBe(2);
expect(editor.commands.sinkListItem("listItem")).toBe(true);
expect(editor.commands.liftListItem("listItem")).toBe(true);
editor.destroy();
});
// The join half of the paragraph's `whitespace` field, which is prosemirror-transform's own
// reading of it and is reached by a Backspace at the start of a paragraph: the most ordinary edit
// there is. Without the field, joining these two rewrote all four of their line wraps as hard
// breaks, so one Backspace put a backslash at the end of four lines the user never touched.
it("joins two hand wrapped paragraphs without rewriting their wraps as breaks", () => {
const source = "one\ntwo\n\nthree\nfour\n";
const parsed = parseMarkdown(source, "/notes/a.md");
const editor = makeEditor(parsed.doc.toJSON());
// The first position inside the second paragraph, which is where Backspace joins from.
const second = editor.state.doc.child(0).nodeSize + 1;
editor.commands.setTextSelection(second);
expect(editor.commands.joinBackward()).toBe(true);
expect(count(editor.state.doc, "hardBreak")).toBe(0);
expect(serializeMarkdown(parsed, editor.state.doc)).toBe("one\ntwothree\nfour\n");
editor.destroy();
});
it("does not carry a ticked box into the item Enter makes after it", () => {
const editor = makeEditor();
editor.commands.insertContent({ type: "text", text: "task" });
expect(editor.commands.toggleList("taskList", "taskItem")).toBe(true);
editor.commands.updateAttributes("taskItem", { checked: true });
expect(editor.commands.splitListItem("taskItem")).toBe(true);
expect(editor.state.doc.firstChild?.lastChild?.attrs.checked).toBe(false);
editor.destroy();
});
});
+182
View File
@@ -0,0 +1,182 @@
// The editor's schema is the contract's schema, node for node and mark for mark.
//
// src/model/schema.ts is frozen and the markdown bridge is written against it: it produces
// callouts, toggles, task lists, tables, math and raw blocks, marks named `strong`, `em` and
// `strikethrough`, a code block that carries the `meta` off its opening fence and lists that
// remember whether they were tight. StarterKit's own nodes are a different schema: no callout, no
// toggle, no raw, `bold` where this one says `strong`, and a code block with nowhere to keep
// `meta`. Adopting them would mean the bridge parsing a document the editor cannot hold, and every
// attribute the two disagree about is a piece of somebody's file quietly lost on the next save.
//
// So the node and mark extensions below are generated from those specs rather than restated: one
// TipTap extension per entry in the contract, carrying that entry's own content expression,
// attributes, parse rules and DOM rendering. A node added to the contract appears here without
// this file being touched, and a node that is not in the contract cannot appear here at all.
//
// StarterKit is still here for the things that are behaviour rather than schema: undo and redo,
// the drop cursor, the gap cursor, and the list backspace and delete handling. Everything it
// contributes to the schema is switched off, `trailingNode` included, because that one appends an
// empty paragraph to the end of a document and an editor that changes a file it was only asked to
// open is the one thing this app must never do.
import { Extension, Mark, Node } from "@tiptap/core";
import type { Extensions, MarkConfig, NodeConfig } from "@tiptap/core";
import type { AttributeSpec } from "@tiptap/pm/model";
import Placeholder from "@tiptap/extension-placeholder";
import StarterKit from "@tiptap/starter-kit";
import { marks as markSpecs, nodes as nodeSpecs } from "../model/schema";
import type { MarkName, NodeName } from "../model/schema";
import { BLOCK_EXTENSIONS } from "./blocks";
import { LinkPicker } from "./linkPicker";
import { createPaste, type PasteContext } from "./paste";
import { Proofing } from "./proofing";
import { SearchHighlight } from "./search";
import { Shortcuts } from "./shortcuts";
const NODE_NAMES = Object.keys(nodeSpecs) as NodeName[];
const MARK_NAMES = Object.keys(markSpecs) as MarkName[];
/** Enter at the end of a finished task starts an unfinished one. Every other attribute carries. */
const RESET_ON_SPLIT: ReadonlySet<string> = new Set(["taskItem.checked"]);
function attributesFrom(owner: string, attrs: Record<string, AttributeSpec> | undefined) {
if (!attrs) return null;
return Object.fromEntries(
Object.entries(attrs).map(([name, spec]) => [
name,
{
default: "default" in spec ? spec.default : null,
validate: spec.validate,
// The spec's own toDOM already writes what the DOM needs under the names the DOM uses,
// so rendering the attribute again under its schema name would put `kind="note"` next to
// `data-callout="note"` on the same element.
rendered: false,
keepOnSplit: !RESET_ON_SPLIT.has(`${owner}.${name}`),
},
]),
);
}
function nodeExtension(name: NodeName) {
const spec = nodeSpecs[name];
const config: Partial<NodeConfig> = {
name,
topNode: name === "doc",
content: spec.content,
marks: spec.marks,
group: spec.group,
inline: spec.inline,
atom: spec.atom,
selectable: spec.selectable,
draggable: spec.draggable,
code: spec.code,
whitespace: spec.whitespace,
linebreakReplacement: spec.linebreakReplacement,
defining: spec.defining,
isolating: spec.isolating,
};
const attributes = attributesFrom(name, spec.attrs);
if (attributes) config.addAttributes = () => attributes;
const { parseDOM, toDOM } = spec;
if (parseDOM) config.parseHTML = () => parseDOM;
if (toDOM) config.renderHTML = ({ node }) => toDOM(node);
return Node.create(config);
}
function markExtension(name: MarkName) {
const spec = markSpecs[name];
const config: Partial<MarkConfig> = {
name,
inclusive: spec.inclusive,
excludes: spec.excludes,
group: spec.group,
spanning: spec.spanning,
code: spec.code,
};
const attributes = attributesFrom(name, spec.attrs);
if (attributes) config.addAttributes = () => attributes;
const { parseDOM, toDOM } = spec;
if (parseDOM) config.parseHTML = () => parseDOM;
// The second argument is ProseMirror's "is this mark on inline content", which TipTap's
// renderHTML does not carry and no mark in the contract reads.
if (toDOM) config.renderHTML = ({ mark }) => toDOM(mark, true);
return Mark.create(config);
}
/**
* `tableRole` is the one field a NodeSpec can carry that TipTap does not have a config key for,
* and prosemirror-tables reads it by that exact name. Nothing in this build reads it yet; it is
* here so the sentence at the top of this file stays true rather than nearly true.
*/
const SchemaExtras = Extension.create({
name: "schemaExtras",
extendNodeSchema(extension) {
const spec = nodeSpecs[extension.name as NodeName];
return spec && spec.tableRole ? { tableRole: spec.tableRole } : {};
},
});
export function createEditorExtensions(context: PasteContext): Extensions {
return [
StarterKit.configure({
blockquote: false,
bold: false,
bulletList: false,
code: false,
codeBlock: false,
document: false,
hardBreak: false,
heading: false,
horizontalRule: false,
italic: false,
link: false,
listItem: false,
orderedList: false,
paragraph: false,
strike: false,
text: false,
underline: false,
trailingNode: false,
}),
...NODE_NAMES.map(nodeExtension),
...MARK_NAMES.map(markExtension),
SchemaExtras,
Placeholder.configure({
emptyEditorClass: "editor-empty",
emptyNodeClass: "block-empty",
placeholder: ({ node }) => (node.type.name === "heading" ? "" : "Start writing…"),
}),
SearchHighlight,
// Spelling underlines the whole document and opens a menu over a word; like SearchHighlight it
// only ever draws, so it sits beside it rather than among the block lanes. It claims no paste
// and no drop, which is what keeps it from ever getting in front of the clipboard guard below.
Proofing,
createPaste(context),
Shortcuts,
// The `[[` picker. Its position in this array does no work: it sets priority 500, and TipTap
// sorts by priority after reversing, so it is asked after the clipboard guard at 1000 and
// before every block lane at 100. It is listed here because this is where a reader looks for
// it. Do not raise it past the clipboard: that guard has to keep the front of the list.
LinkPicker.configure({ documentPath: context.documentPath }),
// Behaviour for the blocks that need more than a spec: tables, code, math and mermaid. It is
// kept in src/editor/blocks/ rather than here because a generated node extension has nowhere
// to put a node view or a keymap, and because those four are worked on independently of each
// other and of this file. Last in the array on purpose: TipTap reverses extensions when it
// collects plugins, so a lane's keymap is asked before the general one above it.
...BLOCK_EXTENSIONS,
];
}
File diff suppressed because it is too large. Load diff
+421
View File
@@ -0,0 +1,421 @@
// Whether a command may touch the document where the caret is. One rule, in one file, because
// every command that edits has to ask it and the answer is the same for all of them.
//
// There are two halves and they answer two different questions. The first half is about a NODE:
// can this thing go here. `fits` answers for a single position and is the rule itself, `placeable`
// asks it for a whole selection, which is the question a command actually has, and `place` is
// `placeable` and the insert together, so a command that goes through it cannot be written without
// the guard because there is no insert in it to forget to guard.
//
// The second half is about a COMMAND: may this edit happen here at all. That question had no
// answer here for two milestones and every bug in the file's history came out of the gap. A block
// conversion asks `setNode` whether the new type fits and never asks whether the old block was the
// user's own bytes, so the Heading tool reformatted a raw block into escaped markdown. A paste
// asks nothing at all unless it is carrying an image, so a plain Cmd+V split a table, a fence and
// a raw block open. `change`, `markable` and `breakable` are that missing half, and they are
// written the same way: the guard and the edit in one call, so the guard is not a line somebody
// adds afterwards.
//
// The three rules the second half enforces, every one of them somebody's file rather than a
// tidiness preference:
//
// A raw block is refused every structural edit. Its bytes are the file's own and conventions.md
// makes that an absolute guarantee, so a conversion, a wrap or a paste of blocks that would
// reformat or split one does not happen. Typing in it is still an ordinary edit: the block is
// shown as an editable field and the text content is what wins once the user has touched it.
//
// An edit may not take away a wrapper whose edges the user cannot see. A callout carries its
// kind and a toggle carries its summary as attributes, so lifting a paragraph out of one deletes
// text that was never selected and never on screen as content. Rather than predict which command
// will lift, `change` builds the transaction, looks at what it did and throws it away if a
// wrapper went missing, which holds however TipTap chooses to implement the command next.
//
// And nothing is left on screen that the file cannot write down. That one is the serializer's
// answer rather than the schema's, and there are two constructs it is asked about. A line break,
// which a table cell and a heading below the two underlined levels both swallow on the way to
// disk, and which the two underlined levels swallow their own marker over when it is the last
// thing in the heading. And a blank line, which every block here can hold except the one whose
// bytes have nothing around them: a blank line is what ends an html block, so one inside a raw
// block is the next open of the file finding pieces of prose where the preserved construct was.
//
// src/editor/fits.test.ts enumerates every entry point the running editor has, not just the
// inserts, and fails when one is added without an answer recorded for each hostile context. The
// enumeration is the point: this project has now shipped a shared guard wired into some of its
// callers and not others three times, and each time the callers that were missed were the ones
// nobody had thought to list.
import { CommandManager } from "@tiptap/core";
import type { ChainedCommands, Editor } from "@tiptap/core";
import type { MarkType, Node as ProseMirrorNode, NodeType, ResolvedPos, Slice } from "@tiptap/pm/model";
import type { EditorState, Transaction } from "@tiptap/pm/state";
import { CellSelection } from "@tiptap/pm/tables";
/**
* The block that is the file's own bytes, named here because the whole of the second half turns on
* it. src/model/schema.ts is frozen, so this string is a contract and not a guess.
*/
const RAW = "raw";
/**
* The wrappers that carry text the user cannot select.
*
* A callout's kind is the `[!NOTE]` line and a toggle's summary is its title: both are on screen,
* neither is content, and an edit that lifts a block out of one takes them with it without the
* selection ever having covered them.
*/
const OPAQUE = ["callout", "toggle"] as const;
/** A line with nothing but spaces on it, which is what ends an html block in markdown. */
const BLANK_LINE = /\n[ \t]*\n/;
/**
* The rectangle of whole cells a drag across a table makes, named once so that the four questions
* below and src/editor/blocks/tables.ts ask it in the same words.
*
* It is a selection of containers rather than of text, and that is what makes it dangerous: an
* insert over one does not replace what is selected, it replaces the content of every cell in the
* rectangle, so six cells of somebody's table become five empty ones and whatever arrived.
*/
export function overCells(state: EditorState): boolean {
return state.selection instanceof CellSelection;
}
/**
* True when a block of this type can go at this position without something being torn open to make
* room for it.
*
* A table cell holds inline content and nothing else, and it is isolating. Asked to insert a block
* there anyway, ProseMirror does the only thing left and splits the table in two around it, which
* leaves a row with no cells behind and a table the serializer cannot write: on the next save that
* empty table goes out as three blank lines and the round trip stops being stable. The raw block is
* isolating for the same reason and a better one, since its whole job is handing back bytes nobody
* has touched.
*
* A code block is not isolating and would take the insert: it would be cut in half and the new
* block put between the pieces. That is somebody's code rewritten by a button that promised to add
* something else, so a fence is a no as well.
*
* All of these are places a block cannot go, and the honest answer where a block cannot go is that
* the button does nothing.
*/
export function fits($pos: ResolvedPos, type: NodeType): boolean {
for (let depth = $pos.depth; depth >= 0; depth -= 1) {
const node = $pos.node(depth);
const index = $pos.index(depth);
if (node.canReplaceWith(index, index, type)) return true;
if (node.type.spec.isolating || node.type.spec.code) return false;
}
return false;
}
/**
* The same question asked of a whole selection, which is the one a command has.
*
* Both ends, because a selection can start somewhere a block fits and end somewhere it does not,
* and an insert takes out everything between them.
*
* A cell selection is refused outright, whatever the type. It is the rectangle of whole cells a
* drag across a table makes, and inserting over it does not put a block anywhere: it replaces the
* content of every cell in the rectangle, so six cells of somebody's text become one node and five
* empty cells. An inline formula passes `fits` in a cell perfectly happily, which is correct for a
* caret and catastrophic for a drag, and that gap is how the same insert lost six cells of real
* text in an editor whose block inserts were already guarded.
*/
export function placeable(state: EditorState, type: NodeType): boolean {
if (overCells(state)) return false;
return state.selection.ranges.every((range) => fits(range.$from, type) && fits(range.$to, type));
}
/**
* The guard and the insert in one call: refuses where the node cannot go, and otherwise runs the
* caller's own chain. True when the document actually changed.
*
* The chain is the caller's because the five inserts do five different things with it, from a rule
* that is one node to a table that has to put the caret in its first cell afterwards. What they
* share is this: focus first, ask before touching anything, and answer for what happened to the
* document rather than for what the chain returned. A chain answers for `focus` as well, and focus
* reports false in any editor that has no view, so a chain's own answer is false in every headless
* test and in the app's first insert after a click on the toolbar.
*/
export function place(
editor: Editor,
type: NodeType,
insert: (chain: ChainedCommands) => void,
): boolean {
if (!placeable(editor.state, type)) return false;
const before = editor.state.doc;
const chain = editor.chain().focus();
insert(chain);
chain.run();
return editor.state.doc !== before;
}
/**
* What a structural edit does to the blocks it runs over, which is what decides where it may run.
*
* "convert" changes what a block IS and leaves it where it is: a heading becomes a paragraph, a
* paragraph becomes a fence. Nothing it does is supposed to move a block out of what holds it, so
* a wrapper going missing is that command failing rather than that command working.
*
* "wrap" puts blocks inside a wrapper. It changes nesting on purpose, but only its own: a list
* button makes a list and a quote button makes a quote, and neither of them was pressed to take a
* callout or a toggle away. A selection dragged from inside a toggle down into the paragraph under
* it and then given to the Bulleted list button used to lift both out and delete the toggle, and
* the title in its summary went with it: text on screen, never selected, gone from the file.
*
* "unwrap" is the one that may, and it is the button for that wrapper: the Toggle button pressed
* inside a toggle removes it, the Callout menu's own entry turns a callout back into a quote, and
* the summary or the label going with it is the edit the user asked for and can undo. Three values
* and no more, because a vocabulary a caller can pick a fourth entry from is a vocabulary that
* ends up meaning nothing.
*/
export type Change = "convert" | "wrap" | "unwrap";
/** Whether this position is inside the one block whose bytes are the file's own. */
function inRaw($pos: ResolvedPos): boolean {
for (let depth = $pos.depth; depth > 0; depth -= 1) {
if ($pos.node(depth).type.name === RAW) return true;
}
return false;
}
/** Whether the selection is inside, or reaches across, a raw block. */
function touchesRaw(state: EditorState): boolean {
return state.selection.ranges.some(({ $from, $to }) => {
if (inRaw($from) || inRaw($to)) return true;
let found = false;
state.doc.nodesBetween($from.pos, $to.pos, (node) => {
if (node.type.name === RAW) found = true;
return !found;
});
return found;
});
}
function countOf(doc: ProseMirrorNode, name: string): number {
let total = 0;
doc.descendants((node) => {
if (node.type.name === name) total += 1;
});
return total;
}
/**
* Whether a structural edit may run over this selection at all.
*
* A raw block is the file's own bytes and refuses all of them: converted, its source comes back as
* escaped markdown, and wrapped, it comes back prefixed. Both are the exact thing conventions.md
* says never happens.
*
* A cell selection is refused for the reason `placeable` gives, and for one more: a cell holds
* inline content, so there is no block in a rectangle of them for a conversion to act on, and what
* a conversion does when it cannot act is fall back to lifting, which takes the table apart.
*/
export function changeable(state: EditorState): boolean {
if (overCells(state)) return false;
return !touchesRaw(state);
}
/**
* Whether the file can hold a line break inside this block.
*
* The only question in the file that the schema cannot answer, so it is the only one written
* against the serializer instead. A hard break is inline content and both of the blocks below hold
* inline content, so the schema is perfectly happy; markdown is not.
*
* A GFM cell is one line, and src/markdown/serialize.ts flattens a break inside one into the space
* a cell can hold. A heading is one line too, except at the two levels that have a setext
* spelling: `#### a` has nowhere to put the second line, so mdast writes the break out as a space,
* while an underlined heading keeps it.
*
* Either way nothing is lost that the user typed, and either way the editor is drawing a line the
* next open of the file will not have. An editor showing a construct the file silently swallows is
* an editor lying about what was saved.
*/
function holdsBreak(parent: ProseMirrorNode): boolean {
const role = parent.type.spec.tableRole;
if (role === "cell" || role === "header_cell") return false;
if (parent.type.name === "heading") return parent.attrs.level <= 2;
return true;
}
/**
* And whether the file can hold one with nothing after it, which is a different question and the
* one that cost a heading.
*
* A break is written as a backslash and a line ending, so a block that ends with one ends with a
* backslash on a line of its own. In prose that is a stray character the next thing the user types
* takes back, which is why it is not counted below. In a heading it is not: the two levels that
* can hold a break at all hold it because they have an underlined spelling, and the underline goes
* under the LAST line of the heading. There is no last line after a trailing break, so mdast writes
* no underline, and `# Title` with Shift+Enter pressed at the end of it goes to disk as `Title\`
* with the marker gone and the backslash left in the user's words. Reopened, it is a paragraph.
*
* Confirmed by running it, at both levels and through the real serializer, rather than reasoned
* about: "# Title\n\nprose\n" came back as "Title\\\n\n\n\nprose\n".
*/
function holdsTrailingBreak(parent: ProseMirrorNode): boolean {
return parent.type.name !== "heading";
}
/**
* The breaks in this document that the next save will swallow or spell wrong.
*
* A break with nothing after it in its block is usually not one of them: that is the half typed
* line somebody is in the middle of, it has no spelling either, and the next character they type
* makes it a real break. The exception is the heading above, where a trailing break does not wait
* to be finished: it takes the heading's own marker with it on the very next save.
*/
function strandedBreaks(doc: ProseMirrorNode): number {
let total = 0;
doc.descendants((parent) => {
const held = holdsBreak(parent);
const trailing = holdsTrailingBreak(parent);
if (held && trailing) return;
parent.forEach((child, _offset, index) => {
if (child.type.name !== "hardBreak") return;
const last = index === parent.childCount - 1;
if (last ? !trailing : !held) total += 1;
});
});
return total;
}
/**
* Builds what the caller's commands would do, without any of it reaching the document.
*
* The chain is given its own transaction, and a chain built that way is TipTap's own way of not
* dispatching: the commands run, the steps land on the transaction, and nothing is handed to the
* view. So the result can be looked at before it is a document rather than after, which is what
* lets `change` answer for what a command did instead of predicting what it will do.
*/
function trial(editor: Editor, run: (chain: ChainedCommands) => void): Transaction {
const manager = new CommandManager({ editor, state: editor.state });
const tr = editor.state.tr;
const chain = manager.createChain(tr);
// Focus is in here rather than around it because it belongs to the same undo step as the edit,
// and because a toolbar click has taken focus out of the document by the time this runs.
chain.focus();
run(chain);
chain.run();
return tr;
}
/**
* The guard, the edit and the check afterwards in one call. True when the document changed.
*
* Refuses outright where `changeable` says no. Otherwise it builds the edit, and for everything
* but the button that names the wrapper it also refuses the finished transaction when a callout or
* a toggle came out of it that was there before. Two commands got there: the Heading menu's
* Paragraph item, which TipTap answers by lifting the block out of everything holding it once the
* block is already a paragraph, and the list buttons over a selection that starts inside a toggle
* and ends outside it, which lift the same way. Both deleted a toggle and the words in its summary.
*
* Answering on the transaction rather than on the command is the point. There is no list here of
* which commands lift and which do not, so a command that starts lifting in some later version of
* TipTap is refused by this on the day it does, rather than on the day somebody notices a file has
* lost a paragraph.
*/
export function change(
editor: Editor,
kind: Change,
run: (chain: ChainedCommands) => void,
): boolean {
if (!changeable(editor.state)) return false;
const before = editor.state.doc;
const tr = trial(editor, run);
if (!tr.docChanged) return false;
if (kind !== "unwrap" && OPAQUE.some((name) => countOf(tr.doc, name) < countOf(before, name))) {
return false;
}
// And the block the content lands in has to be able to write down what is in it. A fence of two
// lines turned into a heading is two lines in a heading, which only the two levels with an
// underlined spelling can hold: the deeper four write the break out as a space, so the second
// line would be on screen and gone from the file, which is `breakable` refusing Shift+Enter in
// the same block, arrived at from the other direction.
if (strandedBreaks(tr.doc) > strandedBreaks(before)) return false;
editor.view.dispatch(tr);
return true;
}
/**
* Whether this mark can exist over the selection.
*
* A fence and a raw block both declare `marks: ""`, so a link in one is not a link the schema can
* hold. ProseMirror already knows that and quietly drops the mark, which is the right answer for a
* command that only adds a mark; it is the wrong answer for the Link tool with a collapsed caret,
* because that one inserts the url as TEXT and then marks it, and the text lands whether the mark
* does or not. That is how a fence gained the characters "https://x.test" on the end of somebody's
* line of code.
*/
export function markable(state: EditorState, type: MarkType): boolean {
return state.selection.ranges.every(
({ $from, $to }) => $from.parent.type.allowsMarkType(type) && $to.parent.type.allowsMarkType(type),
);
}
/**
* Whether the blocks the selection touches can hold this text.
*
* The second question here that the schema is perfectly happy about and the file is not. A raw
* block is written out as its own bytes with nothing around them: no fence, no marker, nothing
* that says where it ends. A blank line is what ends an html block in markdown, so a raw block
* with one in it is not a raw block the next time the file is opened, it is however many pieces
* the blank lines cut it into, and the construct it was preserving is gone with it.
*
* Measured rather than reasoned about: a Cmd+V of "# Pasted\n\n- one\n- two\n" with the caret in
* `<Chart data={points} title="Sales" />` put those bytes through the middle of the tag, and the
* file reopened as a heading, two paragraphs and a list with no raw block anywhere in it.
*
* Text with no blank line in it is fine everywhere, which is what the first line says: a fence and
* a raw block are `whitespace: "pre"` so an ordinary newline stays a newline, and the serializer
* writes a newline inside a table cell as the space a GFM cell can hold.
*/
export function holdsText(state: EditorState, text: string): boolean {
if (!BLANK_LINE.test(text.replace(/\r\n?/g, "\n"))) return true;
return state.selection.ranges.every(
({ $from, $to }) => $from.parent.type.name !== RAW && $to.parent.type.name !== RAW,
);
}
/**
* Whether the file can hold a line break where the selection is.
*
* Two questions rather than one, and the second is about the position and not the block. The break
* lands where the selection starts and takes everything up to where it ends with it, so what
* follows the break afterwards is whatever followed the selection's far end: nothing, when that end
* is already at the end of its block. A break with nothing after it is the one `holdsTrailingBreak`
* refuses, and refusing it is the whole of Shift+Enter at the end of a level 1 or 2 heading.
*/
export function breakable(state: EditorState): boolean {
if (!placeable(state, state.schema.nodes.hardBreak)) return false;
return state.selection.ranges.every(
({ $from, $to }) =>
holdsBreak($from.parent) &&
holdsBreak($to.parent) &&
(holdsTrailingBreak($from.parent) || $to.parentOffset < $to.parent.content.size),
);
}
/**
* Whether putting this slice in the document would put a block boundary in with it.
*
* The question a paste and a drop have, and the one neither of them was asking. A slice of whole
* blocks dropped where blocks do not fit is not refused by ProseMirror: it splits whatever it
* landed in and puts the pieces either side, so a table becomes two tables around a stray
* paragraph, a fence becomes two fences, and a raw block becomes two halves of somebody's html
* with prose in the middle. All three were reachable with an ordinary Cmd+V.
*
* A slice open at both ends with one child in it is the shape the clipboard produces for a
* fragment of a line, and it carries no boundary: it merges into the block it lands in, which is
* what a paste of a few words into a cell should do. Anything else, a second block or an end that
* is closed, is a boundary and belongs to the caller's guard.
*/
export function carriesBlocks(slice: Slice): boolean {
const first = slice.content.firstChild;
if (!first) return false;
if (first.isInline) return false;
return !(slice.content.childCount === 1 && slice.openStart > 0 && slice.openEnd > 0);
}
+195
View File
@@ -0,0 +1,195 @@
// The editor layer's public surface, which is everything the shell is allowed to know about it.
// TipTap and ProseMirror live below this line and nothing above it imports them: the shell renders
// a document and drives a toolbar, and neither of those is a reason for an editor instance to leak
// into a component that draws a button.
//
// There is one editor and one document. No tab bar, nothing rendering two of these, and no
// component reaching in to push content at an editor that is already open.
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import type { CalloutKind, HeadingLevel, MarkdownDocument } from "../model/doc";
import type { MarkName } from "../model/schema";
import { useDocumentFind as useMarkdownFind } from "./Editor";
import { usePlainTextFind } from "./PlainTextEditor";
import type { SearchOptions } from "./search";
export type { SearchOptions };
/**
* A block a toolbar button can turn the current one into. Headings and callouts are not here
* because they carry a variant and have their own setters, and a table is an insert rather than a
* conversion.
*/
export type BlockCommand =
| "paragraph"
| "bulletList"
| "orderedList"
| "taskList"
| "blockquote"
| "codeBlock"
| "toggle";
/**
* The kind of block the cursor is in, named the way the schema names it. `raw` and `mathBlock` can
* be reported but not asked for: the bridge produces them and no button does.
*/
export type BlockKind = BlockCommand | "heading" | "callout" | "table" | "mathBlock" | "raw";
/**
* An edit to the table the cursor is in. The align ops set the GFM delimiter row's alignment for
* the whole column the cursor is in, since markdown has no per cell alignment; `alignClear` puts
* the column back to the delimiter row's default.
*
* There is no header row op. A GFM table has exactly one header row, it is the first one, and there
* is no spelling for a table without one, so a toggle would be an edit the file cannot hold and the
* next open of it would silently take back. src/editor/blocks/tables.ts keeps every other op to
* that same shape instead.
*/
export type TableOp =
| "addRowBefore"
| "addRowAfter"
| "deleteRow"
| "addColumnBefore"
| "addColumnAfter"
| "deleteColumn"
| "deleteTable"
| "alignLeft"
| "alignCenter"
| "alignRight"
| "alignClear";
/**
* What the toolbar draws its pressed states from. A new object on every selection or document
* change, which is what keeps the pill live without it polling anything.
*/
export interface EditorActiveState {
/** The marks under the cursor, or the marks covering the whole of a selection. */
marks: readonly MarkName[];
/** The innermost block the cursor is in. A cursor inside a list item reports the list itself,
* since the list is what the button the user pressed produced. */
block: BlockKind;
/** Set only when `block` is "heading". */
headingLevel: HeadingLevel | null;
/** Set only when `block` is "callout". */
callout: CalloutKind | null;
/** Whether the cursor is anywhere inside a table, which is what the row and column controls are
* enabled by. Not the same question as `block`: a table nested in a callout reports the table,
* but the cell the cursor is in is several levels down from it. */
inTable: boolean;
/** Set only when `block` is "codeBlock". null is a fence with no language on it. */
codeLanguage: string | null;
}
/**
* What the sticky bottom toolbar drives. Deliberately not TipTap's `Editor`: handing the shell an
* editor instance would make every button a place the editor's API leaks out, and the pill would
* end up encoding the schema a second time.
*/
export interface EditorHandle {
active: EditorActiveState;
/** Puts the cursor back where it was. Every button calls this, because clicking one takes focus
* out of the document and a formatting command without a selection has nothing to act on. */
focus: () => void;
toggleMark: (mark: MarkName) => void;
/** null clears the link across the selection. */
setLink: (href: string | null, title?: string | null) => void;
/** Turns the block the cursor is in into this one. Asking for the block it already is turns it
* back into a paragraph, which is what a second press of the same button means. */
setBlock: (block: BlockCommand) => void;
/** null turns a heading back into a paragraph. */
setHeading: (level: HeadingLevel | null) => void;
/** null turns a callout back into the ordinary blockquote it is on disk. */
setCallout: (kind: CalloutKind | null) => void;
insertRule: () => void;
/** Whether an image could go where the cursor is, asked before any bytes are written to disk.
* The Insert image tool has to write the picture into the user's assets folder before it has a
* path to insert, so a refusal after the write is a file sitting beside their document that
* nothing refers to and nobody was told about. */
canInsertImage: () => boolean;
/** `src` is written into the file as it stands, so it is a path relative to the document. */
insertImage: (src: string, alt?: string | null) => void;
insertTable: (rows: number, columns: number) => void;
/** Edits the table the cursor is in. Does nothing when it is not in one, so the caller can ask
* without checking `active.inTable` first. */
tableCommand: (op: TableOp) => void;
/** `display` inserts a mathBlock rather than an inline formula. Both start with a placeholder
* formula in them, selected, so the first keystroke replaces it. Neither starts empty: an empty
* formula is `$$$$` on disk, which is not a formula when the file is read back, so a box the
* editor draws and the file cannot hold is a box that disappears on the next save. */
insertMath: (display: boolean) => void;
/** A mermaid diagram is a fenced code block, so this inserts an empty ```mermaid fence. */
insertMermaid: () => void;
/** The language on the fence the cursor is in. null leaves a bare fence. Whatever the fence
* carried after its language is untouched, since the editor has no model for it. */
setCodeLanguage: (language: string | null) => void;
}
/** Zero based, so a bar showing "3 of 12" draws `current + 1` of `count`. */
export interface FindState {
count: number;
current: number;
}
/**
* Find and replace across the open document, whichever surface it is open in. Highlighting is
* whatever the surface can show without touching the file (a ProseMirror decoration in the
* markdown editor, the browser's own selection in the plain text one) and replacing is an ordinary
* edit, so a search can never write anything by itself.
*
* Structurally the `DocumentFind` that src/components/FindBar.tsx declares it needs, so the bar
* takes what `useDocumentFind` returns with nothing in between adapting one shape to the other, and
* nothing in it about which editor produced it.
*/
export interface DocumentFind {
/** A new object whenever the count or the position in it changes. */
state: FindState;
setQuery: (query: string, options: SearchOptions) => void;
clear: () => void;
next: () => void;
prev: () => void;
replaceCurrent: (text: string) => void;
replaceAll: (text: string) => void;
/** Puts the cursor back in the document, which every replace has to do to be worth anything. */
focus: () => void;
}
export interface EditorProps {
/** The document to edit, already parsed by the bridge. A new object identity means a different
* file or a reload from disk, never a keystroke: while a document is open the editor owns its
* tree and nothing outside pushes changes into it. */
document: MarkdownDocument;
/** Every change to the tree, as it happens. The shell turns this into a dirty flag and a
* debounced save. The editor never writes to disk itself, and never renames anything, whatever
* the first heading now says. */
onChange: (doc: ProseMirrorNode) => void;
/** A click on a link inside the document. Resolving it belongs to the shell: a relative link to
* another markdown file is a navigation, and anything else goes to the system. */
onOpenLink: (href: string) => void;
/** False while a conflict is being resolved, so the buffer cannot drift further from what is on
* disk while the user decides which copy wins. Defaults to true. */
editable?: boolean;
}
/** The same pair, for the .txt surface, which has no links to open and no toolbar to drive. */
export type PlainTextProps = Omit<EditorProps, "onOpenLink">;
/** The document surface itself. */
export { DocumentEditor, useEditorHandle } from "./Editor";
/** The .txt surface. Which of the two to render comes from `documentKindForPath`. */
export { PlainTextEditor } from "./PlainTextEditor";
/**
* Find and replace for whichever surface is on screen. There is one editor and one document, so
* exactly one of the markdown handle and the plain text handle is ever non-null at a time; this is
* only the seam that spares FindBar.tsx from asking `documentKindForPath` to find out which one.
*
* Both hooks are called on every render, unconditionally: `??` on the values they return, not on
* the calls themselves, because a hook skipped on some renders and not others is a Rules of Hooks
* violation the moment the document kind changes.
*/
export function useDocumentFind(): DocumentFind | null {
const markdown = useMarkdownFind();
const plain = usePlainTextFind();
return markdown ?? plain;
}
+720
View File
@@ -0,0 +1,720 @@
// The `[[` picker: two brackets that write an ordinary relative markdown link.
//
// Typing `[` immediately after another `[` opens a list of the documents in every open root, and
// choosing one replaces the run from the first bracket to the caret with `[Title](../notes/x.md)`.
// The brackets are a gesture, not syntax, and nothing in this file can put `[[` into a file. A
// wikilink is a private spelling: a document this app writes has to open, and its links have to
// resolve, in every other markdown tool the folder is ever opened in, and `[[x]]` does neither.
// The href is `relativeFrom`'s answer and only ever `relativeFrom`'s answer, since that function is
// the one place in the app that decides what a link between two documents looks like on disk.
//
// The gesture is read, never driven. This plugin installs no `handleTextInput` and dispatches
// nothing at all while the user types: both brackets and every character of the query go into the
// document through the same ordinary path every other keystroke takes, and this file watches the
// transactions go past. Three things follow from that, and all three are the reason it is written
// this way.
//
// The brackets stay on screen exactly as typed, because nothing rewrites the text under the caret
// while somebody is still typing into it. Cancelling costs nothing and undoes nothing: the document
// already says what the user typed, so closing the picker is forgetting rather than editing, and
// somebody who wanted a literal `[[` in their prose gets to keep it by typing on. And there is no
// insert here to guard, so the only way this file can reach the document at all is the one
// transaction below.
//
// That transaction is the choice, and it is one transaction on purpose. It replaces the whole run
// in a single step and closes the history group in front of itself, so a single undo puts the user
// back with the brackets and the query they had typed, caret included, rather than half of them.
// Two transactions here would be two undos, and two undos is the difference between a feature
// people trust and one they fight.
//
// Where the guard is asked, and why it cannot be missed. `runOf` is the only function that says a
// run is open, and `apply` below is the only caller of it, on every transaction the editor makes.
// So a `[[` in a fence, in a raw block, in an inline code span or anywhere else the file cannot
// hold a link is not a picker that opens and then refuses at the end: it is a picker that never
// opens, and it stops being open the instant the block it is in stops being able to hold a link.
// `choose` asks the same question a second time before it writes, because the answer is what makes
// the write legal and a guard asked once at the start of a gesture is a guard that has not been
// asked at the end of it. This project has now shipped four guards that were correct and never
// reached, and every one of them was a check bolted to one path out of several.
//
// The popup hangs off the caret, is a child of the page body rather than of the document, and is
// therefore clipped by nothing: the scroller, the sheet and every callout, cell and quote in
// between have their own overflow and would each have cut a corner off it. It follows the caret
// while the pane scrolls and closes when the caret scrolls out of the pane, because a list of
// documents left hanging over a document that has moved out from under it is worse than no list.
//
// And when the index is not there, it says so. The Rust side answers `search_quick_open` with an
// error until it has been built, and `quickOpenError` is that error: it goes on screen as it came
// back. Showing an empty list instead would read as "no document matches what you typed", which is
// a different and false statement about the user's own folder.
import { Extension } from "@tiptap/core";
import { closeHistory, isHistoryTransaction } from "@tiptap/pm/history";
import type { MarkType } from "@tiptap/pm/model";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import type { EditorState, Transaction } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
import type { MatchRange } from "../ipc";
import { relativeFrom } from "../links";
import { useDocument } from "../store/useDocument";
import { useSearch } from "../store/useSearch";
import type { QuickOpenHit, QuickOpenPhase } from "../store/useSearch";
import { markable } from "./fits";
/** The gesture, and the single character of it that is watched for. */
const RUN = "[[";
const BRACKET = "[";
/**
* What a query may not contain, which is the whole of "this run is over".
*
* A `]` is somebody closing the brackets themselves and is the explicit cancel. A third `[` starts
* a new gesture rather than continuing this one. A newline means the caret has left the block the
* run started in. The replacement character is what an atom between the brackets and the caret
* reads as through `textBetween` below, and an inline formula or an image in the middle of a query
* is not a query.
*/
const LEAF = "\ufffc";
const OVER = /[[\]\n\ufffc]/;
/**
* Each keystroke crosses into SQLite through the store, so the index is asked once the typing
* pauses rather than once per character. Short enough that a deliberate pause feels like an answer
* rather than a wait.
*/
const DEBOUNCE = 120;
/** How far off the caret the popup sits, and how close to the window edge it may come. */
const GAP = 6;
const EDGE = 8;
/**
* Ahead of every lane and behind src/editor/paste.ts.
*
* TipTap reverses the extension array and then sorts it by priority, so this number and not a
* position in a list is what decides who is asked first. It has to be above the table lane's,
* because prosemirror-tables claims ArrowUp and ArrowDown whenever the caret is on the first or
* last line of a cell and moves to the next row with them: at the default priority a picker opened
* inside a table cell would lose its arrow keys to the table on the very first press, which is the
* one place a picker is most likely to be used. It stays below the clipboard guard's 1000, which
* answers for the whole document and has to keep the front of the list.
*
* The other half of that decision is that this file claims no `handleTextInput`. It does not need
* one, since it reads the transaction rather than the keystroke, and claiming one here would put it
* in front of the table lane's typing guard, which src/editor/blocks/tables.test.ts pins as the
* first plugin in the list that answers a typed character. That guard is what stops one keystroke
* emptying every cell of a dragged rectangle, and being in front of it would be this file quietly
* taking that answer away.
*/
const PRECEDENCE = 500;
/**
* The document the links are written relative to, asked for rather than looked up.
*
* It defaults to the store, which is right in this app because there is one editor and one open
* document, and it is an option so that the editor can hand over the document it is actually
* showing instead. That is the stronger answer and is what src/editor/paste.ts is already given:
* during a switch between files the store and the editor's own props agree only once React has
* rendered, and an href worked out from the wrong end of that is a link to the wrong file written
* into somebody's document.
*/
export type DocumentPath = () => string | null;
/**
* An open run: where it starts, what has been typed into it, and the last answer the index gave.
*
* `from` is the position of the FIRST bracket, so the run the choice replaces is `from` to the
* caret, brackets included. It is document state and is mapped through every transaction like any
* other position.
*
* `answered` is the query `hits` are the answer to, which is not always the query on screen: a
* keystroke moves the query on and the hits do not catch up until the debounce fires. Keeping the
* two apart is what lets the popup show the previous answer rather than flashing empty between
* every letter, and what tells "nothing matches" apart from "nothing has been asked yet".
*/
interface Run {
from: number;
query: string;
answered: string | null;
hits: readonly QuickOpenHit[];
phase: QuickOpenPhase;
error: string | null;
active: number;
}
/**
* What the popup and the keys tell the plugin state. Every one of them is a transaction with no
* steps in it, which is deliberate: TipTap only emits `update` for a transaction that changed the
* document, so opening, moving through and closing this list never dirties the buffer, never
* schedules a save and never enters the undo history.
*/
type Message =
| { kind: "close" }
| { kind: "results"; query: string; hits: QuickOpenHit[]; phase: QuickOpenPhase; error: string | null }
| { kind: "move"; by: number }
| { kind: "point"; at: number };
const key = new PluginKey<Run | null>("linkPicker");
/**
* Whether a link can exist where the selection is.
*
* `markable` is the codebase's own question and answers for the two blocks whose bytes belong to
* the file: a fence and a raw block both declare `marks: ""`, so a link in either is a mark the
* schema throws away while the text it was meant to be on stays behind.
*
* The code span is the case `markable` deliberately says yes to, because `[`x`](y)` is valid
* markdown and the Link tool is allowed to make one. It is still not what somebody typing brackets
* inside backticks meant: `[[` in a code span is on screen as itself, which is the only reason to
* write it there. So the picker declines, and the characters stay literal.
*/
function linkable(state: EditorState, link: MarkType): boolean {
if (!markable(state, link)) return false;
const code = state.schema.marks.code;
if (!code) return true;
return !code.isInSet(state.storedMarks ?? state.selection.$from.marks());
}
/** The character just before `pos` in its own block, or "" at the start of one. */
function charBefore(state: EditorState, pos: number): string {
if (pos < 1 || pos > state.doc.content.size) return "";
const $pos = state.doc.resolve(pos);
if (!$pos.parent.isTextblock || $pos.parentOffset < 1) return "";
return $pos.parent.textBetween($pos.parentOffset - 1, $pos.parentOffset, "\n", LEAF);
}
/**
* The query in the run that starts at `from`, or null when there is no longer a run there.
*
* The single definition of "the picker is open", asked on every transaction, so every way of
* leaving a run ends up here rather than needing a handler of its own: an arrow key or a click out
* of it, a selection dragged across it, a Backspace through the brackets, the block turned into a
* fence by the toolbar, a `]` typed at the end. All of them fail one of these lines.
*
* `textBetween` is given the replacement character for leaves so that an atom sitting in the run
* shows up in the query and is rejected by `OVER`. Left to its default, an inline formula between
* the brackets and the caret would read as nothing at all and the run would look perfectly healthy.
*/
function runOf(state: EditorState, from: number): string | null {
const link = state.schema.marks.link;
if (!link || !linkable(state, link)) return null;
const selection = state.selection;
if (!(selection instanceof TextSelection) || !selection.empty) return null;
if (from < 0 || from + RUN.length > state.doc.content.size) return null;
const $from = state.doc.resolve(from);
const $head = selection.$head;
if (!$from.sameParent($head)) return null;
const parent = $from.parent;
const start = $from.parentOffset;
const end = $head.parentOffset;
if (end < start + RUN.length) return null;
const text = parent.textBetween(start, end, "\n", LEAF);
if (!text.startsWith(RUN)) return null;
const query = text.slice(RUN.length);
return OVER.test(query) ? null : query;
}
/**
* Where a run just opened, or null when this transaction was not the gesture.
*
* The description is of the document rather than of the steps, because the shape of the steps a
* typed character produces is prosemirror-view's business and changes with it. What does not
* change is this: one character went into the document, it went in exactly where the caret was, the
* caret moved on by it, and what stands in front of the caret now is two brackets where there was
* one before. That is the gesture and nothing else is.
*
* The mapping line is the one that is not obvious. A single character inserted anywhere earlier in
* the document also moves the caret on by one and leaves the text around it unchanged, so the four
* other tests pass for an edit the user did not make and was not looking at. Mapped with a bias
* towards the left, a position sitting at an insertion point stays put and a position after one
* moves, which is what proves the character landed under the caret and not somewhere above it.
*
* A paste, a drop and an undo are all excluded. Each of them can put a bracket after a bracket, and
* none of them is somebody typing.
*/
function opened(tr: Transaction, old: EditorState, next: EditorState): number | null {
if (!tr.docChanged || isHistoryTransaction(tr)) return null;
const event = tr.getMeta("uiEvent");
if (event === "paste" || event === "drop" || event === "cut") return null;
if (next.doc.content.size - old.doc.content.size !== 1) return null;
const was = old.selection;
const now = next.selection;
if (!(was instanceof TextSelection) || !was.empty) return null;
if (!(now instanceof TextSelection) || !now.empty) return null;
if (now.head !== was.head + 1) return null;
if (tr.mapping.map(was.head, -1) !== was.head) return null;
if (charBefore(old, was.head) !== BRACKET) return null;
if (charBefore(next, now.head) !== BRACKET) return null;
const from = now.head - RUN.length;
return runOf(next, from) === "" ? from : null;
}
/** The run carried through one transaction, or null when it did not survive it. */
function carried(tr: Transaction, prev: Run, next: EditorState, message: Message | undefined): Run | null {
const from = tr.mapping.map(prev.from, -1);
const query = runOf(next, from);
if (query === null) return null;
let run: Run = prev.from === from && prev.query === query ? prev : { ...prev, from, query };
// A query that has moved on has no answer yet. The hits stay so the list does not flash empty
// between letters, except when there is no query left to have hits for.
if (query !== prev.query) {
run = query === "" ? { ...run, answered: null, hits: [], phase: "idle", error: null, active: 0 } : run;
}
if (message?.kind === "results" && message.query === query) {
return {
...run,
answered: message.query,
hits: message.hits,
phase: message.phase,
error: message.error,
active: 0,
};
}
if (message?.kind === "move" && run.hits.length > 0) {
const count = run.hits.length;
return { ...run, active: (run.active + message.by + count) % count };
}
if (message?.kind === "point" && message.at >= 0 && message.at < run.hits.length) {
return { ...run, active: message.at };
}
return run;
}
/** The link text: the document's filename with its extension taken off. */
function labelOf(hit: QuickOpenHit): string {
const name = hit.name;
const dot = name.lastIndexOf(".");
// A leading dot is not an extension, so a file called `.notes` keeps its whole name.
const stem = dot > 0 ? name.slice(0, dot) : name;
return stem.trim() === "" ? name : stem;
}
function close(view: EditorView): void {
if (!key.getState(view.state)) return;
view.dispatch(view.state.tr.setMeta(key, { kind: "close" } satisfies Message));
}
function move(view: EditorView, by: number): void {
view.dispatch(view.state.tr.setMeta(key, { kind: "move", by } satisfies Message));
}
/**
* The one transaction this file builds: the run replaced by the link, in a single step.
*
* The marks are the ones the brackets themselves were typed in, so a `[[` inside a bold run comes
* out of this as a bold link rather than losing the emphasis around it. The link mark is added to
* that set rather than replacing it, and a link mark already there is replaced by this one, which
* is what a picker used inside existing link text should do.
*
* False when there is nothing legal to write, and false rather than a refusal on screen: the key
* that got here was Enter, and an Enter this file does not use has to still make a paragraph.
*/
function choose(view: EditorView, hit: QuickOpenHit, documentPath: DocumentPath): boolean {
const state = view.state;
const run = key.getState(state);
if (!run) return false;
const from = documentPath();
// No open document means nothing to be relative to, and an href worked out from nothing is a
// link that points at the wrong file rather than at no file.
if (from === null) return false;
const link = state.schema.marks.link;
if (!link || !linkable(state, link)) return false;
const text = labelOf(hit);
if (text === "") return false;
const head = state.selection.head;
const carriedMarks = state.doc.resolve(run.from + 1).marks();
const marks = link.create({ href: relativeFrom(from, hit.path), title: null }).addToSet(carriedMarks);
const tr = state.tr.replaceWith(run.from, head, state.schema.text(text, marks));
tr.setSelection(TextSelection.create(tr.doc, run.from + text.length));
tr.setMeta(key, { kind: "close" } satisfies Message);
// The link is its own undo event. Left to group with the characters of the query it was typed
// over, one Ctrl+Z would take the link and the last letter of the query with it and the user
// would be looking at a run they never typed.
closeHistory(tr);
view.dispatch(tr.scrollIntoView());
return true;
}
/** What the popup says when it has no rows to show, or null when the rows speak for themselves. */
function noteOf(run: Run): string | null {
if (run.phase === "error") return run.error ?? "The search index could not be read";
if (run.query === "") return "Type to find a document";
if (run.hits.length > 0) return null;
return run.answered === run.query ? "No documents match" : "Searching…";
}
/**
* The matched part of a path, marked up without any markup: the ranges are offsets into a string
* that came off the user's disk, and building this with innerHTML would put a filename through the
* HTML parser.
*/
function paintPath(el: HTMLElement, text: string, ranges: readonly MatchRange[]): void {
el.textContent = "";
let at = 0;
for (const range of ranges) {
const start = Math.max(at, Math.min(range.start, text.length));
const end = Math.max(start, Math.min(range.end, text.length));
if (start > at) el.append(text.slice(at, start));
if (end > start) {
const hit = el.ownerDocument.createElement("span");
hit.className = "link-picker-hit";
hit.textContent = text.slice(start, end);
el.append(hit);
}
at = end;
}
if (at < text.length) el.append(text.slice(at));
}
/**
* The list on screen, and the only thing in this file that asks the index anything.
*
* It owns the debounce and the request, and it hands the answer back to the document as a message
* rather than keeping it: the keys need to know how many rows there are to decide whether Enter is
* theirs, and a second copy of that in here would be a second thing to keep in step.
*
* Nothing in `update` may dispatch, since it runs inside the state update that produced it. The
* request that carries a result back is on a timer and is therefore always a later tick, and the
* one case that could have answered immediately, an empty query, is answered by drawing rather
* than by a message.
*/
class Popup {
private readonly view: EditorView;
private readonly documentPath: DocumentPath;
/**
* The window the editor is in, rather than the ambient global one. The popup is a child of that
* window's body and its listeners have to come off the same object they went on to.
*/
private readonly frame: Window | null;
private readonly dom: HTMLElement;
private readonly note: HTMLElement;
private readonly list: HTMLElement;
private rows: HTMLElement[] = [];
private drawn: readonly QuickOpenHit[] | null = null;
private mounted = false;
private requested: string | null = null;
private timer: ReturnType<typeof setTimeout> | null = null;
private seq = 0;
constructor(view: EditorView, documentPath: DocumentPath) {
this.view = view;
this.documentPath = documentPath;
const owner = view.dom.ownerDocument;
this.frame = owner.defaultView;
this.dom = owner.createElement("div");
this.dom.className = "link-picker";
this.dom.setAttribute("data-flip", "down");
this.note = owner.createElement("div");
this.note.className = "link-picker-note";
this.note.setAttribute("aria-live", "polite");
this.dom.appendChild(this.note);
this.list = owner.createElement("div");
this.list.className = "link-picker-list";
this.list.setAttribute("role", "listbox");
this.dom.appendChild(this.list);
// Everything the pointer does in here is the popup's own. A mousedown that reached the page
// would take focus off the document, and the blur that follows closes the picker, so the click
// that chose a document would have cancelled it a moment before choosing.
this.dom.addEventListener("mousedown", (event) => event.preventDefault());
}
update(): void {
const run = key.getState(this.view.state);
if (!run) {
this.hide();
return;
}
if (run.query !== this.requested) this.ask(run.query);
this.show();
this.draw(run);
this.place();
}
destroy(): void {
this.hide();
}
private show(): void {
if (this.mounted) return;
this.mounted = true;
this.view.dom.ownerDocument.body.appendChild(this.dom);
// Capture, because the pane that scrolls under the caret is not the window and a scroll event
// on an element does not bubble up to one.
this.frame?.addEventListener("scroll", this.onScroll, { capture: true, passive: true });
this.frame?.addEventListener("resize", this.onScroll, { passive: true });
}
private hide(): void {
if (!this.mounted) return;
this.mounted = false;
if (this.timer !== null) clearTimeout(this.timer);
// Any answer still on its way belongs to a run that is over, and this is what tells it so.
this.seq += 1;
this.requested = null;
this.dom.remove();
this.frame?.removeEventListener("scroll", this.onScroll, { capture: true });
this.frame?.removeEventListener("resize", this.onScroll);
}
private ask(query: string): void {
this.requested = query;
if (this.timer !== null) clearTimeout(this.timer);
const seq = (this.seq += 1);
if (query === "") return;
this.timer = setTimeout(() => {
// `runQuickOpen` has a sequence guard of its own, so a slow answer to a short query never
// lands on top of a fast answer to a long one. This counter is the other half of the same
// question and cannot be folded into it: it is about whether the run this popup is drawing
// still wants an answer at all.
useSearch
.getState()
.runQuickOpen(query)
.then(
() => {
if (seq !== this.seq) return;
const search = useSearch.getState();
this.answer(query, search.quickOpenHits, search.quickOpenPhase, search.quickOpenError);
},
(error: unknown) => {
if (seq !== this.seq) return;
this.answer(query, [], "error", String(error));
},
);
}, DEBOUNCE);
}
private answer(
query: string,
hits: QuickOpenHit[],
phase: QuickOpenPhase,
error: string | null,
): void {
const run = key.getState(this.view.state);
if (!run || run.query !== query) return;
this.view.dispatch(
this.view.state.tr.setMeta(key, { kind: "results", query, hits, phase, error } satisfies Message),
);
}
private draw(run: Run): void {
if (run.hits !== this.drawn) {
this.drawn = run.hits;
this.rebuild(run.hits);
}
this.rows.forEach((row, index) => {
const on = index === run.active;
row.toggleAttribute("data-active", on);
row.setAttribute("aria-selected", String(on));
});
const active = this.rows[run.active];
if (active) this.reveal(active);
const note = noteOf(run);
this.note.textContent = note ?? "";
this.note.hidden = note === null;
this.note.toggleAttribute("data-error", run.phase === "error");
}
private rebuild(hits: readonly QuickOpenHit[]): void {
const owner = this.dom.ownerDocument;
this.list.textContent = "";
this.rows = hits.map((hit, index) => {
const row = owner.createElement("div");
row.className = "link-picker-row";
row.setAttribute("role", "option");
const name = owner.createElement("span");
name.className = "link-picker-name";
name.textContent = labelOf(hit);
row.appendChild(name);
const path = owner.createElement("span");
path.className = "link-picker-path";
paintPath(path, hit.relPath, hit.ranges);
row.appendChild(path);
row.addEventListener("mousedown", () => {
if (!choose(this.view, hit, this.documentPath)) close(this.view);
});
row.addEventListener("mouseenter", () => {
this.view.dispatch(this.view.state.tr.setMeta(key, { kind: "point", at: index } satisfies Message));
});
this.list.appendChild(row);
return row;
});
this.list.scrollTop = 0;
}
/** The active row brought into the list's own scroll, and never into the page's. */
private reveal(row: HTMLElement): void {
const top = row.offsetTop;
const bottom = top + row.offsetHeight;
if (top < this.list.scrollTop) this.list.scrollTop = top;
else if (bottom > this.list.scrollTop + this.list.clientHeight) {
this.list.scrollTop = bottom - this.list.clientHeight;
}
}
/**
* Where the caret is on screen, or null when it is not on screen at all.
*
* A closed toggle keeps its body in the page with display none, and a run left open inside one
* is a position ProseMirror cannot measure. It throws when asked, and a throw out of `update`
* comes out of the middle of a state update, which is the view and the document disagreeing
* about what the file says.
*/
private caret(): { top: number; bottom: number; left: number } | null {
try {
return this.view.coordsAtPos(this.view.state.selection.head);
} catch {
return null;
}
}
private place(): void {
const frame = this.frame;
const caret = this.caret();
if (!frame || !caret) return;
const box = this.dom.getBoundingClientRect();
const below = frame.innerHeight - caret.bottom;
const up = below < box.height + GAP * 2 && caret.top > box.height + GAP;
const top = up ? caret.top - box.height - GAP : caret.bottom + GAP;
const left = Math.max(EDGE, Math.min(caret.left, frame.innerWidth - box.width - EDGE));
this.dom.setAttribute("data-flip", up ? "up" : "down");
this.dom.style.top = `${Math.round(top)}px`;
this.dom.style.left = `${Math.round(left)}px`;
}
/**
* A scroll moves the popup with the caret, and takes the picker away entirely once the caret has
* left the pane. Closing is done from here rather than from `update`, which cannot dispatch: a
* scroll event is its own tick and a transaction is safe in it.
*/
private readonly onScroll = (): void => {
if (!this.mounted) return;
const pane = this.view.dom.closest(".editor-pane");
const caret = this.caret();
if (pane && caret) {
const bounds = pane.getBoundingClientRect();
if (caret.bottom < bounds.top || caret.top > bounds.bottom) {
close(this.view);
return;
}
}
this.place();
};
}
function picker(documentPath: DocumentPath): Plugin<Run | null> {
return new Plugin<Run | null>({
key,
state: {
init: () => null,
apply(tr, prev, old, next) {
const message = tr.getMeta(key) as Message | undefined;
if (message?.kind === "close") return null;
// A run that did not survive this transaction still lets the same transaction open a new
// one, which is what a third bracket on the end of `[[` is: the run it broke is over and
// the gesture it made is a fresh one.
const held = prev === null ? null : carried(tr, prev, next, message);
if (held) return held;
const from = opened(tr, old, next);
if (from === null) return null;
return { from, query: "", answered: null, hits: [], phase: "idle", error: null, active: 0 };
},
},
props: {
handleKeyDown(view, event) {
if (event.isComposing) return false;
const run = key.getState(view.state);
if (!run) return false;
if (event.key === "Escape") {
close(view);
return true;
}
// Everything else is taken only when there is a row to take it for. A picker open over a
// query nothing answers is a picker in the way, and Enter in the middle of a paragraph has
// to still make a paragraph.
if (run.hits.length === 0) return false;
if (event.key === "ArrowDown") {
move(view, 1);
return true;
}
if (event.key === "ArrowUp") {
move(view, -1);
return true;
}
if (event.key === "Enter") {
// A modifier on Enter is somebody asking for something else, and this list has nothing to
// say about what.
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return false;
const hit = run.hits[run.active];
if (hit && choose(view, hit, documentPath)) return true;
close(view);
return false;
}
return false;
},
handleDOMEvents: {
// The caret is still in the run, but the user is somewhere else: a palette, a dialog,
// another window. Both halves of the picker are wrong then, since the list is drawn over a
// document nobody is typing in and the store it reads from is about to be answering
// somebody else's query.
blur(view) {
close(view);
return false;
},
},
},
view: (view) => new Popup(view, documentPath),
});
}
export interface LinkPickerOptions {
documentPath: DocumentPath;
}
export const LinkPicker = Extension.create<LinkPickerOptions>({
name: "linkPicker",
priority: PRECEDENCE,
addOptions() {
return { documentPath: () => useDocument.getState().path };
},
addProseMirrorPlugins() {
return [picker(this.options.documentPath)];
},
});
+315
View File
@@ -0,0 +1,315 @@
// What arrives from the clipboard.
//
// Two paths. Text pasted with Cmd+Shift+V comes in as plain text, one paragraph per blank line and
// a hard break for every single newline, which is the path margin's editor already had.
//
// An image is the path that had to change. Margin reads the file into a base64 data URL and puts
// that in the document, which is fine when the document is a row in a database. Here the document
// is a markdown file somebody else will open in another editor, and a few hundred kilobytes of
// base64 wedged into a line of it is not markdown anybody wants to receive. The bytes go to Rust,
// which writes them into an assets/ folder beside the document, and what lands in the file is an
// ordinary relative image link. Base64 never reaches a .md file.
//
// Both paths put nodes into the document, so both ask the same guard every other insert in the
// editor asks. A paste is the one insert the user does not aim: the caret is wherever it was left,
// and it is left inside a fence or a raw block often enough that an unguarded paste is how somebody
// discovers their shell script has stopped being code.
//
// The third path is every other paste, and it is the one that was missed twice. Cmd+Shift+V asked
// the guard and the image paste asked the guard, and an ordinary Cmd+V went to ProseMirror's own
// handler with nothing asked at all, because this file claimed image clipboards and returned false
// for the rest. ProseMirror does not refuse a paste that does not fit: it splits whatever the caret
// was in and puts the pieces either side of it, so two pasted paragraphs turned one table into two
// tables with a row destroyed between them, one fence into two fences, and a raw block into two
// halves of somebody's html. A drop is the same insert arriving by a different route and had no
// handler at all.
//
// And the fourth thing that was missed is not a path at all, it is a position in a list. A guard
// written, documented and tested here still never ran, because a paste is offered to the plugins in
// order and the first one to answer wins: prosemirror-tables came ninth and this came fifteenth, so
// a Cmd+V over a rectangle of dragged cells was answered by the library, which replaced the content
// of every cell in the rectangle with whatever was on the clipboard. Four cells of somebody's table
// for one paste of one word. `priority` below is what puts this in front of it, and
// src/editor/fits.test.ts asserts the resulting order rather than trusting the number.
//
// And the fifth is the one this file was sure it had already answered. Text was treated as the
// spelling that fits anywhere, because a fence, a raw block and a cell all hold text and all hold
// newlines. A raw block does not hold a BLANK line: nothing marks where its bytes end, and a blank
// line is what ends an html block, so a paste of two paragraphs into one wrote them through the
// middle of a preserved `<Chart ... />` and the file came back as a heading, two paragraphs and a
// list with no raw block in it. `holdsText` in src/editor/fits.ts is that question.
import { Extension } from "@tiptap/core";
import type { Editor, JSONContent } from "@tiptap/core";
import type { Slice } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { EditorState } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
import { __pastedCells as pastedCells, isInTable } from "@tiptap/pm/tables";
import { assetWrite } from "../api/files";
import { carriesBlocks, fits, holdsText, place, placeable } from "./fits";
/**
* This app's own paste handler, named so that a test can find it in the plugin list and say where
* in that list it is.
*
* Worth naming because it is not the only one: prosemirror-tables installs a paste handler of its
* own, so which plugin answers a given paste is a real question and not an implementation detail.
* It is the question that cost four cells of a table, and the answer has to be this one.
*/
export const pasteKey = new PluginKey("clipboard");
/**
* High enough to be asked first, and asserted rather than believed.
*
* TipTap collects plugins by reversing the extension array and then sorting it by priority, so a
* number above every other extension's puts this file's handlers at the head of the list whatever
* order src/editor/extensions.ts lists them in. 101 is the highest any extension in the tree
* currently asks for, which is exactly the kind of fact that stops being true without anybody
* noticing: src/editor/fits.test.ts reads the built plugin list and fails if anything with a
* handlePaste or a handleDrop of its own ends up in front of this.
*/
const FIRST = 1000;
export interface PasteContext {
/** The open document, which is what a pasted image is written beside. */
documentPath: () => string | null;
onError: (message: string) => void;
}
function imageFiles(data: DataTransfer | null): File[] {
if (!data) return [];
const files = Array.from(data.files).filter((f) => f.type.startsWith("image/"));
if (files.length) return files;
return Array.from(data.items)
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((f): f is File => f !== null);
}
function plainContent(text: string): JSONContent[] {
return text
.replace(/\r\n?/g, "\n")
.split(/\n{2,}/)
.map((block) => {
const content: JSONContent[] = [];
block.split("\n").forEach((line, i) => {
if (i > 0) content.push({ type: "hardBreak" });
if (line) content.push({ type: "text", text: line });
});
return content.length ? { type: "paragraph", content } : { type: "paragraph" };
});
}
/**
* What the user is told when a paste is refused, said once because two routes refuse it: the one
* where this file inserts the clipboard's own text, and the one where it stands aside and lets
* ProseMirror insert the slice.
*/
const BLANK_LINE = "A blank line cannot go inside a raw block, so nothing was pasted.";
/** The words a slice would put in the document: a blank line per block, a newline per break. */
function sliceText(slice: Slice): string {
return slice.content.textBetween(0, slice.content.size, "\n\n", "\n");
}
/**
* The same paste, spelled the way a fence, a raw block or a table cell can hold it.
*
* Those three take text and not paragraphs, so a paragraph pasted into a fence splits it and leaves
* the user with two fences and their lines sitting as prose between them, and one pasted into a
* cell splits the table exactly the way an unguarded block insert does. Text is what all three do
* take, and a paste of text into them is neither a refusal nor a rewrite: a fence and a raw block
* are whitespace: pre so the newlines stay newlines, and the serializer already writes a newline
* inside a cell as the space a GFM cell can hold.
*
* All of which is true of a newline and none of which is true of a BLANK line, which is what the
* guard is for. A fence has ``` at each end and a cell has its pipes, so both of them can hold an
* empty line and still be one block on the next open of the file. A raw block has nothing at
* either end but its own bytes, and a blank line is exactly what ends an html block, so a paste
* carrying one turns the preserved construct into however many pieces of prose it was cut into.
* The paste is refused rather than reflowed: taking the blank lines out would be this handler
* quietly rewriting what the user copied in order to make it fit.
*/
function pasteAsText(editor: Editor, text: string, onError: (message: string) => void): boolean {
const value = text.replace(/\r\n?/g, "\n");
if (!holdsText(editor.state, value)) {
onError(BLANK_LINE);
return false;
}
editor
.chain()
.focus()
.command(({ tr, dispatch }) => {
if (dispatch) dispatch(tr.insertText(value).scrollIntoView());
return true;
})
.run();
return true;
}
/** Where the caret is, are paragraphs something the document can take there. */
function takesBlocks(state: EditorState): boolean {
return placeable(state, state.schema.nodes.paragraph);
}
/**
* And if not, is text. False over a rectangle of table cells, which is the one selection where
* inserting anything at all empties every cell in it rather than replacing what is selected.
*/
function takesText(state: EditorState): boolean {
return placeable(state, state.schema.nodes.text);
}
/**
* The same paste as one string, for the places that take text and not blocks.
*
* The clipboard's own text/plain is what the user copied and is preferred where there is one. A
* drop has no text/plain of its own when it is content dragged from inside the document, so the
* slice is read instead: a blank line between blocks and a newline for a break, which is the
* spelling plainContent above reads back.
*/
function textOf(slice: Slice, data: DataTransfer | null): string {
const plain = data?.getData("text/plain") ?? "";
if (plain) return plain.replace(/\r\n?/g, "\n");
return sliceText(slice);
}
async function insertImages(
editor: Editor,
files: File[],
docPath: string,
onError: (message: string) => void,
): Promise<void> {
for (const file of files) {
try {
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
const asset = await assetWrite(docPath, bytes, file.name || "image.png");
const placed = place(editor, editor.schema.nodes.image, (chain) =>
chain.insertContent({ type: "image", attrs: { src: asset.relPath, alt: null, title: null } }),
);
// Asked a second time because the write is a round trip to disk and the caret is the user's
// in the meantime: it can have moved into a fence between the paste and the bytes landing.
// The file is already written and stays written, which is the harmless half of the two.
if (!placed) {
onError(`${asset.relPath} was saved, but an image cannot go where the cursor is.`);
return;
}
} catch (e) {
onError(`Could not save the pasted image: ${String(e)}`);
return;
}
}
}
export function createPaste(context: PasteContext): Extension {
return Extension.create({
name: "clipboard",
priority: FIRST,
addKeyboardShortcuts() {
const editor = this.editor;
return {
"Mod-Shift-v": () => {
navigator.clipboard
.readText()
.then((text) => {
if (!text) return;
if (takesBlocks(editor.state)) {
editor.chain().focus().insertContent(plainContent(text)).run();
return;
}
if (takesText(editor.state)) pasteAsText(editor, text, context.onError);
})
.catch(() => {});
return true;
},
};
},
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
key: pasteKey,
props: {
handlePaste(view, event, slice) {
// The one paste this file stands aside for, and it stands aside because being first
// in the list is not the same as being right about everything. Cells copied out of a
// table and pasted into a table are prosemirror-tables' own edit: it lays them out
// over the rectangle, grows the table when there are more of them than there is room
// for, and keeps every cell boundary the user copied. Turning that into a line of
// text would be this handler destroying a paste in order to guard it.
if (isInTable(view.state) && pastedCells(slice)) return false;
const files = imageFiles(event.clipboardData);
const docPath = files.length ? context.documentPath() : null;
// Asked before the write, not after it. An image that cannot go where the caret is
// leaves the clipboard alone and the assets folder alone: writing the bytes first
// would put a file on disk beside the user's document that nothing in it ever
// refers to.
if (docPath && placeable(view.state, view.state.schema.nodes.image)) {
event.preventDefault();
void insertImages(editor, files, docPath, context.onError);
return true;
}
// An image that has nowhere to go falls through to the three questions below rather
// than answering false here, which is what it used to do. False is not "nothing
// happens": it is the paste being offered to the next plugin along, and an image
// clipboard carries no text and no html, so what that plugin is handed is an empty
// slice. prosemirror-tables takes an empty slice over a rectangle of cells and
// empties every one of them, which is a PNG on the clipboard deleting a table.
// Everything else. Claimed only where ProseMirror's own handler would do damage,
// because its handler is better than this one at every paste that fits: it keeps
// marks, lists and tables, and this one is a fallback that keeps only the words.
//
// Damage includes a blank line, which is why this branch asks before standing
// aside. The clipboard is parsed against the block the caret is in, so a paste into
// a raw block arrives as one text node with the newlines still in it and no block
// boundary anywhere: `carriesBlocks` says no, the insert is ProseMirror's, and the
// bytes it writes end the html block halfway through the user's tag.
if (!carriesBlocks(slice) && takesText(view.state)) {
if (holdsText(view.state, sliceText(slice))) return false;
event.preventDefault();
context.onError(BLANK_LINE);
return true;
}
if (takesBlocks(view.state)) return false;
// Nowhere for anything to go, which is the rectangle of cells a drag across a table
// makes: a paste over one replaces the content of every cell in it. Nothing is put
// anywhere and the event is claimed so that nothing else puts it there either.
if (!takesText(view.state)) {
event.preventDefault();
return true;
}
event.preventDefault();
pasteAsText(editor, textOf(slice, event.clipboardData), context.onError);
return true;
},
/**
* The same insert arriving by a different route, and the reason this is not a line
* inside handlePaste: a drop lands where the pointer is rather than where the caret
* is, so it is a different position being asked about.
*
* A drop that does not fit is refused outright rather than turned into text. Refusing
* costs nothing, since a drop of the document's own content that nobody claims leaves
* the content where it was, and there is no second guess to make about what the user
* meant by pointing at the middle of a fence.
*/
handleDrop(view: EditorView, event: DragEvent, slice: Slice) {
if (!carriesBlocks(slice)) return false;
const at = view.posAtCoords({ left: event.clientX, top: event.clientY });
if (!at) return false;
if (fits(view.state.doc.resolve(at.pos), view.state.schema.nodes.paragraph)) return false;
event.preventDefault();
return true;
},
},
}),
];
},
});
}
+87
View File
@@ -0,0 +1,87 @@
// The matching and replacing plainFind.ts does, in isolation from the textarea it drives. Whether
// a query actually reaches the DOM and scrolls something into view is what tests/smoke.spec.ts
// proves; this file is only about the same case sensitivity, whole word and offset rules search.ts
// already has, since the point of sharing buildRegex is that the two never disagree.
import { describe, expect, it } from "vitest";
import {
EMPTY_PLAIN_SEARCH,
recomputePlainSearch,
replaceAllMatches,
replaceMatch,
sameQuery,
} from "./plainFind";
const OPTS = { caseSensitive: false, wholeWord: false };
describe("recomputePlainSearch", () => {
it("finds every occurrence, case insensitively by default", () => {
const state = recomputePlainSearch("Cat cat CAT scatter", "cat", OPTS, 0);
expect(state.matches).toEqual([
{ from: 0, to: 3 },
{ from: 4, to: 7 },
{ from: 8, to: 11 },
{ from: 13, to: 16 },
]);
expect(state.current).toBe(0);
});
it("respects case sensitivity when asked", () => {
const state = recomputePlainSearch("Cat cat CAT", "cat", { caseSensitive: true, wholeWord: false }, 0);
expect(state.matches).toEqual([{ from: 4, to: 7 }]);
});
it("respects whole word", () => {
const state = recomputePlainSearch("cat scatter cat", "cat", { caseSensitive: false, wholeWord: true }, 0);
expect(state.matches).toEqual([
{ from: 0, to: 3 },
{ from: 12, to: 15 },
]);
});
it("clamps a desired current back onto a shorter match list", () => {
const state = recomputePlainSearch("one", "one", OPTS, 5);
expect(state.current).toBe(0);
});
it("returns the empty state for an empty query", () => {
expect(recomputePlainSearch("anything", "", OPTS, 0)).toEqual({ ...EMPTY_PLAIN_SEARCH, query: "", options: OPTS });
});
it("escapes regex special characters in the query", () => {
const state = recomputePlainSearch("a.b a.b axb", "a.b", OPTS, 0);
expect(state.matches).toEqual([
{ from: 0, to: 3 },
{ from: 4, to: 7 },
]);
});
});
describe("sameQuery", () => {
it("is true only when the query and both options match", () => {
const state = recomputePlainSearch("abc", "a", OPTS, 0);
expect(sameQuery(state, "a", OPTS)).toBe(true);
expect(sameQuery(state, "b", OPTS)).toBe(false);
expect(sameQuery(state, "a", { caseSensitive: true, wholeWord: false })).toBe(false);
expect(sameQuery(state, "a", { caseSensitive: false, wholeWord: true })).toBe(false);
});
});
describe("replaceMatch and replaceAllMatches", () => {
it("replaces a single match without touching the rest of the string", () => {
const text = "one two three";
expect(replaceMatch(text, { from: 4, to: 7 }, "TWO")).toBe("one TWO three");
});
it("replaces every match back to front so offsets never shift under it", () => {
const text = "cat cat cat";
const matches = recomputePlainSearch(text, "cat", OPTS, 0).matches;
expect(replaceAllMatches(text, matches, "dog")).toBe("dog dog dog");
});
it("replaces correctly even when the replacement is a different length", () => {
const text = "a-a-a";
const matches = recomputePlainSearch(text, "a", OPTS, 0).matches;
expect(replaceAllMatches(text, matches, "bb")).toBe("bb-bb-bb");
});
});
+90
View File
@@ -0,0 +1,90 @@
// Find and replace over a plain string, for the .txt surface, kept in exactly the shape
// src/editor/search.ts keeps it for markdown: a query, a set of matches and a current index. The
// difference is where that state lives. A ProseMirror document carries it in a plugin, dispatched
// through transactions; a textarea has no plugin to carry anything, so PlainTextEditor.tsx keeps
// one of these in a ref and calls the functions below to move it forward.
//
// `buildRegex` is imported rather than reimplemented, which is the whole point: case sensitivity,
// whole word and the characters that get escaped are one rule, asked for twice, not two rules that
// could drift apart the day one of them changes.
import { buildRegex, type SearchMatch, type SearchOptions } from "./search";
export type { SearchMatch, SearchOptions };
export interface PlainSearchState {
query: string;
options: SearchOptions;
matches: SearchMatch[];
/** Zero based, into `matches`. */
current: number;
}
export const EMPTY_PLAIN_SEARCH: PlainSearchState = {
query: "",
options: { caseSensitive: false, wholeWord: false },
matches: [],
current: 0,
};
function matchesOf(text: string, regex: RegExp): SearchMatch[] {
const matches: SearchMatch[] = [];
regex.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = regex.exec(text)) !== null) {
matches.push({ from: m.index, to: m.index + m[0].length });
if (m.index === regex.lastIndex) regex.lastIndex += 1;
}
return matches;
}
/**
* A fresh state for a query, the same way search.ts's own `recompute` builds one: `desiredCurrent`
* survives when it still lands on a match and is clamped back onto the list otherwise, which is
* what keeps a still-running search where it was after the text it is searching changes under it.
*/
export function recomputePlainSearch(
text: string,
query: string,
options: SearchOptions,
desiredCurrent: number,
): PlainSearchState {
const regex = buildRegex(query, options);
if (!regex) return { ...EMPTY_PLAIN_SEARCH, query, options };
const matches = matchesOf(text, regex);
const current = matches.length ? Math.max(0, Math.min(desiredCurrent, matches.length - 1)) : 0;
return { query, options, matches, current };
}
/**
* Whether a query and its options are the same search already running. A find bar re-issues its
* query on every render, and treating that as a new search would reset `current` to the first
* match on every keystroke of navigation rather than only on an actual change of query.
*/
export function sameQuery(state: PlainSearchState, query: string, options: SearchOptions): boolean {
return (
state.query === query &&
state.options.caseSensitive === options.caseSensitive &&
state.options.wholeWord === options.wholeWord
);
}
/** One match, replaced. Safe to call with the match's original offsets only when nothing else has
* touched the string yet. */
export function replaceMatch(text: string, match: SearchMatch, replacement: string): string {
return text.slice(0, match.from) + replacement + text.slice(match.to);
}
/** Every match, replaced back to front so an earlier match's offsets are never invalidated by a
* later replacement changing the length of the string ahead of it. */
export function replaceAllMatches(
text: string,
matches: readonly SearchMatch[],
replacement: string,
): string {
let result = text;
for (let i = matches.length - 1; i >= 0; i -= 1) {
result = replaceMatch(result, matches[i], replacement);
}
return result;
}
+52
View File
@@ -0,0 +1,52 @@
// Where the caret and the scroller were, per document, so reopening a file lands where you left
// it rather than at the top.
//
// Margin keys this by book and then by chapter because a book is a folder of many small documents
// that are read as one. Here there is no such nesting: a document is a file, a file has an
// absolute path, and that path is the whole key. Two roots holding a same-named file are two
// different paths and two different entries, which is the point.
export interface DocumentPosition {
from: number;
to: number;
scroll: number;
}
const KEY = "margindocs-positions";
/** Old entries fall off the end rather than growing a map nobody ever prunes. */
const LIMIT = 200;
type Store = Record<string, DocumentPosition>;
function readAll(): Store {
try {
const raw = localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as Store) : {};
} catch {
return {};
}
}
function writeAll(store: Store): void {
try {
localStorage.setItem(KEY, JSON.stringify(store));
} catch {
return;
}
}
export function loadPosition(path: string): DocumentPosition | null {
return readAll()[path] ?? null;
}
export function savePosition(path: string, position: DocumentPosition): void {
const store = readAll();
// Deleting before setting moves the key to the end, which is what makes insertion order a
// recency order and lets the trim below drop the documents nobody has opened in a long time.
delete store[path];
store[path] = position;
const paths = Object.keys(store);
for (const stale of paths.slice(0, Math.max(0, paths.length - LIMIT))) delete store[stale];
writeAll(store);
}
+460
View File
@@ -0,0 +1,460 @@
// Spelling, drawn over the document as decorations and never as an edit to it.
//
// The whole of this file is paint and a menu. The only transaction in it that carries a step is the
// one a user makes by choosing a suggestion, and that one is a single transaction so a single undo
// puts their word back. Everything else dispatches a decoration set and no steps, which is not a
// document change: TipTap only emits `update` when a transaction changed the document, so nothing
// here dirties a buffer, schedules a save or reaches src/markdown/serialize.ts. A spell checker that
// rewrites text on its own would be a file damaging bug in this project's terms, so it does not have
// the ability to.
//
// WHAT IS CHECKED, and where that guard actually sits.
//
// Prose only. A fenced code block, a raw block, a maths field and an inline code span are not prose,
// and underlining somebody's variable names is the fastest way to get a spell checker turned off for
// good. The guard is `proseBlocks` below, and it is the single producer of every string this file
// sends: `pass` checks only what `proseBlocks` returned, `spellCheck` is called from nowhere else in
// the app outside src/api/spell.ts itself, and a block that never went to the checker has no entry
// in the cache and therefore no decoration. There is no second path to the checker to forget to
// guard, which is the shape of guard this project has now shipped four times unreached.
//
// A URL is the exception and it is deliberately not handled here. The Rust side asks AppKit to
// recognise links in the run it is given, so an autolink is one URL rather than five misspelled
// words, and the text of a link is otherwise ordinary prose that deserves checking like any other.
//
// WHEN IT RUNS.
//
// Not on every keystroke. Each check crosses the IPC boundary into AppKit, so typing schedules a
// pass a few hundred milliseconds out and every further keystroke pushes it back. A pass sends only
// the blocks whose text the checker has not already answered about, so an ordinary edit is one
// paragraph over the wire and a document nobody has touched is nothing at all. The cache is keyed by
// the exact text of a block, which is what makes that true without any range tracking: text the user
// has not touched is text that is still its own key.
//
// AND WHY A STALE ANSWER CANNOT LAND.
//
// Two things, one structural and one the sequence number src/store/useSearch.ts uses for the same
// shape. The structural one is that an answer is stored against the exact text it was about, and
// decorations are always rebuilt from the document that is on screen at that moment, so an answer
// about text that has since changed has nothing in the current document to attach to. The counter is
// the belt to that pair of braces: any edit bumps it, and a pass that comes back to find it has
// moved keeps its answers for the cache and draws nothing.
import { Extension } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import type { EditorState, PluginView } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import type { EditorView } from "@tiptap/pm/view";
import { spellCheck } from "../api/spell";
import type { SpellIssue } from "../ipc";
import { useProofing, type ProofTarget } from "../store/useProofing";
/** How long after the last keystroke a pass runs. Long enough that typing a word is one check. */
const IDLE_MS = 400;
/** And how long after a document is installed, which is a wait for nothing in particular. */
const OPEN_MS = 120;
/**
* How much text goes to the checker in one call.
*
* A pass over a document nobody has opened before is every paragraph in it, and one IPC call per
* paragraph would be several hundred round trips to AppKit for a long file. Blocks are joined into
* runs up to this size instead, separated by a blank line so no word from one paragraph is ever
* adjacent to a word from the next. A block longer than this goes on its own rather than being split,
* because splitting a run cuts a word in half and invents a misspelling that is not in the document.
*/
const RUN_CHARS = 8000;
/** A word is separated from the next block's first word by this, and it is two positions wide. */
const JOIN = "\n\n";
/** What the menu offers, which is what a Mac's own spelling menu offers. */
const MAX_SUGGESTIONS = 5;
/** One prose textblock: where its text starts in the document, and the text itself. */
interface Block {
base: number;
text: string;
}
/** Several blocks' text in one string, and where each of them starts in it. */
interface Run {
text: string;
parts: { text: string; at: number }[];
}
/**
* What the checker has already said, keyed by the exact text it was said about.
*
* Module level rather than per view because it is not about a document: two files that share a
* paragraph share its answer, and switching away from a document and back does not re-ask anything.
* It is pruned at the end of every pass down to the text that is actually in the document, so it
* cannot grow past the size of what is open.
*/
let cache = new Map<string, SpellIssue[]>();
/**
* The view a menu acts on. There is one editor and one document (see src/editor/index.ts), so there
* is one of these, and it is null whenever no document is on screen.
*/
let activeView: EditorView | null = null;
/** Bumped by every edit and every pass. A pass whose number has moved on does not draw. */
let passSeq = 0;
const proofingKey = new PluginKey<DecorationSet>("proofing");
/**
* One textblock's text, in a string whose length is exactly the block's content size, so an offset
* into it is a document position plus a constant.
*
* The two things that are not prose are blanked rather than dropped, for that reason: an inline code
* span and a leaf node (an image, a formula, a line break) become spaces of their own width, which
* keeps every later word at the position the document has it at, keeps the checker from reading a
* function name as a sentence, and leaves the words either side of a break as two words rather than
* one.
*
* Null when the arithmetic did not come out, which nothing in the current schema can cause. It is
* here for the same reason src/editor/blocks/code.ts measures its highlighter's output: a decoration
* built on an offset that is wrong by one is drawn over the wrong characters, and one built past the
* end of the block throws inside the view.
*/
function blockText(node: ProseMirrorNode): string | null {
let text = "";
node.forEach((child) => {
if (!child.isText) {
text += " ".repeat(child.nodeSize);
return;
}
const value = child.text ?? "";
text += child.marks.some((mark) => mark.type.name === "code") ? " ".repeat(value.length) : value;
});
return text.length === node.content.size ? text : null;
}
/**
* Every block of prose in the document, and nothing else.
*
* This is the guard. A node that declares itself code is refused along with everything inside it,
* which is both the fenced block and the raw block whose bytes are the file's own, taken from the
* schema rather than from a list of names kept in step by hand. `mathBlock` is an atom holding its
* LaTeX as an attribute, so it has no text to send anyway; it is named because "it has nothing to
* check today" is a weaker sentence than "it is not prose".
*/
function proseBlocks(doc: ProseMirrorNode): Block[] {
const blocks: Block[] = [];
doc.descendants((node, pos) => {
if (node.type.spec.code) return false;
if (node.type.name === "mathBlock") return false;
if (!node.isTextblock) return true;
const text = blockText(node);
if (text !== null && text.trim() !== "") blocks.push({ base: pos + 1, text });
return false;
});
return blocks;
}
/** The distinct texts in the document the checker has not answered about yet. */
function pending(blocks: readonly Block[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const block of blocks) {
if (cache.has(block.text) || seen.has(block.text)) continue;
seen.add(block.text);
out.push(block.text);
}
return out;
}
function runsOf(texts: readonly string[]): Run[] {
const runs: Run[] = [];
let current: Run | null = null;
for (const text of texts) {
if (current !== null && current.text.length + JOIN.length + text.length > RUN_CHARS) {
current = null;
}
if (current === null) {
current = { text, parts: [{ text, at: 0 }] };
runs.push(current);
continue;
}
current.parts.push({ text, at: current.text.length + JOIN.length });
current.text += JOIN + text;
}
return runs;
}
/**
* Files one run's answers back into the cache, per block and in that block's own offsets.
*
* Every block in the run gets an entry, an empty one included. A block with no entry is a block the
* next pass would send again, so a paragraph the checker is happy with has to be recorded as
* checked or the document with no misspellings in it is the one that never stops asking.
*/
function store(run: Run, issues: readonly SpellIssue[]): void {
const byPart = run.parts.map(() => [] as SpellIssue[]);
for (const issue of issues) {
const index = run.parts.findIndex(
(part) => issue.start >= part.at && issue.end <= part.at + part.text.length,
);
// An issue that reaches across the join between two blocks is not a word in either of them.
if (index === -1) continue;
const part = run.parts[index];
byPart[index].push({ ...issue, start: issue.start - part.at, end: issue.end - part.at });
}
run.parts.forEach((part, index) => cache.set(part.text, byPart[index]));
}
/** Down to what is in the document, so the cache is never larger than what is open. */
function prune(blocks: readonly Block[]): void {
const kept = new Map<string, SpellIssue[]>();
for (const block of blocks) {
const issues = cache.get(block.text);
if (issues) kept.set(block.text, issues);
}
cache = kept;
}
/**
* The decorations for the document as it stands, built from what the checker has already said.
*
* A block with nothing cached contributes nothing, which is what an underline that has not arrived
* yet looks like, and an issue whose offsets do not sit inside the block they claim to be about is
* dropped: the checker is a foreign process walking the user's text, and a decoration running past
* the end of a node throws inside the view rather than merely looking wrong.
*/
function decorationsFor(
doc: ProseMirrorNode,
blocks: readonly Block[],
ignored: ReadonlySet<string>,
): DecorationSet {
const decorations: Decoration[] = [];
for (const block of blocks) {
const issues = cache.get(block.text);
if (!issues) continue;
for (const issue of issues) {
if (issue.start < 0 || issue.end <= issue.start || issue.end > block.text.length) continue;
if (ignored.has(issue.word.toLowerCase())) continue;
decorations.push(
Decoration.inline(
block.base + issue.start,
block.base + issue.end,
{ class: "proof-mark" },
// The word and its suggestions ride on the decoration rather than in a list beside it, so
// that mapping the set through an edit keeps the menu's offer attached to the word it was
// about instead of to a position that has moved.
{ word: issue.word, suggestions: issue.suggestions },
),
);
}
}
return DecorationSet.create(doc, decorations);
}
function draw(view: EditorView, decorations: DecorationSet): void {
view.dispatch(view.state.tr.setMeta(proofingKey, decorations));
}
function clear(view: EditorView): void {
const current = proofingKey.getState(view.state);
if (!current || current === DecorationSet.empty) return;
draw(view, DecorationSet.empty);
}
/**
* One check of whatever the checker has not seen, and then a redraw.
*
* The decorations are built from `view.state.doc` after the awaits rather than from the document the
* pass started on. By then the sequence number has already answered whether anything moved, so the
* two agree; building from what is on screen is what makes that a fact about the code rather than a
* fact about the timing.
*/
async function pass(view: EditorView): Promise<void> {
const seq = passSeq;
const state = useProofing.getState();
if (!state.enabled || state.availability !== "ready") {
clear(view);
return;
}
for (const run of runsOf(pending(proseBlocks(view.state.doc)))) {
try {
store(run, await spellCheck(run.text));
} catch {
// The checker went away mid document, which on a build without one is what the first call
// does. Nothing is drawn and nothing is said: what is already underlined stays, and the next
// edit asks again.
return;
}
}
if (seq !== passSeq || view.isDestroyed) return;
const blocks = proseBlocks(view.state.doc);
draw(view, decorationsFor(view.state.doc, blocks, useProofing.getState().ignored));
prune(blocks);
}
/** The misspelling under a position, if the menu should open over one. */
function menuAt(view: EditorView, pos: number): boolean {
// A document held open while a conflict is resolved is one nothing may edit, and a menu whose
// every item is an edit has nothing to offer there. The underlines stay; the menu does not open.
if (!view.editable) return false;
const decorations = proofingKey.getState(view.state);
if (!decorations) return false;
const found = decorations.find(pos, pos);
if (found.length === 0) return false;
// A position at the seam between two words touches both, so a hit that actually contains it wins.
const hit = found.find((deco) => deco.from < pos && pos < deco.to) ?? found[0];
const spec = hit.spec as { word?: unknown; suggestions?: unknown };
if (typeof spec.word !== "string") return false;
const start = view.coordsAtPos(hit.from);
const end = view.coordsAtPos(hit.to);
useProofing.getState().openMenu({
from: hit.from,
to: hit.to,
word: spec.word,
suggestions: (Array.isArray(spec.suggestions) ? (spec.suggestions as string[]) : []).slice(
0,
MAX_SUGGESTIONS,
),
left: (start.left + end.left) / 2,
top: start.top,
// A word that wraps across two lines ends on the lower one, which is where the menu belongs.
bottom: Math.max(start.bottom, end.bottom),
});
return true;
}
/**
* The debounce, the store subscription and the one view a menu can act on, for as long as there is
* an editor to act on.
*/
class Proofreader implements PluginView {
private readonly view: EditorView;
private timer: ReturnType<typeof setTimeout> | undefined;
private readonly unsubscribe: () => void;
constructor(view: EditorView) {
this.view = view;
activeView = view;
// Once per launch, whatever happens next: the store answers the second caller from what the
// first one asked.
useProofing.getState().ensureAvailable();
this.unsubscribe = useProofing.subscribe((next, previous) => {
// A learned word changes the answer for text nobody has touched, so it is the one thing that
// throws away what the checker already said. Ignoring a word does not: it is filtered when the
// decorations are built, so putting it back costs nothing.
if (next.revision !== previous.revision) cache.clear();
if (
next.enabled === previous.enabled &&
next.availability === previous.availability &&
next.ignored === previous.ignored &&
next.revision === previous.revision
) {
return;
}
this.schedule(OPEN_MS);
});
this.schedule(OPEN_MS);
}
/** Abandons whatever is in flight on the way past, which is the other half of the stale guard. */
private schedule(delay: number): void {
passSeq += 1;
clearTimeout(this.timer);
this.timer = setTimeout(() => {
void pass(this.view);
}, delay);
}
update(view: EditorView, previous: EditorState): void {
if (previous.doc === view.state.doc) return;
// The menu's positions were taken from a document that has now moved, and the word it is offering
// to correct may not be there at all.
useProofing.getState().closeMenu();
this.schedule(IDLE_MS);
}
destroy(): void {
clearTimeout(this.timer);
this.unsubscribe();
passSeq += 1;
if (activeView === this.view) activeView = null;
useProofing.getState().closeMenu();
}
}
/**
* Puts a suggestion in place of the word the menu was opened over.
*
* One transaction, so one undo takes it back, and refused outright unless the word is still exactly
* where the menu said it was. The menu closes on every document change, so that check should never
* fail; it is here because this is the one function in the file that can write to somebody's
* document, and it is worth being unable to write to the wrong part of it.
*
* No `fits` guard, unlike every insert in src/editor/Editor.tsx, and for the reason src/editor/
* fits.test.ts gives the find bar's replace: this is text going into the one textblock it came out
* of, not a node being placed somewhere it may not go. The range is inside a block that `proseBlocks`
* already refused to send if it was code or raw, and a suggestion is one word, so there is no blank
* line in it for `holdsText` to be about.
*/
export function replaceSpelling(target: ProofTarget, suggestion: string): void {
const view = activeView;
if (!view || view.isDestroyed || !view.editable) return;
const { from, to, word } = target;
if (to > view.state.doc.content.size) return;
if (view.state.doc.textBetween(from, to) !== word) return;
const tr = view.state.tr.insertText(suggestion, from, to);
tr.setSelection(TextSelection.create(tr.doc, from + suggestion.length));
view.dispatch(tr);
view.focus();
}
export const Proofing = Extension.create({
name: "proofing",
addProseMirrorPlugins() {
return [
new Plugin<DecorationSet>({
key: proofingKey,
state: {
init: () => DecorationSet.empty,
apply(tr, value) {
const next = tr.getMeta(proofingKey) as DecorationSet | undefined;
if (next) return next;
// Mapped rather than rebuilt, so an underline stays on its word while the rest of the
// paragraph is being typed instead of sliding along the line behind the caret.
return tr.docChanged ? value.map(tr.mapping, tr.doc) : value;
},
},
props: {
decorations: (state) => proofingKey.getState(state) ?? DecorationSet.empty,
// False either way: a click on a misspelled word opens the menu AND puts the caret where
// it was clicked, which is what a click in text does everywhere else in the document.
handleClick: (view, pos) => {
menuAt(view, pos);
return false;
},
handleDOMEvents: {
contextmenu: (view, event) => {
const at = view.posAtCoords({ left: event.clientX, top: event.clientY });
if (!at || !menuAt(view, at.pos)) return false;
event.preventDefault();
return true;
},
},
},
view: (view) => new Proofreader(view),
}),
];
},
});
+264
View File
@@ -0,0 +1,264 @@
// Find and replace inside the open document, as decorations rather than as marks: a highlight is
// something the view draws, not something the file remembers. Nothing in this module can reach the
// document's content, so a search can never dirty a buffer or change what a save would write.
//
// Ported from margin's editor/search.ts. The only change of substance is the name of the last
// command: margin replaces across a chapter because a chapter is the unit it has, and here the
// unit is the whole document.
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey, type EditorState } from "@tiptap/pm/state";
import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view";
import type { Node as PMNode } from "@tiptap/pm/model";
export interface SearchOptions {
caseSensitive: boolean;
wholeWord: boolean;
}
export interface SearchMatch {
from: number;
to: number;
}
export interface SearchState {
query: string;
options: SearchOptions;
matches: SearchMatch[];
current: number;
decorations: DecorationSet;
}
export const searchKey = new PluginKey<SearchState>("search");
const EMPTY: SearchState = {
query: "",
options: { caseSensitive: false, wholeWord: false },
matches: [],
current: 0,
decorations: DecorationSet.empty,
};
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function buildRegex(query: string, options: SearchOptions): RegExp | null {
if (!query) return null;
let pattern = escapeRegExp(query);
if (options.wholeWord) pattern = `\\b${pattern}\\b`;
try {
return new RegExp(pattern, options.caseSensitive ? "g" : "gi");
} catch {
return null;
}
}
export function findMatches(doc: PMNode, regex: RegExp): SearchMatch[] {
const matches: SearchMatch[] = [];
let runText = "";
let runStart = 0;
const flush = () => {
if (!runText) return;
regex.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = regex.exec(runText)) !== null) {
const from = runStart + m.index;
matches.push({ from, to: from + m[0].length });
if (m.index === regex.lastIndex) regex.lastIndex++;
}
runText = "";
};
doc.descendants((node, pos) => {
if (node.isText) {
if (!runText) runStart = pos;
runText += node.text ?? "";
} else {
flush();
}
return true;
});
flush();
return matches;
}
function buildDecorations(doc: PMNode, matches: SearchMatch[], current: number): DecorationSet {
if (!matches.length) return DecorationSet.empty;
const decos = matches.map((m, i) =>
Decoration.inline(m.from, m.to, {
class: i === current ? "search-match search-match-current" : "search-match",
}),
);
return DecorationSet.create(doc, decos);
}
function recompute(
doc: PMNode,
query: string,
options: SearchOptions,
desiredCurrent: number,
): SearchState {
const regex = buildRegex(query, options);
if (!regex) return { ...EMPTY, query, options };
const matches = findMatches(doc, regex);
const current = matches.length ? Math.max(0, Math.min(desiredCurrent, matches.length - 1)) : 0;
return { query, options, matches, current, decorations: buildDecorations(doc, matches, current) };
}
function scrollMatchIntoView(view: EditorView, match: SearchMatch): void {
const { node } = view.domAtPos(match.from);
const el = node.nodeType === Node.TEXT_NODE ? (node as Text).parentElement : (node as HTMLElement);
el?.scrollIntoView({ block: "center", behavior: "smooth" });
}
/** What a find bar reads to say "3 of 12". */
export function searchStateOf(state: EditorState): SearchState | null {
return searchKey.getState(state) ?? null;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
search: {
setSearch: (query: string, options: SearchOptions) => ReturnType;
clearSearch: () => ReturnType;
findNext: () => ReturnType;
findPrev: () => ReturnType;
goToMatch: (index: number) => ReturnType;
replaceCurrent: (replacement: string) => ReturnType;
replaceAllInDocument: (replacement: string) => ReturnType;
};
}
}
export const SearchHighlight = Extension.create({
name: "searchHighlight",
addProseMirrorPlugins() {
return [
new Plugin<SearchState>({
key: searchKey,
state: {
init: () => EMPTY,
apply(tr, value, _old, newState) {
const meta = tr.getMeta(searchKey) as
| { type: "search"; query: string; options: SearchOptions }
| { type: "nav"; current: number }
| { type: "clear" }
| undefined;
if (meta?.type === "search") {
// Asking again for the search that is already running is a no-op rather than a
// reset. A find bar re-issues its query whenever anything about it re-renders, and
// recomputing from zero there would drag the user back to the first match every
// time they stepped to the next one.
if (
meta.query === value.query &&
meta.options.caseSensitive === value.options.caseSensitive &&
meta.options.wholeWord === value.options.wholeWord
) {
return value;
}
return recompute(newState.doc, meta.query, meta.options, 0);
}
if (meta?.type === "nav" && value.matches.length) {
const current =
((meta.current % value.matches.length) + value.matches.length) %
value.matches.length;
return {
...value,
current,
decorations: buildDecorations(newState.doc, value.matches, current),
};
}
if (meta?.type === "clear") {
return { ...EMPTY };
}
if (tr.docChanged && value.query) {
return recompute(newState.doc, value.query, value.options, value.current);
}
return value;
},
},
props: {
decorations(state) {
return searchKey.getState(state)?.decorations ?? DecorationSet.empty;
},
},
}),
];
},
addCommands() {
return {
setSearch:
(query, options) =>
({ tr, dispatch }) => {
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "search", query, options }));
return true;
},
clearSearch:
() =>
({ tr, dispatch }) => {
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "clear" }));
return true;
},
findNext:
() =>
({ state, dispatch, view, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
const current = (s.current + 1) % s.matches.length;
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "nav", current }));
scrollMatchIntoView(view, s.matches[current]);
return true;
},
findPrev:
() =>
({ state, dispatch, view, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
const current = (s.current - 1 + s.matches.length) % s.matches.length;
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "nav", current }));
scrollMatchIntoView(view, s.matches[current]);
return true;
},
goToMatch:
(index) =>
({ state, dispatch, view, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
const current = ((index % s.matches.length) + s.matches.length) % s.matches.length;
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "nav", current }));
scrollMatchIntoView(view, s.matches[current]);
return true;
},
replaceCurrent:
(replacement) =>
({ state, dispatch, view, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
const m = s.matches[s.current];
if (dispatch) {
tr.insertText(replacement, m.from, m.to);
dispatch(tr);
const ns = searchKey.getState(view.state);
if (ns && ns.matches.length) scrollMatchIntoView(view, ns.matches[ns.current]);
}
return true;
},
replaceAllInDocument:
(replacement) =>
({ state, dispatch, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
if (dispatch) {
for (let i = s.matches.length - 1; i >= 0; i--) {
const m = s.matches[i];
tr.insertText(replacement, m.from, m.to);
}
dispatch(tr);
}
return true;
},
};
},
});
+233
View File
@@ -0,0 +1,233 @@
// Everything the keyboard does inside a document: the chords, and the typing rules that turn
// markdown as you type it into the thing it means.
//
// Both live here rather than on the generated node extensions in extensions.ts, because those are
// mechanically derived from the schema in src/model/schema.ts and have no behaviour of their own.
// This is the one file to read to know what a key does.
//
// The typing rules are not a markdown syntax mode. Nothing they produce leaves syntax on screen:
// "## " becomes a heading and the hashes are gone, "**bold**" becomes bold and the stars are gone.
// That is the same WYSIWYG promise the rest of the editor makes, arrived at from the keyboard.
//
// Cmd+K is deliberately absent. src/keys/bindings.ts binds it globally to the command palette from
// a capture-phase listener, so a link shortcut on that chord would never see the key. Links are a
// toolbar control.
import {
Extension,
InputRule,
markInputRule,
nodeInputRule,
textblockTypeInputRule,
wrappingInputRule,
} from "@tiptap/core";
import type { Editor } from "@tiptap/core";
import type { NodeType } from "@tiptap/pm/model";
import { isInTable } from "@tiptap/pm/tables";
import { HEADING_LEVELS } from "../model/schema";
import type { MarkName } from "../model/schema";
import { breakable, change, fits } from "./fits";
const STAR_BOLD = /(?:^|\s)(\*\*(?!\s+\*\*)((?:[^*]+))\*\*(?!\s+\*\*))$/;
const UNDERSCORE_BOLD = /(?:^|\s)(__(?!\s+__)((?:[^_]+))__(?!\s+__))$/;
const STAR_ITALIC = /(?:^|\s)(\*(?!\s+\*)((?:[^*]+))\*(?!\s+\*))$/;
const UNDERSCORE_ITALIC = /(?:^|\s)(_(?!\s+_)((?:[^_]+))_(?!\s+_))$/;
const STRIKETHROUGH = /(?:^|\s)(~~(?!\s+~~)((?:[^~]+))~~(?!\s+~~))$/;
const CODE = /(^|[^`])`([^`]+)`(?!`)$/;
const BULLET = /^\s*([-+*])\s$/;
const ORDERED = /^(\d+)\.\s$/;
const QUOTE = /^\s*>\s$/;
const FENCE = /^```([a-zA-Z0-9_+-]+)?[\s\n]$/;
const RULE = /^(?:---|\*\*\*|___)\s$/;
const HEADING = /^(#{1,6})\s$/;
const CHECKBOX = /^\s*\[([ xX])\]\s$/;
/**
* "- [ ] " inside a list. The bullet rule has already fired on the dash by the time the box is
* typed, so this converts the item that is now there rather than wrapping a paragraph.
*/
function checkboxRule(editor: Editor): InputRule {
return new InputRule({
find: CHECKBOX,
handler: ({ state, range, match, chain }) => {
const checked = match[1].toLowerCase() === "x";
const taskItem = editor.schema.nodes.taskItem;
const taskList = editor.schema.nodes.taskList;
const $from = state.doc.resolve(range.from);
for (let depth = $from.depth; depth > 0; depth -= 1) {
const item = $from.node(depth);
if (item.type.name !== "listItem" && item.type.name !== "taskItem") continue;
const itemPos = $from.before(depth);
const list = $from.node(depth - 1);
const listPos = $from.before(depth - 1);
chain()
.deleteRange(range)
.command(({ tr }) => {
tr.setNodeMarkup(itemPos, taskItem, { checked });
// A list of one becomes a task list outright; a list that still holds plain items
// stays what it is, which the schema allows and the bridge writes back as a mixed
// list rather than reformatting the items that were not touched.
if (list.type.name === "bulletList" && list.childCount === 1) {
tr.setNodeMarkup(listPos, taskList, list.attrs);
}
return true;
})
.run();
return;
}
chain().deleteRange(range).wrapInList(taskList).run();
},
});
}
/**
* "--- " as a horizontal rule, and as three characters of text where a rule cannot go.
*
* The third way a node gets placed in this document, after the toolbar and the clipboard, and the
* one the guard in fits.ts had not been wired into. The other typing rules ask a question of their
* own before they fire, because wrapping and changing a block type are operations ProseMirror
* refuses outright when the result would not fit; a node inserted next to the caret is not, so this
* one fired anywhere and `tr.insert` did what it always does with a block that has nowhere to go.
* Typing "--- " in a table cell cut the table in two around the rule and left a row of empty cells
* behind where the text had been.
*
* Declining is returning null, which is how an InputRule says the match was not for it: the run
* loop drops the transaction and the characters stay as the characters that were typed. So "--- "
* in a cell is the text "--- ", which is what it is in GFM anyway, and in a list item or a callout
* it is still the rule it has always been.
*/
function ruleInputRule(type: NodeType): InputRule {
const rule = nodeInputRule({ find: RULE, type });
return new InputRule({
find: RULE,
handler: (props) => {
if (!fits(props.state.doc.resolve(props.range.from), type)) return null;
return rule.handler(props);
},
});
}
export const Shortcuts = Extension.create({
name: "shortcuts",
addKeyboardShortcuts() {
const editor = this.editor;
const mark = (name: MarkName) => () => editor.commands.toggleMark(name);
// Every chord that changes what a block is goes through the same guard the toolbar's own
// conversions go through, and for the same reason: a chord is a command like any other, and
// the file does not care which of the two the user reached for. Mod-Alt-2 in a raw block
// rewrote the user's html as an escaped heading, and Mod-Alt-0 in a toggle deleted the toggle
// and its title, both of them while the toolbar items beside them were being fixed.
const headings = Object.fromEntries(
HEADING_LEVELS.map((level) => [
`Mod-Alt-${level}`,
() => change(editor, "convert", (chain) => chain.toggleNode("heading", "paragraph", { level })),
]),
);
return {
...headings,
"Mod-b": mark("strong"),
"Mod-i": mark("em"),
"Mod-e": mark("code"),
"Mod-Shift-x": mark("strikethrough"),
"Mod-Alt-0": () => change(editor, "convert", (chain) => chain.setNode("paragraph")),
"Mod-Shift-7": () => change(editor, "wrap", (chain) => chain.toggleList("orderedList", "listItem")),
"Mod-Shift-8": () => change(editor, "wrap", (chain) => chain.toggleList("bulletList", "listItem")),
"Mod-Shift-9": () => change(editor, "wrap", (chain) => chain.toggleList("taskList", "taskItem")),
"Mod-Shift-b": () => change(editor, "wrap", (chain) => chain.toggleWrap("blockquote")),
"Mod-Alt-c": () => change(editor, "convert", (chain) => chain.toggleNode("codeBlock", "paragraph")),
// Falls through to the core keymap's exit-code binding when the cursor is not in a task.
"Mod-Enter": () =>
editor.commands.command(({ state, tr, dispatch }) => {
const { $from } = state.selection;
for (let depth = $from.depth; depth > 0; depth -= 1) {
const item = $from.node(depth);
if (item.type.name !== "taskItem") continue;
if (dispatch) {
tr.setNodeMarkup($from.before(depth), undefined, {
...item.attrs,
checked: !item.attrs.checked,
});
}
return true;
}
return false;
}),
// A fence and a raw block take the newline as the newline they are holding; everywhere else
// this is a `<br>`, and the guard is which of those the file can hold. A cell cannot: GFM
// gives a cell one line, so the serializer writes a break inside one as a space and the next
// open of the file has no break in it. Nothing was lost, and an editor drawing a line the
// file swallows is still an editor showing a save that did not happen.
"Shift-Enter": () => {
if (editor.commands.newlineInCode()) return true;
// Claimed rather than declined, which is the difference between refusing and letting
// somebody else do it: a shortcut that answers false leaves the keydown to the browser,
// and a browser handed Shift+Enter in a contenteditable puts a <br> in by itself.
if (!breakable(editor.state)) return true;
return editor.commands.insertContent({ type: "hardBreak" });
},
Enter: () =>
editor.commands.first(({ commands }) => [
() => commands.splitListItem("taskItem"),
() => commands.splitListItem("listItem"),
]),
// Tab inside a table is src/editor/blocks/tables.ts, which binds it ahead of this one and
// answers with prosemirror-tables' own cell walk, claiming the key in the first and the last
// cell too so that neither of these is reached from inside one. What is left here is the list
// case, and the guard is still said out loud because that is an ordering rather than a rule
// and these two are the direction that costs a document. A cell holds inline content and can
// never hold a list of its own, so the only item either command could find to act on is the
// one the table itself is nested in: a table indented under a bullet, with the whole list
// lifted apart by a key the user pressed to move back one cell.
Tab: () =>
!isInTable(editor.state) &&
(editor.commands.sinkListItem("taskItem") || editor.commands.sinkListItem("listItem")),
"Shift-Tab": () =>
!isInTable(editor.state) &&
(editor.commands.liftListItem("taskItem") || editor.commands.liftListItem("listItem")),
};
},
addInputRules() {
const { schema } = this.editor;
return [
textblockTypeInputRule({
find: HEADING,
type: schema.nodes.heading,
getAttributes: (match) => ({ level: match[1].length }),
}),
textblockTypeInputRule({
find: FENCE,
type: schema.nodes.codeBlock,
getAttributes: (match) => ({ language: match[1] ?? null, meta: null }),
}),
wrappingInputRule({ find: BULLET, type: schema.nodes.bulletList }),
wrappingInputRule({
find: ORDERED,
type: schema.nodes.orderedList,
getAttributes: (match) => ({ start: Number(match[1]) }),
joinPredicate: (match, node) => node.childCount + node.attrs.start === Number(match[1]),
}),
wrappingInputRule({ find: QUOTE, type: schema.nodes.blockquote }),
ruleInputRule(schema.nodes.horizontalRule),
checkboxRule(this.editor),
markInputRule({ find: STAR_BOLD, type: schema.marks.strong }),
markInputRule({ find: UNDERSCORE_BOLD, type: schema.marks.strong }),
markInputRule({ find: STAR_ITALIC, type: schema.marks.em }),
markInputRule({ find: UNDERSCORE_ITALIC, type: schema.marks.em }),
markInputRule({ find: STRIKETHROUGH, type: schema.marks.strikethrough }),
markInputRule({ find: CODE, type: schema.marks.code }),
];
},
});
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useRef } from "react";
type Handler = () => void;
const layers: Handler[] = [];
let bound = false;
function onKeyDown(e: KeyboardEvent) {
if (e.key !== "Escape" || e.isComposing || e.defaultPrevented) return;
const top = layers[layers.length - 1];
if (!top) return;
e.preventDefault();
e.stopPropagation();
top();
}
function pushLayer(handler: Handler): () => void {
if (!bound) {
window.addEventListener("keydown", onKeyDown, true);
bound = true;
}
layers.push(handler);
return () => {
const i = layers.lastIndexOf(handler);
if (i !== -1) layers.splice(i, 1);
};
}
export function useEscapeLayer(active: boolean, onEscape: () => void): void {
const latest = useRef(onEscape);
latest.current = onEscape;
useEffect(() => {
if (!active) return;
return pushLayer(() => latest.current());
}, [active]);
}
+234
View File
@@ -0,0 +1,234 @@
// The IPC contract. Every type here mirrors a struct in src-tauri/src/dto.rs. Both sides are
// frozen once written: implementation modules add bodies, not fields.
//
// Typed per-command wrappers live in src/api/, grouped by domain.
import { invoke } from "@tauri-apps/api/core";
/** A Tauri build of any shape, phone included. There is a Rust backend behind this. */
export const isTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
const isMobileOs =
typeof navigator !== "undefined" &&
(/android|iphone|ipod/i.test(navigator.userAgent) ||
// iPadOS reports itself as a Mac and gives itself away only by having a touchscreen.
(/ipad|macintosh/i.test(navigator.userAgent) && navigator.maxTouchPoints > 1));
/**
* A Tauri build with a real window behind it: something to drag by its title bar, a maximize to
* toggle, a close to intercept before it happens.
*
* The difference from `isTauri` is not cosmetic and this is not "am I in the app". `core:window:*`
* sits in the desktop-only capability, so on a phone those commands are not no-ops, they are
* refused, and calling one is a rejected IPC command rather than nothing happening.
*/
export const isDesktop = isTauri && !isMobileOs;
/**
* The one window whose title bar has the traffic lights inside the page. `titleBarStyle: "Overlay"`
* in tauri.conf.json is a macOS-only setting, so on Linux, Windows and every mobile build the
* header has nothing to leave room for.
*/
export const isMacDesktop =
isDesktop && typeof navigator !== "undefined" && /mac/i.test(navigator.userAgent);
/**
* True when there is a backend to answer a command: Tauri, or the dev fixture in a browser.
* Data-loading actions gate on this. Anything touching a window API must gate on `isDesktop`
* instead, and anything listening for a Tauri event on `isTauri`, because a phone emits those too.
*/
export const live = (): boolean => isTauri || import.meta.env.DEV;
/** The `menu-action` event, whose payload is one of these ids. Mirrors the ids built in lib.rs. */
export const MENU_ACTION_EVENT = "menu-action";
/** The `watch-event` event, whose payload is a `WatchEvent`. */
export const WATCH_EVENT = "watch-event";
/** The `index-progress` event, whose payload is an `IndexStatus`. */
export const INDEX_PROGRESS_EVENT = "index-progress";
export type MenuAction =
| "open-folder"
| "new-doc"
| "new-folder"
| "save"
| "close-folder"
| "settings"
| "find"
| "find-in-files"
| "quick-open"
| "command-palette"
| "toggle-sidebar"
| "check-updates"
| "report-issue";
/**
* One open folder. `id` is derived from the path, so it survives a relaunch and a root can be
* addressed without carrying the path around.
*/
export interface RootInfo {
id: string;
path: string;
/** The folder's own name, which is what the sidebar heading shows. */
name: string;
openedMs: number;
}
export type FileKind = "dir" | "markdown" | "text" | "other";
/**
* A node in one root's tree, including the root itself. The whole tree is read in one go, so
* `children` being empty means a directory is empty, never that it is unexplored.
*/
export interface FileNode {
path: string;
name: string;
kind: FileKind;
/**
* True for markdown and .txt, the two kinds that open in the editor. A directory is not editable
* either, so the greyed row in the tree is `kind === "other"` and not `!editable`. A greyed row
* opens in the system default app.
*/
editable: boolean;
modifiedMs: number;
children: FileNode[];
}
/**
* `modifiedMs` is the timestamp the text was read at. Keep it and hand it back on write: it is
* the only way to tell an unsaved buffer apart from a file something else has touched since.
* Frontmatter is not split out here, because the editor parses it, hides it and writes it back.
*/
export interface ReadResult {
path: string;
text: string;
modifiedMs: number;
}
export interface WriteResult {
path: string;
modifiedMs: number;
/**
* The file moved on from the timestamp the caller expected and nothing was written. Not an
* error: the document is still open and still unsaved, and the user has to be asked which copy
* wins.
*/
conflict: boolean;
}
/**
* Where a pasted image landed. `relPath` is what goes into the markdown link, relative to the
* document that received the paste; `path` is absolute, which is what the tree needs.
*/
export interface AssetResult {
path: string;
relPath: string;
}
/** Payload of `watch-event`. `root` is a `RootInfo` id. */
export interface WatchEvent {
root: string;
path: string;
kind: "created" | "modified" | "removed" | "renamed";
/** Where the file was before a rename, null on every other kind. */
oldPath: string | null;
}
/**
* Progress of the SQLite index, which lives in the app data directory and never in a user folder.
*/
export interface IndexStatus {
phase: "idle" | "indexing" | "error";
indexed: number;
total: number;
/** Epoch milliseconds of the last completed pass. */
lastIndexed: number | null;
error: string | null;
message: string | null;
}
/** Payload of `index-progress`. */
export type IndexProgress = IndexStatus;
/**
* Half-open offsets into whichever string the hit says they belong to, for highlighting.
*
* The unit is a UTF-16 code unit, so these index a JavaScript string directly and `slice` is the
* whole of drawing a highlight. The Rust side works in code points and converts once on the way
* out, because the two agree everywhere in the BMP and part company by one per emoji.
*
* `SpellIssue` is in code points instead, and that is not an inconsistency: its offsets address a
* ProseMirror document, which counts code points, and this one addresses a string.
*/
export interface MatchRange {
start: number;
end: number;
}
/**
* One quick-open result. `ranges` index into `relPath`, which is also what the row shows, so a
* match on a folder name can be highlighted where it actually was.
*/
export interface QuickOpenHit {
path: string;
name: string;
root: string;
relPath: string;
score: number;
ranges: MatchRange[];
}
/**
* One full text result. `line` is one-based and counted over the file as it sits on disk,
* frontmatter included, so jumping to it lands in the right place. `ranges` index into `snippet`.
*/
export interface SearchHit {
path: string;
root: string;
title: string;
line: number;
snippet: string;
ranges: MatchRange[];
}
/**
* A document that links here, shown at the end of the document it points at. Links between
* documents are relative markdown links, so a backlink is a resolved `](../thing.md)` and nothing
* more.
*/
export interface Backlink {
path: string;
title: string;
snippet: string;
}
/**
* One misspelling in a run of text handed to the checker.
*
* `start` and `end` are half-open offsets in characters, which is what ProseMirror counts in, so a
* range can be turned into a decoration without any conversion on this side. The Rust side is the
* only place that knows AppKit answers in UTF-16.
*
* `suggestions` can be empty. The system checker often knows a word is wrong without knowing what
* was meant, and an issue with no menu is still an issue worth underlining.
*/
export interface SpellIssue {
start: number;
end: number;
word: string;
suggestions: string[];
}
/**
* In Tauri this is `invoke`. Opened in a browser during development it is served from the dev
* fixture instead, so the real UI can be driven and looked at without a build of the Rust side
* and without touching anybody's documents. The branch is compiled out of a production bundle,
* and `isTauri` means it can never shadow the real backend inside the app.
*/
export function call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
if (import.meta.env.DEV && !isTauri) {
return import("./dev/mockIpc").then((m) => m.mockCall<T>(command, args));
}
return invoke<T>(command, args);
}
+65
View File
@@ -0,0 +1,65 @@
// The table's own invariants. Two bindings on one combo in one context would silently shadow each
// other, a group the sheet does not render would silently hide a key, and a menu id with no real
// command behind it would build fine in Rust and do nothing at all in TypeScript, so all three are
// asserted here rather than discovered later.
import { describe, expect, it } from "vitest";
import { BINDINGS, GROUPS, bindingLabel, keyLabel, normalizeCombo } from "./bindings";
import { COMMANDS } from "./commands";
import { MENU_IDS } from "./menu";
describe("the binding table", () => {
it("never binds one combo twice in the same context", () => {
const seen = new Set<string>();
for (const binding of BINDINGS) {
for (const key of binding.keys) {
const slot = `${binding.context}:${normalizeCombo(key)}`;
expect(seen.has(slot), `${slot} is bound twice`).toBe(false);
seen.add(slot);
}
}
});
it("puts every binding in a group the sheet renders", () => {
for (const binding of BINDINGS) expect(GROUPS).toContain(binding.group);
});
it("can name every binding, including the ones it does not own", () => {
for (const binding of BINDINGS) {
expect(bindingLabel(binding, (id) => `command ${id}`)).not.toBe("");
}
});
it("documents Escape without claiming to handle it", () => {
const escape = BINDINGS.find((b) => b.keys.includes("Escape"));
expect(escape?.command).toBeNull();
});
it("reads a combo the same way the dispatcher builds one", () => {
expect(normalizeCombo("cmd+k")).toBe("cmd+k");
expect(normalizeCombo("Cmd+K")).toBe("cmd+K");
expect(normalizeCombo("H")).toBe("H");
expect(normalizeCombo("/")).toBe("/");
});
it("prints a shifted letter as a shifted letter", () => {
expect(keyLabel("H")).toBe("⇧H");
expect(keyLabel("h")).toBe("h");
expect(keyLabel("Enter")).toBe("↩");
expect(keyLabel("Escape")).toBe("⎋");
});
it("tells a plain modifier combo apart from its shifted twin", () => {
// The exact glyph depends on the platform PRIMARY_LABEL resolves to; what must hold
// everywhere is that the two combos never collide once normalized or labeled.
expect(normalizeCombo("cmd+f")).not.toBe(normalizeCombo("cmd+F"));
expect(keyLabel("cmd+f")).not.toBe(keyLabel("cmd+F"));
});
});
describe("the menu bridge", () => {
it("maps every menu id src-tauri/src/lib.rs emits to a real command", () => {
const ids = new Set(COMMANDS.map((c) => c.id));
for (const id of MENU_IDS) expect(ids.has(id)).toBe(true);
});
});
+166
View File
@@ -0,0 +1,166 @@
// The keymap, declared once. `keymap.ts` dispatches from this table and the shortcuts sheet
// renders the `?` sheet from it, so a binding that exists but is undocumented is not something you
// can write: the sheet is generated, never maintained.
//
// A combo is canonical: modifiers in `cmd+ctrl+alt` order, then `KeyboardEvent.key` verbatim.
// `cmd` means the platform's primary modifier, Command on macOS and Control everywhere else, which
// is what the native menu's `CmdOrCtrl` accelerators mean too. Shift is not a modifier here: it is
// already baked into the key, so `H` is the shifted `h` and reads that way in the table. That
// still holds once another modifier is in play: `cmd+F` is Cmd+Shift+F, not a typo of `cmd+f`, and
// the case is significant precisely because it is the only thing telling the two apart.
//
// Nothing is chorded and nothing is modal. Two keys never combine into a third meaning.
import type { CommandId } from "./commands";
/**
* Which frame of the context stack a binding belongs to. `overlay` carries no bindings of its own:
* pushing it is how quick open, find in files, the command palette, settings or the shortcuts
* sheet shadow the whole document keymap while leaving `global` reachable.
*/
export type KeyContext = "global" | "document" | "overlay";
export type BindingGroup = "File" | "Navigation" | "Search" | "View" | "App";
interface BindingBase {
/** Every combo that runs it. The sheet shows them all; the dispatcher accepts any. */
keys: readonly string[];
context: KeyContext;
group: BindingGroup;
/** Off by default: a key must never be stolen from an input. On for anything that is purely a
* modifier chord, since the main editing surface is contenteditable essentially all the time and
* a chord can never insert a character by accident the way a bare key can. */
allowInInput?: boolean;
}
export interface CommandBinding extends BindingBase {
command: CommandId;
}
/** A key the keymap deliberately does not own, documented so the sheet is not a half-truth. */
export interface NoteBinding extends BindingBase {
command: null;
label: string;
}
export type Binding = CommandBinding | NoteBinding;
export const BINDINGS: readonly Binding[] = [
{ keys: ["cmd+o"], command: "open-folder", context: "document", group: "File", allowInInput: true },
{ keys: ["cmd+n"], command: "new-doc", context: "document", group: "File", allowInInput: true },
{ keys: ["cmd+s"], command: "save", context: "document", group: "File", allowInInput: true },
{
keys: ["cmd+p"],
command: "quick-open",
context: "document",
group: "Navigation",
allowInInput: true,
},
{
keys: ["cmd+["],
command: "previous-document",
context: "document",
group: "Navigation",
allowInInput: true,
},
{
keys: ["cmd+]"],
command: "next-document",
context: "document",
group: "Navigation",
allowInInput: true,
},
{ keys: ["cmd+f"], command: "find", context: "document", group: "Search", allowInInput: true },
{
keys: ["cmd+F"],
command: "find-in-files",
context: "document",
group: "Search",
allowInInput: true,
},
{
keys: ["cmd+\\"],
command: "toggle-sidebar",
context: "document",
group: "View",
allowInInput: true,
},
// The palette is the one thing an overlay may not shadow: it is how you get anywhere from
// inside anything else.
{ keys: ["cmd+k"], command: "command-palette", context: "global", group: "App", allowInInput: true },
{ keys: ["cmd+,"], command: "settings", context: "document", group: "App", allowInInput: true },
{ keys: ["?", "cmd+/"], command: "shortcuts", context: "document", group: "App" },
// Escape unwinds the layer stack in `src/escape.ts`, which knows about nested confirmations.
{
keys: ["Escape"],
command: null,
label: "Dismiss whatever is open",
context: "global",
group: "App",
},
];
export const GROUPS: readonly BindingGroup[] = ["File", "Navigation", "Search", "View", "App"];
const isMac =
typeof navigator !== "undefined" && /mac|iphone|ipad/i.test(navigator.userAgent ?? "");
/** The primary modifier as the platform names it. */
export const PRIMARY_LABEL = isMac ? "⌘" : "Ctrl+";
/** True when the event holds the platform's primary modifier, whatever the hardware calls it. */
export const primaryHeld = (e: { metaKey: boolean; ctrlKey: boolean }): boolean =>
isMac ? e.metaKey : e.ctrlKey;
export const secondaryHeld = (e: { metaKey: boolean; ctrlKey: boolean }): boolean =>
isMac ? e.ctrlKey : e.metaKey;
/**
* `Cmd+K` and `cmd+k` are the same binding; the table may be written either way. The key itself
* keeps its case, though: that is how `cmd+F` stays distinct from `cmd+f` once a modifier is
* already in the combo and cannot be recovered by re-deriving it from a lowercase string.
*/
export function normalizeCombo(combo: string): string {
const parts = combo.split("+");
const key = parts.pop() ?? "";
const mods = new Set(parts.map((p) => p.toLowerCase()));
const prefix = ["cmd", "ctrl", "alt"].filter((m) => mods.has(m)).join("+");
return prefix ? `${prefix}+${key}` : key;
}
const NAMED: Record<string, string> = {
Enter: "↩",
Escape: "⎋",
ArrowUp: "↑",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
Tab: "⇥",
" ": "Space",
};
/** `cmd+k` becomes ⌘K, `H` becomes ⇧H, `cmd+F` becomes ⌘⇧F. What the sheet and the palette both print. */
export function keyLabel(combo: string): string {
const parts = normalizeCombo(combo).split("+");
const key = parts.pop() ?? "";
const mods = parts
.map((m) => (m === "cmd" ? PRIMARY_LABEL : m === "ctrl" ? "⌃" : "⌥"))
.join("");
const shifted = /^[A-Z]$/.test(key);
const named = NAMED[key];
if (named) return `${mods}${named}`;
if (mods) return `${mods}${shifted ? "⇧" : ""}${key.toUpperCase()}`;
return shifted ? `⇧${key}` : key;
}
export const bindingLabel = (binding: Binding, labelOf: (id: CommandId) => string): string =>
binding.command === null ? binding.label : labelOf(binding.command);
/** The combos a command answers to, for a palette row or a button's title attribute. */
export function keysFor(id: CommandId): readonly string[] {
return BINDINGS.find((b) => b.command === id)?.keys ?? [];
}
+362
View File
@@ -0,0 +1,362 @@
// Every action the app can be asked to perform, in one table.
//
// A key, a menu item and a palette row all end up here, which is the point: the native menu emits
// an id and that id is a command, not a second code path. The label lives on the command rather
// than on the binding so the shortcut sheet, the palette and the menu cannot describe the same
// thing in three different ways.
//
// A command whose result is a panel on screen (quick open, find, find in files, the command
// palette, settings, the shortcuts sheet) does not own a visibility flag here: nothing in this
// module renders anything. Whichever component ends up drawing that panel subscribes with
// `onCommand`, so the panel existing is not a precondition for this table to compile and dispatch
// correctly.
import { openUrl } from "@tauri-apps/plugin-opener";
import { relaunch } from "@tauri-apps/plugin-process";
import { check } from "@tauri-apps/plugin-updater";
import { useDocument } from "../store/useDocument";
import { useProofing } from "../store/useProofing";
import { useTheme } from "../store/useTheme";
import { notify } from "../store/useToast";
import { useWorkspace, type TreeNode } from "../store/useWorkspace";
const ISSUES_URL = "https://github.com/priyanshujain/margin-docs/issues";
export type CommandId =
| "open-folder"
| "new-doc"
| "new-folder"
| "close-folder"
| "save"
| "rename-file"
| "duplicate-file"
| "delete-file"
| "reveal-in-finder"
| "quick-open"
| "find"
| "find-in-files"
| "command-palette"
| "toggle-sidebar"
| "toggle-theme"
| "previous-document"
| "next-document"
| "editor-width-narrow"
| "editor-width-normal"
| "editor-width-wide"
| "toggle-spelling"
| "shortcuts"
| "settings"
| "check-updates"
| "report-issue";
export interface Command {
id: CommandId;
/** The words the shortcut sheet, the palette and any tooltip all use. */
label: string;
/** Whether the palette lists it. An action that needs a target the palette cannot show, or one
* fine-grained enough that fuzzy search would only add noise, is a key or a menu row instead. */
palette: boolean;
run: () => void;
}
const workspace = () => useWorkspace.getState();
const doc = () => useDocument.getState();
function findNode(nodes: readonly TreeNode[], path: string): TreeNode | null {
for (const node of nodes) {
if (node.path === path) return node;
if (node.children) {
const found = findNode(node.children, path);
if (found) return found;
}
}
return null;
}
/**
* A folder to create into: the selection itself if it is one, its parent if it is a file, the one
* open root if nothing is selected and there is only one to guess at.
*/
function targetDir(): string | null {
const { roots, selectedPath } = workspace();
if (selectedPath) {
for (const root of roots) {
const node = findNode(root.tree, selectedPath);
if (node) return node.isDir ? node.path : node.path.slice(0, node.path.lastIndexOf("/"));
}
}
return roots.length === 1 ? roots[0].path : null;
}
function requireSelection(): string | null {
const { selectedPath } = workspace();
if (!selectedPath) {
notify("Select a file or folder first");
return null;
}
return selectedPath;
}
async function openFolder(): Promise<void> {
try {
await workspace().openFolder();
} catch (e) {
notify(`Could not open folder: ${String(e)}`);
}
}
async function createDocument(): Promise<void> {
const dir = targetDir();
if (!dir) {
notify("Open a folder first");
return;
}
try {
const path = await workspace().newDocument(dir);
await doc().open(path);
} catch (e) {
notify(`Could not create the document: ${String(e)}`);
}
}
async function createFolder(): Promise<void> {
const dir = targetDir();
if (!dir) {
notify("Open a folder first");
return;
}
try {
await workspace().newFolder(dir);
} catch (e) {
notify(`Could not create the folder: ${String(e)}`);
}
}
function closeActiveFolder(): void {
const { roots, selectedPath } = workspace();
if (roots.length === 0) {
notify("No folder is open");
return;
}
const owner = selectedPath
? roots.find((r) => selectedPath === r.path || selectedPath.startsWith(`${r.path}/`))
: undefined;
const target = owner?.path ?? (roots.length === 1 ? roots[0].path : null);
if (!target) {
notify("Select which folder to close");
return;
}
workspace().closeFolder(target);
}
async function saveDocument(): Promise<void> {
try {
await doc().save();
} catch (e) {
notify(`Could not save: ${String(e)}`);
}
}
async function renameSelected(): Promise<void> {
const path = requireSelection();
if (!path) return;
const name = path.slice(path.lastIndexOf("/") + 1);
const next = window.prompt("Rename to", name);
if (!next || next === name) return;
try {
await workspace().renameEntry(path, next);
} catch (e) {
notify(`Could not rename: ${String(e)}`);
}
}
async function duplicateSelected(): Promise<void> {
const path = requireSelection();
if (!path) return;
try {
await workspace().duplicateEntry(path);
} catch (e) {
notify(`Could not duplicate: ${String(e)}`);
}
}
async function deleteSelected(): Promise<void> {
const path = requireSelection();
if (!path) return;
try {
await workspace().deleteEntry(path);
} catch (e) {
notify(`Could not delete: ${String(e)}`);
}
}
async function revealSelected(): Promise<void> {
const path = requireSelection();
if (!path) return;
try {
await workspace().revealInFinder(path);
} catch (e) {
notify(`Could not reveal in Finder: ${String(e)}`);
}
}
async function goBack(): Promise<void> {
try {
await doc().back();
} catch (e) {
notify(`Could not go back: ${String(e)}`);
}
}
async function goForward(): Promise<void> {
try {
await doc().forward();
} catch (e) {
notify(`Could not go forward: ${String(e)}`);
}
}
let checkingForUpdates = false;
async function checkForUpdates(): Promise<void> {
if (checkingForUpdates) return;
checkingForUpdates = true;
try {
const update = await check();
if (!update) {
notify("Margin Docs is up to date");
return;
}
notify(`Installing ${update.version}…`);
await update.downloadAndInstall();
await relaunch();
} catch (e) {
notify(`Could not check for updates: ${String(e)}`);
} finally {
checkingForUpdates = false;
}
}
const listeners = new Map<CommandId, Set<() => void>>();
/**
* Lets a not-yet-built panel react to its own command without this module owning that panel's
* visibility. The quick open palette, for instance, calls `onCommand("quick-open", () =>
* setOpen(true))` once, on mount, rather than this table reaching into a store it does not own.
*/
export function onCommand(id: CommandId, listener: () => void): () => void {
const set = listeners.get(id) ?? new Set<() => void>();
set.add(listener);
listeners.set(id, set);
return () => {
set.delete(listener);
};
}
function dispatch(id: CommandId): void {
listeners.get(id)?.forEach((listener) => listener());
}
const TABLE: Record<CommandId, Omit<Command, "id">> = {
"open-folder": { label: "Open Folder…", palette: true, run: () => void openFolder() },
"new-doc": { label: "New Document", palette: true, run: () => void createDocument() },
"new-folder": { label: "New Folder", palette: true, run: () => void createFolder() },
"close-folder": { label: "Close Folder", palette: false, run: closeActiveFolder },
save: { label: "Save", palette: true, run: () => void saveDocument() },
"rename-file": { label: "Rename", palette: false, run: () => void renameSelected() },
"duplicate-file": { label: "Duplicate", palette: false, run: () => void duplicateSelected() },
"delete-file": { label: "Delete", palette: false, run: () => void deleteSelected() },
"reveal-in-finder": {
label: "Reveal in Finder",
palette: false,
run: () => void revealSelected(),
},
"quick-open": { label: "Quick Open…", palette: true, run: () => dispatch("quick-open") },
find: { label: "Find…", palette: true, run: () => dispatch("find") },
"find-in-files": {
label: "Find in Files…",
palette: true,
run: () => dispatch("find-in-files"),
},
"command-palette": {
label: "Command Palette…",
palette: false,
run: () => dispatch("command-palette"),
},
"toggle-sidebar": {
label: "Toggle Sidebar",
palette: true,
run: () => dispatch("toggle-sidebar"),
},
"toggle-theme": { label: "Toggle Theme", palette: true, run: () => useTheme.getState().toggle() },
"previous-document": { label: "Previous Document", palette: false, run: () => void goBack() },
"next-document": { label: "Next Document", palette: false, run: () => void goForward() },
"editor-width-narrow": {
label: "Narrow Editor Width",
palette: true,
run: () => dispatch("editor-width-narrow"),
},
"editor-width-normal": {
label: "Normal Editor Width",
palette: true,
run: () => dispatch("editor-width-normal"),
},
"editor-width-wide": {
label: "Wide Editor Width",
palette: true,
run: () => dispatch("editor-width-wide"),
},
// Not dispatched: there is no panel to open and no component that has to be listening, so this
// one turns the setting over directly and the editor's decoration plugin reads it from there.
"toggle-spelling": {
label: "Check Spelling While Typing",
palette: true,
run: () => useProofing.getState().toggle(),
},
shortcuts: { label: "Keyboard Shortcuts", palette: true, run: () => dispatch("shortcuts") },
settings: { label: "Settings…", palette: true, run: () => dispatch("settings") },
"check-updates": {
label: "Check for Updates…",
palette: true,
run: () => void checkForUpdates(),
},
"report-issue": {
label: "Report an Issue…",
palette: true,
run: () => {
openUrl(ISSUES_URL).catch(() => notify("Could not open the browser"));
},
},
};
/** Declaration order, which is the order the palette lists them in. */
export const COMMANDS: readonly Command[] = (Object.keys(TABLE) as CommandId[]).map((id) => ({
id,
...TABLE[id],
}));
export const commandLabel = (id: CommandId): string => TABLE[id].label;
export function runCommand(id: CommandId): void {
TABLE[id].run();
}
/** Case-insensitive subsequence, so `qo` finds "Quick Open…" and `sidebar` finds "Toggle Sidebar". */
export function commandMatches(label: string, query: string): boolean {
const needle = query.toLowerCase().replace(/\s+/g, "");
if (!needle) return true;
const hay = label.toLowerCase();
let at = 0;
for (const ch of needle) {
at = hay.indexOf(ch, at);
if (at === -1) return false;
at += 1;
}
return true;
}
+135
View File
@@ -0,0 +1,135 @@
// One capture-phase listener for the whole app, and a context stack that decides what it is
// allowed to do.
//
// The stack starts empty, which means the document context: whatever the WYSIWYG editor and the
// tree answer to day to day. Pushing `overlay` is how a panel on screen, quick open, find in
// files, the command palette, settings, the shortcuts sheet, shadows the whole document keymap
// while leaving `global` reachable, and each of those panels pushes its own frame with
// `useKeyContext` when it mounts: this module does not hold a registry of which overlays exist,
// only of which one currently has the floor.
//
// Escape is not part of this: `src/escape.ts` already stacks Escape handlers of its own and knows
// about nested confirmations, so this listener steps over the key entirely rather than racing it.
//
// A key is never taken from a text field, and here that mostly means the document itself: the
// WYSIWYG editor is contenteditable, so it counts as "typing" the same way an `<input>` does, and
// only a binding marked `allowInInput` fires while the cursor sits inside it.
import { useEffect } from "react";
import {
BINDINGS,
normalizeCombo,
primaryHeld,
secondaryHeld,
type Binding,
type KeyContext,
} from "./bindings";
import { runCommand } from "./commands";
const index = new Map<string, Binding[]>();
for (const binding of BINDINGS) {
for (const key of binding.keys) {
const combo = normalizeCombo(key);
const found = index.get(combo);
if (found) found.push(binding);
else index.set(combo, [binding]);
}
}
interface Frame {
context: KeyContext;
}
const stack: Frame[] = [];
const activeContext = (): KeyContext => stack[stack.length - 1]?.context ?? "document";
/** Takes the keyboard until the returned function is called. Frames are identity, never by name. */
export function pushContext(context: KeyContext): () => void {
const frame: Frame = { context };
stack.push(frame);
return () => {
const at = stack.indexOf(frame);
if (at !== -1) stack.splice(at, 1);
};
}
/** The hook form, for a component that owns the keyboard while it is on screen. */
export function useKeyContext(context: KeyContext, active = true): void {
useEffect(() => {
if (!active) return;
return pushContext(context);
}, [context, active]);
}
function comboOf(e: KeyboardEvent): string {
const mods =
(primaryHeld(e) ? "cmd+" : "") + (secondaryHeld(e) ? "ctrl+" : "") + (e.altKey ? "alt+" : "");
// Not lowercased: Cmd+Shift+F and Cmd+F arrive as "F" and "f" respectively, and that case is
// the only thing telling them apart once a real modifier is already in the combo.
return mods ? `${mods}${e.key}` : e.key;
}
function resolve(combo: string): Binding | null {
const candidates = index.get(combo);
if (!candidates) return null;
const top = activeContext();
return (
candidates.find((b) => b.context === top) ??
candidates.find((b) => b.context === "global") ??
null
);
}
function isTyping(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el || typeof el.tagName !== "string") return false;
if (el.isContentEditable) return true;
return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT";
}
/**
* A button the user can tab to activates itself on Enter, so the keymap leaves that alone.
*/
function isActivatable(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el || typeof el.tagName !== "string" || el.tabIndex < 0) return false;
return (
el.tagName === "BUTTON" ||
el.tagName === "A" ||
el.tagName === "SUMMARY" ||
el.getAttribute("role") === "button"
);
}
function onKeyDown(e: KeyboardEvent): void {
if (e.isComposing || e.defaultPrevented) return;
if (e.key === "Escape") return;
if ((e.key === "Enter" || e.key === " ") && isActivatable(e.target)) return;
const binding = resolve(comboOf(e));
if (!binding || binding.command === null) return;
if (!binding.allowInInput && (isTyping(e.target) || isTyping(document.activeElement))) return;
e.preventDefault();
e.stopPropagation();
runCommand(binding.command);
}
let installs = 0;
/** Installs the one listener. Reference counted, so React's double effect in dev is harmless. */
export function installKeymap(): () => void {
installs += 1;
if (installs === 1) window.addEventListener("keydown", onKeyDown, true);
return () => {
installs -= 1;
if (installs === 0) window.removeEventListener("keydown", onKeyDown, true);
};
}
/** Mount once, at the top of the tree. */
export function useKeymap(): void {
useEffect(() => installKeymap(), []);
}
+30
View File
@@ -0,0 +1,30 @@
// The native macOS menu emits `menu-action` with the item id it was built with. Those ids are
// command ids themselves, so this is a guard and a lookup rather than a second dispatch table: a
// menu item and a keystroke run the same function or the build fails.
//
// The Tauri listener itself is mounted at the top of the tree; this is what it calls.
import { runCommand, type CommandId } from "./commands";
/** Exactly the ids `src-tauri/src/lib.rs` emits. */
export const MENU_IDS: readonly CommandId[] = [
"open-folder",
"new-doc",
"new-folder",
"save",
"close-folder",
"settings",
"find",
"find-in-files",
"quick-open",
"command-palette",
"toggle-sidebar",
"check-updates",
"report-issue",
];
const known = new Set<string>(MENU_IDS);
export function handleMenuAction(id: string): void {
if (known.has(id)) runCommand(id as CommandId);
}
+766
View File
@@ -0,0 +1,766 @@
// Keeping relative links true across a move or a rename.
//
// A document that moves takes two sets of broken links with it. The ones pointing at it, written in
// other files against the place it used to be, and the ones inside it, written against the folder
// it used to sit in. Both are wrong the moment the file lands somewhere else, and a move that
// leaves them wrong breaks every path in and out of the file the user just dragged without showing
// them a single character of what changed.
//
// This is the only thing in the app that edits a file nobody is looking at, so it is built to
// refuse rather than to guess. Four rules hold over everything below.
//
// It edits the destination and nothing else. A file is never round tripped through the markdown
// writer to change a link. The writer settles a file into the house style on its first save, which
// is a diff the user asked for by typing in it, and is not something forty other files should get
// because one file moved. So the work is on the raw text and the only bytes that move are the ones
// between one link's own destination offsets.
//
// A link it cannot prove is left exactly as it was. mdast reports the destination it read and the
// offsets of the node that held it; this module then goes and finds those same bytes in the source
// before it touches them. The destination text has to match what the parser reported character for
// character, it has to be the only reading of the node's bytes that ends where the node ends, and
// the href going back has to be spellable in the form the file already used. Anything short of that
// is counted as refused and reported, because a rewrite that silently skips a link tells the user
// their links are fine when they are not.
//
// A link that is not a link is never touched. Something inside a fenced block, an indented block, a
// code span or a chunk of raw html is text that looks like a link, and rewriting it changes what
// the document says. Nothing here tests for that, because remark hands those back as `code`,
// `inlineCode` and `html` nodes with no `link` anywhere inside them, so a destination this module
// can see is one the file really has. Frontmatter is out for the same reason: the parser hands the
// whole block back as one opaque node.
//
// And every write is proved before it goes out. The spliced text is parsed again and compared
// against what the splice was meant to do: the same destinations in the same order, each holding
// the href it was given, each sitting at the offset the replacements before it shift it to. A file
// that does not answer exactly that is not written at all. The proof sits between the splice and
// the only call to `fileWrite` in this module, with no condition in front of it, so there is no
// path to disk that goes around it.
//
// Which files get looked at is decided by walking the open roots rather than by asking the index.
// `backlinksFor` is the cheap route and it is deliberately unused: the index is derived state with
// no freshness this module can check, and a stale answer is a file quietly left broken, which is
// the one outcome this project ranks below doing nothing. The sweep reads every markdown document
// in every open root, skipping the parse for text that cannot name the thing that moved, and it
// gives up and says so rather than reading more documents than `MAX_SWEEP_DOCUMENTS`. A file
// outside every open root is never seen by anything here and never will be.
import type { Root } from "mdast";
import { fileRead, fileWrite } from "./api/files";
import { treeRead } from "./api/roots";
import { documentChangedOnDisk } from "./document";
import type { FileNode } from "./ipc";
import { relativeFrom, resolveRelative } from "./links";
import { BOM } from "./markdown/frontmatter";
import { parseToMdast } from "./markdown/handlers";
import { documentKindForPath } from "./model/doc";
import { useDocument } from "./store/useDocument";
import { useWorkspace } from "./store/useWorkspace";
import { notify } from "./store/useToast";
/**
* Where a file or folder was and where it is now, both absolute and spelled the way the tree
* spells them. `fileMove` and `fileRename` both hand back the new path as `node.path`.
*/
export interface Move {
from: string;
to: string;
}
export interface FailedFile {
path: string;
reason: string;
}
export interface LinkRewriteReport {
/** Files whose bytes changed, absolute. */
rewritten: readonly string[];
/** Links that needed a new destination and did not get one, because it could not be proved. */
refused: number;
/** Files that could not be read, could not be written, or that another writer reached first. */
failed: readonly FailedFile[];
/** The open document, when it was left alone because its buffer holds an edit nothing else has. */
heldBack: string | null;
/**
* Whether every document that could hold a link into the move was actually read. `partial` means
* the open roots hold more documents than one move is allowed to read, or a root would not answer
* at all, so only the moved documents' own links were brought up to date.
*/
coverage: "complete" | "partial";
}
/**
* More documents than one move gets to read. A move is a deliberate and infrequent gesture, so the
* cost of reading a workspace is worth paying to be exact about it, but there is a size past which
* a drag would sit there for a quarter of a minute, and at that point saying so beats doing it.
*/
const MAX_SWEEP_DOCUMENTS = 5000;
/** `file_read` is an async command, so the sweep is bounded by the round trip rather than by disk. */
const READ_CONCURRENCY = 8;
const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
const isUnder = (path: string, dir: string): boolean => path.startsWith(`${dir}/`);
/**
* Where a path ends up after the move, which is the path itself for everything the move did not
* touch. A folder move is the whole of the folder case: every document under it moved at once, so
* the same prefix rewrite answers for the folder and for every path inside it.
*/
function mapped(path: string, move: Move): string {
if (path === move.from) return move.to;
if (isUnder(path, move.from)) return move.to + path.slice(move.from.length);
return path;
}
// ---------------------------------------------------------------------------------------------
// Finding a destination's own bytes.
//
// mdast reports a link's `url` and the offsets of the node around it, and nothing at all about
// where inside those offsets the destination was written. Reconstructing that from the grammar is
// the only way to splice one href without reserializing the file, so the reader below walks the
// resource the way CommonMark defines it and then checks its answer against the url the parser
// already reported. Two readings that both fit is no reading at all.
// ---------------------------------------------------------------------------------------------
interface Destination {
/** Offsets into the slice the reader was given, covering the destination text and not its
* delimiters. */
start: number;
end: number;
/** The `<...>` spelling, which has its own rules about what can be written inside it. */
angled: boolean;
/** Index just past the last character the reader consumed. */
after: number;
}
const WHITESPACE = /\s/;
function skipWhitespace(text: string, from: number): number {
let at = from;
while (at < text.length && WHITESPACE.test(text[at])) at += 1;
return at;
}
/**
* A link destination, in either of the two spellings markdown has for one.
*
* The bare form runs to whitespace or to a closing parenthesis that is not inside a balanced pair,
* and a backslash escape carries the character after it whatever that character is. The angled form
* runs to the first unescaped `>` and holds no line ending. Both refuse a control character, which
* markdown has no way to write in a destination at all.
*/
function readDestination(text: string, from: number): Destination | null {
if (text[from] === "<") {
let at = from + 1;
while (at < text.length) {
const char = text[at];
if (char === "\\") {
at += 2;
continue;
}
if (char === "<" || char === "\n" || char === "\r") return null;
if (char === ">") return { start: from + 1, end: at, angled: true, after: at + 1 };
at += 1;
}
return null;
}
let depth = 0;
let at = from;
while (at < text.length) {
const char = text[at];
if (char === "\\") {
at += 2;
continue;
}
if (char === "(") depth += 1;
else if (char === ")") {
if (depth === 0) break;
depth -= 1;
} else if (WHITESPACE.test(char)) break;
else if (isControl(char)) return null;
at += 1;
}
if (depth !== 0) return null;
// A trailing backslash steps the cursor past the last byte, and an offset outside the text it
// indexes is the one thing nothing below could splice safely.
const end = Math.min(at, text.length);
return { start: from, end, angled: false, after: end };
}
function isControl(char: string): boolean {
const code = char.charCodeAt(0);
return code < 0x20 || code === 0x7f;
}
/** The optional title after a destination, in any of its three delimiters. */
function skipTitle(text: string, from: number): number | null {
const open = text[from];
if (open !== '"' && open !== "'" && open !== "(") return null;
const close = open === "(" ? ")" : open;
let at = from + 1;
while (at < text.length) {
const char = text[at];
if (char === "\\") {
at += 2;
continue;
}
if (char === close) return at + 1;
if (open === "(" && char === "(") return null;
at += 1;
}
return null;
}
/** `( destination "title" )`, starting at the opening parenthesis. */
function readResource(text: string, from: number): Destination | null {
const destination = readDestination(text, skipWhitespace(text, from + 1));
if (destination === null) return null;
let at = skipWhitespace(text, destination.after);
if (at > destination.after) {
const title = skipTitle(text, at);
if (title !== null) at = skipWhitespace(text, title);
}
if (text[at] !== ")") return null;
return { ...destination, after: at + 1 };
}
/**
* The destination inside an inline link or image, proved rather than located.
*
* A label can hold parentheses of its own, a code span inside it can hold an unbalanced one, and
* neither is a thing to reason about from the outside. So every parenthesis in the node is tried as
* the start of the resource, and a reading counts only when it consumes the node exactly to its
* last byte and hands back the destination the parser already reported. If two readings do that,
* neither is provable and the link is left alone.
*/
function inlineDestination(slice: string, url: string): Destination | null {
let found: Destination | null = null;
for (let at = 0; at < slice.length; at += 1) {
if (slice[at] === "\\") {
at += 1;
continue;
}
if (slice[at] !== "(") continue;
const resource = readResource(slice, at);
if (resource === null || resource.after !== slice.length) continue;
if (slice.slice(resource.start, resource.end) !== url) continue;
if (found !== null) return null;
found = resource;
}
return found;
}
/**
* The destination in a reference definition, `[label]: destination "title"`.
*
* Anchored at the first byte rather than searched for, because a definition's label cannot hold an
* unescaped bracket, so the end of it is not a guess.
*/
function definitionDestination(slice: string, url: string): Destination | null {
if (slice[0] !== "[") return null;
let at = 1;
while (at < slice.length) {
const char = slice[at];
if (char === "\\") {
at += 2;
continue;
}
if (char === "[") return null;
if (char === "]") break;
at += 1;
}
if (slice[at] !== "]" || slice[at + 1] !== ":") return null;
const destination = readDestination(slice, skipWhitespace(slice, at + 2));
if (destination === null || destination.end === destination.start) return null;
if (slice.slice(destination.start, destination.end) !== url) return null;
let rest = skipWhitespace(slice, destination.after);
if (rest > destination.after) {
const title = skipTitle(slice, rest);
if (title !== null) rest = skipWhitespace(slice, title);
}
return rest === slice.length ? destination : null;
}
// ---------------------------------------------------------------------------------------------
// What the new href should be.
// ---------------------------------------------------------------------------------------------
/**
* Whether `href` can be written where the file already has one and read back as itself.
*
* A `#` or a `?` is the one class `relativeFrom` cannot make safe: it escapes a space and nothing
* else, so a file whose name holds either character comes back as a href that every reader splits
* into a shorter path and a fragment, pointing at a file that is not there. Whitespace, a
* backslash and an angle bracket are refused for the same reason from the other side, and a bare
* destination additionally has to keep its parentheses balanced, since that is what tells the
* parser where it ends.
*/
function spellable(href: string, angled: boolean): boolean {
if (href === "") return false;
if (/[#?\s\\<>]/.test(href)) return false;
for (const char of href) if (isControl(char)) return false;
if (angled) return true;
let depth = 0;
for (const char of href) {
if (char === "(") depth += 1;
else if (char === ")") depth -= 1;
if (depth < 0) return false;
}
return depth === 0;
}
interface NextHref {
/** Everything up to the fragment, which is the part `spellable` has to answer for. */
path: string;
/** The whole destination going back into the file, the author's own fragment included. */
text: string;
}
/**
* The href to put in place of `href`, or null where the file already says the right thing.
*
* Both ends of a link can move: the document holding it, which changes what its relative paths are
* measured from, and the document it points at. So the question is asked as two spellings of the
* same link, the one this app would have written before the move and the one it would write after,
* and a link is rewritten only when those two differ. That is what keeps a rename in place from
* respelling every link in the file it renamed: a `dir/x.md` the author wrote without the leading
* `./` is left as `dir/x.md`, because the relationship it describes did not change and this module
* is not here to normalise anybody's text.
*
* A href beginning with `/` is left alone whatever moved. This app reads one as a filesystem path,
* every static site generator reads it as site root relative, and rewriting somebody's link on the
* strength of the reading this app happens to have picked is a change of meaning rather than a
* repair.
*/
function nextHref(oldFrom: string, newFrom: string, href: string, move: Move): NextHref | null {
if (href.startsWith("/")) return null;
const target = resolveRelative(oldFrom, href);
if (target === null) return null;
const before = relativeFrom(oldFrom, target);
const after = relativeFrom(newFrom, mapped(target, move));
if (after === before) return null;
const cut = href.search(/[#?]/);
const suffix = cut === -1 ? "" : href.slice(cut);
// A trailing slash is the author saying they mean a folder, and it is a byte of their file.
const was = cut === -1 ? href : href.slice(0, cut);
const path = after + (was.endsWith("/") ? "/" : "");
return { path, text: path + suffix };
}
// ---------------------------------------------------------------------------------------------
// One file's text.
// ---------------------------------------------------------------------------------------------
interface LinkNode {
type: "link" | "image" | "definition";
url: string;
start: number;
end: number;
}
interface Replacement {
start: number;
end: number;
text: string;
}
/**
* Every destination in the tree, in one fixed walk order.
*
* Null when a node came back without offsets or without a url, which means the tree does not
* describe the text it was parsed from and nothing below could be verified against it.
*
* A link node is descended into as well as recorded, because an image can sit inside a link and
* both hold a destination. That makes the node ranges nest, which is why nothing here assumes they
* do not; the destinations themselves stay disjoint, and that is the thing the splice needs.
*/
function destinations(tree: Root): LinkNode[] | null {
const found: LinkNode[] = [];
let whole = true;
const visit = (node: unknown): void => {
if (node === null || typeof node !== "object") return;
const branch = node as {
type?: unknown;
url?: unknown;
children?: unknown;
position?: { start?: { offset?: number }; end?: { offset?: number } };
};
if (branch.type === "link" || branch.type === "image" || branch.type === "definition") {
const start = branch.position?.start?.offset;
const end = branch.position?.end?.offset;
if (typeof start !== "number" || typeof end !== "number" || typeof branch.url !== "string") {
whole = false;
} else {
found.push({ type: branch.type, url: branch.url, start, end });
}
}
if (Array.isArray(branch.children)) for (const child of branch.children) visit(child);
};
for (const child of tree.children) visit(child);
return whole ? found : null;
}
/**
* How far a replacement list moves the byte at `offset`.
*
* Everything that finished at or before the offset has already shifted it, and a replacement the
* offset sits inside has not, which is exactly right for both ends of a node that contains one: a
* link's start is in front of its own destination and its end is behind it.
*/
function shiftFor(replacements: readonly Replacement[], offset: number): number {
let shift = 0;
for (const replacement of replacements) {
if (replacement.end <= offset) shift += replacement.text.length - (replacement.end - replacement.start);
}
return shift;
}
/**
* The proof, run on every file before it is written and never skipped.
*
* A splice is meant to change one run of bytes inside each destination it was aimed at and nothing
* else in the file, and the way to know it did is to read the result back with the same parser.
* Every destination has to still be there, still be the same kind of node, hold the href it was
* given or the one it always had, and sit at the offset the replacements in front of it move it to.
* A destination that swallowed its own title, an href that closed a link early, a splice landing at
* an offset the parse did not agree with: all of them come out as a mismatch here, and a mismatch
* means the file is not written at all.
*/
function proves(
body: string,
before: readonly LinkNode[],
replacements: readonly Replacement[],
expected: ReadonlyMap<number, string>,
): boolean {
const after = destinations(parseToMdast(body));
if (after === null || after.length !== before.length) return false;
for (let index = 0; index < before.length; index += 1) {
const was = before[index];
const now = after[index];
if (now.type !== was.type) return false;
if (now.url !== (expected.get(was.start) ?? was.url)) return false;
if (now.start !== was.start + shiftFor(replacements, was.start)) return false;
if (now.end !== was.end + shiftFor(replacements, was.end)) return false;
}
return true;
}
export interface TextOutcome {
/** The whole file with its destinations brought up to date, or null when nothing was written. */
text: string | null;
/** Links that needed a new destination and were left with the old one. */
refused: number;
/**
* The file as a whole was put down rather than one link in it: the parse did not describe the
* text it came from, or the finished splice did not read back as the thing it was meant to be.
* Nothing was written and the caller reports it rather than counting it as a file with nothing
* to do.
*/
unprovable: boolean;
}
/**
* One file's raw text in, the same text with its destinations brought up to date out.
*
* `path` is where this file was before the move, which for everything outside what moved is simply
* where it still is. Where it went is not a second argument: it is `path` put through the move, so
* the two ends of the arithmetic cannot be handed in disagreeing with each other.
*
* Pure, and the only part of this module that decides what a file's new bytes are. Nothing here
* reads or writes anything.
*
* A byte order mark is cut off and put back rather than parsed around, because remark's own
* handling of a leading mark is not something the offsets below can afford to be wrong about. The
* text is otherwise given to the parser exactly as it came off disk, carriage returns and all: the
* bridge normalises line endings before it parses and this must not, since the offsets have to
* index the bytes that are going back to the file.
*/
export function rewriteLinksIn(text: string, path: string, move: Move): TextOutcome {
const oldFrom = path;
const newFrom = mapped(path, move);
const mark = text.startsWith(BOM) ? BOM : "";
const body = text.slice(mark.length);
const nodes = destinations(parseToMdast(body));
if (nodes === null) return { text: null, refused: 0, unprovable: true };
const replacements: Replacement[] = [];
const expected = new Map<number, string>();
let refused = 0;
for (const node of nodes) {
const href = nextHref(oldFrom, newFrom, node.url, move);
if (href === null) continue;
const slice = body.slice(node.start, node.end);
const where =
node.type === "definition"
? definitionDestination(slice, node.url)
: inlineDestination(slice, node.url);
if (where === null || !spellable(href.path, where.angled)) {
refused += 1;
continue;
}
replacements.push({
start: node.start + where.start,
end: node.start + where.end,
text: href.text,
});
expected.set(node.start, href.text);
}
if (replacements.length === 0) return { text: null, refused, unprovable: false };
replacements.sort((a, b) => a.start - b.start);
for (let index = 1; index < replacements.length; index += 1) {
// Two destinations cannot overlap, so a pair that does means the offsets are not describing
// this text and the splice would cut one of them in half.
if (replacements[index].start < replacements[index - 1].end) {
return { text: null, refused, unprovable: true };
}
}
let next = body;
for (let index = replacements.length - 1; index >= 0; index -= 1) {
const replacement = replacements[index];
next = next.slice(0, replacement.start) + replacement.text + next.slice(replacement.end);
}
if (!proves(next, nodes, replacements, expected)) {
return { text: null, refused, unprovable: true };
}
return { text: mark + next, refused, unprovable: false };
}
// ---------------------------------------------------------------------------------------------
// The files.
// ---------------------------------------------------------------------------------------------
type FileResult =
| { kind: "unchanged"; refused: number }
| { kind: "rewritten"; path: string; refused: number }
| { kind: "failed"; path: string; reason: string };
/**
* Reads one file, rewrites what has to move, and writes it back through the same atomic write every
* save in this app goes through.
*
* `marker` is a name the text has to hold for anything in it to be able to point into the move, and
* skipping the parse on a file that does not hold it is the whole reason a sweep is affordable. A
* percent sign is enough to earn a parse on its own, since a percent escaped path can spell a name
* without containing it.
*
* The mtime the read came back with is handed to the write, so a file another program touched in
* between comes back as a conflict and keeps its bytes rather than losing them to a rewrite built
* on a copy that is no longer there.
*/
async function rewriteFile(path: string, move: Move, marker: string | null): Promise<FileResult> {
const newPath = mapped(path, move);
let text: string;
let modifiedMs: number;
try {
const read = await fileRead(newPath);
text = read.text;
modifiedMs = read.modifiedMs;
} catch (e) {
return { kind: "failed", path: newPath, reason: String(e) };
}
if (marker !== null && !text.includes(marker) && !text.includes("%")) {
return { kind: "unchanged", refused: 0 };
}
let outcome: TextOutcome;
try {
outcome = rewriteLinksIn(text, path, move);
} catch (e) {
// The parser is the only thing in there that can raise, and a file it will not read is a file
// whose links nothing here knows the shape of. Nothing has been written at this point.
return { kind: "failed", path: newPath, reason: String(e) };
}
if (outcome.unprovable) {
return { kind: "failed", path: newPath, reason: "its links could not be matched to its text" };
}
if (outcome.text === null) return { kind: "unchanged", refused: outcome.refused };
try {
const written = await fileWrite(newPath, outcome.text, modifiedMs);
if (written.conflict) {
return { kind: "failed", path: newPath, reason: "it changed on disk part way through" };
}
} catch (e) {
return { kind: "failed", path: newPath, reason: String(e) };
}
return { kind: "rewritten", path: newPath, refused: outcome.refused };
}
function documentsUnder(node: FileNode, into: string[]): void {
if (node.kind === "dir") {
for (const child of node.children) documentsUnder(child, into);
return;
}
// Markdown only. A .txt holding something that looks like a link is not markdown, and parsing one
// as markdown to edit it would be this module deciding what a file is against its own extension.
if (documentKindForPath(node.path) === "markdown") into.push(node.path);
}
/** Every markdown document in every open root, read fresh so the move is already in it. */
async function sweepCandidates(): Promise<string[] | null> {
const roots = useWorkspace.getState().roots;
const found: string[] = [];
for (const root of roots) {
try {
documentsUnder(await treeRead(root.id), found);
} catch {
// A root that will not answer is a root whose documents were not looked at, and the report
// has to say so rather than count the ones that did answer as the whole workspace.
return null;
}
}
return found;
}
async function inParallel<T>(items: readonly T[], run: (item: T) => Promise<void>): Promise<void> {
let next = 0;
const worker = async (): Promise<void> => {
for (;;) {
const index = next;
next += 1;
if (index >= items.length) return;
await run(items[index]);
}
};
await Promise.all(Array.from({ length: Math.min(READ_CONCURRENCY, items.length) }, worker));
}
const count = (n: number, thing: string): string => `${n} ${thing}${n === 1 ? "" : "s"}`;
function describe(report: LinkRewriteReport): string | null {
const trouble: string[] = [];
if (report.failed.length > 0) {
trouble.push(`${count(report.failed.length, "file")} could not be updated`);
}
if (report.heldBack !== null) {
trouble.push(`${baseName(report.heldBack)} has unsaved changes and was left alone`);
}
if (report.refused > 0) {
trouble.push(`${count(report.refused, "link")} could not be matched exactly`);
}
if (report.coverage === "partial") {
trouble.push("only the moved documents' own links were checked");
}
if (trouble.length === 0) {
if (report.rewritten.length === 0) return null;
return `Updated links in ${count(report.rewritten.length, "file")}.`;
}
const done =
report.rewritten.length === 0
? "Links after the move"
: `Updated links in ${count(report.rewritten.length, "file")}`;
return `${done}: ${trouble.join("; ")}.`;
}
/**
* Brings every relative link that the move made wrong back up to date, and says what it could not
* do.
*
* Call it once, after `fileMove` or `fileRename` has come back, and before the open document is
* reopened at its new path. Both halves of the ordering matter. Before the move there is nothing at
* the new path to read; after the reopen the editor is holding a buffer of the bytes as they were
* and the next keystroke would put the old links back.
*
* It never throws and never fails a move. Everything that went wrong comes back in the report, and
* a single toast describing it is raised from here, so a caller has nothing to remember to say and
* should not add a toast of its own.
*
* Two things are worth knowing about what it does not promise. Each file is written on its own, so
* a failure part way through leaves the files before it correctly rewritten, the file that failed
* with every byte it had, and the ones after it untouched; that is what `failed` is for, and there
* is no rollback because a rollback is another round of writes that can fail in the same way. And
* the open document is skipped outright when its buffer is dirty, because the buffer is the only
* copy of that edit and writing under it would put the two on a collision the user has to resolve.
*/
export async function rewriteLinksForMove(move: Move): Promise<LinkRewriteReport> {
const rewritten: string[] = [];
const failed: FailedFile[] = [];
let refused = 0;
let heldBack: string | null = null;
let coverage: "complete" | "partial" = "complete";
// Nothing moved, or a folder was somehow put inside itself, in which case no prefix rewrite below
// describes where anything ended up.
if (move.from === move.to || isUnder(move.to, move.from)) {
return { rewritten, refused, failed, heldBack, coverage };
}
const candidates = await sweepCandidates();
const documents = candidates ?? [];
const inside = new Set(documents.filter((path) => path === move.to || isUnder(path, move.to)));
// A document dragged into a folder the tree does not list, an ignored one, is not in the sweep at
// all, and its own links are the half of this that needs no sweep to be answered.
if (documentKindForPath(move.to) === "markdown") inside.add(move.to);
let outside = documents.filter((path) => !inside.has(path));
if (candidates === null || outside.length > MAX_SWEEP_DOCUMENTS) {
coverage = "partial";
outside = [];
}
const marker = baseName(move.from);
const openPath = useDocument.getState().path;
// Every job is named by where its file was before the move, because that is the path the store
// still has the open document under and the path its own links were written against. A document
// inside what moved has a marker of null: its base directory changed, so every relative link in
// it is worth looking at whatever the text mentions.
const jobs: { path: string; marker: string | null }[] = [
...[...inside].map((path) => ({ path: move.from + path.slice(move.to.length), marker: null })),
...outside.map((path) => ({ path, marker })),
];
const take = (result: FileResult): void => {
refused += result.kind === "failed" ? 0 : result.refused;
if (result.kind === "rewritten") rewritten.push(result.path);
if (result.kind === "failed") failed.push({ path: result.path, reason: result.reason });
};
// The open document is pulled out and done last on its own, because whether it can be written at
// all is a question about the buffer, and because the editor has to be told afterwards.
const open = jobs.find((job) => job.path === openPath);
await inParallel(
jobs.filter((job) => job !== open),
async (job) => take(await rewriteFile(job.path, move, job.marker)),
);
if (open !== undefined) {
if (useDocument.getState().dirty) {
heldBack = open.path;
} else {
const result = await rewriteFile(open.path, move, open.marker);
take(result);
// Only for a document that did not move. The watcher drops this app's own writes, so nothing
// else is going to tell the editor its file changed underneath it. A document that did move
// is about to be reopened at its new path by the caller, which reads the file again anyway.
if (result.kind === "rewritten" && mapped(open.path, move) === openPath) {
await documentChangedOnDisk(openPath).catch(() => {});
}
}
}
rewritten.sort();
failed.sort((a, b) => a.path.localeCompare(b.path));
const report: LinkRewriteReport = { rewritten, refused, failed, heldBack, coverage };
const message = describe(report);
if (message !== null) notify(message);
return report;
}
+114
View File
@@ -0,0 +1,114 @@
// Following a link out of a document.
//
// A relative link to another file is the only kind this app resolves itself, and it resolves it
// against the document that wrote it rather than against anything the shell knows: a markdown file
// is portable, and `](../reference/keyboard.md)` means the same thing here as it does in every
// other editor that folder is opened in. A document that opens this way is a navigation and goes
// into the same history the back and forward commands walk.
//
// Everything else is the system's: an http link goes to the browser, and a relative link to a file
// this editor does not open goes to whatever macOS opens it with. Nothing here writes anything, and
// a link to a file that is not there is a toast rather than a new file.
import { openUrl } from "@tauri-apps/plugin-opener";
import { openExternal } from "./api/roots";
import { isTauri } from "./ipc";
import { documentKindForPath } from "./model/doc";
import { useDocument } from "./store/useDocument";
import { notify } from "./store/useToast";
const dirName = (path: string): string => path.slice(0, path.lastIndexOf("/")) || "/";
const isAbsoluteUrl = (href: string): boolean => /^[a-z][a-z0-9+.-]*:/i.test(href);
/**
* A link written by hand may hold a literal space; a link written by this app escapes one. Both
* have to open the same file, so the href is decoded before it is resolved, and a percent sign
* that is not a valid escape is left exactly as it was rather than throwing.
*/
function decodeTarget(target: string): string {
try {
return decodeURIComponent(target);
} catch {
return target;
}
}
/**
* `href` as it sits in the file, resolved against the document that holds it. Null for anything
* that is not a path: a bare fragment, a scheme, an empty string.
*/
export function resolveRelative(fromFile: string, href: string): string | null {
if (!href || href.startsWith("#") || isAbsoluteUrl(href)) return null;
const target = decodeTarget(href.split("#")[0].split("?")[0]);
if (!target) return null;
const parts = (target.startsWith("/") ? target : `${dirName(fromFile)}/${target}`).split("/");
const out: string[] = [];
for (const part of parts) {
if (part === "" || part === ".") continue;
if (part === "..") out.pop();
else out.push(part);
}
return `/${out.join("/")}`;
}
/**
* The inverse of `resolveRelative`: the href to write into `fromFile` so that it points at
* `toFile`. Both are absolute paths.
*
* This is what the `[[` picker writes and what a move rewrites, and it is the one function in the
* app that decides what a link between two documents looks like on disk. It never produces an
* absolute path and never produces a bare filename that could be read as a scheme or a fragment: a
* sibling comes back as `./thing.md` rather than `thing.md`, because the leading `./` is what
* makes it unambiguous to every reader including this one.
*
* The result is percent-encoded only where it has to be. A space in a filename breaks a bare
* markdown link, so it is escaped; nothing else is, because encoding a path that did not need it
* makes the file worse to read for no gain.
*/
export function relativeFrom(fromFile: string, toFile: string): string {
const from = fromFile.split("/").filter(Boolean).slice(0, -1);
const to = toFile.split("/").filter(Boolean);
let shared = 0;
while (shared < from.length && shared < to.length - 1 && from[shared] === to[shared]) shared += 1;
const up = Array(from.length - shared).fill("..");
const down = to.slice(shared);
const parts = [...up, ...down];
const href = up.length === 0 ? `./${parts.join("/")}` : parts.join("/");
return href.replace(/ /g, "%20");
}
function toSystem(href: string): void {
if (!isTauri) {
window.open(href, "_blank", "noopener,noreferrer");
return;
}
openUrl(href).catch(() => notify("Could not open that link"));
}
/** A click on a link inside the open document. */
export function openLink(href: string): void {
const from = useDocument.getState().path;
if (from === null) return;
if (isAbsoluteUrl(href)) {
toSystem(href);
return;
}
// A bare fragment is a link into this document. There are no heading anchors yet, so following
// one would be a guess, and guessing is worse than staying put.
if (href.startsWith("#")) return;
const target = resolveRelative(from, href);
if (target === null) return;
if (documentKindForPath(target) === null) {
openExternal(target).catch((e) => notify(`Could not open ${target}: ${String(e)}`));
return;
}
useDocument
.getState()
.open(target)
.catch((e) => notify(`Could not open ${target}: ${String(e)}`));
}
+46
View File
@@ -0,0 +1,46 @@
// The stylesheets come first and they come from here, before anything that renders is imported.
// Order matters and modules are evaluated in the order they are written, so a component importing
// its own sheet would put that sheet in front of the tokens it resolves against. This is the whole
// chain and the only place any of it is loaded.
//
// Tokens declare every custom property, fonts bind the families the tokens name, and app.css is the
// first sheet allowed to depend on both. The rest layer on top of app.css and each other in the
// order their rules expect to win: the document's typography, then the three block sheets that
// answer it for a construct with a node view or a decoration of its own, then the page the document
// sits on and the section drawn under the end of it, the pill that floats over that page and the
// two menus that hang off it, and last the shell, which is the one sheet that reaches back into
// the title bar app.css already styled. The palettes come last of all: they override .overlay from
// app.css and .key-cap from tree.css, so they have to be able to see both.
//
// katex.min.css is not here. src/editor/blocks/math.ts imports it itself so that the bundler
// rewrites its font URLs into the bundle, which the app's CSP requires.
import "./styles/tokens.css";
import "./styles/fonts.css";
import "./styles/app.css";
import "./styles/prose.css";
import "./styles/code.css";
import "./styles/math.css";
import "./styles/mermaid.css";
import "./styles/proofing.css";
import "./styles/sheet.css";
import "./styles/backlinks.css";
import "./styles/toolbar.css";
import "./styles/width-menu.css";
import "./styles/link-picker.css";
import "./styles/tree.css";
import "./styles/palette.css";
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { isMacDesktop } from "./ipc";
// The traffic lights only float over the page on a macOS desktop window, so the lane the title bar
// leaves for them opens off an attribute rather than a user agent sniff inside the stylesheet.
if (isMacDesktop) document.documentElement.setAttribute("data-traffic", "");
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+635
View File
@@ -0,0 +1,635 @@
// Adversarial tests for the markdown bridge. Written to break it, not to confirm it.
//
// The bridge makes five promises: opening never writes, parse/serialize is byte stable from the
// second pass on, unmodellable constructs survive byte identical, editing one paragraph is a one
// paragraph diff, and frontmatter passes through untouched.
//
// Three of the tests below fail. They are the failures, not the harness: each is a minimal input
// where the file that comes back off a save is not the file that went in, in a way the "one time
// normalisation" allowance does not cover. Everything else here passed on the first run and is
// kept as a regression net, because a bridge this careful deserves tests that stay honest about
// what already works.
//
// Fixtures live in corpus/adversarial/ rather than corpus/hand/ on purpose: corpus/load.ts globs
// {real,hand}/*.md, and dropping deliberately non-round-tripping files into that glob would fail
// roundtrip.test.ts's "rewritten by the first save only where the house style says so" list, which
// this file is not allowed to edit. These fixtures are loaded directly instead.
import { describe, expect, it } from "vitest";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { schema } from "../model/schema";
import { parseToMdast } from "./handlers";
import { parseMarkdown, serializeMarkdown } from "./index";
const fixtures = import.meta.glob("./corpus/adversarial/*.md", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
function fixture(name: string): string {
const source = fixtures[`./corpus/adversarial/${name}`];
if (source === undefined) throw new Error(`no adversarial fixture named ${name}`);
return source;
}
/** One save. */
function write(source: string): string {
const document = parseMarkdown(source, "/adversarial.md");
return serializeMarkdown(document, document.doc);
}
function doc(source: string): ProseMirrorNode {
return parseMarkdown(source, "/adversarial.md").doc;
}
/** Every link destination in a file, in document order, straight out of the parser. */
function destinations(source: string): string[] {
const out: string[] = [];
const walk = (node: { type?: string; url?: string; children?: unknown[] }) => {
if (node.type === "link") out.push(String(node.url));
for (const child of (node.children ?? []) as Array<Parameters<typeof walk>[0]>) walk(child);
};
walk(parseToMdast(source) as unknown as Parameters<typeof walk>[0]);
return out;
}
/** Every block the bridge could not model, as the bytes it is holding. */
function rawBlocks(node: ProseMirrorNode): string[] {
const out: string[] = [];
node.descendants((child) => {
if (child.type.name === "raw") out.push(child.textContent);
return child.type.name !== "raw";
});
return out;
}
function retypeParagraph(node: ProseMirrorNode, from: string, to: string): ProseMirrorNode {
const children: ProseMirrorNode[] = [];
let hits = 0;
node.forEach((child) => {
if (child.type.name === "paragraph" && child.textContent === from) {
hits += 1;
children.push(schema.nodes.paragraph.create(null, schema.text(to)));
return;
}
children.push(child);
});
expect(hits, `expected exactly one paragraph reading ${JSON.stringify(from)}`).toBe(1);
return schema.nodes.doc.create(null, children);
}
// ---------------------------------------------------------------------------------------------
// Data loss. Content the file had before the save and does not have after it.
// ---------------------------------------------------------------------------------------------
describe("data loss: a bare url swallows the punctuation and the word after it", () => {
// serialize.ts `literalAutolink` writes a link back as a bare url when it can prove GFM would
// read the same link out of it. The proof is wrong at the right hand edge: it checks only the
// FIRST character of the text that follows, allowing ' " . , : ; ! ?, on the theory that GFM
// trims trailing punctuation off an autolink. GFM trims that punctuation only when it is
// genuinely trailing. Followed by another word it is inside the url, and the destination the
// reader clicks is not the destination the author wrote.
it("does not turn <url>'s into a link to url's", () => {
const source = "Read <https://example.com>'s docs\n";
expect(destinations(source)).toEqual(["https://example.com"]);
expect(destinations(write(source))).toEqual(["https://example.com"]);
});
it("does not turn <url>.Word into a link to url.Word", () => {
const source = "See <https://example.com>.Next thing\n";
expect(destinations(write(source))).toEqual(["https://example.com"]);
});
it("does not corrupt a resource link whose text is its own url", () => {
const source = "Read [https://example.com](https://example.com)'s docs\n";
expect(destinations(write(source))).toEqual(["https://example.com"]);
});
it("does not corrupt an email autolink followed by a dot and a word", () => {
const source = "Mail <[email protected]>.Next\n";
expect(destinations(write(source))).toEqual(["mailto:[email protected]"]);
});
it("corrupts every one of these separators", () => {
const broken: string[] = [];
for (const after of ["'s", '"q', ".Next", ",next", ":next", ";next", "!next", "?next"]) {
const source = `See <https://example.com>${after}\n`;
if (destinations(write(source))[0] !== "https://example.com") broken.push(after);
}
expect(broken).toEqual([]);
});
it("is right about the cases it does allow", () => {
// Trailing punctuation followed by a space, an unmatched close paren, and balanced parens
// inside the url are all genuinely safe, and the fixture keeps them honest.
for (const source of [
"See <https://example.com>. Next\n",
"See <https://example.com>) done\n",
"See (<https://example.com/a(b)>).\n",
]) {
expect(destinations(write(source)), source).toEqual(destinations(source));
}
});
it("shows up in a whole file", () => {
const source = fixture("autolink-adjacency.md");
expect(destinations(write(source))).toEqual(destinations(source));
});
});
describe("data loss: a list under a leading thematic break is flattened into escaped text", () => {
// remark-frontmatter is registered for yaml and toml. At the very start of a file its tokenizer
// competes with the thematic break, and when it loses, the block after the break comes back as a
// paragraph instead of a list or a blockquote. The bridge then writes that paragraph out with
// the marker escaped, so `- a` becomes `\- a` and the list is gone from the file for good.
//
// The same document with any block in front of it parses correctly, which is what pins the cause
// on the frontmatter extension rather than on CommonMark.
it("keeps a list that follows a thematic break on line one", () => {
const source = "---\n- a\n";
expect(write(source)).toContain("- a");
expect(write(source)).not.toContain("\\- a");
});
it("keeps a blockquote that follows a thematic break on line one", () => {
const source = "---\n> q\n";
expect(write(source)).toContain("> q");
expect(write(source)).not.toContain("\\> q");
});
it("parses the same document correctly when anything precedes it", () => {
const tree = parseToMdast("x\n\n---\n- a\n");
expect(tree.children.map((child) => child.type)).toEqual(["paragraph", "thematicBreak", "list"]);
});
it("shows up in a whole file", () => {
const source = fixture("leading-rule-list.md");
const out = write(source);
expect(out).toContain("- a list the frontmatter tokenizer eats");
expect(out).not.toContain("\\- a list");
});
});
describe("data loss: a lone carriage return inside content is turned into a line break", () => {
// frontmatter.ts `normaliseSource` collapses CRLF to LF, which the module documents as a
// deliberate one time rewrite of a CRLF file. The regex is /\r\n?/g, so it also rewrites a lone
// CR, and the guard is `body.includes("\r")`, so a single stray CR anywhere in the file arms it
// for the whole file. remark keeps that CR verbatim inside a fenced block; the bridge does not,
// and a one line code sample comes back as two lines.
it("keeps a carriage return that the parser itself keeps", () => {
const source = "```\nline one\rstill line one\n```\n";
const parsed = parseToMdast(source).children[0];
expect(parsed.type).toBe("code");
expect((parsed as { value: string }).value).toBe("line one\rstill line one");
expect(write(source)).toBe(source);
});
it("shows up in a whole file", () => {
expect(write(fixture("lone-carriage-return.md"))).toBe(fixture("lone-carriage-return.md"));
});
});
// ---------------------------------------------------------------------------------------------
// Cosmetic. The file changes on the first save and never again. Allowed by the house style, but
// pinned here so that a change to the list is a change somebody has to justify.
// ---------------------------------------------------------------------------------------------
describe("cosmetic: the first save rewrites these and the second does not", () => {
const cases: Array<[string, string, string]> = [
["setext heading becomes atx", "Title\n=====\n\nbody\n", "# Title\n\nbody\n"],
["closing hashes are dropped", "# Title #\n", "# Title\n"],
["two space hard break becomes a backslash", "a \nb\n", "a\\\nb\n"],
["ordered markers are renumbered", "1. a\n1. b\n1. c\n", "1. a\n2. b\n3. c\n"],
["gappy ordered markers are made sequential", "3. a\n5. b\n7. c\n", "3. a\n4. b\n5. c\n"],
["tilde fences become backtick fences", "~~~\nx\n~~~\n", "```\nx\n```\n"],
["indented code becomes fenced", " code\n", "```\ncode\n```\n"],
// Unified on to `---` everywhere except the first line of a file, where `---` is not a rule at
// all. This row used to expect "---\n\n---\n", and that file reads back as frontmatter of
// "---\n\n---\n" over a single empty paragraph: both rules gone, the whole document with them.
// The second save was byte identical, which is how the row stayed green, because an empty
// document written twice does not move. So the house style keeps its one spelling and the
// leading rule is respelled with the other character markdown has for it, once, and only when
// the reader says it would have eaten the body.
["thematic breaks are unified", "***\n\n___\n", "***\n\n---\n"],
["entities are decoded", "&amp; &copy; &#65;\n", "& © A\n"],
["an ambiguous entity is re-escaped instead", "&amp;copy;\n", "\\&copy;\n"],
["intraword underscores are escaped", "snake_case here\n", "snake\\_case here\n"],
["intraword asterisks become character references", "a*b*c\n", "&#x61;_&#x62;_&#x63;\n"],
["mark nesting order is fixed", "_**x**_\n", "**_x_**\n"],
["a link inside emphasis is turned inside out", "*[a](b)*\n", "[_a_](b)\n"],
["a bold autolink becomes a resource link", "**https://example.com** x\n", "[**https://example.com**](https://example.com) x\n"],
["single tilde strikethrough is doubled", "~x~\n", "~~x~~\n"],
["an empty link title is dropped", '[a](b "")\n', "[a](b)\n"],
["markup inside image alt text is flattened", "![*a* `b`](x.png)\n", "![a b](x.png)\n"],
["a callout label is upper cased", "> [!note]\n> text\n", "> [!NOTE]\n> text\n"],
["a blank quote line under a callout label is dropped", "> [!NOTE]\n>\n> text\n", "> [!NOTE]\n> text\n"],
["a blank quote line is added under a label above a non paragraph", "> [!NOTE]\n> # H\n", "> [!NOTE]\n>\n> # H\n"],
["a lazy blockquote continuation gains its marker", "> a\nb\n", "> a\n> b\n"],
["tabs after a list marker become a space", "-\tfoo\n", "- foo\n"],
["a tab indented paragraph continuation is unindented", "foo\n\tbar\n", "foo\nbar\n"],
["a partly loose list is made wholly loose", "- a\n\n- b\n- c\n", "- a\n\n- b\n\n- c\n"],
["a missing final newline is added", "hello", "hello\n"],
["extra blank lines between blocks collapse", "a\n\n\nb\n", "a\n\nb\n"],
["trailing blank lines are dropped", "a\n\n\n", "a\n"],
["a whitespace only file becomes empty", " \n\n \n", ""],
["a paragraph and the html block under it gain a blank line", "para\n<div>x</div>\n", "para\n\n<div>x</div>\n"],
["a blank line inside an empty fence is dropped", "```\n\n```\n", "```\n```\n"],
["a double quoted link title is requoted and escaped", "[a](b 'ti\"tle')\n", '[a](b "ti\\"tle")\n'],
["an angle bracketed destination is escaped instead", "[a](<b(c>)\n", "[a](b\\(c)\n"],
["parens in a destination are escaped", "[a](http://x.com/a_(b))\n", "[a](http://x.com/a_\\(b\\))\n"],
["an angle autolink becomes a bare url", "Angle <https://example.com> here.\n", "Angle https://example.com here.\n"],
["an uppercase task marker is lowercased", "- [X] done\n", "- [x] done\n"],
["trailing spaces on a paragraph line are dropped", "a \n\nb\n", "a\n\nb\n"],
["leading spaces on a paragraph are dropped", " a\n", "a\n"],
["crlf becomes lf", "# H\r\n\r\npara\r\n", "# H\n\npara\n"],
// A table is a modelled node in M2, so it is written from the node in the one house style
// rather than sliced out of the source, and the house style gives a cell one space either side
// however wide the column is. Every cell here comes through as the bytes it went in as, escaped
// pipes included; the only thing that moves is the spaces around them and the length of the
// delimiter run.
[
"a padded table loses its padding",
"| pipe | code |\n| -------- | -----: |\n| `a \\| b` | \\| raw |\n",
"| pipe | code |\n| - | -: |\n| `a \\| b` | \\| raw |\n",
],
["frontmatter loses its carriage returns too", "---\na: 1\r\n---\r\n\r\np\r\n", "---\na: 1\n---\n\np\n"],
];
for (const [name, source, expected] of cases) {
it(name, () => {
const once = write(source);
expect(once).toBe(expected);
expect(write(once), "second save must not move the file again").toBe(once);
});
}
// The row above says what the bytes are. This says what they mean, which is the assertion the row
// never made and the reason it could sit green over a document that had been destroyed.
it("keeps both rules readable after the save that unified them", () => {
const once = write("***\n\n___\n");
const reopened = parseMarkdown(once, "/adversarial.md");
expect(reopened.frontmatter, once).toBe(null);
expect(reopened.doc.childCount, once).toBe(2);
expect(reopened.doc.child(0).type.name).toBe("horizontalRule");
expect(reopened.doc.child(1).type.name).toBe("horizontalRule");
});
it("normalises a whole CRLF file exactly once", () => {
const source = fixture("crlf-throughout.md");
const once = write(source);
expect(once).not.toBe(source);
expect(once).not.toContain("\r");
expect(write(once)).toBe(once);
expect(once.startsWith("---\ntitle: CRLF\n---\n\n")).toBe(true);
});
it("adds the missing final newline exactly once", () => {
const source = fixture("no-final-newline.md");
const once = write(source);
expect(once).toBe(source + "\n");
expect(write(once)).toBe(once);
});
});
// ---------------------------------------------------------------------------------------------
// Promise 1: opening a file never writes it.
// ---------------------------------------------------------------------------------------------
describe("opening a file", () => {
it("hands back the exact bytes it was given, for every fixture", () => {
for (const [name, source] of Object.entries(fixtures)) {
const before = source;
const document = parseMarkdown(source, name);
expect(document.source, name).toBe(before);
expect(source, name).toBe(before);
}
});
it("is pure: parsing the same bytes twice gives equal documents and touches nothing", () => {
for (const [name, source] of Object.entries(fixtures)) {
expect(parseMarkdown(source, name).doc.eq(parseMarkdown(source, name).doc), name).toBe(true);
}
});
});
// ---------------------------------------------------------------------------------------------
// Promise 2: byte stable from the second pass onward.
// ---------------------------------------------------------------------------------------------
const SNIPPETS: Record<string, string> = {
para: "Plain paragraph text.",
head: "## A heading",
hr: "---",
fence: "```js\nconst x = 1;\n```",
fence4: "````\n```\nx\n```\n````",
ul: "- one\n- two",
ol: "3. three\n4. four",
task: "- [ ] a\n- [x] b",
loose: "- one\n\n- two",
quote: "> quoted",
quote2: "> > deep\n> >\n> > more",
callout: "> [!NOTE]\n> body",
calloutEmpty: "> [!WARNING]",
table: "| a | b |\n| --- | --: |\n| 1 | 2 |",
html: '<div class="x">\n <span>y</span>\n</div>',
comment: "<!-- a comment -->",
details: "<details>\n<summary>S</summary>\n\nbody\n\n</details>",
footnote: "[^n]: A footnote definition.",
footref: "Text with a ref[^n].",
defn: '[ref]: https://example.com "Title"',
refuse: "See [the ref][ref] here.",
math: "$$\nx^2\n$$",
img: '![alt](i.png "t")',
link: "A [link](http://x.com) here.",
emph: "Some **bold** and _em_ and ~~del~~.",
code: "Some `inline code` here.",
nestlist: "- a\n - b\n - c",
listcode: "- a\n ```js\n x\n ```",
listtable: "- a\n\n | a |\n | - |\n | 1 |",
hardbreak: "line one\\\nline two",
unicode: "café \u{1F469}\u200D\u{1F4BB} e\u0301",
mdx: "<Chart data={points} />",
emptyfence: "```\n```",
};
const KEYS = Object.keys(SNIPPETS);
describe("idempotence", () => {
it("holds for every adversarial fixture", () => {
for (const [name, source] of Object.entries(fixtures)) {
const once = write(source);
expect(write(once), name).toBe(once);
expect(write(write(once)), name).toBe(once);
}
});
it("holds for every ordered pair of blocks, at three separations", () => {
const unstable: string[] = [];
for (const a of KEYS) {
for (const b of KEYS) {
for (const sep of ["\n\n", "\n\n\n", "\n"]) {
const source = `${SNIPPETS[a]}${sep}${SNIPPETS[b]}\n`;
const once = write(source);
if (write(once) !== once) unstable.push(`${a} + ${b} (${sep.length} newlines): ${JSON.stringify(once)} -> ${JSON.stringify(write(once))}`);
}
}
}
expect(unstable).toEqual([]);
});
it("holds for a sample of ordered triples, with and without frontmatter", () => {
const unstable: string[] = [];
let seen = 0;
for (const a of KEYS) {
for (const b of KEYS) {
for (const c of KEYS) {
if (seen++ % 23 !== 0) continue;
for (const prefix of ["", "---\ntitle: T\ntags:\n - a\n---\n\n", "+++\ntitle = \"T\"\n+++\n\n", "\uFEFF"]) {
const source = `${prefix}${SNIPPETS[a]}\n\n${SNIPPETS[b]}\n\n${SNIPPETS[c]}\n`;
const once = write(source);
if (write(once) !== once) unstable.push(`${JSON.stringify(prefix)} ${a}|${b}|${c}`);
}
}
}
}
expect(unstable).toEqual([]);
}, 20000);
it("does not change the meaning of a document, for every pair", () => {
const changed: string[] = [];
for (const a of KEYS) {
for (const b of KEYS) {
const source = `${SNIPPETS[a]}\n\n${SNIPPETS[b]}\n`;
if (!doc(write(source)).eq(doc(source))) changed.push(`${a} + ${b}`);
}
}
expect(changed).toEqual([]);
});
});
// ---------------------------------------------------------------------------------------------
// Promise 3: anything the editor cannot model is preserved byte identical.
// ---------------------------------------------------------------------------------------------
describe("preservation", () => {
it("cuts every raw block out of the source and writes it back unchanged, for every fixture", () => {
for (const [name, source] of Object.entries(fixtures)) {
const document = parseMarkdown(source, name);
const out = serializeMarkdown(document, document.doc);
for (const raw of rawBlocks(document.doc)) {
expect(raw, name).not.toBe("");
expect(source.replace(/\r\n/g, "\n"), `${name}: raw block is not a slice of the source`).toContain(raw);
expect(out, `${name}: raw block did not survive the save`).toContain(raw);
}
}
});
it("cuts every raw block out of the source and writes it back unchanged, for every pair", () => {
const lost: string[] = [];
for (const a of KEYS) {
for (const b of KEYS) {
for (const sep of ["\n\n", "\n\n\n", "\n"]) {
const source = `${SNIPPETS[a]}${sep}${SNIPPETS[b]}\n`;
const document = parseMarkdown(source, "/pair.md");
const out = serializeMarkdown(document, document.doc);
for (const raw of rawBlocks(document.doc)) {
if (!source.includes(raw)) lost.push(`${a}|${b} not a source slice: ${JSON.stringify(raw)}`);
else if (!out.includes(raw)) lost.push(`${a}|${b} not in output: ${JSON.stringify(raw)}`);
}
}
}
}
expect(lost).toEqual([]);
});
it("keeps the constructs the schema has no node for", () => {
// A table is not on this list any more: M2 models one, so it is written from the node rather
// than kept as the bytes it was written as. The two below stay because their table is already
// spelled the way the house style spells one, so modelling it changed nothing.
const cases: Array<[string, string]> = [
["footnote definition", "a[^n]\n\n[^n]: A note\n that continues.\n"],
["link reference definition", '[ref]: https://example.com "Title"\n\nuse [ref]\n'],
["link reference definition with a wrapped title", '[ref]: /x\n "Title"\n\nuse [ref]\n'],
["reference style link", "See [one][a] and [two][b].\n\n[a]: http://a.com\n[b]: http://b.com\n"],
["html comment", "<!-- hi -->\n"],
["conditional comment", "<!--[if IE]>x<![endif]-->\n"],
["details with attributes", '<details open class="x">\n<summary>S</summary>\n\nbody\n\n</details>\n'],
["mdx style jsx", "<Chart data={points} title=\"Sales\" />\n"],
["a quote whose label is not a callout kind", "> [!WEIRD]\n> text\n"],
["a callout label with text on the same line", "> [!NOTE] inline text\n"],
["a table inside a list item", "- a\n\n | a |\n | - |\n | 1 |\n"],
["a table inside a blockquote", "> | a |\n> | - |\n> | 1 |\n"],
["inline html inside a paragraph", "para <span>x</span> more\n"],
["a heading containing a footnote reference", "## Heading[^n]\n\n[^n]: note\n"],
];
for (const [name, source] of cases) {
expect(write(source), name).toBe(source);
}
});
it("keeps every fence shape it cannot improve on", () => {
const source = fixture("fence-and-table-torture.md");
const out = write(source);
// The table fragment is written a space either side of each cell, which is the house style;
// what is being asked of it here is that the escaped pipes inside it survive being modelled and
// written back.
for (const fragment of ["| `a \\| b` | \\| raw |", "````md\n```js\nconst x = 1;\n```\n````", "```{r setup, echo=FALSE}"]) {
expect(out, fragment).toContain(fragment);
}
});
it("keeps callouts, quotes and html side by side", () => {
expect(write(fixture("callout-and-html-torture.md"))).toBe(fixture("callout-and-html-torture.md"));
});
it("keeps every unicode oddity byte for byte", () => {
expect(write(fixture("unicode-torture.md"))).toBe(fixture("unicode-torture.md"));
});
});
// ---------------------------------------------------------------------------------------------
// Promise 4: editing one paragraph leaves every other construct alone.
// ---------------------------------------------------------------------------------------------
describe("edit locality", () => {
it("is a one paragraph diff on a file full of things the editor cannot model", () => {
// From the house style form of the fixture, not its bytes. Its delimiter row is spelled `---`
// and the house style writes the shortest one that carries the alignment, so the save that
// settles the file shortens it, which is roundtrip.test.ts's business; the question here is
// whether anything moves after that. This is the same baseline the pair sweep below already
// takes.
const source = write(fixture("locality-edit.md"));
expect(write(source), "the baseline must be byte stable, or the diff is just the first save").toBe(source);
const document = parseMarkdown(source, "/locality-edit.md");
const out = serializeMarkdown(document, retypeParagraph(document.doc, "EDITME", "EDITED"));
expect(out).toBe(source.replace("EDITME", "EDITED"));
});
it("is a one paragraph diff for every pair of neighbouring constructs", () => {
const bad: string[] = [];
for (const a of KEYS) {
for (const b of KEYS) {
const base = write(`${SNIPPETS[a]}\n\nEDITME\n\n${SNIPPETS[b]}\n`);
if (write(base) !== base) continue;
const document = parseMarkdown(base, "/pair.md");
let edited: ProseMirrorNode;
try {
edited = retypeParagraph(document.doc, "EDITME", "EDITED");
} catch {
continue; // "EDITME" between two thematic breaks is a setext heading, not a paragraph
}
const out = serializeMarkdown(document, edited);
if (out !== base.replace("EDITME", "EDITED")) bad.push(`${a}|${b}\n want ${JSON.stringify(base.replace("EDITME", "EDITED"))}\n got ${JSON.stringify(out)}`);
}
}
expect(bad).toEqual([]);
});
it("does not touch a raw block the user did not edit", () => {
const source = write(fixture("locality-edit.md"));
const document = parseMarkdown(source, "/locality-edit.md");
const out = serializeMarkdown(document, retypeParagraph(document.doc, "EDITME", "EDITED"));
for (const raw of rawBlocks(document.doc)) expect(out, raw).toContain(raw);
});
});
// ---------------------------------------------------------------------------------------------
// Promise 5: frontmatter, YAML or TOML, survives byte identical.
// ---------------------------------------------------------------------------------------------
describe("frontmatter", () => {
const blocks: Array<[string, string]> = [
["yaml", "---\ntitle: T\ntags:\n - a\n - b\n---\n\n"],
["yaml with no blank line after", "---\ntitle: T\n---\n"],
["yaml with a blank line inside", "---\na: 1\n\nb: 2\n---\n\n"],
["yaml holding its own delimiter", '---\na: "---"\nb: "+++"\n---\n\n'],
["yaml with comments and odd quoting", "---\n# a comment\na: 'single'\nb: |\n block\n scalar\n---\n\n"],
["yaml with trailing spaces on the delimiters", "--- \na: 1\n--- \n\n"],
["yaml that is empty", "---\n---\n\n"],
["toml", '+++\ntitle = "T"\nlist = [ 1, 2 ]\n+++\n\n'],
["toml holding its own delimiter", '+++\na = "+++"\n+++\n\n'],
["a bom and yaml", '\uFEFF---\na: 1\n---\n\n'],
["a bom alone", "\uFEFF"],
["two blank lines after the delimiter", "---\na: 1\n---\n\n\n"],
];
for (const [name, prefix] of blocks) {
it(`survives byte identical: ${name}`, () => {
for (const key of KEYS) {
const out = write(`${prefix}${SNIPPETS[key]}\n`);
expect(out.startsWith(prefix), `${name} + ${key}: got ${JSON.stringify(out.slice(0, prefix.length + 20))}`).toBe(true);
}
});
}
it("survives a file that is nothing but frontmatter", () => {
for (const source of ["---\na: 1\n---\n", "---\na: 1\n---", "+++\na = 1\n+++\n", "---\na: 1\n---\n\n \n"]) {
expect(write(source), source).toBe(source);
}
});
it("survives an edit to the body", () => {
const source = fixture("frontmatter-toml-torture.md");
expect(write(source)).toBe(source);
const document = parseMarkdown(source, "/toml.md");
const heading: ProseMirrorNode[] = [];
document.doc.forEach((child) => heading.push(child));
const out = serializeMarkdown(document, schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, schema.text("replaced")), ...heading.slice(1)]));
expect(out.startsWith('+++\ntitle = "TOML"\nnested = "+++"\nlist = [ 1, 2 ]\n# a comment\n+++\n\n')).toBe(true);
});
it("survives a bom in front of yaml, and a bom in the middle of the body", () => {
const source = fixture("frontmatter-bom-yaml.md");
expect(write(source)).toBe(source);
expect(write(source).startsWith("\uFEFF---\n")).toBe(true);
expect(write(source)).toContain("a\uFEFFb");
});
it("does not invent frontmatter out of a leading thematic break", () => {
// Not frontmatter: no closing delimiter. The bytes must come back as a rule and a paragraph.
const out = write("---\na: 1\n...\n\np\n");
expect(parseMarkdown(out, "/x.md").frontmatter).toBe(null);
expect(out).toContain("a: 1\n...");
});
});
// ---------------------------------------------------------------------------------------------
// Nodes only the editor can build. The parser never produces a table, a toggle or a math block, so
// nothing above exercises the serializer for them, and the second save of a document containing
// one is the first save that has to be stable.
// ---------------------------------------------------------------------------------------------
describe("editor authored nodes", () => {
const n = schema.nodes;
const cell = (text: string) => n.tableCell.create({ colspan: 1, rowspan: 1, colwidth: null, align: null }, text ? schema.text(text) : null);
const row = (...cells: ProseMirrorNode[]) => n.tableRow.create(null, cells);
const build = (...blocks: ProseMirrorNode[]) => n.doc.create(null, blocks);
const cases: Array<[string, ProseMirrorNode]> = [
["a table", build(n.table.create(null, [row(cell("a"), cell("b")), row(cell("1"), cell("2"))]))],
["a table with a pipe in a cell", build(n.table.create(null, [row(cell("a|b")), row(cell("c"))]))],
["a table with a trailing backslash in a cell", build(n.table.create(null, [row(cell("a\\")), row(cell("b"))]))],
["a table with empty cells", build(n.table.create(null, [row(cell("a"), cell("")), row(cell(""), cell("d"))]))],
["a toggle with markup in its summary", build(n.toggle.create({ summary: "S & <b>", open: true }, [n.paragraph.create(null, schema.text("body"))]))],
["a toggle with an empty body", build(n.toggle.create({ summary: "S" }, [n.paragraph.create()]))],
["a math block containing dollars", build(n.mathBlock.create({ latex: "a $$ b" }))],
["inline math", build(n.paragraph.create(null, [schema.text("a "), n.mathInline.create({ latex: "y" }), schema.text(" b")]))],
["an empty callout", build(n.callout.create({ kind: "tip" }, n.paragraph.create()))],
["a callout inside a callout", build(n.callout.create({ kind: "note" }, [n.callout.create({ kind: "tip" }, n.paragraph.create(null, schema.text("in")))]))],
["an edited raw block", build(n.raw.create({ source: "<div>a</div>" }, schema.text("<div>b</div>")))],
];
for (const [name, node] of cases) {
it(`writes bytes that come straight back: ${name}`, () => {
const once = serializeMarkdown({ frontmatter: null, doc: node, source: "", path: "/x.md" }, node);
expect(write(once), `${name}: ${JSON.stringify(once)}`).toBe(once);
});
}
it("writes an edited raw block instead of the source it was cut from", () => {
const node = n.doc.create(null, [n.raw.create({ source: "<div>a</div>" }, schema.text("<div>b</div>"))]);
const out = serializeMarkdown({ frontmatter: null, doc: node, source: "", path: "/x.md" }, node);
expect(out).toBe("<div>b</div>\n");
});
});
+453
View File
@@ -0,0 +1,453 @@
// Second adversarial pass over the markdown bridge, written after the three data loss bugs in
// adversarial.test.ts were fixed. Same rules as that file: this is here to break the bridge, not
// to congratulate it.
//
// The file is in two halves.
//
// "still fixed" is the regression net. Every test in it passes, and every one of them attacks a
// fixed bug from an angle the first pass did not try: the autolink boundary from both sides and in
// every inline container, the leading thematic break in all six CommonMark spellings against every
// kind of block that can follow it, and the lone carriage return everywhere a carriage return can
// legally sit.
//
// "found" is the result. Every test in it FAILS, and each failure is a minimal input where the
// file that comes back off a save is not the file that went in. Two of them are unbounded: the
// file grows on every save and never converges, which is the "serializing is stable" promise
// broken outright rather than bent. One of them is a regression: it is the direct consequence of
// the carriage return fix, and the pre-fix code handled it correctly.
import { describe, expect, it } from "vitest";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { schema } from "../model/schema";
import { parseToMdast } from "./handlers";
import { parseMarkdown, serializeMarkdown } from "./index";
/** One save. */
function write(source: string): string {
const document = parseMarkdown(source, "/adversarial2.md");
return serializeMarkdown(document, document.doc);
}
function doc(source: string): ProseMirrorNode {
return parseMarkdown(source, "/adversarial2.md").doc;
}
/** Every link destination in a file, in document order, straight out of the parser. */
function destinations(source: string): string[] {
const out: string[] = [];
const walk = (node: { type?: string; url?: string; children?: unknown[] }) => {
if (node.type === "link") out.push(String(node.url));
for (const child of (node.children ?? []) as Array<Parameters<typeof walk>[0]>) walk(child);
};
walk(parseToMdast(source) as unknown as Parameters<typeof walk>[0]);
return out;
}
function rawBlocks(node: ProseMirrorNode): string[] {
const out: string[] = [];
node.descendants((child) => {
if (child.type.name === "raw") out.push(child.textContent);
return child.type.name !== "raw";
});
return out;
}
const n = schema.nodes;
const linked = (text: string, href: string) => schema.text(text, [schema.marks.link.create({ href, title: null })]);
const cell = (...content: ProseMirrorNode[]) => n.tableCell.create({ colspan: 1, rowspan: 1, colwidth: null, align: null }, content.length > 0 ? content : null);
const row = (...cells: ProseMirrorNode[]) => n.tableRow.create(null, cells);
function writeDoc(node: ProseMirrorNode): string {
return serializeMarkdown({ frontmatter: null, doc: node, source: "", path: "/adversarial2.md" }, node);
}
// =============================================================================================
// Still fixed. These pass.
// =============================================================================================
describe("still fixed: the bare url boundary", () => {
it("holds for a matrix of prefixes, urls and suffixes", () => {
// The suffixes are the whole reason the fix exists: GFM forgives trailing punctuation only
// when it is genuinely trailing, so every one of these has to come back as the same link.
const urls = ["https://example.com", "https://example.com/a(b)", "https://example.com/p?q=1&r=2", "https://example.com/#frag", "https://example.com/x&"];
const prefixes = ["", "See ", "(", "((", "*", "_", "~", "x", "[", "text\n"];
const suffixes = ["", ".", "..", "?!.", ",", ":", "!", "?", "'s", '"q', ".Next", ",next", ":next", ";next", "!next", "?next", ". Next", " Next", "\nnext", ")", "))", ").", "&x", "|x", "*b*", "`c`", "$", "-", "/", "…"];
const broken: string[] = [];
for (const url of urls) {
for (const prefix of prefixes) {
for (const suffix of suffixes) {
const source = `${prefix}<${url}>${suffix}\n`;
const once = write(source);
if (JSON.stringify(destinations(once)) !== JSON.stringify(destinations(source))) broken.push(`${JSON.stringify(source)} -> ${JSON.stringify(once)}`);
else if (write(once) !== once) broken.push(`unstable ${JSON.stringify(source)} -> ${JSON.stringify(once)}`);
}
}
}
expect(broken).toEqual([]);
});
it("holds with no trailing newline, so the end of the input is the end of the run", () => {
for (const source of ["See <https://example.com>.", "See <https://example.com>", "See <https://example.com>!?", "See <https://example.com>)"]) {
expect(destinations(write(source)), source).toEqual(["https://example.com"]);
}
});
it("falls back rather than run a bare url into the inline node after it", () => {
// A following image, code span, math span or hard break is not text, so the punctuation
// between cannot be proved trailing and the angle form has to win.
const cases: Array<[string, ProseMirrorNode]> = [
["image", n.doc.create(null, [n.paragraph.create(null, [linked("https://example.com", "https://example.com"), schema.text("."), n.image.create({ src: "i.png", alt: null, title: null })])])],
["hard break", n.doc.create(null, [n.paragraph.create(null, [linked("https://example.com", "https://example.com"), n.hardBreak.create(), schema.text("next")])])],
["inline math", n.doc.create(null, [n.paragraph.create(null, [linked("https://example.com", "https://example.com"), schema.text("."), n.mathInline.create({ latex: "x" })])])],
];
for (const [name, node] of cases) {
const out = writeDoc(node);
expect(destinations(out), `${name}: ${JSON.stringify(out)}`).toEqual(["https://example.com"]);
expect(write(out), name).toBe(out);
}
});
it("keeps the destination through emphasis, strong and strikethrough", () => {
for (const source of ["*<https://example.com>*\n", "*<https://example.com>*.Next\n", "**<https://example.com>**\n", "_a <https://example.com>_ b\n", "~~<https://example.com>~~ x\n", "**a <https://example.com>.Next**\n"]) {
const once = write(source);
expect(destinations(once), source).toEqual(destinations(source));
expect(write(once), source).toBe(once);
}
});
it("does not rewrite a url the file keeps inside a raw block", () => {
// Footnote definitions and html are raw source slices, so the autolink logic must never see
// them and the bytes must come back exactly.
for (const source of ["<div>\n <https://example.com>.Next\n</div>\n", "[^n]: <https://example.com>.Next\n\nuse[^n]\n", "```\n<https://example.com>.Next\n```\n"]) {
expect(write(source), source).toBe(source);
}
});
it("does not rewrite a url in a table cell, which M2 hands to the inline writer", () => {
// A table cell is no longer a slice of somebody else's bytes: it is modelled, so its text goes
// through the same inline serializer as a paragraph's and meets the bare url boundary rule the
// rest of this file is about. The cell is written a space either side, which is the house
// style, and the destination inside it has to come out character for character all the same.
const source = "| a |\n| --- |\n| <https://example.com>.Next |\n";
const once = write(source);
expect(once).toBe("| a |\n| - |\n| <https://example.com>.Next |\n");
expect(destinations(once)).toEqual(destinations(source));
expect(write(once)).toBe(once);
});
it("keeps a bare url in a heading, a quote, a list item and a callout", () => {
for (const source of ["# See <https://example.com>.Next\n", "> See <https://example.com>.Next\n", "- See <https://example.com>.Next\n", "> [!NOTE]\n> See <https://example.com>.Next\n"]) {
const once = write(source);
expect(destinations(once), source).toEqual(destinations(source));
expect(write(once), source).toBe(once);
}
});
it("balances parens the way GFM does when it re-reads them", () => {
for (const source of ["(<https://example.com>)\n", "((<https://example.com>))\n", "(<https://example.com/a(b)>)\n", "((<https://example.com/a(b)>))\n", "(<https://example.com/a(b)>\n", "<https://example.com/a(b)>)\n", "See <https://example.com/a)b> here\n", "See <https://example.com/a(b> here\n"]) {
const once = write(source);
expect(destinations(once), `${source} -> ${once}`).toEqual(destinations(source));
expect(write(once), source).toBe(once);
}
});
it("keeps brackets out of a bare url entirely", () => {
for (const source of ["See <https://example.com/a[b]> here\n", "See <https://example.com/a]b> here\n", "[<https://example.com>]\n"]) {
const once = write(source);
expect(destinations(once), `${source} -> ${once}`).toEqual(destinations(source));
}
});
});
describe("still fixed: a leading thematic break", () => {
it("keeps the block under it, for every spelling of the rule and every kind of block", () => {
const rules = ["---", "***", "___", "- - -", "* * *", "_ _ _", " ---", "--- ", "---\t", "-------", "+++"];
const bodies = ["- a\n- b\n", "> q\n", "# h\n", "```\nx\n```\n", "| a |\n| - |\n| 1 |\n", "<div>x</div>\n", "[^n]: x\n\nuse[^n]\n", "1. one\n", "- [ ] task\n", "Para\n", "***\n\n- a\n"];
const bad: string[] = [];
for (const rule of rules) {
for (const body of bodies) {
const source = `${rule}\n${body}`;
const once = write(source);
if (write(once) !== once) bad.push(`unstable ${JSON.stringify(source)} -> ${JSON.stringify(once)}`);
if (/\\[-*_>#|[]/.test(once)) bad.push(`escaped ${JSON.stringify(source)} -> ${JSON.stringify(once)}`);
}
}
expect(bad).toEqual([]);
});
it("still tells real frontmatter from a rule, at every shape of the delimiter", () => {
const frontmatter: string[] = ["---\na: 1\n---\n\np\n", "---\na: 1\n---", "---\na: 1\n--- ", "---\na: 1\n---\t\n\np\n", "---\n---\n", "---\n\n---\n\np\n", "---\nbody: |\n ---\nb: 2\n---\n\np\n", "+++\na = 1\n+++\n\np\n", "+++\n+++\n", "---\na: 1\n---\n\np\n", "---\na: 1\n---\n- b\n", "---\na: 1\n---\n> q\n"];
for (const source of frontmatter) {
expect(parseMarkdown(source, "/x.md").frontmatter, source).not.toBe(null);
expect(write(source), source).toBe(source);
}
const notFrontmatter: string[] = ["---", "---\n", "----\n", "----\na: 1\n----\n\np\n", "---\na: 1\n----\n\np\n", "--- \n", "--- \nbody\n", "---\na: 1\n---x\n\np\n", " ---\na: 1\n---\n\np\n", " ---\na: 1\n---\n\np\n", " ---\na: 1\n---\n\np\n", "---\na: 1\n ---\n\np\n", "+++\n- a\n"];
for (const source of notFrontmatter) {
expect(parseMarkdown(source, "/x.md").frontmatter, source).toBe(null);
}
// A mark and nothing else still occupies the slot, because it is leading bytes either way.
expect(parseMarkdown("---\n- a\n", "/x.md").frontmatter).toBe("");
expect(write("---\n- a\n")).toBe("---\n\n- a\n");
});
it("does not eat the body when the frontmatter delimiter is the last thing in the file", () => {
for (const source of ["---\na: 1\n---", "+++\na = 1\n+++", "---\n---", "---\na: 1\n--- "]) {
expect(write(source), source).toBe(source);
expect(parseMarkdown(source, "/x.md").frontmatter, source).toBe(source);
}
});
});
describe("still fixed: a lone carriage return", () => {
it("survives everywhere the parser keeps it, and does not change what the document means", () => {
const cases = ["para\rmore\n", "a\rb\rc", "```\nline one\rstill line one\n```\n", "```\na\rb\n```\n", "`a\rb`\n", "<div>\ra\r</div>\n", "| a |\n| - |\n| x\ry |\n", "[^n]: note\rmore\n\na[^n]\n", "- item\rtwo\n", "> q\rmore\n", "a\r*b*\n", "---\na: 1\rb: 2\n---\n\np\n", "---\ra: 1\r---\n\np\n"];
for (const source of cases) {
const once = write(source);
expect(once, `${JSON.stringify(source)} lost its carriage return`).toContain("\r");
expect(doc(once).eq(doc(source)), `${JSON.stringify(source)} -> ${JSON.stringify(once)} changed meaning`).toBe(true);
expect(write(once), source).toBe(once);
}
});
it("collapses CRLF and only CRLF, once", () => {
for (const [source, expected] of [
["# H\r\n\r\npara\r\n", "# H\n\npara\n"],
["one\rtwo\r\nthree\n", "one\rtwo\nthree\n"],
["text\r\n\r\nmore\r\n", "text\n\nmore\n"],
["```\na\r\n```\n", "```\na\n```\n"],
] as Array<[string, string]>) {
const once = write(source);
expect(once, source).toBe(expected);
expect(write(once), source).toBe(once);
}
});
it("handles a carriage return at the end of the file and a file that is only one", () => {
expect(write("para\r")).toBe("para\n");
expect(write("para\r\n")).toBe("para\n");
expect(write("\r")).toBe("");
expect(write("\r\n")).toBe("");
expect(write("---\na: 1\n---\r")).toBe("---\na: 1\n---\r");
});
});
describe("still fixed: the sweeps the fix could have broken", () => {
const SNIPPETS: Record<string, string> = {
para: "Plain paragraph text.",
hr: "---",
fence: "```js\nconst x = 1;\n```",
ul: "- one\n- two",
quote: "> quoted",
callout: "> [!NOTE]\n> body",
table: "| a | b |\n| --- | --: |\n| 1 | 2 |",
html: '<div class="x">\n <span>y</span>\n</div>',
footnote: "[^n]: A footnote definition.",
defn: '[ref]: https://example.com "Title"',
autolink: "See <https://example.com>. Next",
autolink2: "See <https://example.com>.Next",
bareurl: "Go to https://example.com/a for more",
email: "Mail <[email protected]> now",
cr: "one\rtwo",
crfence: "```\na\rb\n```",
math: "$$\nx^2\n$$",
img: '![alt](i.png "t")',
emph: "Some **bold** and _em_ and ~~del~~.",
};
const KEYS = Object.keys(SNIPPETS);
const PREFIXES = ["", "---\ntitle: T\n---\n\n", '+++\ntitle = "T"\n+++\n\n', ""];
it("is idempotent for every ordered pair, at three separations, under four prefixes", () => {
const unstable: string[] = [];
for (const a of KEYS) {
for (const b of KEYS) {
for (const separator of ["\n\n", "\n\n\n", "\n"]) {
for (const prefix of PREFIXES) {
const source = `${prefix}${SNIPPETS[a]}${separator}${SNIPPETS[b]}\n`;
const once = write(source);
if (write(once) !== once) unstable.push(`${JSON.stringify(prefix)} ${a}+${b}: ${JSON.stringify(once)} -> ${JSON.stringify(write(once))}`);
}
}
}
}
expect(unstable).toEqual([]);
}, 30000);
it("does not change the meaning of a document, for every ordered pair", () => {
const changed: string[] = [];
for (const a of KEYS) {
for (const b of KEYS) {
const source = `${SNIPPETS[a]}\n\n${SNIPPETS[b]}\n`;
if (!doc(write(source)).eq(doc(source))) changed.push(`${a}+${b}: ${JSON.stringify(write(source))}`);
}
}
expect(changed).toEqual([]);
});
it("writes every raw block back as the bytes it cut, for every ordered pair", () => {
const lost: string[] = [];
for (const a of KEYS) {
for (const b of KEYS) {
const source = `${SNIPPETS[a]}\n\n${SNIPPETS[b]}\n`;
const document = parseMarkdown(source, "/pair.md");
const out = serializeMarkdown(document, document.doc);
for (const raw of rawBlocks(document.doc)) {
if (!source.replace(/\r\n/g, "\n").includes(raw)) lost.push(`${a}+${b} not a source slice: ${JSON.stringify(raw)}`);
else if (!out.includes(raw)) lost.push(`${a}+${b} not in the output: ${JSON.stringify(raw)}`);
}
}
}
expect(lost).toEqual([]);
});
it("keeps frontmatter byte identical in front of every snippet", () => {
for (const prefix of PREFIXES.slice(1)) {
for (const key of KEYS) {
const out = write(`${prefix}${SNIPPETS[key]}\n`);
expect(out.startsWith(prefix), `${key}: ${JSON.stringify(out.slice(0, prefix.length + 16))}`).toBe(true);
}
}
});
});
// =============================================================================================
// Found. These fail. Each one is a save that loses or corrupts something.
// =============================================================================================
describe("found: the file grows on every save and never converges", () => {
it("does not add a bracket pair to an email autolink whose domain has an underscore", () => {
// `<a@b_c.com>` is not an angle autolink to CommonMark (an underscore is not legal in a
// domain label there) but IS an email to the GFM literal autolink extension, so the tree is
// text "<", link, text ">". The serializer refuses the bare form because the character in
// front is "<" (correctly: a bare url may not start there), and mdast's fallback for a link
// whose text is its own destination is the angle form. So the "<" that was already there
// gains another, and the next save gains another, without limit.
let current = "Mail <a@b_c.com> here\n";
const generations: string[] = [];
for (let generation = 0; generation < 4; generation += 1) {
current = write(current);
generations.push(current);
}
expect(generations[1], `grew: ${JSON.stringify(generations)}`).toBe(generations[0]);
});
it("does not double a backslash inside an autolink destination on every save", () => {
// Backslash escapes do not apply inside `<...>`, so the destination genuinely contains a
// backslash. mdast writes the autolink back with the backslash escaped, the parser reads the
// escape as two characters, and the run doubles: 1, 2, 4, 8, 16 backslashes.
const source = "See <https://example.com/a\\_b> here\n";
const once = write(source);
expect(destinations(once), `${JSON.stringify(source)} -> ${JSON.stringify(once)}`).toEqual(destinations(source));
expect(write(once), "second save must not move the file again").toBe(once);
});
});
describe("found: a link destination changes or disappears on the first save", () => {
it("keeps a link whose domain GFM will not autolink", () => {
// GFM will not read a bare url back as a link when either of the last two domain labels
// contains an underscore. `LITERAL_URL` does not know that rule, writes the url bare anyway,
// and the link is gone from the file: not redirected, gone. The save after that escapes the
// leftovers, so the file moves twice as well.
const source = "See <https://exa_mple.com/a> here\n";
const once = write(source);
expect(destinations(once), `${JSON.stringify(source)} -> ${JSON.stringify(once)}`).toEqual(["https://exa_mple.com/a"]);
expect(write(once), "second save must not move the file again").toBe(once);
});
it("keeps a relative destination that happens to start with www.", () => {
// `href === text` is the test for "writing this bare is the same link", and it is true here,
// but only for a url with a scheme. A bare `www.` url is read back with `http://` bolted on,
// so a relative link to a file called `www.example.com` becomes a link to the internet.
for (const source of ["[www.example.com](www.example.com)\n", "See [www.a.b/c](www.a.b/c) here\n", "[www.example.com/a_b](www.example.com/a_b)\n"]) {
const once = write(source);
expect(destinations(once), `${JSON.stringify(source)} -> ${JSON.stringify(once)}`).toEqual(destinations(source));
expect(doc(once).eq(doc(source)), source).toBe(true);
}
});
it("does not let a following semicolon eat the tail of the url as an entity", () => {
// `endsWhereItSaysItDoes` counts ";" as ordinary trailing punctuation. GFM does not: a ";" at
// the end of a bare url makes it look backwards for an "&" and drop the whole entity-shaped
// tail, which is more than the semicolon.
const source = "See <https://example.com/a&amp>; here\n";
const once = write(source);
expect(destinations(once), `${JSON.stringify(source)} -> ${JSON.stringify(once)}`).toEqual(["https://example.com/a&amp"]);
});
it("keeps an email address GFM's literal autolink grammar does not accept", () => {
// `LITERAL_EMAIL` is much looser than the grammar that has to read the result back, so the
// bare form starts somewhere else in the address, or is not a link at all.
for (const source of ["[a:[email protected]](mailto:a:[email protected])\n", "[a([email protected]](mailto:a\\([email protected])\n", "[[email protected]](mailto:[email protected])\n", "[[email protected]_](mailto:[email protected]_)\n"]) {
const once = write(source);
expect(destinations(once), `${JSON.stringify(source)} -> ${JSON.stringify(once)}`).toEqual(destinations(source));
}
});
it("keeps a url that is inside the text of another link", () => {
// Two links, one destination out. A link is a mark and marks do not nest, so the inner
// destination has nowhere to live; writing the inner one bare then hides the loss behind a
// file that looks fine. Losing it quietly is the thing this bridge exists not to do: the
// outer link should fail to model and the paragraph should be kept as raw source.
for (const source of ["[see <https://example.com> more](http://y.com)\n", "[<https://example.com>](http://y.com)\n"]) {
const once = write(source);
expect(destinations(once), `${JSON.stringify(source)} -> ${JSON.stringify(once)}`).toEqual(destinations(source));
}
});
it("escapes a pipe in a bare url written into a table cell", () => {
// The bare url goes out as an inline html node, and html is written with no escaping at all.
// Inside a table cell that is a column separator: the row gains a column and the destination
// is truncated at the pipe. Every other inline node in a cell has its pipes escaped.
const node = n.doc.create(null, [n.table.create(null, [row(cell(schema.text("a"))), row(cell(linked("https://example.com/a|b", "https://example.com/a|b")))])]);
const out = writeDoc(node);
expect(destinations(out), out).toEqual(["https://example.com/a|b"]);
});
});
describe("found: a lone carriage return after the frontmatter welds the file together", () => {
// A regression, and the clearest one in the file. `normaliseSource` now leaves a lone carriage
// return alone, and the parser treats it as a line ending, so the frontmatter node ends at a
// "\r" that `splitFrontmatter` does not recognise: it scans for "\n" to swallow the blank lines
// after the delimiter, finds the wrong one or none at all, and stops before the line ending.
// The frontmatter string it hands back therefore does not end a line, and the body is
// concatenated straight onto the closing delimiter.
//
// The old CRLF-and-lone-CR normalisation made this impossible, so the fix caused it.
it("keeps the closing delimiter and the body on separate lines", () => {
const source = "---\na: 1\n---\rp\n";
const once = write(source);
expect(once, "the body was welded onto the closing delimiter").not.toContain("---p");
expect(parseMarkdown(once, "/x.md").frontmatter, "the frontmatter did not survive the save").not.toBe(null);
expect(write(once), "second save must not move the file again").toBe(once);
});
it("holds for toml, for a byte order mark, and for a run of carriage returns", () => {
for (const source of ["+++\na = 1\n+++\rp\n", "---\na: 1\n---\rp\n", "---\na: 1\n---\r\rp\n", "---\na: 1\n---\r\r\np\n"]) {
const once = write(source);
expect(parseMarkdown(once, "/x.md").frontmatter, `${JSON.stringify(source)} -> ${JSON.stringify(once)}`).not.toBe(null);
expect(write(once), source).toBe(once);
}
});
});
describe("found: the preservation sweep normalises differently from the code it tests", () => {
it("would misfire on a raw block holding a lone carriage return", () => {
// adversarial.test.ts checks that a raw block is a slice of `source.replace(/\r\n?/g, "\n")`.
// That was the right normalisation before the fix and is the wrong one now: `normaliseSource`
// keeps a lone carriage return, so the raw block keeps it too and the assertion fails on a
// block the bridge preserved perfectly. It has not fired only because no fixture in
// corpus/adversarial/ has a carriage return inside an unmodellable block yet, and that folder
// is a glob: the day one lands there the sweep goes red for the wrong reason.
const source = "<div>\ra\r</div>\n";
const document = parseMarkdown(source, "/x.md");
const raws = rawBlocks(document.doc);
expect(raws).toEqual(["<div>\ra\r</div>"]);
expect(write(source), "the bridge itself preserves it exactly").toBe(source);
for (const raw of raws) expect(source.replace(/\r\n/g, "\n"), "the sweep's own normalisation").toContain(raw);
});
});
+728
View File
@@ -0,0 +1,728 @@
// Third adversarial pass over the markdown bridge, written after the serializer was reworked to
// stop reproducing GFM's literal autolink grammar by hand and start proving the bare form instead:
// write the block with every url bare, read it back with the parser the bridge opens files with,
// and keep the bare form only when what comes back is the same block. Same rules as the two files
// before it. This is here to break the bridge, not to sign it off.
//
// The file is in two halves, the same way adversarial2.test.ts is.
//
// "still fixed" is the regression net. Every test in it passes. The six autolink losses and the
// frontmatter regression from the first two passes are pinned byte for byte rather than by
// destination, because "the destination survived" is a weaker promise than the one the bridge
// makes, and the new machinery is attacked where it is new: the fallback spelling, which is now
// load bearing and had never been attacked; blocks holding several candidates at once; and the
// question of whether a block verified on its own is still right once it is written into a file.
//
// "found" is the result. Every test in it FAILS. There is no data loss in it: no destination
// changes, nothing is dropped, nothing grows, and every input in this file still means the same
// document after a save as before it. What is left is a first save that rewrites documents nobody
// asked it to rewrite, and a save that goes quadratic on a document made of links.
import { describe, expect, it } from "vitest";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { schema } from "../model/schema";
import { corpus } from "./corpus/load";
import { parseToMdast, stringifyMdast } from "./handlers";
import { parseMarkdown, serializeMarkdown } from "./index";
const fixtures = import.meta.glob("./corpus/adversarial/*.md", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
function fixture(name: string): string {
const source = fixtures[`./corpus/adversarial/${name}`];
if (source === undefined) throw new Error(`no adversarial fixture named ${name}`);
return source;
}
/** One save. */
function write(source: string): string {
const document = parseMarkdown(source, "/adversarial3.md");
return serializeMarkdown(document, document.doc);
}
function doc(source: string): ProseMirrorNode {
return parseMarkdown(source, "/adversarial3.md").doc;
}
function writeDoc(node: ProseMirrorNode): string {
return serializeMarkdown({ frontmatter: null, doc: node, source: "", path: "/adversarial3.md" }, node);
}
/**
* Every link and image in a file, in document order, as destination, title and text.
*
* The destination alone is what the first two passes checked. It is not enough for the fallback:
* `[text](href)` writes the text out as markdown too, so a spelling that keeps the destination and
* mangles the text is still a save that changed the file's content.
*/
function linksIn(source: string): string[] {
const out: string[] = [];
const text = (node: { value?: string; children?: unknown[] }): string => (node.value !== undefined ? String(node.value) : ((node.children ?? []) as Array<Parameters<typeof text>[0]>).map(text).join(""));
const walk = (node: { type?: string; url?: string; title?: string | null; children?: unknown[] }) => {
if (node.type === "link" || node.type === "image") out.push(`${node.type} ${node.url} ${node.title ?? ""} ${JSON.stringify(text(node))}`);
for (const child of (node.children ?? []) as Array<Parameters<typeof walk>[0]>) walk(child);
};
walk(parseToMdast(source) as unknown as Parameters<typeof walk>[0]);
return out;
}
function rawBlocks(node: ProseMirrorNode): string[] {
const out: string[] = [];
node.descendants((child) => {
if (child.type.name === "raw") out.push(child.textContent);
return child.type.name !== "raw";
});
return out;
}
function retypeParagraph(node: ProseMirrorNode, from: string, to: string): ProseMirrorNode {
const children: ProseMirrorNode[] = [];
let hits = 0;
node.forEach((child) => {
if (child.type.name === "paragraph" && child.textContent === from) {
hits += 1;
children.push(schema.nodes.paragraph.create(null, schema.text(to)));
return;
}
children.push(child);
});
expect(hits, `expected exactly one paragraph reading ${JSON.stringify(from)}`).toBe(1);
return schema.nodes.doc.create(null, children);
}
const n = schema.nodes;
const linked = (text: string, href: string, title: string | null = null) => schema.text(text, [schema.marks.link.create({ href, title })]);
const marked = (text: string, href: string, ...names: string[]) => schema.text(text, [schema.marks.link.create({ href, title: null }), ...names.map((name) => schema.marks[name].create())]);
const para = (...content: ProseMirrorNode[]) => n.paragraph.create(null, content);
const cell = (...content: ProseMirrorNode[]) => n.tableCell.create({ colspan: 1, rowspan: 1, colwidth: null, align: null }, content.length > 0 ? content : null);
const row = (...cells: ProseMirrorNode[]) => n.tableRow.create(null, cells);
// =============================================================================================
// Still fixed. These pass.
// =============================================================================================
describe("still fixed: the six autolink losses, byte for byte", () => {
// Each of these cost a destination or grew the file without limit in one of the first two
// passes. They are pinned as exact bytes rather than as surviving destinations, so that a
// future spelling change has to be looked at rather than absorbed.
const cases: Array<[string, string, string]> = [
["an email whose domain has an underscore does not gain a bracket", "Mail <a@b_c.com> here\n", "Mail <a@b_c.com> here\n"],
["a backslash in a destination does not double", "See <https://example.com/a\\_b> here\n", "See https://example.com/a\\_b here\n"],
["a domain GFM will not autolink keeps its angle brackets", "See <https://exa_mple.com/a> here\n", "See <https://exa_mple.com/a> here\n"],
["a relative destination starting www. stays relative", "[www.example.com](www.example.com)\n", "[www.example.com](www.example.com)\n"],
["a following semicolon cannot eat an entity shaped tail", "See <https://example.com/a&amp>; here\n", "See <https://example.com/a&amp>; here\n"],
["an address the literal grammar rejects is spelled out", "[a:[email protected]](mailto:a:[email protected])\n", "[a:[email protected]](mailto:a:[email protected])\n"],
["an address with a paren is spelled out", "[a([email protected]](mailto:a\\([email protected])\n", "[a([email protected]](mailto:a\\([email protected])\n"],
["an address ending in a dash is spelled out", "[[email protected]](mailto:[email protected])\n", "[[email protected]](mailto:[email protected])\n"],
["a url inside another link's text keeps the paragraph as source", "[see <https://example.com> more](http://y.com)\n", "[see <https://example.com> more](http://y.com)\n"],
["so does a url that is the whole of another link's text", "[<https://example.com>](http://y.com)\n", "[<https://example.com>](http://y.com)\n"],
["an apostrophe after a url is not swallowed", "Read <https://example.com>'s docs\n", "Read <https://example.com>'s docs\n"],
["nor is a full stop and the word after it", "See <https://example.com>.Next thing\n", "See <https://example.com>.Next thing\n"],
["a url that can be written bare still is", "Angle <https://example.com> here.\n", "Angle https://example.com here.\n"],
["so is an address that can be", "Mail <[email protected]> now\n", "Mail [email protected] now\n"],
["and a www url that was already bare", "www.example.com\n", "www.example.com\n"],
["and a url in the middle of a sentence", "Go to https://example.com/a for more\n", "Go to https://example.com/a for more\n"],
];
for (const [name, source, expected] of cases) {
it(name, () => {
const once = write(source);
expect(once).toBe(expected);
expect(write(once), "second save must not move the file again").toBe(once);
expect(linksIn(once), "the links must be the same links").toEqual(linksIn(source));
expect(doc(once).eq(doc(source)), "and the same document").toBe(true);
});
}
it("escapes a pipe in a bare url written into a table cell, without splitting the cell", () => {
const node = n.doc.create(null, [n.table.create(null, [row(cell(schema.text("a"))), row(cell(linked("https://example.com/a|b", "https://example.com/a|b")))])]);
const out = writeDoc(node);
expect(linksIn(out)).toEqual(['link https://example.com/a|b "https://example.com/a|b"']);
// The escaped pipe has to stay inside the cell rather than opening another column, which the
// parser is the only honest judge of: the row the url is in still has exactly one cell.
const table = parseToMdast(out).children[0] as { type: string; children: Array<{ children: unknown[] }> };
expect(table.type).toBe("table");
expect(table.children.map((tableRow) => tableRow.children.length)).toEqual([1, 1]);
expect(write(out)).toBe(out);
});
it("holds for the adjacency fixture as a whole file", () => {
const source = fixture("autolink-adjacency.md");
const once = write(source);
expect(linksIn(once)).toEqual(linksIn(source));
expect(write(once)).toBe(once);
});
});
describe("still fixed: the bare url boundary, widened", () => {
const URLS = [
"https://example.com",
"http://example.com",
"www.example.com/a",
"https://exa_mple.com/a",
"https://a.exa_mple.com/b",
"https://example.com/a_b",
"https://example.com/a\\_b",
"https://example.com/a\\b",
"https://example.com/a&amp",
"https://example.com/x&",
"https://example.com/a(b)",
"https://example.com/a(b",
"https://example.com/a)b",
"https://example.com/a[b]",
"https://example.com/a]b",
"https://example.com/a*b",
"https://example.com/a`b",
"https://example.com/a|b",
"https://example.com/#frag",
"https://example.com/?q=1&r=2",
"https://example.com/ünïcode",
"https://例え.jp/a",
];
const SUFFIXES = ["", ".", "!?", "'s", ".Next", ";", "&x", ")", "))", "*b*", "`c`", " next", "|x", "]", "[x]", "…", "-", "\\", "<b>"];
const PREFIXES = ["", "See ", "(", "*", "_", "~~", "x", "[", "<", "\\", "|"];
it("keeps every link through every angle autolink and suffix, without growing", () => {
const broken: string[] = [];
for (const url of URLS) {
for (const suffix of SUFFIXES) {
const source = `See <${url}>${suffix}\n`;
const first = write(source);
const second = write(first);
if (JSON.stringify(linksIn(first)) !== JSON.stringify(linksIn(source))) broken.push(`links ${JSON.stringify(source)} -> ${JSON.stringify(first)}`);
else if (second !== first) broken.push(`unstable ${JSON.stringify(source)} -> ${JSON.stringify(first)} -> ${JSON.stringify(second)}`);
else if (!doc(first).eq(doc(source))) broken.push(`meaning ${JSON.stringify(source)} -> ${JSON.stringify(first)}`);
}
}
expect(broken).toEqual([]);
}, 20000);
it("keeps every link through every bare url, prefix and suffix", () => {
const broken: string[] = [];
for (const url of URLS) {
for (const prefix of PREFIXES) {
for (const suffix of SUFFIXES) {
const source = `${prefix}${url}${suffix}\n`;
if (linksIn(source).length === 0) continue;
const first = write(source);
if (JSON.stringify(linksIn(first)) !== JSON.stringify(linksIn(source))) broken.push(`links ${JSON.stringify(source)} -> ${JSON.stringify(first)}`);
else if (write(first) !== first) broken.push(`unstable ${JSON.stringify(source)} -> ${JSON.stringify(first)}`);
}
}
}
expect(broken).toEqual([]);
}, 30000);
it("keeps the destination in every inline container the schema has", () => {
const containers = ["*<URL>*", "**<URL>**", "~~<URL>~~", "_a <URL>_ b", "# <URL>", "## a <URL>.Next", "> <URL>", "> a\n> <URL>", "- <URL>", "- [ ] <URL>", "1. <URL>", "> [!NOTE]\n> a <URL>", "> [!TIP]\n> a\n>\n> <URL>", "a <URL> b", "(<URL>)", "[l](x.md) <URL>", "![i](i.png) <URL>", "`c` <URL>", "$$m$$ <URL>"];
const broken: string[] = [];
for (const url of ["https://example.com/a", "https://exa_mple.com/a", "[email protected]", "a@b_c.com"]) {
for (const container of containers) {
const source = `${container.replace("URL", url)}\n`;
const first = write(source);
if (JSON.stringify(linksIn(first)) !== JSON.stringify(linksIn(source))) broken.push(`links ${JSON.stringify(source)} -> ${JSON.stringify(first)}`);
else if (write(first) !== first) broken.push(`unstable ${JSON.stringify(source)} -> ${JSON.stringify(first)}`);
}
}
expect(broken).toEqual([]);
});
});
describe("still fixed: the fallback carries the link on its own", () => {
// `[text](href)` is now the only spelling left when the bare form cannot be proved, so it is
// load bearing in a way it never was: if it does not round trip, nothing catches it, because
// the verifier compares the bare form against the explicit one and returns the explicit one
// when neither matches.
const URLS = [
"https://example.com/a_b",
"https://exa_mple.com/a",
"https://example.com/a\\b",
"https://example.com/a\\",
"https://example.com/a(b)",
"https://example.com/a(b",
"https://example.com/a)b",
"https://example.com/a[b]",
"https://example.com/a]b",
"https://example.com/a*b*c",
"https://example.com/a`b",
"https://example.com/a&amp",
"https://example.com/a<b",
"https://example.com/a>b",
'https://example.com/a"b',
"https://example.com/a'b",
"https://example.com/a|b",
"https://example.com/#frag",
"https://example.com/a%20b",
"https://example.com/a b",
"https://example.com/.",
"https://example.com/a-",
"www.exa_mple.com",
"https://例え.jp/パス",
];
it("round trips a link whose text is its own destination, in every block that takes one", () => {
const blocks: Array<[string, (content: ProseMirrorNode) => ProseMirrorNode]> = [
["paragraph", (content) => para(schema.text("See "), content, schema.text(" here"))],
["paragraph alone", (content) => para(content)],
["heading", (content) => n.heading.create({ level: 2 }, [schema.text("H "), content])],
["blockquote", (content) => n.blockquote.create(null, [para(content)])],
["callout", (content) => n.callout.create({ kind: "note" }, [para(content)])],
["list item", (content) => n.bulletList.create({ tight: true }, [n.listItem.create(null, [para(content)])])],
["task item", (content) => n.taskList.create({ tight: true }, [n.taskItem.create({ checked: false }, [para(content)])])],
["table cell", (content) => n.table.create(null, [row(cell(schema.text("h")), cell(schema.text("h2"))), row(cell(content), cell(schema.text("x")))])],
];
const broken: string[] = [];
for (const url of URLS) {
for (const [name, build] of blocks) {
const node = n.doc.create(null, [build(linked(url, url))]);
const out = writeDoc(node);
const want = `link ${url} ${JSON.stringify(url)}`;
if (!linksIn(out).includes(want)) broken.push(`${name} ${JSON.stringify(url)} -> ${JSON.stringify(out)} gave ${JSON.stringify(linksIn(out))}`);
else if (write(out) !== out) broken.push(`${name} ${JSON.stringify(url)} unstable -> ${JSON.stringify(out)}`);
}
}
expect(broken).toEqual([]);
}, 20000);
it("round trips a destination that could never have been bare", () => {
const hrefs = ["", "#", "#frag", "a b.md", "a(b).md", "a(b.md", "a)b.md", "a\\b.md", "a\\", "a<b.md", "./a b/c.md", "mailto:[email protected]", "a%20b", 'a"b.md', "a'b.md", "a`b.md", "a|b.md", "../x.md", "?q=1"];
const broken: string[] = [];
for (const href of hrefs) {
const node = n.doc.create(null, [para(schema.text("x "), linked("t", href), schema.text(" y"))]);
const out = writeDoc(node);
if (!linksIn(out).includes(`link ${href} "t"`)) broken.push(`${JSON.stringify(href)} -> ${JSON.stringify(out)} gave ${JSON.stringify(linksIn(out))}`);
else if (write(out) !== out) broken.push(`${JSON.stringify(href)} unstable -> ${JSON.stringify(out)}`);
}
expect(broken).toEqual([]);
});
it("round trips a fallback whose text carries marks of its own", () => {
const url = "https://exa_mple.com/a";
const cases: Array<[string, ProseMirrorNode]> = [
["strong", n.doc.create(null, [para(marked(url, url, "strong"))])],
["em", n.doc.create(null, [para(marked(url, url, "em"))])],
["strikethrough", n.doc.create(null, [para(marked(url, url, "strikethrough"))])],
["code", n.doc.create(null, [para(marked(url, url, "code"))])],
["strong and em", n.doc.create(null, [para(marked(url, url, "strong", "em"))])],
["half of it strong", n.doc.create(null, [para(marked("https://exa", url, "strong"), linked("_mple.com/a", url))])],
["a title as well", n.doc.create(null, [para(linked(url, url, 'a "quoted" title'))])],
["an image for text", n.doc.create(null, [para(n.image.create({ src: "i.png", alt: "a", title: null }, null, [schema.marks.link.create({ href: url, title: null })]))])],
];
for (const [name, node] of cases) {
const out = writeDoc(node);
const found = linksIn(out).filter((entry) => entry.startsWith("link "));
expect(found, `${name}: ${JSON.stringify(out)}`).toHaveLength(1);
expect(found[0].startsWith(`link ${url} `), `${name}: ${JSON.stringify(out)}`).toBe(true);
expect(write(out), name).toBe(out);
expect(doc(write(out)).eq(doc(out)), name).toBe(true);
}
});
});
describe("still fixed: a block holding more than one candidate", () => {
it("spells out only the url that cannot be bare, and keeps every destination", () => {
const good = "https://example.com/a";
const bad = "https://exa_mple.com/b";
const other = "https://ok.example.com/c";
const cases: Array<[string, ProseMirrorNode]> = [
["good then bad", n.doc.create(null, [para(linked(good, good), schema.text(" and "), linked(bad, bad))])],
["bad then good", n.doc.create(null, [para(linked(bad, bad), schema.text(" and "), linked(good, good))])],
["good bad good", n.doc.create(null, [para(linked(good, good), schema.text(" "), linked(bad, bad), schema.text(" "), linked(other, other))])],
["two bad", n.doc.create(null, [para(linked(bad, bad), schema.text(" "), linked("https://exc_mple.com/d", "https://exc_mple.com/d"))])],
["across list items", n.doc.create(null, [n.bulletList.create({ tight: true }, [n.listItem.create(null, [para(linked(good, good))]), n.listItem.create(null, [para(linked(bad, bad))])])])],
["across quote lines", n.doc.create(null, [n.blockquote.create(null, [para(linked(good, good)), para(linked(bad, bad))])])],
];
for (const [name, node] of cases) {
const out = writeDoc(node);
const want: string[] = [];
node.descendants((child) => {
for (const mark of child.marks) if (mark.type.name === "link") want.push(`link ${mark.attrs.href} ${JSON.stringify(child.textContent)}`);
return true;
});
expect(linksIn(out), `${name}: ${JSON.stringify(out)}`).toEqual(want);
expect(write(out), name).toBe(out);
}
});
it("does not spell out the whole paragraph because one url in it is awkward", () => {
const source = "See <https://a.example.com> and <https://exa_mple.com/b> and <https://c.example.com> here\n";
const out = write(source);
// The awkward url keeps its angle brackets rather than being spelled out, so this paragraph is
// now byte identical to its source. The property under test is the same either way: one url the
// bare rung cannot take must not drag the two beside it out of the bare form with it.
expect(out).toBe("See https://a.example.com and <https://exa_mple.com/b> and https://c.example.com here\n");
expect(linksIn(out)).toEqual(linksIn(source));
expect(write(out)).toBe(out);
});
it("keeps a bare url next to a link that is not a candidate at all", () => {
for (const source of ["[docs](./docs.md) and https://example.com/a\n", "https://example.com/a and [docs](./docs.md)\n", "[docs](./docs.md 'T') https://example.com/a\n", "![i](i.png) https://example.com/a and <[email protected]>\n"]) {
const once = write(source);
expect(linksIn(once), source).toEqual(linksIn(source));
expect(write(once), source).toBe(once);
}
});
});
describe("still fixed: the frontmatter boundary agrees with the parser about line endings", () => {
const heads = ["---\na: 1\n---", "---\na: 1\n--- ", "---\na: 1\n---\t", "---\n---", "---\na: 1\rb: 2\n---", "---\ra: 1\r---", "---\na: 1\r---", "---\r\na: 1\r\n---", "+++\na = 1\n+++", "+++\ra = 1\r+++", "---\nbody: |\n ---\nb: 2\n---"];
const separators = ["\n", "\r", "\r\n", "\n\n", "\r\r", "\r\n\r\n", "\n\r", "\r\n\r", "\r\n\n", "\n\r\r", "\r\r\r", "\n \n"];
const bodies = ["p\n", "p", "- a\n- b\n", "> q\n", "# h\n", "```\nx\n```\n", "| a |\n| - |\n| 1 |\n", "", "\n", "para\rmore\n", "See <https://example.com>.Next\n"];
it("keeps the slot, the body and the line between them, at every shape of the boundary", () => {
const bad: string[] = [];
let checked = 0;
for (const bom of ["", ""]) {
for (const head of heads) {
for (const separator of separators) {
for (const body of bodies) {
const source = bom + head + separator + body;
checked += 1;
const slot = parseMarkdown(source, "/x.md").frontmatter;
const once = write(source);
if (write(once) !== once) bad.push(`unstable ${JSON.stringify(source)} -> ${JSON.stringify(once)}`);
else if (!doc(once).eq(doc(source))) bad.push(`meaning ${JSON.stringify(source)} -> ${JSON.stringify(once)}`);
else if (slot !== null && !once.startsWith(slot)) bad.push(`slot lost ${JSON.stringify(source)} -> ${JSON.stringify(once)}`);
else if (slot !== null && parseMarkdown(once, "/x.md").frontmatter === null) bad.push(`slot stopped being frontmatter ${JSON.stringify(source)} -> ${JSON.stringify(once)}`);
}
}
}
}
expect(checked).toBeGreaterThan(2000);
expect(bad).toEqual([]);
}, 30000);
it("never welds the first body line onto the closing delimiter", () => {
const bad: string[] = [];
for (const bom of ["", ""]) {
for (const head of heads) {
for (const separator of separators) {
const source = `${bom}${head}${separator}the body\n`;
const once = write(source);
const slot = parseMarkdown(source, "/x.md").frontmatter;
if (slot === null) continue;
if (/(---|\+\+\+)the body/.test(once)) bad.push(`${JSON.stringify(source)} -> ${JSON.stringify(once)}`);
if (!once.includes("the body")) bad.push(`body lost ${JSON.stringify(source)} -> ${JSON.stringify(once)}`);
}
}
}
expect(bad).toEqual([]);
});
it("still tells frontmatter from a rule when the only line ending is a carriage return", () => {
expect(parseMarkdown("---\ra: 1\r---\rp\r", "/x.md").frontmatter).toBe("---\ra: 1\r---\r");
expect(write("---\ra: 1\r---\rp\r")).toBe("---\ra: 1\r---\rp\n");
expect(write(write("---\ra: 1\r---\rp\r"))).toBe(write("---\ra: 1\r---\rp\r"));
});
});
describe("still fixed: the sweeps the rework could have broken", () => {
const SNIPPETS: Record<string, string> = {
para: "Plain paragraph text.",
head: "## A heading",
hr: "---",
fence: "```js\nconst x = 1;\n```",
ul: "- one\n- two",
task: "- [ ] a\n- [x] b",
quote: "> quoted",
callout: "> [!NOTE]\n> body",
table: "| a | b |\n| --- | --: |\n| 1 | 2 |",
html: '<div class="x">\n <span>y</span>\n</div>',
details: "<details>\n<summary>S</summary>\n\nbody\n\n</details>",
footnote: "[^n]: A footnote definition.",
defn: '[ref]: https://example.com "Title"',
math: "$$\nx^2\n$$",
img: '![alt](i.png "t")',
link: "A [link](http://x.com) here.",
emph: "Some **bold** and _em_ and ~~del~~.",
autolink: "See <https://example.com>. Next",
autolinkTight: "See <https://example.com>.Next",
badurl: "See <https://exa_mple.com/a> here",
backslash: "See <https://example.com/a\\_b> here",
entity: "See <https://example.com/a&amp>; here",
email: "Mail <a@b_c.com> here",
bareurl: "Go to https://example.com/a for more",
wwwrel: "[www.example.com](www.example.com)",
manyurls: "<https://a.com> <https://b.com> <https://exa_mple.com/c> <https://d.com>",
cr: "one\rtwo",
crfence: "```\na\rb\n```",
unicode: "café \u{1F469}‍\u{1F4BB} é",
};
const KEYS = Object.keys(SNIPPETS);
const PREFIXES = ["", "---\ntitle: T\n---\n\n", '+++\ntitle = "T"\n+++\n\n', ""];
it("is idempotent for every ordered pair, at three separations, under four prefixes", () => {
const unstable: string[] = [];
for (const a of KEYS) {
for (const b of KEYS) {
for (const separator of ["\n\n", "\n\n\n", "\n"]) {
for (const prefix of PREFIXES) {
const source = `${prefix}${SNIPPETS[a]}${separator}${SNIPPETS[b]}\n`;
const once = write(source);
if (write(once) !== once) unstable.push(`${JSON.stringify(prefix)} ${a}+${b}: ${JSON.stringify(once)} -> ${JSON.stringify(write(once))}`);
}
}
}
}
expect(unstable).toEqual([]);
}, 60000);
it("does not change the meaning of a document, for every ordered pair", () => {
const changed: string[] = [];
for (const a of KEYS) {
for (const b of KEYS) {
const source = `${SNIPPETS[a]}\n\n${SNIPPETS[b]}\n`;
if (!doc(write(source)).eq(doc(source))) changed.push(`${a}+${b}: ${JSON.stringify(write(source))}`);
}
}
expect(changed).toEqual([]);
}, 20000);
it("keeps every link in the file, for every ordered pair", () => {
const lost: string[] = [];
for (const a of KEYS) {
for (const b of KEYS) {
const source = `${SNIPPETS[a]}\n\n${SNIPPETS[b]}\n`;
const once = write(source);
if (JSON.stringify(linksIn(once)) !== JSON.stringify(linksIn(source))) lost.push(`${a}+${b}: ${JSON.stringify(linksIn(source))} -> ${JSON.stringify(linksIn(once))}`);
}
}
expect(lost).toEqual([]);
}, 20000);
it("writes every raw block back as the bytes it cut, for every ordered pair", () => {
const lost: string[] = [];
for (const a of KEYS) {
for (const b of KEYS) {
const source = `${SNIPPETS[a]}\n\n${SNIPPETS[b]}\n`;
const document = parseMarkdown(source, "/pair.md");
const out = serializeMarkdown(document, document.doc);
for (const raw of rawBlocks(document.doc)) {
// The bridge's own normalisation, rather than an approximation of it: a lone carriage
// return survives, and so does the CRLF at the end of a run of them.
const normalised = source.replace(/\r*\n/g, (ending) => (ending.length === 2 ? "\n" : ending));
if (!normalised.includes(raw)) lost.push(`${a}+${b} not a source slice: ${JSON.stringify(raw)}`);
else if (!out.includes(raw)) lost.push(`${a}+${b} not in the output: ${JSON.stringify(raw)}`);
}
}
}
expect(lost).toEqual([]);
}, 20000);
it("is a one paragraph diff for every pair of neighbouring constructs", () => {
const bad: string[] = [];
for (const a of KEYS) {
for (const b of KEYS) {
const base = write(`${SNIPPETS[a]}\n\nEDITME\n\n${SNIPPETS[b]}\n`);
if (write(base) !== base) continue;
const document = parseMarkdown(base, "/pair.md");
let edited: ProseMirrorNode;
try {
edited = retypeParagraph(document.doc, "EDITME", "EDITED");
} catch {
continue;
}
const out = serializeMarkdown(document, edited);
if (out !== base.replace("EDITME", "EDITED")) bad.push(`${a}|${b}\n want ${JSON.stringify(base.replace("EDITME", "EDITED"))}\n got ${JSON.stringify(out)}`);
}
}
expect(bad).toEqual([]);
}, 30000);
it("keeps frontmatter byte identical in front of every snippet", () => {
for (const prefix of PREFIXES.slice(1)) {
for (const key of KEYS) {
const out = write(`${prefix}${SNIPPETS[key]}\n`);
expect(out.startsWith(prefix), `${key}: ${JSON.stringify(out.slice(0, prefix.length + 16))}`).toBe(true);
}
}
});
it("is a one paragraph diff on the file full of things the editor cannot model", () => {
// The house style form of the fixture, not its bytes: its table is written compact and M2 pads
// a modelled table out to its column on the save that settles the file. That one time rewrite
// is roundtrip.test.ts's to police. What is asked here is what happens after it.
const source = write(fixture("locality-edit.md"));
expect(write(source), "the baseline must be byte stable").toBe(source);
const document = parseMarkdown(source, "/locality-edit.md");
const out = serializeMarkdown(document, retypeParagraph(document.doc, "EDITME", "EDITED"));
expect(out).toBe(source.replace("EDITME", "EDITED"));
});
});
describe("still fixed: nothing grows on the tenth save", () => {
// Two of the three losses the second pass found were files that grew on every save, so this is
// the cheap sweep that catches the whole class: save ten times and the length has to stop moving
// after the first.
function tenSaves(name: string, source: string, growing: string[]) {
const generations: string[] = [];
let current = source;
for (let generation = 0; generation < 10; generation += 1) {
current = write(current);
generations.push(current);
}
for (let generation = 1; generation < generations.length; generation += 1) {
if (generations[generation] !== generations[0]) {
growing.push(`${name}: save ${generation + 1} differs, lengths ${generations.map((text) => text.length).join(",")}`);
return;
}
}
}
it("holds for every file in the corpus", () => {
const growing: string[] = [];
for (const file of corpus()) tenSaves(file.name, file.source, growing);
expect(growing).toEqual([]);
}, 20000);
it("holds for every adversarial fixture", () => {
const growing: string[] = [];
for (const [name, source] of Object.entries(fixtures)) tenSaves(name, source, growing);
expect(growing).toEqual([]);
}, 20000);
it("holds for every construct that has ever gone wrong here, alone and paired", () => {
const CONSTRUCTS: Record<string, string> = {
angle: "See <https://example.com>. Next",
angleTight: "See <https://example.com>.Next",
badDomain: "See <https://exa_mple.com/a> here",
backslash: "See <https://example.com/a\\_b> here",
entity: "See <https://example.com/a&amp>; here",
emailUnderscore: "Mail <a@b_c.com> here",
email: "Mail <[email protected]> now",
wwwRelative: "[www.example.com](www.example.com)",
wrapped: "See the docs\nhttps://example.com/a\nfor more",
wrappedAngle: "See\n<https://example.com/a>",
calloutUrl: "> [!NOTE]\n> https://example.com/a",
calloutUrlInline: "> [!NOTE]\n> See https://example.com/a here",
hardBreakUrl: "a\\\nhttps://example.com/a",
several: "<https://a.com> <https://b.com> <https://exa_mple.com/c> <https://d.com>",
nested: "- a\n - <https://exa_mple.com/b>",
innerLink: "[see <https://example.com> more](http://y.com)",
rule: "---",
cr: "one\rtwo",
html: "<div>x</div>",
footnote: "[^n]: note",
table: "| a | b |\n| --- | --: |\n| 1 | 2 |",
};
const keys = Object.keys(CONSTRUCTS);
const growing: string[] = [];
for (const key of keys) {
for (const prefix of ["", "---\ntitle: T\n---\n\n", '+++\nt = "1"\n+++\n\n', ""]) tenSaves(`${JSON.stringify(prefix)} ${key}`, `${prefix}${CONSTRUCTS[key]}\n`, growing);
}
for (const a of keys) for (const b of keys) tenSaves(`${a}+${b}`, `${CONSTRUCTS[a]}\n\n${CONSTRUCTS[b]}\n`, growing);
expect(growing).toEqual([]);
}, 60000);
it("holds for the editor's own nodes, which no parse ever produces", () => {
const url = "https://exa_mple.com/a|b";
const nodes: Array<[string, ProseMirrorNode]> = [
["a table of urls", n.doc.create(null, [n.table.create(null, [row(cell(schema.text("a")), cell(schema.text("b"))), row(cell(linked(url, url)), cell(linked("https://ok.com", "https://ok.com")))])])],
["a callout of urls", n.doc.create(null, [n.callout.create({ kind: "note" }, [para(linked("https://a.com", "https://a.com")), para(linked(url, url))])])],
["a toggle of urls", n.doc.create(null, [n.toggle.create({ summary: "S", open: true }, [para(linked("https://a.com", "https://a.com"))])])],
["a url with a title", n.doc.create(null, [para(linked("https://a.com", "https://a.com", "T"))])],
["a url in every mark", n.doc.create(null, [para(marked("https://a.com", "https://a.com", "strong", "em", "strikethrough"))])],
];
const growing: string[] = [];
for (const [name, node] of nodes) tenSaves(name, writeDoc(node), growing);
expect(growing).toEqual([]);
});
});
// =============================================================================================
// Found. These fail.
// =============================================================================================
describe("found: a bare url that starts a line is spelled out on the first save", () => {
// The serializer writes a bare url as an inline html node. mdast writes a text node that ends in
// a soft line break followed by an inline html node onto ONE line: the line ending is turned
// into a space. So the bare spelling of a url that begins a continuation line is not the same
// paragraph, and the verifier is right to refuse it.
//
// Refusing it is not the bug. The bug is that the serializer has no spelling left that keeps the
// file as it is: the angle form was removed from the house style in the same change, so what is
// written is `[url](url)`, and an ordinary hard wrapped document with a url at the start of a
// line is rewritten the first time it is opened and saved. That is precisely the diff the bare
// url machinery exists to avoid, and it is not on the cosmetic list in adversarial.test.ts that
// a first save is allowed to produce.
//
// Nothing is lost: the destination, the text, the line break and the meaning all survive, the
// second save is stable and the file does not grow. It is a rewrite nobody asked for.
it("cannot write a bare url onto a continuation line at all", () => {
const written = stringifyMdast({
type: "root",
children: [{ type: "paragraph", children: [{ type: "text", value: "See the docs\n" }, { type: "html", value: "https://example.com/a" }] }],
});
expect(written, "the soft line break in front of the url was turned into a space").toBe("See the docs\nhttps://example.com/a\n");
});
it("leaves a wrapped paragraph whose next line is a url alone", () => {
const source = "See the docs\nhttps://example.com/a\nfor more.\n";
expect(write(source)).toBe(source);
});
it("leaves a callout whose body line is a url alone", () => {
// Here the fallback is not optional: the bare spelling would put the label and the url on one
// line, `> [!NOTE] https://example.com/a`, which is not a GitHub alert at all. The label is
// written as inline html and the newline after it is the same soft break, so a callout whose
// first paragraph starts with a url can never keep it bare.
for (const source of ["> [!NOTE]\n> https://example.com/a\n", "> [!TIP]\n> [email protected]\n"]) {
expect(write(source), source).toBe(source);
}
});
it("leaves a url after a hard break alone", () => {
expect(write("a\\\nhttps://example.com/a\n")).toBe("a\\\nhttps://example.com/a\n");
});
it("leaves a list item that wraps onto a url alone", () => {
expect(write("- item\n https://example.com/a\n")).toBe("- item\n https://example.com/a\n");
});
it("shows up all over an ordinary hard wrapped file", () => {
const source = fixture("wrapped-url.md");
const once = write(source);
// Everything that matters survives, which is why this is a rewrite and not a loss.
expect(linksIn(once)).toEqual(linksIn(source));
expect(doc(once).eq(doc(source))).toBe(true);
expect(write(once)).toBe(once);
expect(once, "eight of the ten urls in the file were spelled out").toBe(source);
});
});
describe("found: one url that cannot be bare makes every save quadratic", () => {
// When the whole block will not verify with every url bare, each candidate is tried on its own,
// and each try writes the whole block out and reads the whole block back. A block with N urls in
// it therefore costs N round trips through the parser, and the cost is paid on every save
// forever, not once: the `[url](url)` the fallback writes is read back as a link whose text is
// its own destination, which is a candidate again next time.
//
// A 7 KB list of links with one awkward url in it takes seconds to save. The same list without
// it takes milliseconds, because the fast path verifies the whole block in a single round trip.
function linkList(count: number, awkward: boolean): string {
const lines: string[] = [];
for (let index = 0; index < count; index += 1) lines.push(`- Item ${index}: <https://example.com/${index}>`);
if (awkward) lines.push("- Odd one out: <https://exa_mple.com/x>");
return `${lines.join("\n")}\n`;
}
it("costs about the same either way", () => {
const plain = linkList(100, false);
const awkward = linkList(100, true);
write(plain);
write(awkward);
const startPlain = performance.now();
write(plain);
const plainCost = performance.now() - startPlain;
const startAwkward = performance.now();
write(awkward);
const awkwardCost = performance.now() - startAwkward;
expect(awkwardCost / Math.max(plainCost, 1), `${plainCost.toFixed(0)}ms without the awkward url, ${awkwardCost.toFixed(0)}ms with it`).toBeLessThan(20);
}, 30000);
});
+550
View File
@@ -0,0 +1,550 @@
// Fourth adversarial pass over the markdown bridge, written after the third pass's finding was
// fixed: a bare url that begins a continuation line is no longer spelled out, because inline
// literals now go out as `phrasingLiteral` rather than `html` and so keep the line ending in front
// of them. Same rules as the three files before it. This is here to break the bridge.
//
// The brief for this pass named three things to attack. Two of them do not exist.
//
// There is no third rung on the autolink ladder. `resourceLink` is still `true` and nothing in the
// serializer writes `<url>`: the angle form is only ever preserved by a raw block, never produced.
// The two ways it used to grow a file without limit, `<a@b_c.com>` and `<https://example.com/a\_b>`,
// are checked here over ten saves rather than two, along with every other loss the first three
// passes found, and all of them are still dead.
//
// The linear fallback selection is real and it holds. It cannot lose anything by construction: the
// mixed spelling it infers is returned only when a full re-parse of it matches the same criterion
// the all-bare spelling had to meet, so a wrong inference costs a fallback to `[text](url)` and
// never a destination. Ambiguous, duplicated, dropped, interfering and demotion-sensitive
// candidates are all attacked below and none of them gets past it.
//
// "found" is the result, and it is not in the autolink machinery at all. It is a strikethrough
// that spans a link. One shape of it corrupts the document's text permanently and another grows
// the file by thirty two bytes on every save for the rest of its life.
import { describe, expect, it } from "vitest";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { schema } from "../model/schema";
import { corpus } from "./corpus/load";
import { parseToMdast } from "./handlers";
import { parseMarkdown, serializeMarkdown } from "./index";
const fixtures = import.meta.glob("./corpus/adversarial/*.md", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
function fixture(name: string): string {
const source = fixtures[`./corpus/adversarial/${name}`];
if (source === undefined) throw new Error(`no adversarial fixture named ${name}`);
return source;
}
/** One save. */
function write(source: string): string {
const document = parseMarkdown(source, "/adversarial4.md");
return serializeMarkdown(document, document.doc);
}
function doc(source: string): ProseMirrorNode {
return parseMarkdown(source, "/adversarial4.md").doc;
}
function writeDoc(node: ProseMirrorNode): string {
return serializeMarkdown({ frontmatter: null, doc: node, source: "", path: "/adversarial4.md" }, node);
}
/** Every link and image in a file, in document order, as destination, title and text. */
function linksIn(source: string): string[] {
const out: string[] = [];
const text = (node: { value?: string; children?: unknown[] }): string => (node.value !== undefined ? String(node.value) : ((node.children ?? []) as Array<Parameters<typeof text>[0]>).map(text).join(""));
const walk = (node: { type?: string; url?: string; title?: string | null; children?: unknown[] }) => {
if (node.type === "link" || node.type === "image") out.push(`${node.type} ${node.url} ${node.title ?? ""} ${JSON.stringify(text(node))}`);
for (const child of (node.children ?? []) as Array<Parameters<typeof walk>[0]>) walk(child);
};
walk(parseToMdast(source) as unknown as Parameters<typeof walk>[0]);
return out;
}
/**
* Every character the document says, with the markup taken off.
*
* The first three passes compared destinations and whole files. Neither catches a save that keeps
* every link and every byte count but moves a delimiter into the text, which is what the finding
* below does: the file still has all its links, and four characters that were markup are now
* content.
*/
function saidText(source: string): string {
const walk = (node: { value?: string; children?: unknown[] }): string => (node.value !== undefined ? String(node.value) : ((node.children ?? []) as Array<Parameters<typeof walk>[0]>).map(walk).join(""));
return walk(parseToMdast(source) as unknown as Parameters<typeof walk>[0]);
}
/**
* Every link in a piece of markdown that is spelled `<url>`, exactly.
*
* Read off the parser's own offsets rather than by looking for a bracket, because a bare url
* between two literal brackets, which is what `Mail <a@b_c.com> here` is, looks identical to an
* angle autolink and is not one: the link node's span covers the brackets for the angle form and
* only the url for the bare one.
*/
function angleAutolinks(text: string): string[] {
const out: string[] = [];
const walk = (node: { type?: string; position?: { start: { offset: number }; end: { offset: number } }; children?: unknown[] }) => {
if (node.type === "link" && node.position) {
const slice = text.slice(node.position.start.offset, node.position.end.offset);
if (slice.startsWith("<") && slice.endsWith(">")) out.push(slice);
}
for (const child of (node.children ?? []) as Array<Parameters<typeof walk>[0]>) walk(child);
};
walk(parseToMdast(text) as unknown as Parameters<typeof walk>[0]);
return out;
}
/** The generations a file goes through, so growth and convergence are one call apart. */
function saves(source: string, count: number): string[] {
const out: string[] = [];
let current = source;
for (let generation = 0; generation < count; generation += 1) {
current = write(current);
out.push(current);
}
return out;
}
const n = schema.nodes;
const linked = (text: string, href: string, title: string | null = null) => schema.text(text, [schema.marks.link.create({ href, title })]);
const struck = (text: string, ...names: string[]) => schema.text(text, names.map((name) => schema.marks[name].create()));
const para = (...content: ProseMirrorNode[]) => n.paragraph.create(null, content);
const cell = (...content: ProseMirrorNode[]) => n.tableCell.create({ colspan: 1, rowspan: 1, colwidth: null, align: null }, content.length > 0 ? content : null);
const row = (...cells: ProseMirrorNode[]) => n.tableRow.create(null, cells);
const only = (block: ProseMirrorNode) => n.doc.create(null, [block]);
// =============================================================================================
// Still fixed. These pass.
// =============================================================================================
describe("still fixed: the angle form is safe because it is verified, not because it is banned", () => {
// The second pass found two unbounded growths in `<url>` and the fix at the time was to stop
// writing the form at all. A later decision put it back as the middle rung of the ladder,
// because for a source that already says `<url>` the ban meant rewriting the user's own bytes
// into a longer spelling for no reason.
//
// These two tests used to assert the ban. The ban was only ever a proxy for the property that
// matters, which is that no spelling loses a destination and no spelling grows. They now assert
// that property directly, which is what the growths would have violated and is strictly more
// than the ban proved: the ban could not have caught a growth in the bare or explicit form.
const URLS = ["https://example.com", "https://exa_mple.com/a", "https://example.com/a\\_b", "https://example.com/a&amp", "www.example.com/a", "[email protected]", "a@b_c.com", "a:[email protected]"];
it("keeps the destination and stops growing, whatever the url and whatever holds it", () => {
const builds: Array<[string, (link: ProseMirrorNode) => ProseMirrorNode]> = [
["alone", (link) => para(link)],
["mid sentence", (link) => para(schema.text("See "), link, schema.text(" here"))],
["line start", (link) => para(schema.text("See\n"), link)],
["tight suffix", (link) => para(link, schema.text(".Next"))],
["apostrophe", (link) => para(link, schema.text("'s"))],
["semicolon", (link) => para(link, schema.text("; x"))],
["angle before", (link) => para(schema.text("<"), link, schema.text(">"))],
["heading", (link) => n.heading.create({ level: 2 }, [link])],
["callout", (link) => n.callout.create({ kind: "note" }, [para(link)])],
["list item", (link) => n.bulletList.create({ tight: true }, [n.listItem.create(null, [para(link)])])],
["table cell", (link) => n.table.create(null, [row(cell(schema.text("h"))), row(cell(link))])],
];
const found: string[] = [];
for (const url of URLS) {
const href = url.includes("@") ? `mailto:${url}` : url;
for (const [name, build] of builds) {
const out = writeDoc(only(build(linked(url, href))));
const links = linksIn(out);
if (links.length !== 1) found.push(`lost the link: ${name} ${JSON.stringify(url)} -> ${JSON.stringify(out)}`);
if (!links[0]?.startsWith(`link ${href} `)) found.push(`destination: ${name} ${JSON.stringify(url)} -> ${JSON.stringify(out)} gave ${JSON.stringify(links)}`);
// Ten saves, not two. Both growths the ban was standing in for were stable on the second
// save and only diverged later, which is why two is not enough to see them.
let text = out;
for (let i = 0; i < 10; i++) {
const next = write(text);
if (i > 0 && next !== text) found.push(`unstable at save ${i + 2}: ${name} ${JSON.stringify(url)} -> ${JSON.stringify(next)}`);
text = next;
}
if (text.length > out.length) found.push(`grew ${out.length} to ${text.length}: ${name} ${JSON.stringify(url)}`);
// The second pass's growth was a bare url between literal brackets being re-read as an
// angle autolink and re-bracketed, so "<<url>>" then "<<<url>>>". Only the parser's spans
// tell the two apart, which is what angleAutolinks reads. One angle form at most, ever.
if (angleAutolinks(out).length > 1) found.push(`nested angle: ${name} ${JSON.stringify(url)} -> ${JSON.stringify(out)}`);
}
}
expect(found).toEqual([]);
});
it("survives a source that already spells it that way, and settles", () => {
const found: string[] = [];
for (const url of URLS) {
const source = `See <${url}> here\n`;
const once = write(source);
if (JSON.stringify(linksIn(once)) !== JSON.stringify(linksIn(source))) found.push(`links ${JSON.stringify(source)} -> ${JSON.stringify(once)}`);
if (saidText(once) !== saidText(source)) found.push(`text ${JSON.stringify(source)} -> ${JSON.stringify(once)}`);
// The two growths the removed ban was guarding, "<a@b_c.com>" and an escaped underscore in
// the path, are both in URLS. Neither is angle eligible now, but the point is that the rung
// is safe because the verifier re-reads what it wrote, not because the form is forbidden.
let text = once;
for (let i = 0; i < 10; i++) {
const next = write(text);
if (next !== text) found.push(`unstable at save ${i + 2} ${JSON.stringify(source)} -> ${JSON.stringify(next)}`);
text = next;
}
if (text.length > once.length) found.push(`grew ${once.length} to ${text.length} ${JSON.stringify(source)}`);
}
expect(found).toEqual([]);
});
});
describe("still fixed: every loss the first three passes found, over ten saves", () => {
// Pinned as ten generations rather than two, because both of the second pass's growths were
// stable on the second save and only diverged afterwards.
const cases: Array<[string, string]> = [
["an email whose domain has an underscore", "Mail <a@b_c.com> here\n"],
["a backslash in a destination", "See <https://example.com/a\\_b> here\n"],
["a domain GFM will not autolink", "See <https://exa_mple.com/a> here\n"],
["a relative destination starting www.", "[www.example.com](www.example.com)\n"],
["a semicolon after an entity shaped tail", "See <https://example.com/a&amp>; here\n"],
["an address the literal grammar rejects", "[a:[email protected]](mailto:a:[email protected])\n"],
["an apostrophe after a url", "Read <https://example.com>'s docs\n"],
["a full stop and the word after it", "See <https://example.com>.Next thing\n"],
["a url inside another link's text", "[see <https://example.com> more](http://y.com)\n"],
["a url that can be written bare", "Angle <https://example.com> here.\n"],
["a url that begins a continuation line", "See the docs\nhttps://example.com/a\nfor more.\n"],
["a lone carriage return", "one\rtwo\n"],
["a list under a leading thematic break", "---\n\n- a\n- b\n"],
["frontmatter closed by a carriage return", "---\ra: 1\r---\rp\r"],
];
for (const [name, source] of cases) {
it(name, () => {
const generations = saves(source, 10);
expect(generations.slice(1), `lengths ${generations.map((text) => text.length).join(",")}`).toEqual(Array(9).fill(generations[0]));
expect(linksIn(generations[0]), "the links must be the same links").toEqual(linksIn(source));
expect(saidText(generations[0]), "and the text must be the same text").toBe(saidText(source));
expect(doc(generations[0]).eq(doc(source)), "and the same document").toBe(true);
});
}
});
describe("still fixed: the linear fallback selection", () => {
// The old code proved one candidate at a time. This one proves the whole block once and reads
// which candidates failed off an ordered comparison of two link lists. The inference is allowed
// to be wrong; what is not allowed is for a wrong inference to reach the file. Every shape below
// is one the comparison cannot line up on its own.
const GOOD = "https://example.com/a";
const BAD = "https://exa_mple.com/b";
function intended(node: ProseMirrorNode): string[] {
const out: string[] = [];
node.descendants((child) => {
if (child.type.name === "image") {
const mark = child.marks.find((entry) => entry.type.name === "link");
if (mark) out.push(`link ${mark.attrs.href} ${mark.attrs.title ?? ""} ${JSON.stringify("")}`);
out.push(`image ${child.attrs.src} ${child.attrs.title ?? ""} ${JSON.stringify("")}`);
return true;
}
for (const mark of child.marks) if (mark.type.name === "link") out.push(`link ${mark.attrs.href} ${mark.attrs.title ?? ""} ${JSON.stringify(child.textContent)}`);
return true;
});
return out;
}
const cases: Array<[string, ProseMirrorNode]> = [
["ten identical goods and one bad", para(...Array.from({ length: 10 }, () => [linked(GOOD, GOOD), schema.text(" ")]).flat(), linked(BAD, BAD))],
["ten identical bads and one good", para(...Array.from({ length: 10 }, () => [linked(BAD, BAD), schema.text(" ")]).flat(), linked(GOOD, GOOD))],
["identical urls, one demoted by what follows it", para(linked(GOOD, GOOD), schema.text(" "), linked(GOOD, GOOD), schema.text(".Next "), linked(GOOD, GOOD))],
["two candidates with nothing between them", para(linked("https://a.com", "https://a.com"), linked("https://b.com", "https://b.com"))],
["a good glued to a bad", para(linked(GOOD, GOOD), linked(BAD, BAD))],
["a bad glued to a good", para(linked(BAD, BAD), linked(GOOD, GOOD))],
["one url running into the next", para(linked("https://a.com", "https://a.com"), schema.text("/"), linked("https://b.com", "https://b.com"))],
["a candidate the bare form would lose entirely", para(schema.text("<"), linked("https://a.com", "https://a.com"), schema.text(">"))],
["an email the bare form would lose entirely", para(schema.text("<"), linked("[email protected]", "mailto:[email protected]"), schema.text(">"))],
["twenty three candidates, three of them bad", para(...Array.from({ length: 23 }, (_, index) => [linked(`${index % 8 === 3 ? BAD : GOOD}${index}`, `${index % 8 === 3 ? BAD : GOOD}${index}`), schema.text(" x ")]).flat())],
["a candidate whose tail eats the next one", para(linked(GOOD, GOOD), schema.text("."), linked("https://b.com", "https://b.com"))],
["candidates split across list items", n.bulletList.create({ tight: true }, [n.listItem.create(null, [para(linked(GOOD, GOOD), schema.text(" "), linked(BAD, BAD))]), n.listItem.create(null, [para(linked(GOOD, GOOD), schema.text(" x "), linked(GOOD, GOOD))])])],
["candidates in a table row", n.table.create(null, [row(cell(schema.text("h")), cell(schema.text("h2"))), row(cell(linked("https://x.com/a|b", "https://x.com/a|b")), cell(linked(GOOD, GOOD)))])],
["candidates under a callout label", n.callout.create({ kind: "note" }, [para(linked(GOOD, GOOD), schema.text(" and "), linked(BAD, BAD))])],
["candidates separated by hard breaks", para(linked(GOOD, GOOD), n.hardBreak.create(), linked(BAD, BAD), n.hardBreak.create(), linked(GOOD, GOOD))],
["candidates separated by soft breaks", para(linked(GOOD, GOOD), schema.text("\n"), linked(BAD, BAD), schema.text("\n"), linked(GOOD, GOOD))],
["a titled link beside a candidate", para(linked(GOOD, GOOD, "T"), schema.text(" "), linked("https://c.com", "https://c.com"))],
["an image link beside a candidate", para(n.image.create({ src: "i.png", alt: "a", title: null }, null, [schema.marks.link.create({ href: "./x.md", title: null })]), schema.text(" "), linked(GOOD, GOOD))],
["a plain url in the text beside a candidate", para(linked(GOOD, GOOD), schema.text(" and https://exa_mple.com/plain here"))],
];
for (const [name, block] of cases) {
it(`keeps every destination: ${name}`, () => {
const node = only(block);
const out = writeDoc(node);
expect(linksIn(out), JSON.stringify(out)).toEqual(intended(node));
expect(saves(out, 10).slice(1), `unstable: ${JSON.stringify(out)}`).toEqual(Array(9).fill(out));
});
}
it("cannot be made to return a spelling it did not prove", () => {
// The mixed spelling is only ever returned after a full re-parse of it agrees with the
// explicit spelling, so demoting one candidate can never quietly break another. A block where
// demotion changes the answer for a neighbour therefore falls back rather than guesses.
const node = only(para(linked(BAD, BAD), linked(GOOD, GOOD), schema.text("."), linked(BAD, BAD)));
const out = writeDoc(node);
expect(linksIn(out)).toEqual(intended(node));
expect(write(out)).toBe(out);
});
});
describe("still fixed: a url that begins a line", () => {
// The third pass's finding. Each of these was rewritten to `[url](url)` on the first save.
it("is left alone in hard wrapped prose", () => {
expect(write("See the docs\nhttps://example.com/a\nfor more.\n")).toBe("See the docs\nhttps://example.com/a\nfor more.\n");
});
it("is left alone in a list item that wraps", () => {
expect(write("- item\n https://example.com/a\n")).toBe("- item\n https://example.com/a\n");
expect(write("1. item\n https://example.com/a\n")).toBe("1. item\n https://example.com/a\n");
});
it("is left alone in a callout body", () => {
for (const source of ["> [!NOTE]\n> https://example.com/a\n", "> [!TIP]\n> [email protected]\n", "> [!WARNING]\n> words first\n> https://example.com/b\n"]) {
expect(write(source), source).toBe(source);
}
});
it("is left alone after a hard break", () => {
expect(write("a\\\nhttps://example.com/a\n")).toBe("a\\\nhttps://example.com/a\n");
});
it("is left alone in a quote", () => {
expect(write("> quote\n> https://example.com/a\n")).toBe("> quote\n> https://example.com/a\n");
});
it("leaves the whole hard wrapped fixture byte identical", () => {
const source = fixture("wrapped-url.md");
expect(write(source)).toBe(source);
});
it("did not buy that by rewrapping anything", () => {
// The pre-rework code joined hard wrapped lines. Every line break the author put in has to
// still be exactly where they put it, url or no url.
const sources = [
"A paragraph that the author\nhard wrapped at some column\nand nowhere else.\n",
"Short\nlines\neverywhere\n",
"A line ending in a url https://example.com/a\nand a continuation.\n",
"text with https://a.com in it\nand https://b.com starting the next line\nand text after\n",
"- a list item that wraps\n onto a second line\n- and another\n",
"> a quote that wraps\n> onto a second line\n",
"> [!NOTE]\n> a callout that wraps\n> onto a second line\n",
"one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n",
];
for (const source of sources) expect(write(source), source).toBe(source);
});
});
describe("still fixed: the sweeps", () => {
it("writes every corpus file the same way ten times over", () => {
const growing: string[] = [];
for (const file of corpus()) {
const generations = saves(file.source, 10);
if (generations.slice(1).some((text) => text !== generations[0])) growing.push(`${file.name}: ${generations.map((text) => text.length).join(",")}`);
}
expect(growing).toEqual([]);
}, 30000);
it("keeps every link and every character of every corpus file", () => {
const lost: string[] = [];
for (const file of corpus()) {
const once = write(file.source);
if (JSON.stringify(linksIn(once)) !== JSON.stringify(linksIn(file.source))) lost.push(`links ${file.name}`);
if (saidText(once) !== saidText(file.source)) lost.push(`text ${file.name}`);
if (!doc(once).eq(doc(file.source))) lost.push(`meaning ${file.name}`);
}
expect(lost).toEqual([]);
}, 20000);
it("keeps frontmatter byte identical in front of every corpus body", () => {
const bad: string[] = [];
for (const file of corpus()) {
const slot = parseMarkdown(file.source, `/${file.name}`).frontmatter;
if (slot === null) continue;
const once = write(file.source);
if (!once.startsWith(slot)) bad.push(file.name);
if (parseMarkdown(once, `/${file.name}`).frontmatter !== slot) bad.push(`reparse ${file.name}`);
}
expect(bad).toEqual([]);
});
// The word this sweep appends has to be a word no corpus file already contains, and "EDITME" is
// not one: two fixtures under `adversarial/` are built around that very word for the locality
// tests in adversarial.test.ts and m2.test.ts, which read the sentinel out of the file rather
// than appending one. While the corpus glob named its folders those two were out of reach here;
// once it took every folder they were in, and appending a second EDITME left the retype with two
// paragraphs to choose from and `replace` picking the fixture's own. That is a collision in the
// harness, not a serializer fault: with a word nothing in the corpus spells, all 64 files pass.
const APPENDED = "SWEEPPARAGRAPH";
const RETYPED = "SWEEPRETYPED";
it("appends a paragraph no corpus file already contains", () => {
expect(corpus().filter((file) => file.source.includes(APPENDED)).map((file) => file.name)).toEqual([]);
});
it("is a one paragraph diff on every corpus file", () => {
// A paragraph the test owns is appended to each file, the file is settled, and then only that
// paragraph is retyped. Anything but a one word diff means a save rewrote a block nobody
// touched, which is the promise a serializer change is most likely to break quietly.
const bad: string[] = [];
for (const file of corpus()) {
const base = write(`${file.source}\n\n${APPENDED}\n`);
if (write(base) !== base) {
bad.push(`${file.name}: not settled`);
continue;
}
const document = parseMarkdown(base, `/${file.name}`);
const children: ProseMirrorNode[] = [];
let hits = 0;
document.doc.forEach((child) => {
if (child.type.name === "paragraph" && child.textContent === APPENDED) {
hits += 1;
children.push(n.paragraph.create(null, schema.text(RETYPED)));
return;
}
children.push(child);
});
if (hits !== 1) {
bad.push(`${file.name}: ${hits} paragraphs reading ${APPENDED}`);
continue;
}
const out = serializeMarkdown(document, n.doc.create(null, children));
if (out !== base.replace(APPENDED, RETYPED)) bad.push(`${file.name}: ${JSON.stringify(out.slice(-80))}`);
}
expect(bad).toEqual([]);
}, 20000);
it("survives a deterministic fuzz over urls, links and containers", () => {
// The generator below is the one that found the strikethrough loss in the "found" section.
// With strikethrough taken out of the glue it is a clean sweep of the autolink machinery:
// six thousand blocks, each checked for its links, its text, its meaning and ten saves.
const random = (() => {
let state = 20260824 >>> 0;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 4294967296;
};
})();
const URLS = ["https://example.com/a", "https://b.example.com/x", "https://exa_mple.com/a", "www.example.com/a", "http://example.com", "https://example.com/a_b", "https://example.com/a(b)", "https://example.com/a&amp", "https://example.com/a|b", "https://example.com/#f", "https://例え.jp/a", "https://example.com/a\\_b", "https://example.com/a*b", "https://example.com/a]b"];
const EMAILS = ["[email protected]", "a@b_c.com", "[email protected]", "[email protected]"];
const GLUE = [" ", ". ", ", ", "'s ", ".Next ", "; ", " and ", "\n", " (", ") ", " `x` ", " [t](./y.md) ", " ![i](i.png) ", "\n\n"];
const WORDS = ["See", "the", "docs", "for", "more", "text", "x86_64", "a*b", "n<m"];
const pick = <T,>(list: T[]): T => list[Math.floor(random() * list.length)];
const segment = (): string => {
const kind = random();
if (kind < 0.34) return `<${pick(URLS)}>`;
if (kind < 0.5) return pick(URLS);
if (kind < 0.62) return `<${pick(EMAILS)}>`;
if (kind < 0.7) return pick(EMAILS);
if (kind < 0.8) return `[${pick(URLS)}](${pick(URLS)})`;
return pick(WORDS);
};
const WRAPPERS: Array<(body: string) => string> = [
(body) => `${body}\n`,
(body) => `See ${body} here\n`,
(body) => `- ${body}\n`,
(body) => `- item\n ${body.replace(/\n\n/g, "\n ")}\n`,
(body) => `> ${body.replace(/\n/g, "\n> ")}\n`,
(body) => `> [!NOTE]\n> ${body.replace(/\n/g, "\n> ")}\n`,
(body) => `## ${body.replace(/\n/g, " ")}\n`,
(body) => `| h |\n| - |\n| ${body.replace(/\n/g, " ").replace(/\|/g, "\\|")} |\n`,
(body) => `text\n${body}\nmore text\n`,
(body) => `a\\\n${body}\n`,
];
const broken: string[] = [];
let checked = 0;
for (let attempt = 0; attempt < 6000 && broken.length < 5; attempt += 1) {
const count = 1 + Math.floor(random() * 4);
let body = "";
for (let index = 0; index < count; index += 1) {
body += segment();
if (index < count - 1) body += pick(GLUE);
}
const source = pick(WRAPPERS)(body);
if (!source.trim()) continue;
checked += 1;
const once = write(source);
if (JSON.stringify(linksIn(once)) !== JSON.stringify(linksIn(source))) broken.push(`links ${JSON.stringify(source)} -> ${JSON.stringify(once)}`);
else if (saidText(once) !== saidText(source)) broken.push(`text ${JSON.stringify(source)} -> ${JSON.stringify(once)}`);
else if (!doc(once).eq(doc(source))) broken.push(`meaning ${JSON.stringify(source)} -> ${JSON.stringify(once)}`);
else if (saves(once, 9).some((text) => text !== once)) broken.push(`unstable ${JSON.stringify(source)} -> ${JSON.stringify(once)}`);
}
// A generator that stops generating is a test that stops testing.
expect(checked).toBeGreaterThan(5000);
expect(broken).toEqual([]);
}, 120000);
});
// =============================================================================================
// Found. These fail.
// =============================================================================================
describe("found: a strikethrough that spans a link is written back as literal tildes", () => {
// `MARK_ORDER` puts `link` outside `strikethrough`, so a strikethrough that covers a link AND
// some of the words around it cannot stay one node: it is split into the delete inside the link
// and one delete run for the text on either side, and those runs begin or end with the space
// that used to be in the middle of the strikethrough.
//
// mdast will not write that. `strong` and `emphasis` guard against it, encoding an edge space as
// `&#x20;` so the delimiter still binds, which is why `**a [t](./x.md) b**` survives. The GFM
// `delete` handler in mdast-util-gfm-strikethrough has no such guard: it writes `~~` + children
// + `~~` and nothing else, so a run whose first character is a space goes out as `~~ b~~`, which
// GFM does not read back as a strikethrough at all.
//
// What comes back is a paragraph with four more characters in its text than the one that was
// saved. The strikethrough is gone and `~~` is now content. Nothing warns, and the file looks
// fine until somebody reads it.
it("keeps the strikethrough over an ordinary struck out sentence", () => {
const source = "~~[the old guide](./old.md) has moved~~\n";
const once = write(source);
expect(saidText(once), `the tildes became text: ${JSON.stringify(once)}`).toBe("the old guide has moved");
expect(doc(once).eq(doc(source)), JSON.stringify(once)).toBe(true);
});
it("does not turn a strikethrough delimiter into content", () => {
const cases = ["~~[t](./x.md) b~~\n", "~~a [t](./x.md)~~\n", "[email protected] b~~\n", "~~a~~ and ~~b [t](./x.md)~~\n", "- ~~[done](./x.md) already~~\n", "## ~~[old](./o.md) title~~\n", "> [!NOTE]\n> ~~[old](./o.md) note~~\n"];
const corrupted: string[] = [];
for (const source of cases) {
const once = write(source);
if (saidText(once) !== saidText(source)) corrupted.push(`${JSON.stringify(source)} -> ${JSON.stringify(once)}\n said ${JSON.stringify(saidText(source))} then ${JSON.stringify(saidText(once))}`);
}
expect(corrupted).toEqual([]);
});
it("writes a delete run whose edge is a space the way it writes a strong one", () => {
// The same document with `strong` instead of `strikethrough` round trips exactly, which is
// what the fix has to match: encode the edge space rather than emit a delimiter that cannot
// bind. No link is needed to show it, so this is not really about autolinks at all.
const strong = only(para(schema.text("a"), struck(" b", "strong")));
expect(doc(writeDoc(strong)).eq(strong), JSON.stringify(writeDoc(strong))).toBe(true);
const delete_ = only(para(schema.text("a"), struck(" b", "strikethrough")));
expect(doc(writeDoc(delete_)).eq(delete_), `wrote ${JSON.stringify(writeDoc(delete_))}`).toBe(true);
});
it("does not grow the file by thirty two bytes on every save, forever", () => {
const source = "We ~~use [the old API](./api.md) here~~ now.\n";
const generations = saves(source, 10);
expect(generations.slice(1), `lengths ${generations.map((text) => text.length).join(",")}`).toEqual(Array(9).fill(generations[0]));
});
it("does not inject a hundred and fifty tildes into a document that is saved twenty times", () => {
const source = "~~a [t](./x.md) b~~\n";
let current = source;
for (let generation = 0; generation < 20; generation += 1) current = write(current);
expect((saidText(current).match(/~/g) ?? []).length, `after twenty saves: ${JSON.stringify(current)}`).toBe(0);
});
it("holds for the fixture, which grows without limit", () => {
const source = fixture("struck-through-link.md");
const generations = saves(source, 10);
expect(generations.slice(1), `lengths ${generations.map((text) => text.length).join(",")}`).toEqual(Array(9).fill(generations[0]));
expect(saidText(generations[0]), "and keeps what the document says").toBe(saidText(source));
});
});
+667
View File
@@ -0,0 +1,667 @@
// Fifth and final adversarial pass over the markdown bridge, written after the delete handler was
// given the `encodeInfo` guard and the angle rung was put back on the autolink ladder. Same rules
// as the four files before it: this is here to break the bridge, not to sign it off.
//
// The file is in three parts.
//
// "audited" re-proves the four expectations that were changed in adversarial3.test.ts under
// authorisation. Each is checked against the property the old expectation carried rather than
// against the new bytes: same destinations, same text, same document, and stable over ten saves
// rather than two. None of the four is a weakening. The changes are recorded honestly.
//
// "still fixed" is the regression net and it passes. The angle rung is attacked where the second
// pass broke it, over ten generations rather than two, and the delete handler is attacked with the
// sweep the fourth pass said nobody had run: every ordered pair of the five marks the schema has,
// nested inside each other, spanning partially and fully, with and without whitespace at the
// boundary, inside every block type the editor can build. Then every sweep that has passed before.
//
// "found" is the result, and it is data loss. A mark nested inside itself in the source, spanning a
// link, doubles its delimiters on the way out. GFM reads a doubled run as literal text, so the
// document permanently gains characters the author never typed and the link loses its text. At two
// levels of nesting it converges after one extra save with the text corrupted; at three it grows by
// thirty two bytes on every save for the rest of the file's life. The cause is in the parser rather
// than in either of the two things this pass was sent to attack, which is why four passes over the
// serializer did not see it.
import { describe, expect, it } from "vitest";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { schema } from "../model/schema";
import { corpus } from "./corpus/load";
import { parseToMdast } from "./handlers";
import { parseMarkdown, serializeMarkdown } from "./index";
const fixtures = import.meta.glob("./corpus/adversarial/*.md", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
/** One save. */
function write(source: string): string {
const document = parseMarkdown(source, "/adversarial5.md");
return serializeMarkdown(document, document.doc);
}
function doc(source: string): ProseMirrorNode {
return parseMarkdown(source, "/adversarial5.md").doc;
}
function writeDoc(node: ProseMirrorNode): string {
return serializeMarkdown({ frontmatter: null, doc: node, source: "", path: "/adversarial5.md" }, node);
}
/** The generations a file goes through, so growth and convergence are one call apart. */
function saves(source: string, count: number): string[] {
const out: string[] = [];
let current = source;
for (let generation = 0; generation < count; generation += 1) {
current = write(current);
out.push(current);
}
return out;
}
/** Every link and image in a file, in document order, as destination, title and text. */
function linksIn(source: string): string[] {
const out: string[] = [];
const text = (node: { value?: string; children?: unknown[] }): string => (node.value !== undefined ? String(node.value) : ((node.children ?? []) as Array<Parameters<typeof text>[0]>).map(text).join(""));
const walk = (node: { type?: string; url?: string; title?: string | null; children?: unknown[] }) => {
if (node.type === "link" || node.type === "image") out.push(`${node.type} ${node.url} ${node.title ?? ""} ${JSON.stringify(text(node))}`);
for (const child of (node.children ?? []) as Array<Parameters<typeof walk>[0]>) walk(child);
};
walk(parseToMdast(source) as unknown as Parameters<typeof walk>[0]);
return out;
}
/** Every character the document says, with the markup taken off. */
function saidText(source: string): string {
const walk = (node: { value?: string; children?: unknown[] }): string => (node.value !== undefined ? String(node.value) : ((node.children ?? []) as Array<Parameters<typeof walk>[0]>).map(walk).join(""));
return walk(parseToMdast(source) as unknown as Parameters<typeof walk>[0]);
}
const n = schema.nodes;
const para = (...content: ProseMirrorNode[]) => n.paragraph.createChecked(null, content);
const only = (...blocks: ProseMirrorNode[]) => n.doc.createChecked(null, blocks);
const cell = (...content: ProseMirrorNode[]) => n.tableCell.createChecked({ colspan: 1, rowspan: 1, colwidth: null, align: null }, content.length > 0 ? content : null);
const row = (...cells: ProseMirrorNode[]) => n.tableRow.createChecked(null, cells);
const linked = (text: string, href: string) => schema.text(text, [schema.marks.link.create({ href, title: null })]);
/**
* A mark by name, with a link's attributes filled in.
*
* Everything below builds documents through `createChecked` rather than `create`, because an
* invalid document written by a test proves nothing about a valid one and reads exactly like a
* finding: a cell holding a paragraph, which the schema forbids, serializes as its bare text.
*/
const mk = (name: string, href = "./x.md") => (name === "link" ? schema.marks.link.create({ href, title: null }) : schema.marks[name as "strong"].create());
const marked = (text: string, ...names: string[]) => schema.text(text, names.map((name) => mk(name)));
// =============================================================================================
// Audited: the four expectations changed in adversarial3.test.ts under authorisation.
// =============================================================================================
describe("audited: the four authorised expectation changes", () => {
// The old expectation and the new one, so the change is in the file rather than only in a
// report. What is asserted is not the new bytes but the property the old bytes carried: the
// links are the same links, the text is the same text, the document is the same document, and
// the file stops moving. A change that kept nicer bytes and dropped one of those would be a
// weakening, and none of these four is one.
const changed: Array<[string, string, string, string]> = [
["a domain GFM will not autolink", "See <https://exa_mple.com/a> here\n", "See [https://exa\\_mple.com/a](https://exa_mple.com/a) here\n", "See <https://exa_mple.com/a> here\n"],
["an entity shaped tail", "See <https://example.com/a&amp>; here\n", "See [https://example.com/a\\&amp](https://example.com/a\\&amp); here\n", "See <https://example.com/a&amp>; here\n"],
["an apostrophe after a url", "Read <https://example.com>'s docs\n", "Read [https://example.com](https://example.com)'s docs\n", "Read <https://example.com>'s docs\n"],
["a full stop and the word after it", "See <https://example.com>.Next thing\n", "See [https://example.com](https://example.com).Next thing\n", "See <https://example.com>.Next thing\n"],
];
for (const [name, source, oldExpected, newExpected] of changed) {
it(`${name}: the new spelling still carries every destination`, () => {
const once = write(source);
expect(once, "the recorded new expectation must be what the bridge writes").toBe(newExpected);
expect(linksIn(once), "same destinations, titles and link text as the source").toEqual(linksIn(source));
expect(saidText(once), "and the same characters, with the markup taken off").toBe(saidText(source));
expect(doc(once).eq(doc(source)), "and the same document").toBe(true);
});
it(`${name}: the old spelling carried the same destinations, so the change is a spelling`, () => {
// If the expectation that was replaced had described a different document, the change would
// have been a behaviour change dressed as a spelling change. Both spellings are read back
// and compared against the source, so the claim is checked rather than trusted.
expect(linksIn(oldExpected), "the old expectation's destinations").toEqual(linksIn(source));
expect(saidText(oldExpected), "the old expectation's text").toBe(saidText(source));
expect(doc(oldExpected).eq(doc(source)), "the old expectation's document").toBe(true);
});
it(`${name}: stops moving, over ten saves rather than two`, () => {
const generations = saves(source, 10);
expect(new Set(generations).size, `ten generations: ${JSON.stringify(generations.filter((g) => g !== generations[0]).slice(0, 2))}`).toBe(1);
expect(linksIn(generations[9]), "ten saves later, the same destinations").toEqual(linksIn(source));
expect(saidText(generations[9]), "ten saves later, the same text").toBe(saidText(source));
});
}
it("the four changes are the only ones the angle rung needed, and a fifth was missed", () => {
// adversarial3.test.ts pins one more expectation that the angle rung moved and that was not
// updated with the other four, so the suite does not currently pass. It is a spelling: all
// three destinations survive and the file is stable. Recorded here as the property, so this
// file states what is true whichever spelling that test ends up pinning.
const source = "See <https://a.example.com> and <https://exa_mple.com/b> and <https://c.example.com> here\n";
const generations = saves(source, 10);
expect(linksIn(generations[9])).toEqual(linksIn(source));
expect(saidText(generations[9])).toBe(saidText(source));
expect(new Set(generations).size).toBe(1);
});
});
// =============================================================================================
// Still fixed: the angle rung, where the second pass broke it.
// =============================================================================================
describe("still fixed: the angle rung the second pass removed", () => {
// Both of the second pass's growths were in `<url>`, and both were stable on the second save and
// only diverged afterwards, so everything here is ten generations rather than two.
const cases: Array<[string, string]> = [
["an email whose domain has an underscore", "Mail <a@b_c.com> here\n"],
["a backslash in a destination", "See <https://example.com/a\\_b> here\n"],
["a backslash in a destination GFM will not autolink", "See <https://exa_mple.com/a\\_b> here\n"],
["a doubled backslash run", "See <https://exa_mple.com/a\\\\b> here\n"],
["an entity shaped tail", "See <https://exa_mple.com/a&amp> here\n"],
["a host with no dot", "Mail <a@localhost> now\n"],
["an angle url that is the whole paragraph", "<https://exa_mple.com/a>\n"],
];
for (const [name, source] of cases) {
it(`${name} neither grows nor loses its destination over ten saves`, () => {
const generations = saves(source, 10);
expect(generations[9].length, `lengths: ${generations.map((g) => g.length).join(",")}`).toBe(generations[1].length);
expect(new Set(generations.slice(1)).size, "must reach a fixed point on the second save").toBe(1);
expect(linksIn(generations[9]), "the destinations must survive").toEqual(linksIn(source));
expect(saidText(generations[9]), "and the text must survive").toBe(saidText(source));
});
}
it("verification, not the shape of the url, is what keeps the rung safe", () => {
// Every one of these is a url the bare form cannot carry, so the rung is what is under test.
// A url the angle form cannot carry either has to fall through to `[text](url)` rather than be
// written and hoped for, and either way the destination is the thing that has to survive.
const URLS = [
"https://exa_mple.com/a",
"https://exa_mple.com/a|b",
"https://exa_mple.com/a\\_b",
"https://exa_mple.com/a\\\\b",
"https://exa_mple.com/a&amp",
"https://exa_mple.com/a?b=1&c=2",
"https://exa_mple.com/(a)",
"https://exa_mple.com/a)b",
"https://exa_mple.com/a]b",
"https://exa_mple.com/a'b",
"https://exa_mple.com/a`b",
"https://exa_mple.com/*a*",
"https://exa_mple.com/a__b",
"https://exa_mple.com/a<b",
"https://exa_mple.com/a b",
"https://exa_mple.com/#a",
"https://exa_mple.com/a%20b",
"ftp://exa_mple.com/x",
"a@localhost",
"a@b_c.com",
"[email protected]",
"mailto:a@b_c.com",
];
const builds: Array<[string, (link: ProseMirrorNode) => ProseMirrorNode]> = [
["alone", (link) => para(link)],
["mid sentence", (link) => para(schema.text("See "), link, schema.text(" here"))],
["continuation line", (link) => para(schema.text("See\n"), link)],
["tight suffix", (link) => para(link, schema.text(".Next"))],
["apostrophe", (link) => para(link, schema.text("'s"))],
["semicolon", (link) => para(link, schema.text("; x"))],
["between angles", (link) => para(schema.text("<"), link, schema.text(">"))],
["heading", (link) => n.heading.createChecked({ level: 2 }, [link])],
["callout", (link) => n.callout.createChecked({ kind: "note" }, [para(link)])],
["list item", (link) => n.bulletList.createChecked({ tight: true }, [n.listItem.createChecked(null, [para(link)])])],
["table cell", (link) => n.table.createChecked(null, [row(cell(schema.text("h"))), row(cell(link))])],
["struck", (link) => para(schema.text("x "), link, schema.text(" y"))],
["among good urls", (link) => para(linked("https://a.com", "https://a.com"), schema.text(" "), link, schema.text(" "), linked("https://b.com", "https://b.com"))],
];
const found: string[] = [];
for (const url of URLS) {
const href = url.includes("@") && !url.includes("://") && !url.startsWith("mailto:") ? `mailto:${url}` : url;
for (const [name, build] of builds) {
const node = only(build(linked(url, href)));
const out = writeDoc(node);
const want: string[] = [];
node.descendants((child) => {
for (const mark of child.marks) if (mark.type.name === "link") want.push(`link ${mark.attrs.href} ${JSON.stringify(child.textContent)}`);
return true;
});
if (JSON.stringify(linksIn(out)) !== JSON.stringify(want)) found.push(`${name} ${JSON.stringify(url)} lost a destination: ${JSON.stringify(out)} read ${JSON.stringify(linksIn(out))} wanted ${JSON.stringify(want)}`);
const generations = saves(out, 10);
if (generations[9] !== out) found.push(`${name} ${JSON.stringify(url)} did not settle: ${JSON.stringify(out)} -> ${JSON.stringify(generations[9])}`);
if (generations[9].length !== out.length) found.push(`${name} ${JSON.stringify(url)} grew: ${[out.length, ...generations.map((g) => g.length)].join(",")}`);
}
}
expect(found).toEqual([]);
}, 30000);
});
// =============================================================================================
// Still fixed: the delete handler, and the mark nesting sweep nobody had run.
// =============================================================================================
/**
* Every run of literal text in a piece of markdown, with the set of marks covering it.
*
* This is the comparison the sweep needs and neither of the two the earlier passes used will do.
* Comparing whole files cannot say which of two spellings is right, comparing destinations misses
* a lost emphasis entirely, and comparing documents with `eq` fails for reasons that have nothing
* to do with marks: a table is not modelled by the parser at all and comes back as a raw block, and
* a one item loose list comes back tight. Runs of text with their marks are exactly what a mark
* bug damages and nothing else touches.
*/
const WRAPPER: Record<string, string> = { emphasis: "em", strong: "strong", delete: "strikethrough" };
function spansOf(markdown: string): Array<[string, string]> {
const out: Array<[string, string]> = [];
const push = (text: string, marks: string[]) => {
if (!text) return;
const key = [...marks].sort().join("+");
const last = out[out.length - 1];
if (last && last[1] === key) last[0] += text;
else out.push([text, key]);
};
const walk = (node: { type?: string; value?: string; url?: string; alt?: string | null; children?: unknown[] }, marks: string[]) => {
if (node.type === "text" || node.type === "html") return push(String(node.value ?? ""), marks);
if (node.type === "inlineCode") return push(String(node.value ?? ""), [...marks, "code"]);
if (node.type === "break") return push("\n", marks);
if (node.type === "image") return push(` img:${node.url}:${node.alt ?? ""}`, marks);
if (node.type === "inlineMath") return push(` math:${node.value}`, marks);
const inner = node.type === "link" ? [...marks, `link:${node.url}`] : WRAPPER[node.type ?? ""] ? [...marks, WRAPPER[node.type ?? ""]] : marks;
for (const child of (node.children ?? []) as Array<Parameters<typeof walk>[0]>) walk(child, inner);
if (node.children && !["link", "emphasis", "strong", "delete"].includes(node.type ?? "")) push(" /", []);
};
for (const child of ((parseToMdast(markdown) as unknown as { children?: unknown[] }).children ?? []) as Array<Parameters<typeof walk>[0]>) walk(child, []);
return out;
}
/** The same reading, taken off the document the editor holds, so the two can be compared. */
function spansOfDoc(document: ProseMirrorNode): Array<[string, string]> {
const out: Array<[string, string]> = [];
const push = (text: string, marks: string[]) => {
if (!text) return;
const key = [...marks].sort().join("+");
const last = out[out.length - 1];
if (last && last[1] === key) last[0] += text;
else out.push([text, key]);
};
const names = (node: ProseMirrorNode) => node.marks.map((mark) => (mark.type.name === "link" ? `link:${mark.attrs.href}` : mark.type.name));
const walk = (node: ProseMirrorNode) => {
// The callout's label is an attribute in the document and text in the file.
if (node.type.name === "callout") push(`[!${String(node.attrs.kind).toUpperCase()}]\n`, []);
node.forEach((child) => {
if (child.isText) push(child.text ?? "", names(child));
else if (child.type.name === "hardBreak") push("\n", names(child));
else if (child.type.name === "image") push(` img:${child.attrs.src}:${child.attrs.alt ?? ""}`, names(child));
else if (child.type.name === "mathInline") push(` math:${child.attrs.latex}`, names(child));
else {
walk(child);
if (child.isBlock) push(" /", []);
}
});
};
walk(document);
return out;
}
/** The eleven containers an inline run can sit in, built straight from the schema. */
const CONTAINERS: Array<[string, (inline: ProseMirrorNode[]) => ProseMirrorNode]> = [
["paragraph", (i) => para(...i)],
["heading", (i) => n.heading.createChecked({ level: 3 }, i)],
["blockquote", (i) => n.blockquote.createChecked(null, [para(...i)])],
["callout", (i) => n.callout.createChecked({ kind: "warning" }, [para(...i)])],
["bulletList", (i) => n.bulletList.createChecked({ tight: true }, [n.listItem.createChecked(null, [para(...i)])])],
["orderedList", (i) => n.orderedList.createChecked({ tight: true, start: 1 }, [n.listItem.createChecked(null, [para(...i)])])],
["taskList", (i) => n.taskList.createChecked({ tight: true }, [n.taskItem.createChecked({ checked: false }, [para(...i)])])],
["looseList", (i) => n.bulletList.createChecked({ tight: false }, [n.listItem.createChecked(null, [para(...i)])])],
["listInQuote", (i) => n.blockquote.createChecked(null, [n.bulletList.createChecked({ tight: true }, [n.listItem.createChecked(null, [para(...i)])])])],
["secondParagraph", (i) => n.blockquote.createChecked(null, [para(schema.text("lead")), para(...i)])],
["tableCell", (i) => n.table.createChecked(null, [row(cell(schema.text("h")), cell(schema.text("k"))), row(cell(...i), cell(schema.text("z")))])],
];
const MARKS = ["link", "strikethrough", "strong", "em", "code"];
/** Every pair the schema actually permits: code excludes the formatting marks, and is a leaf. */
function pairs(): Array<[string, string]> {
const out: Array<[string, string]> = [];
for (const outer of MARKS) {
for (const inner of MARKS) {
if (outer === inner || outer === "code") continue;
if (inner === "code" && outer !== "link") continue;
out.push([outer, inner]);
}
}
return out;
}
describe("still fixed: every mark nested inside every other, in every block", () => {
// The fourth pass found the delete handler's missing whitespace guard and said why three passes
// had missed it: the sweeps carried strikethrough and link as sibling snippets and never nested
// them. This is that gap closed. Thirteen ordered pairs, five spanning shapes, three boundary
// paddings and eleven containers, which is 2145 documents, each built from the schema, written,
// read back and then saved ten more times.
it("keeps every mark over every span, in every container, without moving or growing", () => {
const found: string[] = [];
let checked = 0;
for (const [container, build] of CONTAINERS) {
for (const [outer, inner] of pairs()) {
for (const pad of ["", " ", "\t"]) {
const shapes: Array<[string, ProseMirrorNode[]]> = [
["partial", [marked("aa", outer), marked(`${pad}bb${pad}`, outer, inner), marked("cc", outer)]],
["full", [marked(`${pad}bb${pad}`, outer, inner)]],
["head", [marked(`${pad}bb${pad}`, outer, inner), marked("cc", outer)]],
["tail", [marked("aa", outer), marked(`${pad}bb${pad}`, outer, inner)]],
["surrounded", [schema.text("pre"), marked("aa", outer), marked(`${pad}bb${pad}`, outer, inner), marked("cc", outer), schema.text("post")]],
];
for (const [shape, inline] of shapes) {
checked += 1;
const where = `${container} ${outer} over ${inner} ${shape} pad=${JSON.stringify(pad)}`;
const node = only(build(inline));
const out = writeDoc(node);
const want = JSON.stringify(spansOfDoc(node));
const got = JSON.stringify(spansOf(out));
if (want !== got) found.push(`${where} changed the marked text\n wrote ${JSON.stringify(out)}\n want ${want}\n got ${got}`);
const generations = saves(out, 10);
if (generations[9] !== out) found.push(`${where} did not settle\n wrote ${JSON.stringify(out)}\n ten ${JSON.stringify(generations[9])}`);
if (generations[9].length !== out.length) found.push(`${where} grew: ${[out.length, ...generations.map((g) => g.length)].join(",")}`);
}
}
}
}
expect(checked, "the sweep has to actually be the size it claims").toBe(2145);
expect(found).toEqual([]);
}, 120000);
it("keeps three and four marks nested at once", () => {
const found: string[] = [];
const NESTABLE = ["link", "strikethrough", "strong", "em"];
for (const a of NESTABLE) {
for (const b of NESTABLE) {
for (const c of NESTABLE) {
if (a === b || b === c || a === c) continue;
for (const pad of ["", " "]) {
const cases: Array<[string, ProseMirrorNode]> = [
[`${a}>${b}>${c} stepped`, only(para(marked("q", a), marked("r", a, b), marked(`${pad}s${pad}`, a, b, c), marked("u", a, b), marked("v", a)))],
[`${a}>${b}>${c} whole`, only(para(marked(`${pad}s${pad}`, a, b, c)))],
[`${a}>${b}>${c} all four`, only(para(marked("q", a), marked(`${pad}s${pad}`, "link", "strikethrough", "strong", "em"), marked("v", a)))],
];
for (const [name, node] of cases) {
const out = writeDoc(node);
if (JSON.stringify(spansOfDoc(node)) !== JSON.stringify(spansOf(out))) found.push(`${name} pad=${JSON.stringify(pad)} changed the marked text: ${JSON.stringify(out)}`);
const generations = saves(out, 10);
if (generations[9] !== out) found.push(`${name} pad=${JSON.stringify(pad)} did not settle: ${JSON.stringify(out)} -> ${JSON.stringify(generations[9])}`);
}
}
}
}
}
expect(found).toEqual([]);
}, 30000);
it("keeps a mark that covers an image, a hard break or an inline equation", () => {
const found: string[] = [];
const image = (names: string[]) => n.image.createChecked({ src: "./i.png", alt: "a", title: null }, null, names.map((name) => mk(name)));
const brk = (names: string[]) => n.hardBreak.createChecked(null, null, names.map((name) => mk(name)));
const math = (names: string[]) => n.mathInline.createChecked({ latex: "x^2" }, null, names.map((name) => mk(name)));
for (const outer of ["strikethrough", "strong", "em", "link"]) {
const cases: Array<[string, ProseMirrorNode]> = [
[`${outer} over an image`, only(para(marked("a", outer), image([outer]), marked("b", outer)))],
[`${outer} over a hard break`, only(para(marked("a", outer), brk([outer]), marked("b", outer)))],
[`${outer} over an equation`, only(para(marked("a", outer), math([outer]), marked("b", outer)))],
[`${outer} over an image alone`, only(para(image([outer])))],
];
for (const [name, node] of cases) {
const out = writeDoc(node);
if (JSON.stringify(spansOfDoc(node)) !== JSON.stringify(spansOf(out))) found.push(`${name}: ${JSON.stringify(out)}`);
if (saves(out, 10)[9] !== out) found.push(`${name} did not settle: ${JSON.stringify(out)}`);
}
}
expect(found).toEqual([]);
});
it("keeps a marked run whose content is nothing but delimiters", () => {
const found: string[] = [];
const CONTENT = [" ", " ", "\t", " ", "*", "_", "~", "~~", "`", "[", "]", "\\", "<", ">", "&", "|", "#", "-", "​", "\n", " x ", "*x*", "~~x~~", "&#x20;"];
for (const content of CONTENT) {
for (const outer of ["strikethrough", "strong", "em"]) {
const cases: Array<[string, ProseMirrorNode]> = [
[`${outer} ${JSON.stringify(content)} between text`, only(para(schema.text("L"), marked(content, outer), schema.text("R")))],
[`${outer} ${JSON.stringify(content)} alone`, only(para(marked(content, outer)))],
[`${outer} ${JSON.stringify(content)} after a link`, only(para(linked("L", "./x.md"), schema.text(" "), marked(content, outer)))],
];
for (const [name, node] of cases) {
const out = writeDoc(node);
if (JSON.stringify(spansOfDoc(node)) !== JSON.stringify(spansOf(out))) found.push(`${name}: ${JSON.stringify(out)}`);
if (saves(out, 10)[9] !== out) found.push(`${name} did not settle: ${JSON.stringify(out)}`);
}
}
}
expect(found).toEqual([]);
}, 30000);
it("keeps a strikethrough that spans a url the bare form has to prove", () => {
// The delete handler and the autolink ladder are the two things this pass was sent to attack,
// and this is where they meet: the ladder builds the block three times over, and a delete's
// escaping depends on what its neighbours are, which is different in each of the three.
const found: string[] = [];
const URLS = ["https://example.com/a", "https://exa_mple.com/a", "www.example.com/a", "[email protected]", "a@b_c.com", "a@localhost"];
for (const url of URLS) {
const href = url.includes("@") ? `mailto:${url}` : url.startsWith("www.") ? `http://${url}` : url;
const struckLink = (...names: string[]) => schema.text(url, [schema.marks.link.create({ href, title: null }), ...names.map((name) => mk(name))]);
for (const pad of ["", " "]) {
const cases: Array<[string, ProseMirrorNode]> = [
[`struck across ${url}`, only(para(marked(`use${pad}`, "strikethrough"), struckLink("strikethrough"), marked(`${pad}here`, "strikethrough")))],
[`struck only ${url}`, only(para(schema.text("x"), struckLink("strikethrough"), schema.text("y")))],
[`struck ${url} beside a good one`, only(para(marked(`use${pad}`, "strikethrough"), struckLink("strikethrough"), marked(`${pad}and `, "strikethrough"), schema.text("then "), linked("https://ok.example.com/z", "https://ok.example.com/z")))],
[`struck and strong across ${url}`, only(para(marked(`use${pad}`, "strikethrough", "strong"), struckLink("strikethrough", "strong"), marked(`${pad}here`, "strikethrough", "strong")))],
];
for (const [name, node] of cases) {
const out = writeDoc(node);
if (JSON.stringify(spansOfDoc(node)) !== JSON.stringify(spansOf(out))) found.push(`${name} pad=${JSON.stringify(pad)}: ${JSON.stringify(out)}`);
if (saves(out, 10)[9] !== out) found.push(`${name} pad=${JSON.stringify(pad)} did not settle: ${JSON.stringify(out)}`);
}
}
}
expect(found).toEqual([]);
});
});
// =============================================================================================
// Still fixed: every sweep that has passed before, re-run.
// =============================================================================================
describe("still fixed: the sweeps, re-run over ten generations", () => {
const files = (): Array<[string, string]> => [...corpus().map((file) => [file.name, file.source] as [string, string]), ...Object.entries(fixtures)];
it("settles on the second save and never grows again, for every corpus file and fixture", () => {
const found: string[] = [];
for (const [name, source] of files()) {
const generations = saves(source, 10);
if (new Set(generations.slice(1)).size !== 1) found.push(`${name} never settled: ${generations.map((g) => g.length).join(",")}`);
if (generations[9].length !== generations[1].length) found.push(`${name} grew: ${generations.map((g) => g.length).join(",")}`);
}
expect(found).toEqual([]);
}, 60000);
it("keeps every destination and every character, for every corpus file and fixture", () => {
const found: string[] = [];
for (const [name, source] of files()) {
const tenth = saves(source, 10)[9];
if (JSON.stringify(linksIn(tenth)) !== JSON.stringify(linksIn(source))) found.push(`${name} lost a destination`);
if (saidText(tenth) !== saidText(source)) found.push(`${name} changed the text it says`);
}
expect(found).toEqual([]);
}, 60000);
it("writes every raw block back as the exact bytes it cut out, for every fixture", () => {
const found: string[] = [];
for (const [name, source] of Object.entries(fixtures)) {
const document = parseMarkdown(source, name);
const out = serializeMarkdown(document, document.doc);
document.doc.forEach((block) => {
if (block.type.name !== "raw") return;
const raw = block.textContent;
if (!raw) found.push(`${name} has an empty raw block`);
else if (!source.replace(/\r\n/g, "\n").includes(raw)) found.push(`${name} raw block is not a slice of the source: ${JSON.stringify(raw.slice(0, 60))}`);
else if (!out.includes(raw)) found.push(`${name} raw block did not survive: ${JSON.stringify(raw.slice(0, 60))}`);
});
}
expect(found).toEqual([]);
});
it("is idempotent for every ordered pair of the shapes this pass added", () => {
const SNIPPETS: Record<string, string> = {
struckLink: "~~use&#x20;~~[~~the old API~~](./api.md)~~&#x20;here~~ now.",
struckWhole: "[~~all of it~~](./whole.md) and ~~plain~~.",
angle: "See <https://exa_mple.com/a> here",
angleTight: "See <https://example.com>.Next",
angleMail: "Mail <a@localhost> now",
nestedMarks: "**q**~~**&#x20;r&#x20;**~~**s**",
codeInLink: "[`code span`](./z.md) and [a`b`c](./w.md)",
bare: "Go to https://example.com/a for more",
table: "| a | b |\n| - | - |\n| ~~x~~ | <https://exa_mple.com/c> |",
list: "- [~~gone~~](./gone.md)~~&#x20;and more~~",
callout: "> [!NOTE]\n> [~~the old note~~](./old.md)~~&#x20;is gone~~",
heading: "### ~~a~~ and **b**",
};
const keys = Object.keys(SNIPPETS);
const found: string[] = [];
for (const a of keys) {
for (const b of keys) {
for (const separator of ["\n\n", "\n\n\n", "\n"]) {
const source = `${SNIPPETS[a]}${separator}${SNIPPETS[b]}\n`;
const generations = saves(source, 3);
if (new Set(generations).size !== 1) found.push(`${a} + ${b} (${separator.length}) never settled: ${JSON.stringify(generations[0])} -> ${JSON.stringify(generations[1])}`);
if (saidText(generations[2]) !== saidText(source)) found.push(`${a} + ${b} (${separator.length}) changed its text`);
if (JSON.stringify(linksIn(generations[2])) !== JSON.stringify(linksIn(source))) found.push(`${a} + ${b} (${separator.length}) lost a destination`);
}
}
}
expect(found).toEqual([]);
}, 60000);
it("carries frontmatter through untouched, past every shape this pass added", () => {
const heads = ["---\ntitle: T\n---", "+++\ntitle = \"T\"\n+++", "---\n---", "---\nbody: |\n ---\nb: 2\n---"];
const bodies = ["~~use&#x20;~~[~~the old API~~](./api.md)~~&#x20;here~~ now.\n", "See <https://exa_mple.com/a> here\n", "**q**~~**&#x20;r&#x20;**~~**s**\n", "- [~~gone~~](./gone.md)~~&#x20;and more~~\n"];
const found: string[] = [];
for (const head of heads) {
for (const body of bodies) {
const source = `${head}\n\n${body}`;
const generations = saves(source, 10);
if (!generations[9].startsWith(head)) found.push(`${JSON.stringify(head)} + ${JSON.stringify(body)} moved the frontmatter: ${JSON.stringify(generations[9].slice(0, head.length + 8))}`);
if (new Set(generations.slice(1)).size !== 1) found.push(`${JSON.stringify(head)} + ${JSON.stringify(body)} never settled`);
}
}
expect(found).toEqual([]);
});
it("is a one paragraph diff when a paragraph beside these shapes is edited", () => {
// Written through once first. The table here is compact and M2 pads a modelled table out to its
// column, so the bytes as typed are not the bytes the file settles at; the diff being measured
// is the one an edit makes to a file that has already settled.
const source = write("Edit me.\n\n~~use&#x20;~~[~~the old API~~](./api.md)~~&#x20;here~~ now.\n\nSee <https://exa_mple.com/a> here\n\n| a | b |\n| - | - |\n| ~~x~~ | y |\n\n<div class=\"widget\">raw</div>\n");
expect(write(source), "the baseline has to be stable first, or the diff is just the first save").toBe(source);
const document = parseMarkdown(source, "/locality.md");
const children: ProseMirrorNode[] = [];
let hits = 0;
document.doc.forEach((child) => {
if (child.type.name === "paragraph" && child.textContent === "Edit me.") {
hits += 1;
children.push(n.paragraph.createChecked(null, schema.text("Edited.")));
return;
}
children.push(child);
});
expect(hits).toBe(1);
const out = serializeMarkdown(document, n.doc.createChecked(null, children));
expect(out).toBe(source.replace("Edit me.", "Edited."));
});
});
// =============================================================================================
// Found. These FAIL. A mark nested inside itself over a link corrupts the document.
// =============================================================================================
describe("found: a mark nested inside itself doubles its delimiters over a link", () => {
// `inlineFrom` in parse.ts adds a mark to the set for every wrapper node it walks through:
//
// const inner = inlineFrom(node.children, [...marks, mark.create()]);
//
// ProseMirror's `Mark.setFrom` sorts that array but does not deduplicate it, so a source that
// nests a mark inside itself puts two identical marks on one text node. GFM produces exactly
// that tree for `~~a ~~b~~ c~~`, which is an ordinary thing to write when striking a phrase that
// already had a struck word in it, and which renders on GitHub as plain strikethrough so the
// author has no reason to think anything is wrong.
//
// Without a link the damage is invisible: `nest` writes the two deletes nested, `~~a ~~b~~ c~~`
// comes back out byte identical, and only the document in memory is odd.
//
// With a link it is not. MARK_ORDER puts link outermost, so the two deletes are pushed inside
// the link's text where they end up adjacent: `[~~~~b~~~~](./x.md)`. A run of four tildes does
// not open a strikethrough, so the parser reads them as literal text, and the next save escapes
// them. The document permanently says `a ~~~~b~~~~ c` where the author wrote `a b c`.
//
// The one line fix is to let ProseMirror do the set arithmetic it already has:
//
// const inner = inlineFrom(node.children, mark.create().addToSet(marks));
//
// Applied, every case below passes and the rest of the suite is unchanged.
const cases: Array<[string, string]> = [
["a link", "~~a ~~[b](./x.md)~~ c~~\n"],
["an angle autolink", "~~a ~~<https://exa_mple.com/q>~~ c~~\n"],
["a bare url", "~~a ~~https://example.com/q~~ c~~\n"],
["a link in a heading", "# ~~a ~~[b](./x.md)~~ c~~\n"],
["a link in a list item", "- ~~a ~~[b](./x.md)~~ c~~\n"],
["a link in a quote", "> ~~a ~~[b](./x.md)~~ c~~\n"],
["a link in a callout", "> [!NOTE]\n> ~~a ~~[b](./x.md)~~ c~~\n"],
["the shape an older save of this bridge produced", "~~use ~~[~~the old API~~](./api.md)~~ here~~ now.\n"],
];
for (const [name, source] of cases) {
it(`does not put tildes into the text around ${name}`, () => {
const once = write(source);
expect(saidText(once), "the document must say what it said before the save").toBe(saidText(source));
expect(linksIn(once), "and the link must keep its text").toEqual(linksIn(source));
});
}
it("does not grow without limit when the nesting is three deep", () => {
// Two levels converge with the text already corrupted. Three never converge at all: every save
// doubles the tilde run again and escapes the last one, which is thirty two more bytes on disk
// every time the user hits save, for the rest of the file's life.
const source = "~~a ~~b ~~[c](./x.md)~~ d~~ e~~\n";
const generations = saves(source, 10);
expect(generations[9].length, `lengths: ${generations.map((g) => g.length).join(",")}`).toBe(generations[1].length);
expect(saidText(generations[9])).toBe(saidText(source));
});
it("does not put a duplicate mark on a text node in the first place", () => {
// The root cause, stated where a fix can be aimed at it rather than at the symptom.
const found: string[] = [];
for (const source of ["~~a ~~b~~ c~~\n", "**a **b** c**\n", "_a _b_ c_\n", "~~a ~~[b](./x.md)~~ c~~\n", "~~a ~~b ~~c~~ d~~ e~~\n"]) {
parseMarkdown(source, "/dup.md").doc.descendants((node) => {
if (!node.isText) return true;
const names = node.marks.map((mark) => mark.type.name);
if (new Set(names).size !== names.length) found.push(`${JSON.stringify(source)} put ${JSON.stringify(names)} on ${JSON.stringify(node.text)}`);
return true;
});
}
expect(found).toEqual([]);
});
});
+265
View File
@@ -0,0 +1,265 @@
// What the bridge models, what it refuses to model, and what it writes for each. The two lists
// below are the inventory: adding a handler means moving a case from one to the other, and the
// milestone that gives tables and footnotes real nodes should have to edit this file to do it.
import { describe, expect, it } from "vitest";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { schema } from "../model/schema";
import { parseMarkdown, parsePlainText, serializeMarkdown, serializePlainText } from "./index";
import { HOUSE_STYLE } from "./handlers";
const n = schema.nodes;
function parse(source: string): ProseMirrorNode {
return parseMarkdown(source, "/x.md").doc;
}
function shape(source: string): string[] {
const out: string[] = [];
parse(source).forEach((child) => out.push(child.type.name));
return out;
}
function first(source: string): ProseMirrorNode {
const child = parse(source).firstChild;
if (!child) throw new Error("no content");
return child;
}
function write(doc: ProseMirrorNode): string {
return serializeMarkdown({ frontmatter: null, doc, source: "", path: "/x.md" }, doc);
}
function roundTrip(source: string): string {
const document = parseMarkdown(source, "/x.md");
return serializeMarkdown(document, document.doc);
}
describe("the modelled inventory", () => {
it("maps headings by level", () => {
for (const level of [1, 2, 3, 4, 5, 6]) {
const heading = first(`${"#".repeat(level)} Title\n`);
expect(heading.type.name).toBe("heading");
expect(heading.attrs.level).toBe(level);
}
});
it("maps the inline marks", () => {
const paragraph = first("_em_ *also em* **strong** ~~gone~~ `code` [text](./a.md \"T\").\n");
const marks = new Set<string>();
paragraph.forEach((child) => child.marks.forEach((mark) => marks.add(mark.type.name)));
expect([...marks].sort()).toEqual(["code", "em", "link", "strikethrough", "strong"]);
const link = paragraph.child(paragraph.childCount - 2);
expect(link.marks[0].attrs).toEqual({ href: "./a.md", title: "T" });
});
it("maps images, hard breaks and inline math", () => {
const paragraph = first("![alt](./a.png \"T\") and $$x^2$$ then a\\\nbreak\n");
const kinds = new Set<string>();
paragraph.forEach((child) => kinds.add(child.type.name));
expect(kinds.has("image")).toBe(true);
expect(kinds.has("mathInline")).toBe(true);
expect(kinds.has("hardBreak")).toBe(true);
expect(paragraph.child(0).attrs).toEqual({ src: "./a.png", alt: "alt", title: "T" });
});
it("keeps a soft wrap where the author put it", () => {
expect(first("one\ntwo\nthree\n").textContent).toBe("one\ntwo\nthree");
expect(roundTrip("one\ntwo\nthree\n")).toBe("one\ntwo\nthree\n");
});
it("maps lists, tightness, start and task state", () => {
expect(shape("- a\n- b\n")).toEqual(["bulletList"]);
expect(first("- a\n- b\n").attrs.tight).toBe(true);
expect(first("- a\n\n- b\n").attrs.tight).toBe(false);
const ordered = first("5. five\n6. six\n");
expect(ordered.type.name).toBe("orderedList");
expect(ordered.attrs.start).toBe(5);
const tasks = first("- [ ] no\n- [x] yes\n");
expect(tasks.type.name).toBe("taskList");
expect(tasks.child(0).attrs.checked).toBe(false);
expect(tasks.child(1).attrs.checked).toBe(true);
const mixed = first("- [ ] task\n- plain\n");
expect(mixed.type.name).toBe("bulletList");
expect([mixed.child(0).type.name, mixed.child(1).type.name]).toEqual(["taskItem", "listItem"]);
const orderedTasks = first("1. [ ] first\n2. [x] second\n");
expect(orderedTasks.type.name).toBe("orderedList");
expect(orderedTasks.child(0).type.name).toBe("taskItem");
});
it("maps code blocks with their language and meta", () => {
const code = first("```ts twoslash\nconst a = 1;\n```\n");
expect(code.type.name).toBe("codeBlock");
expect(code.attrs).toEqual({ language: "ts", meta: "twoslash" });
expect(code.textContent).toBe("const a = 1;");
});
it("maps blockquotes, rules and callouts", () => {
expect(shape("> quoted\n")).toEqual(["blockquote"]);
expect(shape("---\n")).toEqual(["horizontalRule"]);
for (const kind of ["note", "tip", "important", "warning", "caution"]) {
const callout = first(`> [!${kind.toUpperCase()}]\n> Body.\n`);
expect(callout.type.name).toBe("callout");
expect(callout.attrs.kind).toBe(kind);
expect(callout.textContent).toBe("Body.");
}
expect(first("> [!NOTE]\n> One.\n>\n> Two.\n").childCount).toBe(2);
expect(roundTrip("> [!NOTE]\n> Body.\n")).toBe("> [!NOTE]\n> Body.\n");
expect(roundTrip("> [!NOTE]\n> One.\n>\n> Two.\n")).toBe("> [!NOTE]\n> One.\n>\n> Two.\n");
expect(roundTrip("> [!WARNING]\n> **Bold** start.\n")).toBe("> [!WARNING]\n> **Bold** start.\n");
expect(roundTrip("> [!TIP]\n>\n> - a\n> - b\n")).toBe("> [!TIP]\n>\n> - a\n> - b\n");
});
});
// The four cases M2 moved out of the raw inventory below. They are the milestone: a table and a
// math block are nodes now, at any depth, so the assertion that used to read "this comes back as
// raw" reads "this comes back as itself" instead. The byte for byte round trip is the half that did
// not change, and it is the half that matters, so it is still asserted here.
describe("the inventory M2 moved", () => {
const nowModelled: Array<[string, string, string]> = [
["a GFM table", "table", "| a | b |\n| - | - |\n| 1 | 2 |"],
["a math block", "mathBlock", "$$\n\\alpha\n$$"],
["a blockquote holding a table", "blockquote", "> | a |\n> | - |\n> | 1 |"],
["a list holding a table", "bulletList", "- item\n\n | a |\n | - |\n | 1 |"],
];
for (const [what, kind, source] of nowModelled) {
it(`models ${what} and still writes it back byte for byte`, () => {
const block = first(`${source}\n`);
expect(block.type.name).toBe(kind);
expect(block.attrs.source).toBeUndefined();
expect(roundTrip(`${source}\n`)).toBe(`${source}\n`);
});
}
});
describe("the raw inventory", () => {
const unmodellable: Array<[string, string]> = [
["a footnote definition", "[^1]: The note."],
["a link definition", "[spec]: https://example.com \"T\""],
["an html block", "<div class=\"x\">\n <p>hi</p>\n</div>"],
["an html comment", "<!-- a comment -->"],
["a details block", "<details>\n<summary>S</summary>\n</details>"],
["jsx", "<Chart data={points} />"],
["a paragraph holding inline html", "Text with <span>markup</span> in it."],
["a paragraph holding an empty link", "An empty link [](./nothing.md) here."],
["a list item that opens with a fence", "- ```js\n const a = 1;\n ```"],
["an alert that is not one of the five", "> [!BOGUS]\n> Body."],
["a quote that only looks like an alert", "> [!NOTE] and then more text."],
];
for (const [what, source] of unmodellable) {
it(`preserves ${what} verbatim`, () => {
const block = first(`${source}\n`);
expect(block.type.name).toBe("raw");
expect(block.textContent).toBe(source);
expect(block.attrs.source).toBe(source);
expect(roundTrip(`${source}\n`)).toBe(`${source}\n`);
});
}
// A reference only exists as one when its definition does, so these two need the whole document.
const referencing: Array<[string, string]> = [
["a footnote reference", "Text with a note[^1] in it.\n\n[^1]: The note.\n"],
["a link reference", "Text with [a reference][spec] in it.\n\n[spec]: https://example.com\n"],
];
for (const [what, source] of referencing) {
it(`preserves a paragraph holding ${what} verbatim`, () => {
expect(shape(source)).toEqual(["raw"]);
expect(roundTrip(source)).toBe(source);
});
}
it("joins a run of neighbouring raw blocks into one", () => {
const source = "[^1]: one\n[^2]: two\n";
expect(shape(source)).toEqual(["raw"]);
expect(first(source).textContent).toBe("[^1]: one\n[^2]: two");
expect(roundTrip(source)).toBe(source);
});
it("writes an edited raw block as whatever the user typed", () => {
const doc = n.doc.create(null, [n.raw.create({ source: "<div>old</div>" }, schema.text("<div>new</div>"))]);
expect(write(doc)).toBe("<div>new</div>\n");
});
it("writes nothing for a raw block the user emptied", () => {
const doc = n.doc.create(null, [n.paragraph.create(null, schema.text("a")), n.raw.create({ source: "<div>old</div>" })]);
expect(write(doc)).toBe("a\n");
});
});
describe("blocks only the editor can make", () => {
it("writes a table", () => {
const cell = (text: string, align: string | null) => n.tableCell.create({ align }, schema.text(text));
const doc = n.doc.create(null, [
n.table.create(null, [
n.tableRow.create(null, [n.tableHeader.create({ align: null }, schema.text("A")), n.tableHeader.create({ align: "center" }, schema.text("B"))]),
n.tableRow.create(null, [cell("1", null), cell("2", "center")]),
]),
]);
expect(write(doc)).toBe("| A | B |\n| - | :-: |\n| 1 | 2 |\n");
});
it("writes a toggle as a details block", () => {
const doc = n.doc.create(null, [n.toggle.create({ summary: "More", open: true }, n.paragraph.create(null, schema.text("Body.")))]);
expect(write(doc)).toBe("<details open>\n<summary>More</summary>\n\nBody.\n\n</details>\n");
});
it("writes a math block", () => {
const doc = n.doc.create(null, [n.mathBlock.create({ latex: "\\alpha" })]);
expect(write(doc)).toBe("$$\n\\alpha\n$$\n");
});
});
describe("the house style", () => {
it("is declared in exactly one place", () => {
const sources = import.meta.glob("./*.ts", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
const declaring = Object.entries(sources).filter(([name, text]) => !name.endsWith(".test.ts") && text.includes("bullet:"));
expect(declaring.map(([name]) => name)).toEqual(["./handlers.ts"]);
});
it("is the one the serializer writes", () => {
expect(HOUSE_STYLE.bullet).toBe("-");
expect(HOUSE_STYLE.emphasis).toBe("_");
expect(HOUSE_STYLE.strong).toBe("*");
expect(HOUSE_STYLE.rule).toBe("-");
expect(HOUSE_STYLE.fences).toBe(true);
expect(HOUSE_STYLE.listItemIndent).toBe("one");
expect(roundTrip("* star\n")).toBe("- star\n");
expect(roundTrip("*emphasis*\n")).toBe("_emphasis_\n");
expect(roundTrip("__strong__\n")).toBe("**strong**\n");
expect(roundTrip("***\n")).toBe("---\n");
});
it("nests overlapping marks in one fixed order", () => {
expect(roundTrip("**_both_**\n")).toBe("**_both_**\n");
expect(roundTrip("_**both**_\n")).toBe("**_both_**\n");
});
});
describe("plain text", () => {
const cases = ["", "\n", "one line", "one line\n", "a\nb\nc", "a\nb\nc\n", "trailing spaces \n", "\n\n\n", "# not a heading\n- not a list\n", "tabs\tand spaces\n", "unicode 🎉 é\n"];
for (const source of cases) {
it(`is byte identical: ${JSON.stringify(source)}`, () => {
const document = parsePlainText(source, "/x.txt");
expect(document.frontmatter).toBe(null);
expect(serializePlainText(document, document.doc)).toBe(source);
});
}
it("is never read as markdown", () => {
const document = parsePlainText("# heading\n", "/x.txt");
expect(document.doc.firstChild?.type.name).toBe("paragraph");
expect(document.doc.firstChild?.textContent).toBe("# heading");
});
});
@@ -0,0 +1,6 @@
Read <https://example.com>'s docs, then <https://example.com>.Next thing,
and [https://example.com](https://example.com),also this.
Mail <a@b.com>.Next and <a@b.com> alone.
Safe: <https://example.com>. Safe: <https://example.com>) and (<https://example.com/a(b)>).
@@ -0,0 +1,23 @@
> [!NOTE]
> A note.
> [!WARNING]
> [!WEIRD]
> Not a callout, must stay raw.
> [!NOTE] inline, must stay raw.
> > nested quote
> >
> > - list
> > - deeper
<details open class="x">
<summary>Attributes</summary>
body
</details>
<!--[if IE]>conditional<![endif]-->
@@ -0,0 +1,11 @@
---
title: CRLF
---
# Heading
```
code
```
<div>html</div>
@@ -0,0 +1,24 @@
| pipe | code |
| --- | ---: |
| `a \| b` | \| raw |
| x | `` ` `` |
````md
```js
const x = 1;
```
````
~~~text
tilde fence
~~~
```
```
```{r setup, echo=FALSE}
plot(1)
```
@@ -0,0 +1,12 @@
---
title: "BOM and YAML"
empty:
quoted: "---"
multi: |
line one
line two
---
Body after a BOM.
Mid-file BOM: ab.
@@ -0,0 +1,16 @@
+++
title = "TOML"
nested = "+++"
list = [ 1, 2 ]
# a comment
+++
# Body
<div class="keep" data-x="1">
<span>html</span>
</div>
[^n]: A footnote definition.
[ref]: https://example.com "Title"
@@ -0,0 +1,5 @@
---
- a list the frontmatter tokenizer eats
- second item
> a quote it eats too
@@ -0,0 +1,30 @@
---
title: Locality
---
| Column A | Column B |
| --- | --- |
| one | two |
EDITME
<div class="widget" data-id="7">
<span>hand written html</span>
</div>
A paragraph with a ref[^note] and a [link][ref].
<details>
<summary>Details block</summary>
inner
</details>
```js
const untouched = true;
```
[^note]: The footnote definition, which must not move.
[ref]: https://example.com "Title"
@@ -0,0 +1,5 @@
```
line one␍still line one
```
A paragraph.
@@ -0,0 +1,26 @@
---
title: M2 locality
---
# Locality
EDITME
| Column A | Column B |
| - | -: |
| one | two |
<details>
<summary>A toggle beside it</summary>
Body of the toggle.
</details>
$$
\frac{a}{b}
$$
[^note]: The footnote definition, which must not move.
Final paragraph.
@@ -0,0 +1,34 @@
# Math
A display block:
$$
\frac{a}{b} = \sum_{i=0}^{n} x_i
$$
Two dollars inside it, which the fence has to grow for:
$$$
a $$ b
$$$
One dollar inside it, which it does not:
$$
a $ b
$$
A block with nothing in it:
$$
$$
A fence carrying meta, which has nowhere to go and stays as it is:
$$ tag
x
$$
Money is not mathematics: $5, $10 and $1,000.
Inline $$e^{i\pi} + 1 = 0$$ in the middle of a sentence.
@@ -0,0 +1,44 @@
# Marks inside marks
A strikethrough that spans a link and the words either side of it, in the spelling
the serializer settles on: ~~use&#x20;~~[~~the old API~~](./api.md)~~&#x20;here~~ now.
Strong inside a strikethrough, and a strikethrough inside strong:
~~a**b**c~~ and **a**~~**b**~~**c**.
Emphasis inside a link, and a link inside emphasis:
[a\_b\_c](./x.md) and _a_[_b_](./y.md)_c_.
Code inside a link, which is the only pair code takes:
[`code span`](./z.md) and [a`b`c](./w.md).
All four at once, over a boundary space:
**q**~~**&#x20;r&#x20;**~~**s**
A struck run whose edge is a tab:
&#x78;~~&#x9;t&#x9;~~&#x79;
A struck run that covers exactly one whole link, and one that covers two:
[~~all of it~~](./whole.md) and [~~a~~](./a.md)~~&#x20;and&#x20;~~[~~b~~](./b.md).
## Angle urls the bare form cannot carry
An underscore in the domain: <https://exa_mple.com/a>
A backslash in the path: <https://exa_mple.com/a\_b>
An entity shaped tail followed by a semicolon: <https://exa_mple.com/a&amp>; here
A host with no dot: <a@localhost>
A pipe, inside a table cell:
| url | note |
| --- | ---- |
| <https://exa_mple.com/c> | inside a cell |
- <https://exa_mple.com/d>
- [~~gone~~](./gone.md)~~&#x20;and more~~
> [!NOTE]
> [~~the old note~~](./old.md)~~&#x20;is gone~~ and <https://exa_mple.com/e> stays.
@@ -0,0 +1,3 @@
# No final newline
<div>raw at eof</div>
@@ -0,0 +1,16 @@
# Struck out links
The old guide has been ~~[moved to the archive](./archive/old.md) and renamed~~,
so use the new one instead.
We ~~use [the old API](./api.md) here~~ now.
- ~~[done](./done.md) already~~
- ~~see https://example.com/a now~~
- ~~a plain struck item with no link~~
> [!NOTE]
> ~~[the old note](./old.md) is gone~~
A strikethrough that covers exactly one whole link is fine:
~~[all of it](./whole.md)~~ and so is ~~plain struck text~~.
@@ -0,0 +1,27 @@
# Tables the editor has no model for
A cell holding inline html:
| a |
| - |
| <b>x</b> |
A cell holding a link with no text, whose destination would go with it:
| a |
| - |
| [](./nothing.md) |
A cell holding a footnote reference:
| a |
| - |
| note[^n] |
[^n]: The note.
A cell holding a link inside a link:
| a |
| - |
| [see <https://x.example> more](./y.md) |
@@ -0,0 +1,55 @@
# Tables
Escaped pipes, which are the one character a cell cannot hold plainly:
| pipe | code |
| -------- | ------: |
| `a \| b` | \| raw |
| x | `` ` `` |
A link whose text carries a pipe, and two urls that are their own text:
| link |
| --------------------------------------- |
| [a \| b](./x.md) |
| <https://example.com> |
| https://example.com |
Rows that are not the width of the header:
| a | b |
| - | - |
| 1 | 2 | 3 |
| 4 |
Rows shorter than the header, which GFM already renders as blank cells:
| a | b | c |
| - | - | - |
| 1 |
| 2 | 3 |
A header with nothing in it:
| | |
| - | - |
| 1 | 2 |
A table with no body rows at all:
| only | header |
| ---- | ------ |
Alignment, with the dashes counted differently in every column:
| a | b | c | d |
| :- | :---: | ----: | - |
| 1 | 2 | 3 | 4 |
Marks and images inside cells:
| a | b |
| ---------- | ----------------- |
| **bold** | _em_ |
| ~~struck~~ | [link](./y.md) |
| `code` | ![alt](./i.png) |
@@ -0,0 +1,68 @@
# Toggles
<details>
<summary>Plain</summary>
Body text.
</details>
<details open>
<summary>Already open</summary>
Body text.
</details>
<details>
<summary>An &amp; ampersand with &lt;angle&gt; brackets</summary>
Body text.
</details>
<details>
<summary>A fence inside</summary>
```js
const x = 1;
```
</details>
<details>
<summary>Several blocks</summary>
One.
- a list
- inside a toggle
> and a quote
</details>
<details>
<summary>A table and an equation inside</summary>
| a | b |
| - | - |
| 1 | 2 |
$$
x^2
$$
</details>
<details>
<summary></summary>
An empty summary.
</details>
<details>
<summary>Nothing at all between the tags</summary>
</details>
@@ -0,0 +1,58 @@
# Toggles that stay as the file wrote them
Attributes beyond a bare open:
<details open class="x">
<summary>S</summary>
Body.
</details>
A summary carrying markup:
<details>
<summary>A <b>bold</b> summary</summary>
Body.
</details>
An entity this bridge does not know:
<details>
<summary>A &quot;quoted&quot; summary</summary>
Body.
</details>
A bare ampersand, which would come back escaped:
<details>
<summary>A & B</summary>
Body.
</details>
One inside another:
<details>
<summary>Outer</summary>
<details>
<summary>Inner</summary>
Body.
</details>
</details>
An opening tag with nothing closing it:
<details>
<summary>Unclosed</summary>
Body.
@@ -0,0 +1,9 @@
Family 👩‍💻 and 🏳️‍🌈 flag.
Combining: café vs café. Astral: 𝄞.
RTL: a‏b‎c. Heart: ❤️.
NBSP: between words.

paragraph with U+2028
@@ -0,0 +1,33 @@
# Hard wrapped prose with urls in it
The bridge writes a url bare when it can prove the bare form reads back as the
same link. This paragraph is wrapped the way a person wraps prose, so one of the
urls lands at the start of a line:
https://example.com/a
and the rest of the sentence carries on underneath it.
A url in the middle of a line, https://example.com/b for instance, is on the
same line as the words before it.
See the docs
https://example.com/c
for the details.
> [!NOTE]
> https://example.com/d
> [!TIP]
> A callout whose first line is words and whose second is a url:
> https://example.com/e
- A list item that wraps
https://example.com/f
onto a second line.
- A list item with https://example.com/g on the first line.
A hard break, then a url:\
https://example.com/h
Mail a@b.com or
c@d.com
depending on the day.
+6
View File
@@ -0,0 +1,6 @@
# BOM file
This file starts with a UTF-8 byte order mark.
- one
- two
+33
View File
@@ -0,0 +1,33 @@
# Callouts
> [!NOTE]
> Useful information the user should know.
> [!TIP]
> Helpful advice.
> [!IMPORTANT]
> Key information.
> [!WARNING]
> Urgent info.
> [!CAUTION]
> Advises about risks.
> [!NOTE]
> A callout with two paragraphs.
>
> The second one.
> [!TIP]
>
> - a list
> - inside a callout
> An ordinary blockquote, no alert.
>
> With a second paragraph.
> [!BOGUS]
> Not one of the five.
+26
View File
@@ -0,0 +1,26 @@
# Code
```ts twoslash
const a: number = 1;
```
```
no language
```
~~~python
print("tilde fence")
~~~
````md
```
a fence inside a fence
```
````
Inline `code`, and ``code with a ` backtick``, and `` ` `` alone.
```diff
- removed
+ added
```
+10
View File
@@ -0,0 +1,10 @@
# CRLF
A paragraph with Windows line endings.
- one
- two
```js
const a = 1;
```
@@ -0,0 +1,12 @@
# Definitions and references
Term
: The definition, which CommonMark does not model.
Another term
: Its definition.
See [the spec][spec] and ![the logo][logo].
[spec]: https://spec.commonmark.org/ "CommonMark"
[logo]: ./assets/logo.png
+17
View File
@@ -0,0 +1,17 @@
# Emphasis
*star emphasis* and _underscore emphasis_.
__double underscore strong__ and **double star strong**.
***both at once*** and ___both again___.
~~strikethrough~~ and ~single tilde~.
Mixed **bold with _nested italic_ inside**.
An intra_word_underscore stays literal.
A literal asterisk \* and a literal underscore \_.
`code span with *stars*` stays literal.
View File
Whitespace-only changes.
@@ -0,0 +1,13 @@
# Entities and escapes
An entity &copy; and &amp; and &nbsp; and &#169; here.
Escaped characters: \# \* \_ \[ \] \< \> \\ \` \|.
A line starting with a number that is not a list:
1986\. What a year.
Angle brackets in text: 3 < 5 and 5 > 3.
An ampersand alone: fish & chips.
+11
View File
@@ -0,0 +1,11 @@
# Footnotes
A claim that needs support[^one] and another[^long-name].
Some prose in between.
[^one]: The first note.
[^long-name]: A longer note.
It continues on a second line.
Inline footnotes^[like this one] also exist.
Loaded 100 of 252 files, more files were not shown because too many files have changed in this diff. Show more