mirror of
https://github.com/priyanshujain/margin-docs.git
synced 2026-10-02 19:17:05 +00:00
features
This commit is contained in:
commit
852cba1840
252 files changed
+55050
No files matched your search
+204
@@ -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;
|
||||
@@ -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 });
|
||||
@@ -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 });
|
||||
@@ -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 });
|
||||
@@ -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");
|
||||
@@ -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 });
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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("/")}`;
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 } }),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 & Jerry <3>";
|
||||
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("<3>", "<4>"));
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
// The failure this is shaped to catch is invisible: an editor that put the escaped form on the
|
||||
// node would write `&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();
|
||||
});
|
||||
});
|
||||
@@ -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 `&` 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,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)];
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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 }),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(), []);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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)}`));
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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", "& © A\n", "& © A\n"],
|
||||
["an ambiguous entity is re-escaped instead", "&copy;\n", "\\©\n"],
|
||||
["intraword underscores are escaped", "snake_case here\n", "snake\\_case here\n"],
|
||||
["intraword asterisks become character references", "a*b*c\n", "a_b_c\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", "\n", "\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: '',
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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: '',
|
||||
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&>; here\n";
|
||||
const once = write(source);
|
||||
expect(destinations(once), `${JSON.stringify(source)} -> ${JSON.stringify(once)}`).toEqual(["https://example.com/a&"]);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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&>; here\n", "See <https://example.com/a&>; 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&",
|
||||
"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>", " <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&",
|
||||
"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", " 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: '',
|
||||
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&>; 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&>; 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);
|
||||
});
|
||||
@@ -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&", "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&>; 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&", "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) ", "  ", "\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
|
||||
// ` ` 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));
|
||||
});
|
||||
});
|
||||
@@ -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&>; here\n", "See [https://example.com/a\\&](https://example.com/a\\&); here\n", "See <https://example.com/a&>; 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&> 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&",
|
||||
"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~~", " "];
|
||||
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 ~~[~~the old API~~](./api.md)~~ 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**~~** r **~~**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)~~ and more~~",
|
||||
callout: "> [!NOTE]\n> [~~the old note~~](./old.md)~~ 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 ~~[~~the old API~~](./api.md)~~ here~~ now.\n", "See <https://exa_mple.com/a> here\n", "**q**~~** r **~~**s**\n", "- [~~gone~~](./gone.md)~~ 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 ~~[~~the old API~~](./api.md)~~ 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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(" 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 ~~[~~the old API~~](./api.md)~~ 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**~~** r **~~**s**
|
||||
|
||||
A struck run whose edge is a tab:
|
||||
x~~	t	~~y
|
||||
|
||||
A struck run that covers exactly one whole link, and one that covers two:
|
||||
[~~all of it~~](./whole.md) and [~~a~~](./a.md)~~ and ~~[~~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&>; 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)~~ and more~~
|
||||
|
||||
> [!NOTE]
|
||||
> [~~the old note~~](./old.md)~~ 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` |  |
|
||||
@@ -0,0 +1,68 @@
|
||||
# Toggles
|
||||
|
||||
<details>
|
||||
<summary>Plain</summary>
|
||||
|
||||
Body text.
|
||||
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>Already open</summary>
|
||||
|
||||
Body text.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>An & ampersand with <angle> 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 "quoted" 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: abc. 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.
|
||||
@@ -0,0 +1,6 @@
|
||||
# BOM file
|
||||
|
||||
This file starts with a UTF-8 byte order mark.
|
||||
|
||||
- one
|
||||
- two
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Whitespace-only changes.
@@ -0,0 +1,13 @@
|
||||
# Entities and escapes
|
||||
|
||||
An entity © and & and and © 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.
|
||||
@@ -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
Reference in new issue
Block a user