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

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 26
- uses: pnpm/action-setup@v6
with:
version: 10
- run: pnpm install --frozen-lockfile
# `pnpm build` is tsc then vite, so this is the typecheck and the bundle in one step.
- run: pnpm build
- run: pnpm test
rust:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v7
- name: Install Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
libxdo-dev \
libssl-dev \
build-essential
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- uses: swatinem/rust-cache@v2
with:
workspaces: src-tauri -> target
# tauri_build::build() wants a frontendDist that exists. The example file is what a fresh
# clone compiles against, so that is what CI uses.
- name: Stub the build inputs
run: mkdir -p dist && touch dist/index.html
- name: Test
working-directory: src-tauri
run: cargo test
+168
View File
@@ -0,0 +1,168 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: "Release version, e.g. 0.2.0. Leave empty to bump the patch number."
required: false
type: string
permissions:
contents: write
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
release_id: ${{ steps.release.outputs.release_id }}
steps:
- uses: actions/checkout@v7
- name: Determine version
id: version
run: |
if [ -n "${{ inputs.version }}" ]; then
VERSION="${{ inputs.version }}"
VERSION="${VERSION#v}"
else
CURRENT=$(jq -r .version src-tauri/tauri.conf.json)
IFS=. read -r MAJOR MINOR PATCH <<< "$CURRENT"
VERSION="$MAJOR.$MINOR.$((PATCH + 1))"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
echo "Releasing v$VERSION"
- name: Bump version in manifests
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
tmp=$(mktemp)
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > "$tmp" && mv "$tmp" src-tauri/tauri.conf.json
jq --arg v "$VERSION" '.version = $v' package.json > "$tmp" && mv "$tmp" package.json
sed -i "0,/^version = \".*\"/s//version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Commit and tag
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add src-tauri/tauri.conf.json package.json src-tauri/Cargo.toml
git commit -m "chore(release): $TAG"
for attempt in 1 2 3 4 5; do
git fetch origin main
git rebase origin/main
if git push origin HEAD; then
break
fi
if [ "$attempt" = "5" ]; then
echo "::error::main kept advancing; could not push release bump after 5 attempts."
exit 1
fi
echo "main advanced during release; rebasing and retrying ($attempt)…"
sleep 3
done
git tag "$TAG"
git push origin "$TAG"
- name: Create draft release
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
run: |
gh release create "$TAG" --draft --title "Margin Docs $TAG" --notes "Release $TAG"
ID=$(gh release view "$TAG" --json databaseId --jq .databaseId)
echo "release_id=$ID" >> "$GITHUB_OUTPUT"
build:
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- os: macos-26
args: "--target universal-apple-darwin --config src-tauri/tauri.release.conf.json"
rust-targets: "aarch64-apple-darwin,x86_64-apple-darwin"
# Ubuntu 22.04 is the glibc baseline: the bundle will not run on anything older than the
# glibc it was linked against, so build on the oldest supported.
- os: ubuntu-22.04
args: "--config src-tauri/tauri.release.conf.json"
rust-targets: ""
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.prepare.outputs.tag }}
- name: Install Linux dependencies
if: startsWith(matrix.os, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
libxdo-dev \
libssl-dev \
build-essential \
curl \
wget \
file
- uses: actions/setup-node@v6
with:
node-version: 26
- uses: pnpm/action-setup@v6
with:
version: 10
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust-targets }}
- uses: swatinem/rust-cache@v2
with:
workspaces: src-tauri -> target
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Build and upload
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
releaseId: ${{ needs.prepare.outputs.release_id }}
args: ${{ matrix.args }}
publish:
needs: [prepare, build]
runs-on: ubuntu-latest
steps:
- name: Verify manifest is complete, then publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
TAG: ${{ needs.prepare.outputs.tag }}
run: |
gh release download "$TAG" --repo "$REPO" --pattern latest.json --output latest.json --clobber
echo "Platforms in latest.json:"
jq '.platforms | keys' latest.json
for key in darwin-aarch64 darwin-x86_64 linux-x86_64; do
if ! jq -e ".platforms[\"$key\"].url" latest.json > /dev/null; then
echo "::error::latest.json is missing platform '$key': refusing to publish a partial update manifest. Re-run the release."
exit 1
fi
done
gh release edit "$TAG" --repo "$REPO" --draft=false --latest
+45
View File
@@ -0,0 +1,45 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Tauri build output
src-tauri/target/
src-tauri/gen/schemas/
# Xcode build output. The project under src-tauri/gen/apple is generated but committed, the
# products of building it are not: Externals holds the static Rust library per architecture and
# runs to several hundred MB.
src-tauri/gen/apple/build/
src-tauri/gen/apple/Externals/
src-tauri/gen/apple/Pods/
src-tauri/gen/apple/Podfile.lock
xcuserdata/
# Screenshots & Playwright MCP artifacts
.playwright-mcp/
/*.png
# Signing keys for the updater. Never committed.
.env
.env.*
!.env.example
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Priyanshu Jain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+14
View File
@@ -0,0 +1,14 @@
# Margin Docs
A local first WYSIWYG editor for the plain markdown files already on your disk, macOS only.
Markdown syntax is never visible, and nothing is written to your folders but the markdown itself
and the images you paste into it.
It is a sibling to [margin](https://github.com/priyanshujain/margin) and
[margin-calendar](https://github.com/priyanshujain/margin-calendar) and shares their stack and
their visual language.
Setup is in [docs/setup.md](docs/setup.md). How releases are cut is in
[docs/release.md](docs/release.md). The product decisions are in [docs/design.md](docs/design.md),
how it is built is in [docs/architecture.md](docs/architecture.md), and the conventions it follows
are in [docs/conventions.md](docs/conventions.md).
+432
View File
@@ -0,0 +1,432 @@
# Architecture
Tauri 2, React 19, Vite, TypeScript and zustand on the front, Rust behind. Same stack as margin and
margin-calendar, which means the build and bundle setup, the token layer and the TipTap editor core
carry over from margin rather than being invented again, while the zustand and CSS conventions
carry over from margin-calendar, the newer and more disciplined of the two scaffolds.
The split is strict. Rust owns the filesystem: reading and writing documents, walking and watching
the tree, the SQLite index, moving files to the trash, and atomic writes. TypeScript owns
everything above that: the markdown bridge that turns file bytes into an editable document and
back, the WYSIWYG editor itself, and all rendering. Rust never parses markdown and TypeScript never
touches the filesystem directly; everything between the two crosses through the `dto.rs`/`ipc.ts`
contract described below.
## The markdown bridge
The bridge is built on `remark-parse` and `remark-stringify` through `unified`, not on
`@tiptap/markdown`, TipTap's own markdown extension. Two things rule that out. `@tiptap/markdown` is
built on `marked`, which has no frontmatter support, and frontmatter here is not optional: it has to
be parsed, hidden from the editor, and written back unchanged on every save. More fundamentally, a
`marked` AST does not carry source positions, while every node `remark-parse` produces carries a
`position` with byte offsets into the original file. That offset is the whole trick the rest of this
document depends on: when a piece of syntax has no model in the editor, the bridge does not
reconstruct its text from a generic AST node, it slices the exact bytes out of the original source
between `position.start.offset` and `position.end.offset` and carries that slice forward untouched.
Writing back byte identical is a property of the source string, not of the printer, and only an AST
that remembers where it came from can hand that string back.
## Where a hand wrapped line lives
Most markdown in the world is hard wrapped: a paragraph is several lines in the file and one
paragraph on screen, and the author chose where those lines end. That choice is theirs and this
editor does not get to reflow it, so a soft line break survives the round trip as a real newline in
the paragraph's text rather than being folded into a space.
Keeping it takes three declarations that have to agree, and it is worth naming all three because
each one on its own looks like an optimisation somebody could remove. `src/markdown/parse.ts` puts
the newline in the text. `prose.css` draws a paragraph `pre-wrap` so it is on screen where the
author put it. `src/model/schema.ts` declares the paragraph `whitespace: "pre"`, and that is the one
that was missing: two libraries read that field and both of them rewrite the paragraph without it.
`prosemirror-view` reads it to decide how to parse the editor's own DOM back after a keystroke, and
at the default every soft wrap in the paragraph being typed into came back as a hard break, so one
character typed into a hand wrapped file put a backslash at the end of every line in that paragraph.
`prosemirror-transform` asks the same field before it joins two blocks, so Backspace between two
hand wrapped paragraphs rewrote the wraps in both.
The paragraph's parse rule says the opposite on purpose, and a rule's own answer outranks the
node's. Whitespace is significant in a paragraph this editor rendered, and meaningless in a `<p>`
off somebody else's web page, where the line endings are that page's source indentation and keeping
them would put breaks and runs of spaces through the middle of a pasted sentence.
Only a running editor can prove any of this: the fault is between the keystroke and the serializer,
and the serializer never saw it. `tests/bytes.spec.ts` is where that proof lives.
## Callouts, toggles and unknown syntax
On disk a callout is a GitHub alert: a blockquote whose first line reads `[!NOTE]`, `[!WARNING]`,
`[!IMPORTANT]`, `[!TIP]` or `[!CAUTION]`. `remark-parse` hands back an ordinary `blockquote` node for
these, so a visitor walks the tree afterward, recognises the marker in the first child, tags the
node with its variant and strips the marker before the editor ever sees it. Serialization reverses
exactly that: re-emit the blockquote and put the marker back as its first line.
A toggle is `<details>` and `<summary>`, and remark does not parse HTML blocks into a tree at all;
it hands them back as opaque `html` nodes carrying raw text. The bridge looks for a matching
`details`/`summary` pair in that raw text, and where it finds one it lifts the summary and the body
into a toggle node the editor can open and close like a native control. A `details` block that does
not match that exact shape, nested oddly, missing a summary, carrying attributes the editor has no
model for, is left exactly as it was found. Everything else the parser cannot classify, an HTML
block that is not a details/summary pair, a construct remark itself does not model, a piece of
syntax nobody has written a visitor for yet, becomes a raw node under the rule in
[conventions.md](conventions.md): the literal source slice, editable as monospace text, written
back unchanged. Nothing is ever silently reformatted or dropped.
## Tables, maths and the rest of what the bridge learned to model
The first pass over the bridge modelled prose and left everything structural as a raw node. A GFM
table, a `$$` math block and a `<details>` toggle were all bytes the editor showed and refused to
touch. Modelling them is what turns those blocks from something the editor carries into something
it edits, and every one of them was built as the exact inverse of the writer that puts it back: the
first mdast row becomes header cells and the delimiter row's per column alignment is copied on to
every cell of that column, because markdown has no way to say that one cell is centred and the rest
of its column is not. That inverse relationship is the whole test: a construct is modelled only
where parsing it and writing it back is the identity, and the parser refuses everything else.
The refusals are the interesting half and they are all the same shape. A table row with more cells
than its header has no column to put them in, so there is no answer between dropping the cells and
inventing a column, and the block stays raw. A toggle whose summary does not survive being unescaped
and escaped again character for character stays raw. A math fence that runs to the end of the file
unclosed stays raw, because writing it back would invent the closing delimiter. So does a carriage
return anywhere inside any of the three, since remark reads a lone `\r` as a line ending and the
writer never puts one back. In every case the file keeps its exact bytes and the user gets an
editable monospace block instead of a rich one, which is the trade this project has always made.
Modelling a table does cost one thing, and it is on the record rather than hidden. The house style
writes a table with its cells padded out to the width of the column, and a table written compact by
hand is repadded by the save that settles the file. Nothing else about it moves, no cell content
changes, and the second save is byte identical, which is the same one time normalisation the house
style has always applied to a setext heading or a tilde fence. The alternative would be a `source`
attribute on the table node holding the author's spelling, and that cannot work: the schema is
frozen, and the editor rebuilds every node from JSON when it installs a document, so the node the
writer sees is never the node the parser made. `roundtrip.test.ts` names the two corpus files this
affects and asserts, cell by cell, that the padding is the only thing that changed.
## What the writer proves before it writes
mdast writes the tree, so escaping, fence lengths and list indentation are decided by the library
that read the file rather than by string concatenation. What mdast cannot decide is whether this
reader will hand the block back as the block the document holds, and several spellings turn on
exactly that. A url the file wrote bare should stay bare, not become `[https://x](https://x)` and
put a diff in front of somebody who edited a different paragraph. A code span or an equation the
file wrapped across two lines should keep its line ending rather than have it swapped for a space.
A `<summary>` should keep the line endings the file gave it. None of those is safe on the strength
of a regular expression here, because GFM's literal autolink grammar has more corners than one, and
every corner that was got wrong cost a destination.
So the writer proves rather than assumes, and it proves the same way every time: write the block
out, read it back with the pair the app opens files with, and compare. There are two ladders. The
outer one is about the block, and it has three rungs, from writing every line ending where the
document puts it, through handing the spans back to mdast, to writing the two a text node cannot
hold as `&#xA;` and `&#xD;`. The inner one is about a url and also has three, shortest first: the
url on its own, `<url>`, then `[url](url)`, which has no grammar left to get wrong. The last rung of
the outer ladder is written whether it verifies or not, because a document holding something
markdown cannot spell still has to be saved and the one thing that must not happen is a file that
gains bytes on every save for the rest of its life.
The comparison is against the ProseMirror node, not against the mdast tree the block was built
from, and that is the whole point of it. The two trees disagree in ways that mean nothing: remark
reads an item's `spread` off the blank lines inside it while the writer sets it from the list, and a
parser extension hangs its own fields on the nodes it made. Every one of those differences is a
comparison that fails on a block that is perfectly fine, and each one was a rung of this ladder
quietly switched off. The document is the thing that has to survive, so the document is what is
compared.
The comparison itself is by name, not by object. The block the writer built and the block it reads
back are bound to two different `Schema` instances over one set of specs, so every `NodeType` and
every `MarkType` in one is a different object from its twin in the other, and `Node.eq`, which
compares them by identity, answered "different" for every block it was ever given. That was the
whole ladder switched off at the top rung, and `src/document.ts` has the same comparison for the
same reason.
Two questions about a file are not questions about any block in it, and each has a check of its
own because the ladder cannot see either. The seam between two blocks is one: a blank line closes
every construct markdown has except a list, which carries on over one and swallows the block below,
so a list written next to preserved source is proved apart and respelled, with a wider item indent
or the other bullet, until it is.
Those respellings cover every raw block a file can hand over, because a file's own raw block starts
in the first three columns and column four is indented code. An edited one can be anything, and for
some of them no spelling exists at all: four spaces typed into a raw block below a list makes bytes
that no seam can hold, since they read back as a code block wherever they are put. So the seam has a
last resort. When a list beside a raw block has no spelling left, the raw block is written from the
bytes the file gave it and the edit does not reach disk that save. That is the one place this editor
knowingly drops something the user typed, and it is the right way round: the alternative measured on
the same file swallowed the list into the raw block and then moved bytes around inside somebody's
html on the save after. A raw block with no list beside it is written exactly as it always was.
Where the body starts is the other: `---` on the first line of a
file is not a rule, it is the opening delimiter of frontmatter, and everything down to the next one
stops being markdown. That check is asked only of a body whose first three characters could open a
delimiter, and only when the body really is at the first byte of the file. A body with frontmatter
coming in front of it was never in danger, and running the defence there did the damage it exists
to prevent, because the blank line it pushes the body down by is handed back as part of the
frontmatter and written again on the next save. `serializeMarkdown` is the one thing that knows
whether a prefix is coming, so it is what tells the body writer.
One inline shape has no spelling either, and the writer settles it rather than the editor. A hard
break is a line ending inside a block, and there is no line left for one to start at the end of a
block, so a break with nothing under it is written as nothing. It used to be written as a backslash
on a line of its own, which reads back as a literal backslash in the user's words: a character they
never typed. That answer is in the serializer and nowhere else on purpose, because the trailing
break has more than one way in. Shift+Enter at the end of a paragraph is the obvious one, and is
still allowed, since it is a line somebody is halfway through typing and the next character makes it
a real break. Deleting the text out from under a break that was legal where it was put is the other,
and no keystroke guard sees that one at all. The editor refuses only the level 1 and 2 heading,
where a trailing break also takes the setext underline with it, and that refusal is about the
marker, not about the backslash.
Reading costs about eight times what writing does, and this editor saves half a second after the
user stops typing, so neither ladder runs on a block that has nothing in it to get wrong. A block
reaches the outer one only when it holds a line ending somewhere markdown has no way to write one,
or bytes mdast did not choose, and it reaches the inner one only when it holds a url that could be
written short. On the real corpus and on a 200 KB document of ordinary prose that is no blocks at
all: a save is 24 ms and no reparse happens. The shape that pays is a document with a bare url in
every paragraph, which costs two trips through the parser per block. Measured in Chromium on three
large real files, a save is 26 ms for the 179 KB yt-dlp README, 56 ms for the 266 KB
opentelemetry-js changelog and 91 ms for the 251 KB node changelog, each byte identical on the
second save.
## The block lanes
A node's shape is in the frozen schema and is turned into a TipTap extension mechanically. What a
node does, its ProseMirror plugins, its node views, its keymap and its input rules, is not derivable
from a shape and does not belong on a generated extension, so each of the five blocks with real
behaviour is one extension in one file, listed once in a registry that the extension list spreads
without knowing what is in it. Tables, syntax highlighting, maths, mermaid and toggles are then five
files that can be worked on at once, each exporting its extension and whatever commands the editor
handle delegates to when a toolbar button is pressed.
The toggle is the one whose behaviour is mostly outside ProseMirror. It is a real `<details>`, and
its summary row is chrome rather than content: the row is declared not editable, the title inside it
is declared editable again, and every keystroke in that island is turned into an ordinary
transaction that writes the `summary` attribute. Native disclosure is cancelled and `open` is
written by a command for the same reason, since both of those are bytes in the file and a browser
flipping an attribute behind the document's back is a title or a state the next save has to guess
at. Cancelling the click is not the whole of it either: a disclosure is a control, so the browser
works one from the keyboard too and sends the row a click for every space typed anywhere inside it,
which was every other space in a title flipping the toggle and writing `open` to disk. The flip asks
for a pointer press, or for the row itself holding the keyboard, and takes nothing else as consent.
Being chrome has a second consequence that reaches further than the node. A caret in the title is
not in the document at all, so ProseMirror's selection stays wherever it last was, and a toolbar
button pressed while a title is being typed runs its command against a paragraph the user is not
looking at. Rather than give the title a selection, which makes Horizontal rule and Insert image
replace or split the toggle, the file refuses: a transaction that changes the document is thrown out
while a title holds the caret, bar the two the title's own surface makes, and the caret is put back
in the title on the frame after TipTap's own focus lands. The toolbar draws itself disabled for the
same condition, read off the page rather than off a transaction, because clicking into a title
dispatches none.
And a toggle only ever sits among the document's own children, because that is the only place the
bridge pairs one. A `<details>` inside a quote or a list item goes to disk as a `<details>` inside
that container and comes back as a single raw block: the bytes survive, and both constructs stop
being editable. So a transaction that puts a toggle below the top level is refused, and the ranges
it is asked about are widened to the whole top level block first, since a wrap rewrites only the two
markers it puts either side and the thing it moved a level down is in the gap between them.
The order in that registry is load bearing rather than alphabetical. ProseMirror resolves a node
view first plugin wins by node name and gives a node view constructor no way to decline, and a
mermaid diagram is not a node of its own but a code block whose language happens to be `mermaid`. So
mermaid owns the code block node view outright and draws an ordinary fence for every other language,
while the highlighter contributes decorations only, which is what it wanted anyway: highlighting is
inherently a decoration and never a transaction that touches the text.
Two rules cut across all five. A command that has nothing to act on where the cursor is returns
false and does nothing, which is what a button pressed in the wrong place should do. And anything
that puts a node in the document asks first whether a node can go there at all: a table cell holds
inline content and is isolating, and ProseMirror asked to insert a block into one will split the
table around it and leave a row with no cells behind, which is a table the writer turns into three
blank lines. A fence and a raw block are the same question from the other side, since both hold
bytes that are the user's and an insert cuts them in half.
That guard is `src/editor/fits.ts`, and it now answers two questions rather than one, because the
first one turned out to be half the problem.
The first is about a node: can this thing go here. `fits` answers for one position and is the rule
itself. `placeable` asks it for a whole selection, which is the question a command actually has: a
selection has two ends, and a rectangle of table cells dragged out has neither of them anywhere a
caret would be, so it is refused outright whatever the type. An inline formula fits a cell perfectly
happily, which is right for a caret and catastrophic for a drag, and that gap is how one insert
emptied six cells of somebody's table. `place` is the guard and the insert in one call, so a command
written through it has no insert in it to forget to guard.
The second is about a command: may this edit happen here at all. Nothing asked that for two
milestones, and a block conversion is where it showed: `setNode` asks whether the new type fits and
never asks what the old block was, so the Heading tool rewrote a raw block's own bytes as escaped
markdown, and the Paragraph item, which TipTap answers by lifting once the block is already a
paragraph, deleted the callout around the caret along with its label. `change` is that half. It
refuses over a raw block outright, and otherwise it builds the transaction without dispatching it,
looks at what it did, and throws it away when a callout or a toggle that was there is gone, or when
a finished line break has landed somewhere the file swallows. Answering on the finished transaction
rather than on the command is the point: there is no list here of which commands lift, so a command
that starts lifting in a later TipTap is refused on the day it does. Its one exemption is the button
that names the wrapper, since the Toggle button pressed inside a toggle is meant to remove it.
`markable` and `breakable` are the same shape for the Link tool and for Shift+Enter.
Four things reach these guards from outside the toolbar. A pasted image asks before the bytes are
sent to be written, so a paste that cannot land leaves nothing in the assets folder, and again after
the write returns, because the caret is the user's during a round trip to disk; the Insert image
tool asks the same question through `canInsertImage`, since it has to write the picture before it
has a path to insert. A plain text paste is not refused anywhere, since text goes everywhere, but
where paragraphs would tear the block open it arrives as text instead, and a drop of blocks where
blocks do not fit is refused outright. And "--- " typed at the start of a line is the one typing
rule that inserts a node beside the caret rather than wrapping or retyping the block the caret is
in, so it is the one typing rule that has to ask; the others are operations ProseMirror declines on
its own.
`src/editor/fits.test.ts` is what holds all of that together, and it enumerates rather than lists.
It asks the running editor for its entry points: every method on the editor handle, off
`createCommands`; every method on the find handle, off `createFind`; every chord, read off every
extension's `addKeyboardShortcuts` the way the extension manager reads them; every ProseMirror prop,
read off every extension's plugins; and every prop the component puts straight on the view, off
`createEditorProps`. That last channel is neither an extension nor a handle and was invisible to the
enumeration until it was lifted out of the component, which is exactly the shape of gap this file
exists for. Each entry declares a family and each hostile context answers for every family, so a new
family forces a column and a new context forces a row, both at compile time. Three checks then apply
to every cell whatever its row says: the number of raw blocks is unchanged, no callout or toggle is
lost by anything not asked to lose one, and no toggle ends up below the top level.
Nothing in that file calls a handler. Every entry point goes through `EditorView.prototype.someProp`,
borrowed off prosemirror-view rather than modelled here, because the walk it does is the question:
the props the component put on the view, then the direct plugins, then the state's plugins in order,
first answer that is not false. Calling a guard by name and asserting what it answers proves nothing
about whether the running program asks it, and four guards in this project's history were shipped
with a green test of exactly that shape. What the unit suite still cannot do is construct a view at
all, because vitest runs in node with no DOM, so the event object and the parsed slice are built by
hand there. `tests/clipboard.spec.ts` is the other half: a real `EditorView` in Chromium, a real
mouse drag across a rectangle of cells, a real Cmd+V off the system clipboard, and assertions only
about what is on screen afterwards. It is the only place in the repository where the whole chain
from a key to a byte is real.
Where a guard sits in the plugin list is part of the guard. ProseMirror offers an event to the
plugins in order and takes the first answer that is not false, and prosemirror-tables installs a
paste handler that claims every paste made while a rectangle of cells is up, replacing the content
of every cell in the rectangle with whatever the slice holds. It sat at index nine and this app's
clipboard plugin sat at fifteen, so for two rounds the app's paste guard was written, documented,
unit tested and asked about nothing. One word on the clipboard replaced six cells; an image, which
carries no HTML for ProseMirror to parse and so arrives as the empty slice, emptied them. The
clipboard extension now declares a priority above every other extension in the tree, which puts it
at the head of the list whatever order the extension file lists things in, and being first is
asserted rather than believed: `src/editor/fits.test.ts` reads the built plugin list, computes the
index of every plugin claiming a paste or a drop, and fails if anything is in front. Being first
also means standing aside deliberately, for the one paste the library does better: cells copied out
of a table and pasted into one, recognised with prosemirror-tables' own predicate rather than a
reimplementation of it. Emptying cells is a real thing to want and it is Backspace.
There is no table op for the header row for a related reason. GFM has exactly one header row, it is
the first one, and there is no spelling for a table without one, so a toggle would offer an edit the
file cannot hold and the next open would silently take back. The same rule is why a new formula is
created holding a placeholder rather than nothing: an empty formula is `$$$$` on disk, which reads
back as four characters of text, so a box the editor draws and the file cannot hold is a box that
disappears on the next save.
Not every change to the document is a change to the file, and the block layer is where that first
became true. Dragging a column edge writes a width on to every cell in the column, which is a real
ProseMirror transaction and marks the buffer dirty, and GFM has nowhere at all to put a column
width. Sniffing for that particular transaction would be fragile, so the question is asked of two
trees instead: a walk comparing type, text, marks, child count and attributes, skipping the handful
of attributes the serializer never reads. It short circuits on identity, which is every subtree a
transaction did not visit, so it costs nothing per keystroke. The allowlist is deliberately short
and each entry has its reason written beside it, because an attribute wrongly called insignificant
is a user edit that never gets saved.
Types are compared by name and marks by name and attributes, never by object, and that is not a
shortcut. The two trees this walk is handed are never built on the same schema: the disk side comes
off the bridge, which parses against the frozen schema, and the live side is bound to the schema
TipTap generates from those same specs and which the editor rebinds every opened document on to.
Two instances over one set of specs means every type object in one differs from its twin in the
other, so `a.type !== b.type` and `Mark.sameSet` were both true of every pair this function had
ever been given, and everything behind them, the attribute allowlist included, was unreachable.
Name is also the right thing to compare rather than a way around that: the serializer dispatches on
`node.type.name` and `mark.type.name` and reads nothing else off a type.
Which two trees is the part that had to be got right twice. Asking whether this keystroke changed
anything since the last one makes dirty a count of transactions rather than a fact about the file:
the answer can only ever go up, so an edit and its undo inside one debounce still wrote, and on a
hand written file the save path's own byte comparison cannot catch it, since that file's serialized
bytes differ from its own bytes from the moment it opens. So `src/document.ts` keeps `diskDoc`
beside `diskText`, the tree whose serialization is the bytes currently on disk, and
`differsFromDisk` asks the question of that rather than of the previous keystroke. The two move
together through one function, so they cannot drift into one guard sending a write and the other
holding it back. Behind that the save path still serializes and compares the result against the
bytes it last saw on disk, which catches an edit that really did happen and really did come out
identical. Between them they are what stops a gesture that moved a line on screen, or a typo typed
and taken back, from putting a whole file diff in somebody's git status.
## Mermaid, and what is not in the main bundle
Mermaid is about 700kB before its per diagram chunks, which are several megabytes between them, and
almost no document has a diagram in it. It is imported dynamically the first time a diagram is
actually drawn, not when one is merely present with the caret inside it, so the library is a chunk
of its own and the main bundle contains none of it. Rendering is asynchronous, so the node view
draws the fence first and swaps the SVG in when it arrives, and it does that without dispatching a
transaction: nothing mermaid does reaches the document, and a diagram that fails to parse stays as
the code the user typed with the parser's complaint beside it. Nothing it needs is fetched over the
network, which matters because the app runs under a strict CSP with no `connect-src` for anything
but the Tauri IPC. KaTeX is the opposite trade and is bundled outright, since a formula is small,
common, and has to render synchronously; its fonts are emitted as files rather than inlined, because
the CSP's `font-src` is `self` and refuses the `data:` URI a small enough font would otherwise
become.
## The dto.rs and ipc.ts contract
Rust and TypeScript meet at exactly one seam: `src-tauri/src/dto.rs`, a set of
`#[serde(rename_all = "camelCase")]` structs, and its hand-written mirror `src/ipc.ts`. Both sides
are built against that shape rather than against each other, which is what lets the filesystem
layer and the markdown bridge be built independently of one another: the tree, the read and write
commands and the index queries only need their DTO agreed, not implemented, before anything above
them is built against a stand-in of the same shape. The file is frozen by convention: a command's
Rust body can change freely, but adding, renaming or retyping a field is a decision made once, in
both files, in the same change.
## The dev IPC mock
`pnpm dev` runs the Vite dev server on its own, with no Tauri process behind it and no
`window.__TAURI_INTERNALS__` for `@tauri-apps/api` to find. `src/ipc.ts` checks for that bridge and,
when it is absent, swaps in a mock implementation of the same command surface backed by an
in-memory fixture, rather than throwing or leaving the screen blank. That is what lets the
Playwright suite in `tests/` (`pnpm test:ui`) drive the real editor, the real store and the real
components in plain Chromium without a compiled Rust binary anywhere in the loop, and it is also
what makes iterating on the editor fast day to day: no Rust recompile between changes. The mock is
exactly as wide as `dto.rs`, never wider, so a command the mock cannot answer is a command that has
not been added to the contract yet, not a gap in the mock to be patched around.
That fixture is also how a test reads what a save actually wrote. `tests/disk.ts` puts the shim and
the fixture behind one import, and `tests/bytes.spec.ts` uses it to type one character into a
running editor and then ask the file what happened. Every data loss bug this project has had lived
between the keystroke and the serializer, where a unit test that builds a document by hand cannot
see it, so that suite is deliberately about bytes and deliberately short: one test per way the app
has been caught rewriting something nobody touched.
The corpus under `src/markdown/corpus/` is globbed by folder rather than by a list of folder names,
so a fixture written for one test is inside every sweep the moment it is added. That is the whole
reason a sweep is worth having, and naming folders meant the twenty hardest files in the corpus sat
outside four of the gates that were supposed to cover them.
## The SQLite index
SQLite through `rusqlite` with the `bundled` feature, so there is no system SQLite dependency, and
with FTS5 compiled in as a side effect of that same feature rather than a separate cargo flag. The
database lives in the app data directory, never inside any folder the user opened, and it is
derived state rather than a source of truth: every row is rebuilt from the markdown files on disk,
so deleting it costs nothing but the time to walk the open roots again. It is kept current by the
same `notify`/`notify-debouncer-full` watcher the tree uses, debounced because a git checkout or
another editor's save fires a burst of filesystem events for what is really one change. The index
answers three things a plain file tree cannot: quick open by filename and path on `Cmd+P`, full text
search across every open root on `Cmd+Shift+F`, and the backlinks section appended to a document,
a reverse lookup of every relative markdown link elsewhere that resolves to the file currently open.
## Order of work
The filesystem layer and the markdown bridge are the two things everything else depends on, and
neither is proven by the other, so the first milestone is Rust: roots, the tree, atomic read and
write, the watcher and trash, built from the start against a frozen `dto.rs` and `ipc.ts`, with the
dev IPC mock standing in for it on the TypeScript side. The second is the bridge itself: parsing,
the raw-node slicing that makes the round trip byte identical, frontmatter separation and
serialization, proven with round-trip tests before a single line of editor UI exists, because a
round-trip bug is obvious in a plain text diff and invisible once it is glued to a rich text view.
The third is the WYSIWYG editor: TipTap wired to the bridge, the sticky bottom toolbar, callouts,
toggles and image paste into `assets/`. The fourth is the SQLite index: the schema, the
watcher-driven indexer, quick open, full text search and backlinks. The fifth is the rest of the
shell: multiple roots open at once, the tree showing every file including the greyed-out ones that
open in the system default app, and settings and the updater.
+102
View File
@@ -0,0 +1,102 @@
# Conventions
This project is a sibling to `../margin` and `../margin-calendar` and follows their conventions
deliberately rather than inventing new ones. When something here is unclear, the answer is almost
always "do what margin does" for the editor, the token layer and the visual language, and "do what
margin-calendar does" for the scaffold underneath: the store, the CSS rules and the shape of the
IPC boundary.
## Rust
`Result<T, String>` everywhere. No `anyhow`, no custom error enums.
DTOs crossing the IPC boundary live in `src-tauri/src/dto.rs` and are marked
`#[serde(rename_all = "camelCase")]`. That file is the contract and is frozen: implementation
modules add bodies, not fields. Its mirror is `src/ipc.ts`.
Every write to a document is atomic: write to a temp file beside it, flush, then rename over the
original. A crash or a full disk must never leave a half-written file sitting where the user's file
used to be. Deletions go through the `trash` crate, never `fs::remove_file`, since these are the
user's own files and this app does not get to be the reason one of them is gone for good.
Comments are rare and explain why, never what.
## TypeScript
One zustand store per domain in `src/store/`. No middleware. One selector call per field
(`useThing((s) => s.field)`, never a destructured object), actions as inline arrow properties, and
`set((s) => ...)` returning `{}` to no-op.
Async actions use a string phase union, never boolean loading flags. Errors stringify with
`String(e)` and surface as a toast.
Side effects that touch disk, the DOM or Tauri live in a sibling module, never inside the store.
Typed IPC wrappers live in `src/api/`, one module per domain, one thin function per command,
mirroring `dto.rs` field for field.
## Markdown
The serializer has one house style, fixed in one place under `src/markdown/` and never varied per
document or per call site: one heading style, one list marker, one rule for when a link gets angle
brackets. A file saved twice with no edits in between produces byte-identical output, and a file
opened and then closed with no edits is never written at all.
No markdown construct is ever dropped on a round trip. Anything the editor cannot model, rather
than being reformatted into the nearest thing it understands or silently discarded, becomes a raw
node: the exact source bytes, preserved and shown as an editable monospace block. Losing a
construct silently is worse than never having supported it, because the loss is invisible until the
file is opened somewhere else and the diff shows a missing paragraph.
The editor never writes a file the user has not edited. Opening a document, looking at it,
switching away from it and closing the app again must leave the file on disk untouched, and that is
worth an actual round-trip test rather than an assumption.
An edit that has no spelling is refused, never approximated. Where a keystroke or a paste would
produce bytes the reader hands back as a different document, the answer is to decline it and say so
in a toast, or to write the block from the source the file gave it and leave the edit off that save.
Both cost the user one gesture. Reflowing their text into the nearest thing that does have a
spelling costs them the file, and they find out somewhere else.
## Tests
A guard is proved by running the program, not by calling the guard. Four rounds of review found a
guard that answered correctly and was never reached, which is a guard that ships green and does
nothing, so anything that claims to refuse a gesture is asserted through the gesture: the real key,
the real paste, the real drag, in `tests/`. A unit test that builds the document by hand cannot see
what happens between the keystroke and the serializer, and that is where every data loss bug in this
project has been.
A sweep that appends its own marker to the corpus asserts that no corpus file already contains it.
The word is otherwise a collision waiting for somebody to add a fixture, and a collided sweep does
not fail loudly, it quietly stops sweeping.
Never weaken, skip or delete a test to reach green. A test pinning behaviour that has since changed
is rewritten to the new truth with the reason written next to it.
## CSS
Flat kebab-case class names, not BEM. State is a `data-*` attribute, never an `is-` class.
Every colour, radius and size goes through a token in `src/styles/tokens.css`. If a value is not in
there, add a token rather than a literal.
Dark mode is `data-theme` on `<html>`, with both palettes defining an identical variable set. Never
a media query for theme.
Transitions name explicit properties and use `var(--ease)`. Never `transition: all`.
## Icons
Inline Feather-style 24x24 stroke `d` strings passed to `<Icon d={...} />`. There is no icon set
and no registry, and there will not be one.
## Storage keys
Anything in `localStorage` is prefixed `margindocs-`, following margin and margin-calendar's own
prefixes.
## Never
No CSS framework, no component library, no router, no zustand middleware, no directory trees in any
document, and no em dashes or en dashes anywhere, including code comments.
+66
View File
@@ -0,0 +1,66 @@
# Design
Margin Docs is a local first WYSIWYG editor for the plain markdown files already on your disk,
macOS only. It exists because the documents that actually matter, notes, specs, journals, project
READMEs, already live as files in folders you control, readable by every other tool you own and
tracked by git if you want that, while the editors that are pleasant to write prose in tend to
belong to a browser tab and own the content themselves: a database, a proprietary block format, a
folder of the app's own metadata sitting next to your text. Margin Docs starts from the file and
refuses to add a second copy of the content anywhere. The SQLite index it keeps is deliberately
disposable (see [architecture.md](architecture.md)), because the moment an index is required for a
file to mean anything, the file has quietly stopped being the truth.
## The file contract
Nothing is written into a user's folders except the markdown file itself and the images pasted into
the `assets/` folder beside it. Opening a file never writes it: reading it into the editor, looking
at it, and closing it again leaves the bytes on disk exactly as they were. This is a promise to the
user before it is an implementation detail, which is why [conventions.md](conventions.md) turns it
into a rule with a test behind it rather than an intention.
## Why there is no slash menu and no drag handles
Full WYSIWYG means markdown syntax is never visible, but it does not mean the editor secretly
models the document as a stack of blocks you assemble one command at a time. A slash menu and a
drag handle both come from that block-based idea of a document, and a markdown file is not one: it
is prose with headings, lists and the occasional table, written top to bottom the way you would
type it into any text editor. Reaching for a menu to insert a callout is slower than typing the
paragraph and toggling it from the toolbar, and a drag handle implies blocks are things you
rearrange as objects, which is a different mental model from writing, and the wrong one for a tool
whose whole pitch is that the file underneath stays exactly as legible as it always was.
## Why the toolbar is a permanent pill at the bottom
A toolbar that appears only on selection hides its own existence until you have already selected
something, which is backwards for someone who does not yet know a feature is there. A toolbar
fixed to the top competes with the title and the frontmatter for the same strip of attention a
document opens with. The pill at the bottom is a stable landmark instead: always in the same place,
never jumping to follow the selection, out of the way of what you are reading, and close to where a
trackpad or a thumb already is.
## Why the filename and the H1 are unrelated
A markdown file's identity outside this app is its path. Git tracks it by path, every other editor
opens it by path, and a relative link from another document points at that path, not at whatever
the first heading happens to say today. Treating the H1 as the filename, the way some note apps do,
means every edit to a title is secretly a rename, and a rename nothing else agreed to breaks every
link that pointed at the old path and confuses git into showing a delete and an add instead of an
edit. Margin Docs keeps the two separate and never renames a file behind the user's back: the H1 is
content, the filename is identity, and conflating them only looks harmless until real folders and
real links are involved.
## Why one document at a time
One editor instance, one parsed frontmatter, one set of raw nodes, one dirty flag. A tab bar would
let a document sit half-edited in the background, invisible, while attention moved elsewhere, which
is exactly the kind of silent state the file contract above is trying to rule out. Multiple roots
open at once is about how much of the disk you can see; one document open at a time is about how
much of it you are allowed to be quietly changing.
## Visual language
Lifted from margin unchanged, the same way margin-calendar's is: warm paper surfaces, ink and two
softer ink tones, hairline borders, a four-step type scale, three radii, one easing curve, light and
dark driven by `data-theme` on the root. Margin Docs adds nothing to that layer; it is a sibling
application, not a new visual identity, and the token file is the proof of that rather than a
description of it.
+56
View File
@@ -0,0 +1,56 @@
# Releasing
## Installing locally
`just install` builds the app for whatever machine you are sitting at and installs it where that
machine expects to find applications: `/Applications` (or `~/Applications` when `/Applications`
is not writable) on macOS, the package manager or an AppImage plus a desktop entry on Linux. It is
the same command whether or not the app is already installed, so it doubles as the update. On
macOS it asks a running copy to quit first, because replacing a bundle under a live process leaves
it half old and half new. `just uninstall` reverses it and leaves the data directory alone.
The local build skips the dmg and builds only the `.app` on macOS, or the `.deb` and the AppImage
on Linux, since nothing about copying a bundle into place needs a disk image and building one is
the slowest part of a mac bundle. That makes a locally installed app slightly different from a
released one: it is ad hoc signed and carries no updater artifacts, so it will not update itself.
Rerun `just install`.
## Cutting a release
Releases are manual: run the Release workflow from the Actions tab. Leave the version empty to
bump the patch number, or give one to set it. The workflow bumps `tauri.conf.json`, `package.json`
and `src-tauri/Cargo.toml` together, commits that to main, tags it, and builds the tag rather than
whatever main happens to be by then.
It builds a universal macOS bundle and an x86_64 Linux one, and publishes nothing until both have
landed. The last job downloads `latest.json` and refuses to take the release out of draft unless
`darwin-aarch64`, `darwin-x86_64` and `linux-x86_64` are all present in it. A half-populated
manifest is worse than no release at all: the updater would offer an update to the platforms that
made it and error on the ones that did not.
Linux builds on Ubuntu 22.04 on purpose. The bundle will not run on anything older than the glibc
it was linked against, and 22.04 is the oldest baseline worth supporting.
Windows is not built. Nothing in `tauri.conf.json` targets it and the app has never claimed it.
## What the build needs
Two repository secrets: `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,
which sign the updater artifacts so an installed copy can tell a real update from anything else
offered at the same URL. The public half lives in `src-tauri/tauri.release.conf.json`, baked into
every build, so the private half can never be rotated without stranding everyone who has not
updated yet; back up the key and its password somewhere that is not the machine that generated
them. That file currently carries the placeholder `REPLACE_WITH_TAURI_SIGNER_PUBKEY`; generating
the real keypair and committing its public half in is a one-time step that has to happen before the
first signed release can go out.
Beyond the signing key there is nothing to provision. Margin Docs talks to no external API and
holds no OAuth client, unlike margin-calendar, so there are no other repository secrets and nothing
equivalent to a `google-credentials.json` to embed at build time.
## Updates
Installed copies check
`https://github.com/priyanshujain/margin-docs/releases/latest/download/latest.json` and update
themselves from it. `--latest` on the publish step is what moves that pointer, so a release that
fails the manifest check stays a draft and no one is offered a broken update.
+29
View File
@@ -0,0 +1,29 @@
# Setup
## Building
```
pnpm tauri dev
```
or `just dev`, which is the same command. `just test` is the gate: `pnpm test` for the frontend
suites and `cargo test` inside `src-tauri` for the Rust ones, both green before anything is
considered done. `just test-ui` runs the Playwright suite in `tests/` against the real UI in
Chromium, using the dev IPC mock described in [architecture.md](architecture.md) rather than a
built Tauri binary.
## Where the data lives
`~/Library/Application Support/studio.margin.docs/` on macOS. It holds nothing but the SQLite
index described in [architecture.md](architecture.md): the tables behind quick open, full text
search and the backlinks section it powers. It never holds a document or a copy of one, so deleting
the directory costs nothing except the time the app takes to walk your open folders and rebuild the
index the next time it starts. Quick open and search go blank until that finishes; nothing else
notices.
## No credentials to provision
Unlike margin-calendar, there is no OAuth client to create and no `google-credentials.json` to drop
in the repo root before the app does anything useful. Margin Docs talks to no external service; the
only account it needs is the one already logged into the machine it is running on, to read and
write the folders you point it at. A fresh clone builds and runs immediately.
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<script>
(function () {
try {
var root = document.documentElement;
var t = localStorage.getItem("margindocs-theme");
if (t !== "light" && t !== "dark")
t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
root.setAttribute("data-theme", t);
var s = localStorage.getItem("margindocs-sidebar");
root.setAttribute("data-sidebar", s === "false" ? "false" : "true");
// The three names are the editor-width command ids in src/keys/commands.ts, so a width
// the palette can set is a width this script can restore. What each name is worth lives
// in sheet.css and only there: this sets the attribute those rules key off, never a
// length of its own.
var w = localStorage.getItem("margindocs-width");
if (w === "narrow" || w === "normal" || w === "wide") root.setAttribute("data-width", w);
} catch (e) {}
})();
</script>
<title>Margin Docs</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+132
View File
@@ -0,0 +1,132 @@
# Building and installing Margin Docs on the machine you are sitting at. The release pipeline in
# .github/workflows/release.yml is what builds for everyone else; this is the local equivalent, and
# `just install` is deliberately the same command whether or not the app is already installed.
set shell := ["bash", "-euo", "pipefail", "-c"]
app := "Margin Docs"
bundle := "src-tauri/target/release/bundle"
# List the recipes.
default:
@just --list
# Run the app against the Vite dev server.
dev:
pnpm tauri dev
# The gate: the frontend suites and the Rust suites, both of which must be green.
test:
pnpm test
cd src-tauri && cargo test
# The browser suite that drives the real UI.
test-ui:
pnpm test:ui
# Build the release bundle for this machine.
build:
#!/usr/bin/env bash
set -euo pipefail
# The .app on macOS, the .deb and AppImage on Linux. No dmg: nothing here needs a disk image
# to copy a bundle into place, and building one is the slowest part of a mac bundle.
pnpm install
case "$(uname -s)" in
Darwin) pnpm tauri build --bundles app ;;
Linux) pnpm tauri build --bundles deb,appimage ;;
*) echo "just: no local build for $(uname -s); macOS and Linux are the desktop targets." >&2; exit 1 ;;
esac
# Build and install, replacing whatever version is already installed.
install: build
#!/usr/bin/env bash
set -euo pipefail
case "$(uname -s)" in
Darwin) just _install-macos ;;
Linux) just _install-linux ;;
*) echo "just: no installer for $(uname -s); macOS and Linux are the desktop targets." >&2; exit 1 ;;
esac
_install-macos:
#!/usr/bin/env bash
set -euo pipefail
src="{{bundle}}/macos/{{app}}.app"
[ -d "$src" ] || { echo "just: nothing to install, $src does not exist." >&2; exit 1; }
# /Applications is writable by admin users, so the common case needs no sudo. When it is not,
# ~/Applications is a real Launchpad location and beats prompting for a password mid-build.
if [ -w /Applications ]; then dir=/Applications; else dir="$HOME/Applications"; mkdir -p "$dir"; fi
dest="$dir/{{app}}.app"
# Which of the two names the executable carries depends on how the bundle was configured, so
# ask about both rather than assuming, and replacing a bundle out from under a live process.
running() { pgrep -x "{{app}}" > /dev/null || pgrep -x margin-docs > /dev/null; }
# Replacing the bundle under a running app leaves it half old and half new, and the running
# copy holds the deleted files open. Ask it to quit and wait, rather than killing it.
if running; then
echo "Quitting the running {{app}}…"
osascript -e 'quit app "{{app}}"' 2> /dev/null || true
for _ in $(seq 40); do running || break; sleep 0.25; done
if running; then echo "just: {{app}} is still running; quit it and try again." >&2; exit 1; fi
fi
rm -rf "$dest"
cp -R "$src" "$dest"
version=$(defaults read "$dest/Contents/Info.plist" CFBundleShortVersionString 2> /dev/null || echo "?")
echo "Installed $version to $dest"
_install-linux:
#!/usr/bin/env bash
set -euo pipefail
deb=$(ls -t {{bundle}}/deb/*.deb 2> /dev/null | head -1 || true)
appimage=$(ls -t {{bundle}}/appimage/*.AppImage 2> /dev/null | head -1 || true)
# apt over dpkg where it exists: it pulls in the webkit and gtk runtime the package depends on,
# and --reinstall makes installing over the same version an update rather than a no-op.
if [ -n "$deb" ] && command -v apt-get > /dev/null; then
sudo apt-get install -y --reinstall "$PWD/$deb"
echo "Installed $deb"
elif [ -n "$deb" ] && command -v dpkg > /dev/null; then
sudo dpkg -i "$deb"
echo "Installed $deb"
elif [ -n "$appimage" ]; then
install -Dm755 "$appimage" "$HOME/.local/bin/margin-docs"
install -Dm644 src-tauri/icons/128x128.png "$HOME/.local/share/icons/margin-docs.png"
mkdir -p "$HOME/.local/share/applications"
printf '%s\n' \
'[Desktop Entry]' \
'Type=Application' \
'Name=Margin Docs' \
'Comment=A folder of markdown documents' \
'Exec=margin-docs' \
'Icon=margin-docs' \
'Categories=Office;' \
> "$HOME/.local/share/applications/margin-docs.desktop"
echo "Installed $appimage to ~/.local/bin/margin-docs"
echo "Make sure ~/.local/bin is on your PATH."
else
echo "just: nothing to install, no .deb or AppImage under {{bundle}}." >&2
exit 1
fi
# Remove the installed app, leaving its data directory alone.
uninstall:
#!/usr/bin/env bash
set -euo pipefail
# What the data directory is and what deleting it costs you is in docs/setup.md.
case "$(uname -s)" in
Darwin)
for dest in "/Applications/{{app}}.app" "$HOME/Applications/{{app}}.app"; do
if [ -d "$dest" ]; then rm -rf "$dest"; echo "Removed $dest"; fi
done
;;
Linux)
if dpkg -s margin-docs > /dev/null 2>&1; then sudo apt-get remove -y margin-docs; fi
rm -f "$HOME/.local/bin/margin-docs" \
"$HOME/.local/share/icons/margin-docs.png" \
"$HOME/.local/share/applications/margin-docs.desktop"
echo "Removed the AppImage install, if there was one."
;;
*) echo "just: nothing to uninstall on $(uname -s)." >&2; exit 1 ;;
esac
+55
View File
@@ -0,0 +1,55 @@
{
"name": "margin-docs",
"private": true,
"version": "0.0.1",
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "playwright test"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-updater": "^2",
"@tiptap/core": "3.30.2",
"@tiptap/extension-placeholder": "3.30.2",
"@tiptap/pm": "3.30.2",
"@tiptap/react": "3.30.2",
"@tiptap/starter-kit": "3.30.2",
"@types/mdast": "^4",
"highlight.js": "^11.12.0",
"katex": "^0.18.4",
"lowlight": "^3.3.0",
"mdast-util-to-markdown": "^2",
"mermaid": "^11.17.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"remark-frontmatter": "^5",
"remark-gfm": "^4.0.1",
"remark-math": "^6",
"remark-parse": "^11",
"remark-stringify": "^11",
"unified": "^11",
"unist-util-visit": "^5",
"zustand": "^5.0.14"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@tauri-apps/cli": "^2",
"@types/katex": "^0.16.8",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"vitest": "^3.2.4"
}
}
+45
View File
@@ -0,0 +1,45 @@
// The end-to-end suite drives the real UI in a browser. One command starts everything:
// `pnpm test:ui` brings up Vite itself and reuses a dev server that is already running.
import { defineConfig } from "@playwright/test";
const PORT = 1440;
const BASE_URL = `http://localhost:${PORT}`;
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
// No retries on purpose. A test that only passes on the second go is a test that is lying about
// something.
retries: 0,
reporter: [["list"]],
// Traces and failure screenshots go somewhere already ignored, so a run cannot leave anything
// behind for the next `git add` to pick up. The paths are printed with the failure that made
// them.
outputDir: "node_modules/.cache/playwright",
timeout: 30_000,
expect: { timeout: 5_000 },
use: {
baseURL: BASE_URL,
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
locale: "en-GB",
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
// Deliberately not `devices["Desktop Chrome"]`: that device pins a Windows user agent, and the
// keymap reads the platform off the user agent to decide whether the primary modifier is Command
// or Control. A faked platform would test the wrong half of every shortcut.
projects: [{ name: "chromium", use: { browserName: "chromium" } }],
webServer: {
command: "pnpm dev",
url: BASE_URL,
reuseExistingServer: true,
timeout: 60_000,
stdout: "ignore",
stderr: "pipe",
},
});
+3826
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
# tests/no_write_on_open.rs runs against one real git repository on disk and several of its tests
# mutate it, so they have to run one at a time. The file has always said so in a comment; this is
# what makes plain `cargo test` obey it, rather than leaving the suite green only for whoever
# remembers to pass `-- --test-threads=1`.
#
# The other suites use a TempDir each and do not care. Serializing them costs well under a second.
[env]
RUST_TEST_THREADS = "1"
+5882
View File
File diff suppressed because it is too large. Load diff
+52
View File
@@ -0,0 +1,52 @@
[package]
name = "margin-docs"
version = "0.0.1"
description = "A folder of markdown documents"
authors = ["Margin"]
edition = "2021"
license = "MIT"
[lib]
name = "margin_docs_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Gitignore-aware directory walking for the folder tree: a document folder under version control
# should not surface .git or its own ignored build output as if they were documents.
ignore = "0.4"
notify = "8"
notify-debouncer-full = "0.7"
# Deleting a document goes to the OS trash rather than unlinking it outright.
trash = "5"
# rusqlite 0.40 has no separate "fts5" cargo feature to ask for: libsqlite3-sys's bundled build
# compiles SQLITE_ENABLE_FTS5 in unconditionally, so "bundled" alone is what gets full text search.
# margin-calendar does not carry this comment because it has no need for full text search.
rusqlite = { version = "0.40", features = ["bundled"] }
tokio = { version = "1", features = ["sync", "time"] }
# Spelling is the system's, not ours. NSSpellChecker is the same checker every other Mac app
# corrects into, so a word learned in Mail is not underlined here, and it carries the user's own
# languages without this app shipping a dictionary. These objc2 crates are already in the graph
# through Tauri, so asking for them adds nothing to the build but the features named.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSSpellChecker"] }
objc2-foundation = { version = "0.3", features = ["NSString", "NSArray", "NSRange", "NSTextCheckingResult"] }
# There is no auto-updater and no process to restart on a phone: the store is the update channel.
# Gated here as well as behind cfg(desktop) in lib.rs so a mobile build does not compile them at all.
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-process = "2"
tauri-plugin-updater = "2"
[dev-dependencies]
tempfile = "3"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window, on every platform",
"windows": ["main"],
"permissions": ["core:default", "opener:default", "dialog:default"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop",
"description": "Window control, the updater and process restart, none of which a phone has",
"platforms": ["macOS", "windows", "linux"],
"windows": ["main"],
"permissions": [
"core:window:allow-destroy",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"updater:default",
"process:allow-restart"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg width="350" height="350" viewBox="0 0 350 350" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Margin Docs mark: a page with a margin rule. Glyph only, matching margin's logo-dark.svg conventions.
App icon composition on a 512 canvas: squircle rect x=49 y=49 w=414 h=414 rx=92.5 fill #0d0c0a,
then this glyph under transform="translate(56,48) scale(1.142857142857)".
That scale puts every stroke centreline on a pixel centre at 32x32, and lifts the glyph 8px
above the geometric centre for optical balance. -->
<rect x="70" y="35" width="210" height="280" rx="14" stroke="#fcfbf7" stroke-width="10"/>
<rect x="93" y="72" width="10" height="206" rx="5" fill="#fcfbf7"/>
<rect x="135" y="100" width="122" height="10" rx="5" fill="#fcfbf7"/>
<rect x="135" y="170" width="122" height="10" rx="5" fill="#fcfbf7"/>
<rect x="135" y="240" width="80" height="10" rx="5" fill="#fcfbf7"/>
</svg>

After

Width:  |  Height:  |  Size: 915 B

+185
View File
@@ -0,0 +1,185 @@
// The IPC contract. Every type here has a matching declaration in src/ipc.ts. Both sides are
// frozen once written: implementation modules add bodies, not fields.
//
// Types only. No `#[tauri::command]` lives here: the commands sit in the modules that implement
// them and are registered in lib.rs.
use serde::{Deserialize, Serialize};
/// One open folder. `id` is derived from the path, so it survives a relaunch and a root can be
/// addressed without the frontend carrying the path around.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RootInfo {
pub id: String,
pub path: String,
/// The folder's own name, which is what the sidebar heading shows.
pub name: String,
pub opened_ms: i64,
}
/// 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.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileNode {
pub path: String,
pub name: String,
/// dir | markdown | text | other
pub kind: String,
/// 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`.
pub editable: bool,
pub modified_ms: i64,
#[serde(default)]
pub children: Vec<FileNode>,
}
/// `modified_ms` is the timestamp the text was read at. The frontend keeps it and hands it back
/// on write, which is the only way it can tell its buffer apart from a file something else has
/// touched since. Frontmatter is not split out here: the editor parses it, hides it and writes it
/// back, so the backend only ever sees a whole document.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadResult {
pub path: String,
pub text: String,
pub modified_ms: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WriteResult {
pub path: String,
pub modified_ms: i64,
/// 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.
pub conflict: bool,
}
/// Where a pasted image landed. `rel_path` is what goes into the markdown link, relative to the
/// document that received the paste; `path` is absolute, which is what the tree needs.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssetResult {
pub path: String,
pub rel_path: String,
}
/// Payload of the `watch-event` event. `root` is a `RootInfo` id.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WatchEvent {
pub root: String,
pub path: String,
/// created | modified | removed | renamed
pub kind: String,
/// Where the file was before a rename, absent on every other kind.
#[serde(default)]
pub old_path: Option<String>,
}
/// Progress of the SQLite index, which lives in the app data directory and never in a user
/// folder. Also the payload of the `index-progress` event.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IndexStatus {
/// idle | indexing | error
pub phase: String,
pub indexed: u32,
pub total: u32,
/// Epoch milliseconds of the last completed pass.
pub last_indexed: Option<i64>,
pub error: Option<String>,
pub message: Option<String>,
}
impl Default for IndexStatus {
fn default() -> Self {
IndexStatus {
phase: "idle".to_string(),
indexed: 0,
total: 0,
last_indexed: None,
error: None,
message: None,
}
}
}
/// Half-open offsets into whichever string the hit says they belong to, for highlighting.
///
/// The unit is a UTF-16 code unit, which is what a JavaScript string is indexed in and what the
/// `slice` that draws the highlight counts. Not bytes, and deliberately not code points either:
/// index.rs works in code points throughout and converts once at the boundary, in `to_utf16`,
/// because the two agree on everything in the BMP and disagree by one per emoji, which is exactly
/// the kind of difference that is invisible until somebody puts one in a filename.
///
/// `SpellIssue` counts differently on purpose. Its offsets address a ProseMirror document, and
/// ProseMirror counts code points.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MatchRange {
pub start: u32,
pub end: u32,
}
/// One quick-open result. `ranges` index into `rel_path`, which is also what the row shows, so a
/// match on a folder name can be highlighted where it actually was.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QuickOpenHit {
pub path: String,
pub name: String,
pub root: String,
pub rel_path: String,
pub score: i32,
#[serde(default)]
pub ranges: Vec<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`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchHit {
pub path: String,
pub root: String,
pub title: String,
pub line: u32,
pub snippet: String,
#[serde(default)]
pub ranges: Vec<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.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Backlink {
pub path: String,
pub title: String,
pub snippet: String,
}
/// One misspelling in a run of text handed to the checker.
///
/// `start` and `end` are half-open offsets in *characters*, not bytes and not UTF-16 units,
/// because the other end is JavaScript addressing a ProseMirror document and ProseMirror counts
/// in code points. macspell.rs does the conversion from the UTF-16 ranges AppKit answers in, and
/// it is the only place in the app where that conversion is allowed to happen.
///
/// A word with no guesses is still an issue: NSSpellChecker regularly flags a typo it has no
/// suggestion for, and dropping it because the menu would be empty is how a checker earns a
/// reputation for missing things.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpellIssue {
pub start: usize,
pub end: usize,
pub word: String,
#[serde(default)]
pub suggestions: Vec<String>,
}
+975
View File
@@ -0,0 +1,975 @@
// Rust owns the filesystem and nothing above it. Every byte that reaches or leaves the disk goes
// through this module: opening a folder, walking it, reading a document, writing one back, sending
// a file to the Trash, dropping a pasted image beside the document that received it, and the
// SQLite index that answers the three questions a plain tree cannot. Markdown is never parsed
// here; that is the bridge's job in TypeScript.
//
// Two promises constrain nearly every function below, and both are the product's rather than the
// implementation's. Opening a folder or a file never writes anything, so nothing here may leave a
// dotfile, a lock, a cache or a sidecar inside a folder the user opened. And every write is
// atomic: a temp file beside the target, flushed, then renamed over it, so a crash or a full disk
// can never leave a half written document where the user's document used to be.
use std::cmp::Ordering;
use std::collections::HashMap;
use std::ffi::OsString;
use std::fs;
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering as Memory};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use ignore::WalkBuilder;
use tauri::{AppHandle, State};
use tauri_plugin_opener::OpenerExt;
use crate::dto::{
AssetResult, Backlink, FileNode, IndexStatus, QuickOpenHit, ReadResult, RootInfo, SearchHit,
WriteResult,
};
use crate::Roots;
/// Skipped whatever the folder's own gitignore says, because not one of the four is ever a
/// document and a documents folder that happens to be a checkout should not open with its build
/// output filling the sidebar.
///
/// The index walks by the same rule, so the sidebar and the search box agree about what a folder
/// holds.
pub(crate) const ALWAYS_SKIPPED: [&str; 4] = [".git", "node_modules", "target", "dist"];
/// These two mirror `src/model/doc.ts` and have to keep agreeing with it: the frontend decides
/// from the extension whether a row opens in the editor, and `FileNode.editable` is that same
/// decision made here.
const MARKDOWN_EXTENSIONS: [&str; 5] = ["md", "markdown", "mdown", "mkd", "mkdn"];
const TEXT_EXTENSIONS: [&str; 2] = ["txt", "text"];
/// Where the open folders are remembered between launches, inside the app data directory and never
/// inside a folder the user opened.
const ROOTS_FILE: &str = "roots.json";
/// What a pasted image is called when the clipboard suggests nothing usable.
const FALLBACK_ASSET_NAME: &str = "image.png";
// Path validation. Every path below arrives as a string from the frontend, and the frontend is a
// webview: a bug in a link resolver, a crafted document, a drag from somewhere unexpected or a
// stale path belonging to a folder that has since been closed can all put an arbitrary string
// here. This is the one place in the app where being wrong damages files the user never opened, so
// the rule is deliberately blunt and every command that takes a path goes through it, reads as
// well as writes.
//
// A path is accepted only when it holds no `..` component at all and, once symlinks have been
// resolved, sits inside a folder that is currently open. Canonicalising first is what makes the
// second half mean anything: without it both `~/notes/../../.ssh/id_rsa` and a symlink pointing at
// /etc read as being inside the root. A path that does not exist yet is resolved against its
// deepest existing ancestor and the remaining components are appended, so creating a file is
// checked exactly as strictly as writing one. With no folder open nothing is inside a root, so
// every path is rejected, which is the right default rather than an inconvenience.
fn path_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
fn ms_since_epoch(time: SystemTime) -> i64 {
time.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
fn now_ms() -> i64 {
ms_since_epoch(SystemTime::now())
}
pub(crate) fn modified_ms(meta: &fs::Metadata) -> i64 {
meta.modified().map(ms_since_epoch).unwrap_or(0)
}
/// True for a broken symlink too, which `Path::exists` is not. A name pointing at nothing is still
/// a name that cannot be created.
fn taken(path: &Path) -> bool {
fs::symlink_metadata(path).is_ok()
}
pub(crate) fn kind_for(path: &Path, is_dir: bool) -> &'static str {
if is_dir {
return "dir";
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_lowercase())
.unwrap_or_default();
match name.rfind('.') {
Some(dot) if dot > 0 => {
let ext = &name[dot + 1..];
if MARKDOWN_EXTENSIONS.contains(&ext) {
"markdown"
} else if TEXT_EXTENSIONS.contains(&ext) {
"text"
} else {
"other"
}
}
_ => "other",
}
}
fn node_from(path: &Path, is_dir: bool, modified: i64) -> FileNode {
let kind = kind_for(path, is_dir);
FileNode {
path: path_string(path),
name: path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path_string(path)),
kind: kind.to_string(),
editable: kind == "markdown" || kind == "text",
modified_ms: modified,
children: Vec::new(),
}
}
fn node_for(path: &Path) -> Result<FileNode, String> {
let meta = fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(node_from(path, meta.is_dir(), modified_ms(&meta)))
}
/// A base name and not a path. `file_rename` cannot move anything, so a name carrying a separator
/// is refused rather than quietly turned into a move.
fn check_name(name: &str) -> Result<&str, String> {
let trimmed = name.trim();
if trimmed.is_empty() || trimmed == "." || trimmed == ".." {
return Err(format!("not a usable name: {name}"));
}
if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains('\0') {
return Err(format!("a name cannot contain a path separator: {name}"));
}
Ok(trimmed)
}
fn resolve(path: &Path) -> Result<PathBuf, String> {
if !path.is_absolute() {
return Err(format!("path is not absolute: {}", path.display()));
}
if path.components().any(|c| matches!(c, Component::ParentDir)) {
return Err(format!("path contains a parent traversal: {}", path.display()));
}
let mut tail: Vec<OsString> = Vec::new();
let mut cursor = path.to_path_buf();
loop {
if let Ok(base) = fs::canonicalize(&cursor) {
let mut out = base;
for part in tail.iter().rev() {
out.push(part);
}
return Ok(out);
}
let name = cursor
.file_name()
.ok_or_else(|| format!("cannot resolve path: {}", path.display()))?
.to_os_string();
tail.push(name);
cursor = cursor
.parent()
.ok_or_else(|| format!("cannot resolve path: {}", path.display()))?
.to_path_buf();
}
}
/// The gate described above. `root_paths` are the folders the user has actually opened.
pub fn resolve_in_roots(root_paths: &[String], raw: &str) -> Result<PathBuf, String> {
let resolved = resolve(Path::new(raw))?;
for root in root_paths {
let base = match fs::canonicalize(root) {
Ok(base) => base,
Err(_) => continue,
};
// Component wise, so /notes-old is not read as being inside /notes.
if resolved.starts_with(&base) {
return Ok(resolved);
}
}
Err(format!("path is outside every open folder: {raw}"))
}
/// The lock is taken and dropped before any filesystem call, so a slow disk never blocks a command
/// that only wants to know which folders are open.
fn open_root_paths(roots: &State<'_, Roots>) -> Result<Vec<String>, String> {
let open = roots.0.lock().map_err(|e| e.to_string())?;
Ok(open.iter().map(|root| root.path.clone()).collect())
}
fn checked(roots: &State<'_, Roots>, raw: &str) -> Result<PathBuf, String> {
resolve_in_roots(&open_root_paths(roots)?, raw)
}
/// One lock per document being written, so two saves of one file cannot interleave.
///
/// Keyed by the resolved path, because `/tmp/notes/a.md` and `/private/tmp/notes/a.md` are one
/// document and two keys would be two locks and no mutual exclusion at all. An entry lives only
/// while somebody holds it: every caller drops the locks nobody is using on the way in, so the map
/// is the size of the writes in flight rather than of every document ever saved.
static WRITE_LOCKS: LazyLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Separates one temp name from the next inside this process, as the pid and the clock separate
/// this process from any other.
static WRITE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
/// Enough tries that a name collision has to be deliberate rather than unlucky.
const TEMP_NAME_TRIES: u32 = 64;
fn write_lock_for(path: &Path) -> Arc<Mutex<()>> {
let key = resolve(path).unwrap_or_else(|_| path.to_path_buf());
let mut locks = match WRITE_LOCKS.lock() {
Ok(locks) => locks,
// The guarded value is `()`, so a writer that panicked left nothing half-built behind.
Err(poisoned) => poisoned.into_inner(),
};
locks.retain(|_, held| Arc::strong_count(held) > 1);
locks.entry(key).or_default().clone()
}
/// A name for the temp file that no other write is using and no user is plausibly holding.
///
/// Beside the target, because a rename is only atomic within one filesystem. Hidden, so it is not
/// mistaken for a document by the tree, by the watcher or by the person looking at the folder.
/// Unique per call, because a name derived from the target alone is a name two concurrent saves
/// both own and neither can safely delete. `.tmp` last so the watcher's transient rule catches it
/// whatever the document happens to be called.
fn temp_path(path: &Path) -> Result<PathBuf, String> {
let dir = path
.parent()
.ok_or_else(|| format!("cannot write {}: no folder to write in", path.display()))?;
let name = path
.file_name()
.ok_or_else(|| format!("cannot write {}: no name to write to", path.display()))?
.to_string_lossy()
.into_owned();
let pid = std::process::id();
for _ in 0..TEMP_NAME_TRIES {
let n = WRITE_SEQUENCE.fetch_add(1, Memory::Relaxed);
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let candidate = dir.join(format!(".{name}.{pid}-{n}-{stamp:x}.tmp"));
// A name already on disk is somebody else's, and this call is the only thing allowed to
// delete the name it picks.
if !taken(&candidate) {
return Ok(candidate);
}
}
Err(format!("cannot find a free temp name beside {}", path.display()))
}
fn fill_temp(path: &Path, tmp: &Path, bytes: &[u8], existed: bool) -> Result<(), String> {
let mut options = fs::OpenOptions::new();
options.write(true);
if existed {
fs::copy(path, tmp).map_err(|e| e.to_string())?;
options.truncate(true);
} else {
options.create_new(true);
}
let mut file = options.open(tmp).map_err(|e| e.to_string())?;
file.write_all(bytes).map_err(|e| e.to_string())?;
file.sync_all().map_err(|e| e.to_string())
}
/// Writes `bytes` to `path` through a temp file beside it and a rename, which is atomic within a
/// filesystem. At no instant does the target hold half a document: it holds every old byte or
/// every new one, whatever happens in between, and that is what makes an autosaving editor safe
/// against a crash or a full disk mid-write. A rename that fails has not happened, so the original
/// is still whole and still where it was.
///
/// The temp file starts as a copy of the original rather than as an empty file. On macOS
/// `fs::copy` carries permissions, ACLs and extended attributes across, and since the file the
/// user is left with is the temp file, that copy is the only thing stopping a save from quietly
/// dropping a Finder tag or the executable bit.
///
/// There is no `.bak` rotation, deliberately. This is the user's own markdown in the user's own
/// folder, very often under version control, and the app is already holding the whole source
/// string in memory and refusing to write when the mtime on disk has moved. A backup sibling buys
/// none of that back, and a backup named after the target is a file the user may own themselves,
/// which the rotation would unlink without asking and without the Trash. Nothing is deleted here
/// but the temp file this call created.
///
/// Writes to one path are serialized. Two saves of one document, which is all a debounced autosave
/// and a Cmd+S landing together are, would otherwise race between two renames and leave the
/// document at neither name.
pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
let lock = write_lock_for(path);
let _held = match lock.lock() {
Ok(held) => held,
Err(poisoned) => poisoned.into_inner(),
};
write_through_temp(path, bytes)
}
fn write_through_temp(path: &Path, bytes: &[u8]) -> Result<(), String> {
let tmp = temp_path(path)?;
let existed = taken(path);
// Both ends of the rename, before either is touched: the watcher sees the temp file appear and
// the document change as two unrelated events, and either one getting through is the app's own
// save coming back to the frontend as somebody else's edit.
crate::watch::note_self_write(&tmp);
crate::watch::note_self_write(path);
if let Err(e) = fill_temp(path, &tmp, bytes, existed) {
let _ = fs::remove_file(&tmp);
return Err(e);
}
// Again, because the suppression is a window that started before the copy and the fsync, and on
// a large document those are most of it.
crate::watch::note_self_write(&tmp);
crate::watch::note_self_write(path);
match fs::rename(&tmp, path) {
Ok(()) => Ok(()),
Err(e) => {
let _ = fs::remove_file(&tmp);
Err(e.to_string())
}
}
}
/// The untitled rule: `untitled.md`, then `untitled-2.md`, and never an overwrite. The suffix goes
/// before the extension so the file keeps opening in the same app as the one it was named after.
pub fn free_path(dir: &Path, name: &str) -> PathBuf {
let first = dir.join(name);
if !taken(&first) {
return first;
}
let as_path = Path::new(name);
let stem = as_path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| name.to_string());
let ext = as_path.extension().map(|e| e.to_string_lossy().into_owned());
let joined = |suffix: String| match &ext {
Some(ext) => dir.join(format!("{stem}-{suffix}.{ext}")),
None => dir.join(format!("{stem}-{suffix}")),
};
for n in 2..10_000u32 {
let candidate = joined(n.to_string());
if !taken(&candidate) {
return candidate;
}
}
joined(now_ms().to_string())
}
fn compare_nodes(a: &FileNode, b: &FileNode) -> Ordering {
let a_dir = a.kind == "dir";
let b_dir = b.kind == "dir";
b_dir
.cmp(&a_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
.then_with(|| a.name.cmp(&b.name))
}
fn assemble(
path: &Path,
nodes: &mut HashMap<PathBuf, FileNode>,
children: &HashMap<PathBuf, Vec<PathBuf>>,
) -> Option<FileNode> {
let mut node = nodes.remove(path)?;
if let Some(kids) = children.get(path) {
let mut built: Vec<FileNode> = kids
.iter()
.filter_map(|kid| assemble(kid, nodes, children))
.collect();
built.sort_by(compare_nodes);
node.children = built;
}
Some(node)
}
/// One pass over a folder, returning the root node with everything under it already attached.
///
/// `show_ignored` turns off gitignore, the hidden file rule and the four always skipped folders in
/// one go, for a settings toggle that lets a user see what the tree is holding back.
pub fn scan_tree(root: &Path, show_ignored: bool) -> Result<FileNode, String> {
let meta = fs::metadata(root).map_err(|e| format!("{}: {e}", root.display()))?;
if !meta.is_dir() {
return Err(format!("not a folder: {}", root.display()));
}
let mut builder = WalkBuilder::new(root);
builder
// A symlinked folder pointing back at one of its own ancestors would otherwise walk for
// ever, and a documents folder is exactly where somebody keeps one.
.follow_links(false)
// A .gitignore is worth honouring whether or not the folder is a checkout: the user wrote
// it about these files either way.
.require_git(false)
.standard_filters(!show_ignored);
if !show_ignored {
builder.filter_entry(|entry| {
if entry.depth() == 0 {
return true;
}
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
return true;
}
!ALWAYS_SKIPPED.contains(&entry.file_name().to_string_lossy().as_ref())
});
}
let mut nodes: HashMap<PathBuf, FileNode> = HashMap::new();
let mut children: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
for entry in builder.build() {
// One unreadable entry is one missing row and not a failed tree. A documents folder can
// easily hold something the user cannot stat, and losing the whole sidebar over it would
// be a far worse answer than losing the row.
let entry = match entry {
Ok(entry) => entry,
Err(_) => continue,
};
let path = entry.path().to_path_buf();
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
let modified = entry.metadata().map(|m| modified_ms(&m)).unwrap_or(0);
if entry.depth() > 0 {
if let Some(parent) = path.parent() {
children
.entry(parent.to_path_buf())
.or_default()
.push(path.clone());
}
}
nodes.insert(path.clone(), node_from(&path, is_dir, modified));
}
assemble(root, &mut nodes, &children).ok_or_else(|| format!("cannot read {}", root.display()))
}
pub fn read_document(path: &Path) -> Result<ReadResult, String> {
// The mtime is taken before the read rather than after. Read the other way round and a change
// landing between the two would be stamped onto older text, and the next save would overwrite
// it believing it had seen it.
let meta = fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
if meta.is_dir() {
return Err(format!("not a file: {}", path.display()));
}
let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(ReadResult {
path: path_string(path),
text,
modified_ms: modified_ms(&meta),
})
}
pub fn write_document(
path: &Path,
text: &str,
expected_modified_ms: Option<i64>,
) -> Result<WriteResult, String> {
// A file that is gone falls through to the write. Recreating a document somebody deleted under
// the user is not clobbering a change, and refusing would strand the buffer with nowhere to go.
if let (Some(expected), Ok(meta)) = (expected_modified_ms, fs::metadata(path)) {
let current = modified_ms(&meta);
if current != expected {
return Ok(WriteResult {
path: path_string(path),
modified_ms: current,
conflict: true,
});
}
}
atomic_write(path, text.as_bytes())?;
let meta = fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(WriteResult {
path: path_string(path),
modified_ms: modified_ms(&meta),
conflict: false,
})
}
pub fn create_file(parent: &Path, name: &str) -> Result<FileNode, String> {
let name = check_name(name)?;
if !parent.is_dir() {
return Err(format!("not a folder: {}", parent.display()));
}
let target = free_path(parent, name);
// create_new rather than a check and then a create: the whole point of the untitled rule is
// that nothing is ever overwritten, and another process can take the name between the two.
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&target)
.map_err(|e| format!("{}: {e}", target.display()))?;
node_for(&target)
}
pub fn create_folder(parent: &Path, name: &str) -> Result<FileNode, String> {
let name = check_name(name)?;
if !parent.is_dir() {
return Err(format!("not a folder: {}", parent.display()));
}
let target = free_path(parent, name);
fs::create_dir(&target).map_err(|e| format!("{}: {e}", target.display()))?;
node_for(&target)
}
pub fn rename_entry(path: &Path, name: &str) -> Result<FileNode, String> {
let name = check_name(name)?;
let parent = path
.parent()
.ok_or_else(|| format!("cannot rename {}", path.display()))?;
let target = parent.join(name);
if target == path {
return node_for(path);
}
// On a case insensitive volume a case only rename finds the file being renamed already sitting
// at the target, which is not a collision.
if taken(&target) && fs::canonicalize(&target).ok() != fs::canonicalize(path).ok() {
return Err(format!("already exists: {}", target.display()));
}
fs::rename(path, &target).map_err(|e| format!("{}: {e}", target.display()))?;
node_for(&target)
}
pub fn move_entry(path: &Path, dest_dir: &Path) -> Result<FileNode, String> {
if !dest_dir.is_dir() {
return Err(format!("not a folder: {}", dest_dir.display()));
}
if dest_dir.starts_with(path) {
return Err(format!("cannot move {} inside itself", path.display()));
}
if path.parent() == Some(dest_dir) {
// Already there. Going on would hand it a free name and leave two of it.
return node_for(path);
}
let name = path
.file_name()
.ok_or_else(|| format!("cannot move {}", path.display()))?
.to_string_lossy()
.into_owned();
let target = free_path(dest_dir, &name);
if fs::rename(path, &target).is_ok() {
return node_for(&target);
}
// A rename cannot cross a volume, so the move becomes a copy and a trip to the Trash. Never a
// remove: if anything about this went wrong the original is still recoverable in Finder.
copy_tree(path, &target)?;
trash_entry(path)?;
node_for(&target)
}
fn copy_tree(src: &Path, dest: &Path) -> Result<(), String> {
let meta = fs::symlink_metadata(src).map_err(|e| format!("{}: {e}", src.display()))?;
if !meta.is_dir() {
return fs::copy(src, dest)
.map(|_| ())
.map_err(|e| format!("{}: {e}", dest.display()));
}
fs::create_dir(dest).map_err(|e| format!("{}: {e}", dest.display()))?;
for entry in fs::read_dir(src).map_err(|e| format!("{}: {e}", src.display()))? {
let entry = entry.map_err(|e| format!("{}: {e}", src.display()))?;
copy_tree(&entry.path(), &dest.join(entry.file_name()))?;
}
Ok(())
}
pub fn duplicate_entry(path: &Path) -> Result<FileNode, String> {
let parent = path
.parent()
.ok_or_else(|| format!("cannot duplicate {}", path.display()))?;
let name = path
.file_name()
.ok_or_else(|| format!("cannot duplicate {}", path.display()))?
.to_string_lossy()
.into_owned();
let target = free_path(parent, &name);
copy_tree(path, &target)?;
node_for(&target)
}
/// Never `fs::remove_file`. These are the user's own documents and this app does not get to be the
/// reason one of them is gone for good.
pub fn trash_entry(path: &Path) -> Result<(), String> {
trash::delete(path).map_err(|e| format!("{}: {e}", path.display()))
}
pub fn write_asset(doc_path: &Path, bytes: &[u8], name: &str) -> Result<AssetResult, String> {
let dir = doc_path
.parent()
.ok_or_else(|| format!("cannot place an image beside {}", doc_path.display()))?;
// The clipboard suggests the name, so it is a suggestion and not a path: only the last
// component of it is ever used.
let suggested = Path::new(name)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.filter(|n| check_name(n).is_ok())
.unwrap_or_else(|| FALLBACK_ASSET_NAME.to_string());
let assets = dir.join("assets");
if taken(&assets) {
if !assets.is_dir() {
return Err(format!("not a folder: {}", assets.display()));
}
} else {
fs::create_dir_all(&assets).map_err(|e| format!("{}: {e}", assets.display()))?;
}
let target = free_path(&assets, &suggested);
atomic_write(&target, bytes)?;
let file = target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or(suggested);
Ok(AssetResult {
path: path_string(&target),
rel_path: format!("assets/{file}"),
})
}
/// A root id is a hash of the path and of nothing else, so the same folder is the same root after
/// a relaunch and the frontend can address one without carrying its path around. FNV-1a rather
/// than the standard hasher, whose output is only promised to be stable within one build.
pub fn root_id_for(path: &str) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in path.as_bytes() {
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{hash:016x}")
}
fn roots_file(app: &AppHandle) -> Result<PathBuf, String> {
Ok(crate::library::app_data_dir(app)?.join(ROOTS_FILE))
}
fn load_roots(app: &AppHandle) -> Vec<RootInfo> {
let Ok(file) = roots_file(app) else {
return Vec::new();
};
let Ok(text) = fs::read_to_string(file) else {
return Vec::new();
};
serde_json::from_str(&text).unwrap_or_default()
}
fn save_roots(app: &AppHandle, roots: &[RootInfo]) -> Result<(), String> {
let file = roots_file(app)?;
let text = serde_json::to_string_pretty(roots).map_err(|e| e.to_string())?;
atomic_write(&file, text.as_bytes())
}
/// Every folder currently open, in the order they were opened, which is the order the sidebar
/// lists them in.
///
/// The list outlives a relaunch, so the first call after launch reads it back from the app data
/// directory and fills the managed state from it. A root whose folder has since been deleted,
/// renamed or unmounted is dropped rather than handed back as a row that cannot be expanded.
#[tauri::command]
pub fn roots_list(app: AppHandle, roots: State<'_, Roots>) -> Result<Vec<RootInfo>, String> {
let mut open = roots.0.lock().map_err(|e| e.to_string())?;
if open.is_empty() {
*open = load_roots(&app);
}
let before = open.len();
open.retain(|root| Path::new(&root.path).is_dir());
if open.len() != before {
save_roots(&app, &open)?;
}
Ok(open.clone())
}
/// Adds `path` to the open roots and returns it. Idempotent: opening a folder that is already open
/// returns the entry that is already there rather than a second copy of it.
///
/// `id` is derived from the path and from nothing else, so the same folder is the same root across
/// relaunches and the frontend can address a root without carrying its path around. Opening a
/// folder never writes anything into it, and that includes not creating it: a `path` that is not
/// an existing directory is an error, not a mkdir.
#[tauri::command]
pub fn root_open(app: AppHandle, roots: State<'_, Roots>, path: String) -> Result<RootInfo, String> {
let canonical = fs::canonicalize(&path).map_err(|e| format!("{path}: {e}"))?;
if !canonical.is_dir() {
return Err(format!("not a folder: {}", canonical.display()));
}
let path = path_string(&canonical);
let id = root_id_for(&path);
let mut open = roots.0.lock().map_err(|e| e.to_string())?;
if open.is_empty() {
*open = load_roots(&app);
}
if let Some(existing) = open.iter().find(|root| root.id == id) {
return Ok(existing.clone());
}
let info = RootInfo {
id,
name: canonical
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.clone()),
path,
opened_ms: now_ms(),
};
open.push(info.clone());
save_roots(&app, &open)?;
// The lock goes before the index hears about the folder: the indexer's first move is to ask
// `Roots` where that root is, and it should not have to wait for this command to return.
drop(open);
// Scanned now rather than at the next rebuild, or a folder just opened would answer nothing at
// all to a search until something else asked for a full pass.
crate::index::scan_root(&app, info.clone());
Ok(info)
}
/// Forgets a root and persists the shorter list. Touches nothing inside the folder itself.
///
/// Stopping the watcher is not done here. The frontend calls `watch_stop` for the same root, which
/// keeps this module from having to know that the watcher exists.
#[tauri::command]
pub fn root_close(app: AppHandle, roots: State<'_, Roots>, root_id: String) -> Result<(), String> {
let mut open = roots.0.lock().map_err(|e| e.to_string())?;
let before = open.len();
open.retain(|root| root.id != root_id);
if open.len() == before {
return Ok(());
}
save_roots(&app, &open)?;
drop(open);
// The rows go with the folder. Nothing can be opened from a search result that belongs to a
// folder that is no longer there to open it in.
crate::index::forget_root(&app, &root_id);
Ok(())
}
/// The whole tree for one root in a single pass, the root node itself included. Empty `children`
/// therefore means an empty directory, never one that has not been explored yet.
///
/// Gitignore aware through the `ignore` crate, and `.git` itself is skipped too: a documents folder
/// under version control should not surface its own ignored build output as if it were documents.
/// Everything else is returned, including files the editor cannot open, because the tree greys
/// those rows out rather than hiding them. Children come back sorted directories first and then by
/// name, case insensitively, so the tree does not reshuffle itself between two reads of an
/// unchanged folder.
#[tauri::command(async)]
pub fn tree_read(roots: State<'_, Roots>, root_id: String) -> Result<FileNode, String> {
let path = roots.path_for(&root_id)?;
scan_tree(Path::new(&path), false)
}
/// Opens Finder with the file selected, rather than opening the file.
#[tauri::command]
pub fn reveal_in_finder(
app: AppHandle,
roots: State<'_, Roots>,
path: String,
) -> Result<(), String> {
let path = checked(&roots, &path)?;
app.opener()
.reveal_item_in_dir(&path)
.map_err(|e| format!("{}: {e}", path.display()))
}
/// Hands a file to whatever macOS opens it with. This is the only way a non editable file in the
/// tree can be opened at all, so it has to work for anything, not just for documents.
#[tauri::command]
pub fn open_external(app: AppHandle, roots: State<'_, Roots>, path: String) -> Result<(), String> {
let path = checked(&roots, &path)?;
app.opener()
.open_path(path_string(&path), None::<&str>)
.map_err(|e| format!("{}: {e}", path.display()))
}
/// Reads a document as UTF-8, and reads nothing else: no metadata is written, no lock is taken and
/// no sidecar appears beside it.
///
/// `modified_ms` is the file's mtime as it was at the moment of the read. The caller keeps it and
/// hands it back on write, which is the only thing that can tell an unsaved buffer apart from a
/// file another program has touched since. A file that is not valid UTF-8 is an error rather than
/// a lossy conversion, because a lossy read followed by a save would corrupt the user's file.
#[tauri::command(async)]
pub fn file_read(roots: State<'_, Roots>, path: String) -> Result<ReadResult, String> {
read_document(&checked(&roots, &path)?)
}
/// Writes a document atomically: a temp file in the same directory, flushed and synced, then
/// renamed over the target. The old bytes survive a crash, a full disk and a power cut mid-write.
///
/// `expected_modified_ms` is the mtime the caller last saw. If the file has moved on from it,
/// nothing is written and the result carries `conflict`, which is not an error: the document is
/// still open, still unsaved, and the user is the one who decides which copy wins. `None` means
/// write regardless, which is what a first save of a new file does.
///
/// Permissions, ownership and any extended attributes of the original survive the rename, since
/// the file the user ends up with is the temp file and it must not arrive with different bits.
///
/// The index is told directly rather than through the watcher. `watch::note_self_write` drops the
/// app's own writes out of the watch stream so an autosave does not come back as somebody else's
/// edit, which means the one document the watcher never reports is the one the user is working in.
/// Without this line the only version of it the index would ever hold is the one from before they
/// started typing.
#[tauri::command(async)]
pub fn file_write(
app: AppHandle,
roots: State<'_, Roots>,
path: String,
text: String,
expected_modified_ms: Option<i64>,
) -> Result<WriteResult, String> {
let path = checked(&roots, &path)?;
let result = write_document(&path, &text, expected_modified_ms)?;
// A conflict wrote nothing, and whatever moved the file on is an outside change the watcher
// does report.
if !result.conflict {
crate::index::note_write(&app, &path);
}
Ok(result)
}
/// Creates an empty file inside `parent_path`. `name` is a suggestion: a name already taken gets a
/// suffix, and the node that comes back carries the name that was really used, so the caller never
/// has to guess at it or race another process for it.
#[tauri::command]
pub fn file_create(
roots: State<'_, Roots>,
parent_path: String,
name: String,
) -> Result<FileNode, String> {
create_file(&checked(&roots, &parent_path)?, &name)
}
/// Creates an empty directory inside `parent_path`, under the same suggested-name rule as
/// `file_create`.
#[tauri::command]
pub fn file_folder_create(
roots: State<'_, Roots>,
parent_path: String,
name: String,
) -> Result<FileNode, String> {
create_folder(&checked(&roots, &parent_path)?, &name)
}
/// Renames a file or folder where it stands. `name` is a base name and not a path: a `name` holding
/// a path separator is an error, because this command cannot move anything and quietly doing so
/// would be worse than refusing.
///
/// This is the only thing that changes a document's identity, and it happens because the user asked
/// for it. Nothing in this app renames a file on its own, least of all because a heading changed.
#[tauri::command]
pub fn file_rename(
roots: State<'_, Roots>,
path: String,
name: String,
) -> Result<FileNode, String> {
rename_entry(&checked(&roots, &path)?, &name)
}
/// Moves a file or folder into `dest_dir`, keeping its name unless that name is taken there.
///
/// This command moves bytes and nothing else. The relative links a move breaks are rewritten a
/// layer up, in src/linkRewrite.ts, which splices one destination at a time into the file's own
/// text and never hands a document to the serializer, so a file whose links did not move is not
/// written at all.
#[tauri::command]
pub fn file_move(
roots: State<'_, Roots>,
path: String,
dest_dir: String,
) -> Result<FileNode, String> {
let open = open_root_paths(&roots)?;
let path = resolve_in_roots(&open, &path)?;
let dest_dir = resolve_in_roots(&open, &dest_dir)?;
move_entry(&path, &dest_dir)
}
/// Copies a file, or a folder and everything under it, beside itself under a free name. The copy is
/// byte for byte: nothing is parsed, normalised or reformatted on the way through.
#[tauri::command(async)]
pub fn file_duplicate(roots: State<'_, Roots>, path: String) -> Result<FileNode, String> {
duplicate_entry(&checked(&roots, &path)?)
}
/// Sends a file or folder to the system Trash through the `trash` crate, never `remove_file`. These
/// are the user's own documents and this app does not get to be the reason one of them is gone for
/// good, so a delete is always something Finder can undo.
#[tauri::command(async)]
pub fn file_trash(roots: State<'_, Roots>, path: String) -> Result<(), String> {
trash_entry(&checked(&roots, &path)?)
}
/// Writes a pasted image into an `assets/` folder beside the document that received the paste,
/// creating that folder when it is not already there. Images are the only thing other than markdown
/// this app ever puts inside a user's folder.
///
/// `name` is what the clipboard suggested, which is usually `image.png` and usually already taken,
/// so a taken name gets a suffix. `rel_path` in the result is what goes into the markdown link,
/// relative to the document, so the folder stays movable and shareable as a whole.
#[tauri::command(async)]
pub fn asset_write(
roots: State<'_, Roots>,
doc_path: String,
bytes: Vec<u8>,
name: String,
) -> Result<AssetResult, String> {
write_asset(&checked(&roots, &doc_path)?, &bytes, &name)
}
// The SQLite index, which lives in the app data directory and never inside a folder the user
// opened. It is derived state rather than a source of truth: every row is rebuilt from the files on
// disk, so deleting the database costs nothing but the time to walk the open roots again. It is
// kept current from the same debounced batch the watcher already sends the frontend, plus one call
// in `file_write` for the app's own saves, which are the changes that batch deliberately never
// mentions.
//
// Everything below is a handful of lines because the index itself is a module of its own: these are
// the commands, and index.rs is the database.
/// Rescans every open root from scratch and returns the status the pass started with. Progress
/// arrives on the `index-progress` event, because a full rescan of a large folder outlives any one
/// command.
#[tauri::command(async)]
pub fn index_rebuild(app: AppHandle, roots: State<'_, Roots>) -> Result<IndexStatus, String> {
// The roots are read here rather than on the indexer's thread, so the pass covers the folders
// that were open when the user asked for it and not whatever the list has become since.
let open = roots.0.lock().map_err(|e| e.to_string())?.clone();
crate::index::rebuild(&app, open)
}
/// Where the index has got to, for the status line. Cheap enough to poll and safe to call before
/// any indexing has ever run.
#[tauri::command]
pub fn index_status(app: AppHandle) -> Result<IndexStatus, String> {
crate::index::status(&app)
}
/// Fuzzy match over paths relative to their root, across every open root, best score first.
///
/// `ranges` index into `rel_path`, which is also the string the row shows, so a match on a folder
/// name is highlighted where it really was. They are character offsets and not byte offsets,
/// because the other end is JavaScript and highlights by character.
#[tauri::command(async)]
pub fn search_quick_open(
app: AppHandle,
query: String,
limit: u32,
) -> Result<Vec<QuickOpenHit>, String> {
crate::index::quick_open(&app, &query, limit)
}
/// Full text search across every open root through FTS5.
///
/// `line` is one based and counted over the file as it sits on disk, frontmatter included, so
/// jumping to a hit lands on the line the user can see in any other editor. `ranges` index into
/// `snippet`, again by character.
#[tauri::command(async)]
pub fn search_text(app: AppHandle, query: String, limit: u32) -> Result<Vec<SearchHit>, String> {
crate::index::search(&app, &query, limit)
}
/// Every document holding a relative markdown link that resolves to `path`.
///
/// This is a reverse lookup over links that are already in the files. Nothing is written anywhere
/// to make a backlink exist, and a document with no incoming links simply has none.
#[tauri::command(async)]
pub fn backlinks_for(app: AppHandle, path: String) -> Result<Vec<Backlink>, String> {
crate::index::backlinks(&app, &path)
}
File diff suppressed because it is too large. Load diff
+258
View File
@@ -0,0 +1,258 @@
pub mod dto;
pub mod fs;
pub mod index;
mod library;
#[cfg(target_os = "macos")]
mod macspell;
pub mod spell;
pub mod watch;
use std::sync::Mutex;
use crate::dto::RootInfo;
#[cfg(desktop)]
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder};
#[cfg(desktop)]
use tauri::{Emitter, Runtime};
/// The open folders, in the order they were opened.
///
/// This lives here rather than in either module because both need it and neither owns the other:
/// `fs` puts roots in and takes them out, `watch` only ever turns an id back into a path. It is the
/// in-memory copy of the list; persisting it across a relaunch is `fs`'s business.
#[derive(Default)]
pub struct Roots(pub Mutex<Vec<RootInfo>>);
impl Roots {
/// The absolute path of an open root. Every command that takes a `rootId` needs this before it
/// can touch anything, and an id that is not open is an error rather than an empty result.
pub fn path_for(&self, id: &str) -> Result<String, String> {
let roots = self.0.lock().map_err(|e| e.to_string())?;
roots
.iter()
.find(|root| root.id == id)
.map(|root| root.path.clone())
.ok_or_else(|| format!("no such root: {id}"))
}
}
#[cfg(desktop)]
fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
let menu = Menu::default(handle)?;
let open_folder = MenuItemBuilder::with_id("open-folder", "Open Folder…")
.accelerator("CmdOrCtrl+O")
.build(handle)?;
let new_doc = MenuItemBuilder::with_id("new-doc", "New Document")
.accelerator("CmdOrCtrl+N")
.build(handle)?;
let new_folder = MenuItemBuilder::with_id("new-folder", "New Folder").build(handle)?;
let quick_open = MenuItemBuilder::with_id("quick-open", "Quick Open…")
.accelerator("CmdOrCtrl+P")
.build(handle)?;
let command_palette = MenuItemBuilder::with_id("command-palette", "Command Palette…")
.accelerator("CmdOrCtrl+K")
.build(handle)?;
let save = MenuItemBuilder::with_id("save", "Save")
.accelerator("CmdOrCtrl+S")
.build(handle)?;
let close_folder = MenuItemBuilder::with_id("close-folder", "Close Folder").build(handle)?;
let check_updates =
MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?;
let settings = MenuItemBuilder::with_id("settings", "Settings…")
.accelerator("CmdOrCtrl+,")
.build(handle)?;
let find = MenuItemBuilder::with_id("find", "Find…")
.accelerator("CmdOrCtrl+F")
.build(handle)?;
let find_in_files = MenuItemBuilder::with_id("find-in-files", "Find in Files…")
.accelerator("CmdOrCtrl+Shift+F")
.build(handle)?;
let report_issue =
MenuItemBuilder::with_id("report-issue", "Report an Issue…").build(handle)?;
let submenus: Vec<_> = menu
.items()?
.into_iter()
.filter_map(|item| match item {
MenuItemKind::Submenu(submenu) => Some(submenu),
_ => None,
})
.collect();
let find_submenu = |name: &str| {
submenus
.iter()
.find(|submenu| submenu.text().map(|t| t == name).unwrap_or(false))
.cloned()
};
match find_submenu("File") {
Some(submenu) => {
submenu.prepend_items(&[
&open_folder,
&new_doc,
&new_folder,
&PredefinedMenuItem::separator(handle)?,
&quick_open,
&command_palette,
&PredefinedMenuItem::separator(handle)?,
&save,
&PredefinedMenuItem::separator(handle)?,
&close_folder,
&PredefinedMenuItem::separator(handle)?,
])?;
}
None => {
let submenu = SubmenuBuilder::new(handle, "File")
.item(&open_folder)
.item(&new_doc)
.item(&new_folder)
.item(&PredefinedMenuItem::separator(handle)?)
.item(&quick_open)
.item(&command_palette)
.item(&PredefinedMenuItem::separator(handle)?)
.item(&save)
.item(&PredefinedMenuItem::separator(handle)?)
.item(&close_folder)
.build()?;
menu.insert(&submenu, 1)?;
}
}
if let Some(edit) = find_submenu("Edit") {
edit.append_items(&[
&PredefinedMenuItem::separator(handle)?,
&find,
&find_in_files,
])?;
}
if let Some(help) = find_submenu("Help") {
help.append_items(&[&report_issue])?;
}
#[cfg(target_os = "macos")]
{
if let Some(app_submenu) = submenus.first() {
app_submenu.insert(&check_updates, 1)?;
app_submenu.insert(&settings, 3)?;
app_submenu.insert(&PredefinedMenuItem::separator(handle)?, 4)?;
}
if let Some(view) = find_submenu("View") {
let toggle_sidebar = MenuItemBuilder::with_id("toggle-sidebar", "Toggle Sidebar")
.accelerator("CmdOrCtrl+\\")
.build(handle)?;
view.prepend_items(&[&toggle_sidebar, &PredefinedMenuItem::separator(handle)?])?;
}
}
#[cfg(not(target_os = "macos"))]
{
if let Some(file) = find_submenu("File") {
file.append_items(&[&PredefinedMenuItem::separator(handle)?, &check_updates])?;
}
if let Some(edit) = find_submenu("Edit") {
edit.append_items(&[&settings])?;
}
}
Ok(menu)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let context = tauri::generate_context!();
#[cfg_attr(mobile, allow(unused_mut))]
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.manage(Roots::default())
.manage(watch::Watchers::default())
.manage(index::Index::default());
#[cfg(desktop)]
{
builder = builder.plugin(tauri_plugin_process::init());
if context.config().plugins.0.contains_key("updater") {
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
}
}
builder = builder.setup(|app| {
if let Err(e) = library::app_data_dir(app.handle()) {
eprintln!("failed to prepare app data dir: {e}");
}
// The index is opened here rather than lazily on the first search, because opening it is
// where a schema migration runs and a migration that fails should say so at launch rather
// than the first time somebody presses Cmd+P. A failure is not fatal: the app is a text
// editor with a broken search box, which is worth far more than a window that will not
// open.
if let Err(e) = index::open(app.handle()) {
eprintln!("failed to open the search index: {e}");
}
Ok(())
});
#[cfg(desktop)]
{
builder = builder
.menu(|handle| build_menu(handle))
.on_menu_event(|app, event| {
if matches!(
event.id().0.as_str(),
"open-folder"
| "new-doc"
| "new-folder"
| "save"
| "close-folder"
| "settings"
| "find"
| "find-in-files"
| "quick-open"
| "command-palette"
| "toggle-sidebar"
| "check-updates"
| "report-issue"
) {
app.emit("menu-action", event.id().0.as_str()).ok();
}
});
}
// The whole command surface, in the order dto.rs describes it. Registering a command is this
// file's job alone: a module adds a body, never a line here.
builder
.invoke_handler(tauri::generate_handler![
fs::roots_list,
fs::root_open,
fs::root_close,
fs::tree_read,
fs::reveal_in_finder,
fs::open_external,
fs::file_read,
fs::file_write,
fs::file_create,
fs::file_folder_create,
fs::file_rename,
fs::file_move,
fs::file_duplicate,
fs::file_trash,
fs::asset_write,
watch::watch_start,
watch::watch_stop,
fs::index_rebuild,
fs::index_status,
fs::search_quick_open,
fs::search_text,
fs::backlinks_for,
spell::spell_check,
spell::spell_learn,
spell::spell_unlearn,
spell::spell_available,
])
.run(context)
.expect("error while running Margin Docs");
}
+9
View File
@@ -0,0 +1,9 @@
use std::fs;
use std::path::PathBuf;
use tauri::Manager;
pub fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
+162
View File
@@ -0,0 +1,162 @@
// The one file in this app that talks to AppKit, and the whole of spelling on macOS.
//
// Spelling is NSSpellChecker's rather than this app's. It is the same shared checker Mail, Notes
// and TextEdit correct into, so a word learned anywhere on the machine is a word this editor does
// not underline, the user's own configured languages come along for free, and nothing here ships a
// dictionary or holds an opinion about English. There is no custom word list beside it either:
// learning a word teaches it to the system, which is where every other Mac app puts it.
//
// Three things make this more than a one line binding.
//
// Offsets are the first. AppKit answers in NSRange, which counts UTF-16 code units, and the caller
// is a ProseMirror document, which counts code points. The two agree exactly until the paragraph
// holds an emoji or anything else outside the basic plane, and from that character onwards every
// later offset in the run is out by one per astral character. An underline drawn from a UTF-16
// offset onto a code point document sits under the wrong word, and a suggestion applied at that
// offset replaces the wrong characters, which is a silent edit to the user's file. `utf16_to_codepoint`
// is the whole of the fix, and this file is the only place in the app allowed to do that conversion
// so that there is exactly one thing to keep right.
//
// Which results to keep is the second. The checker is asked for Spelling and Link together and
// only the spelling results are returned. Link earns its place in the request because it makes the
// checker treat a URL as one span: without it `https://github.com/some-repo` is a run of tokens
// none of which are in any dictionary, and a paragraph carrying a link comes back with half of it
// underlined.
//
// The main thread is the third, and the answer is that none of this needs it. objc2 asks for a
// `MainThreadMarker` on exactly the panel accessors of NSSpellChecker (`spellingPanel`,
// `accessoryView`, `substitutionsPanel`), which this file never touches. The checking and learning
// calls carry no such requirement, and since each of them round trips to the system spell service
// over XPC, the caller deliberately runs them off the main thread.
use objc2::rc::autoreleasepool;
use objc2_app_kit::NSSpellChecker;
use objc2_foundation::{NSRange, NSString, NSTextCheckingType};
use crate::dto::SpellIssue;
/// A context menu is a menu, not a dictionary page. The checker will happily offer thirty guesses
/// and the ones past the first few are noise the user has to read past to reach Learn Spelling.
const MAX_SUGGESTIONS: usize = 5;
/// Zero as the spell document tag, everywhere below. A tag buys a per-document session the checker
/// remembers ignored words against, and this app has no Ignore: a word is either learned for good
/// or it stays underlined, so there is no session to allocate.
const NO_DOCUMENT: isize = 0;
/// UTF-16 offset to code point offset, one entry per code unit of `text` plus a terminal entry, so
/// both ends of a half-open range are a lookup and neither is a special case.
///
/// A character outside the basic plane occupies two code units and one code point, so both of its
/// units map to the same code point index. An NSRange landing in the middle of a surrogate pair,
/// which the checker will not produce, therefore resolves to the start of that character rather
/// than to a position that does not exist.
fn utf16_to_codepoint(text: &str, utf16_len: usize) -> Vec<usize> {
let mut map = Vec::with_capacity(utf16_len + 1);
let mut cp = 0;
for ch in text.chars() {
for _ in 0..ch.len_utf16() {
map.push(cp);
}
cp += 1;
}
map.push(cp);
map
}
/// Every misspelling in one run of text, with half-open offsets in characters counted from the
/// start of that run.
///
/// The run is not split into words here. NSSpellChecker does that better than any rule this app
/// could write: it knows about contractions, hyphenation, proper nouns, capitalisation and
/// whichever languages the user has turned on, and it decides where a word begins in each of them.
pub fn check(text: &str) -> Vec<SpellIssue> {
autoreleasepool(|_| {
let checker = NSSpellChecker::sharedSpellChecker();
let ns = NSString::from_str(text);
let len = ns.length();
let results = unsafe {
checker.checkString_range_types_options_inSpellDocumentWithTag_orthography_wordCount(
&ns,
NSRange {
location: 0,
length: len,
},
(NSTextCheckingType::Spelling | NSTextCheckingType::Link).bits(),
None,
NO_DOCUMENT,
None,
std::ptr::null_mut(),
)
};
let map = utf16_to_codepoint(text, len);
let chars: Vec<char> = text.chars().collect();
let mut issues = Vec::new();
for result in results.iter() {
// Link results were asked for so the checker would recognise a URL as one span, not so
// that anything would be reported about them.
if result.resultType() != NSTextCheckingType::Spelling {
continue;
}
let range = result.range();
// Clamped to the length the map was built from. A range past the end would index out
// of it and panic, and a panic here takes down a command the frontend runs on every
// keystroke.
let start = map[range.location.min(len)];
let end = map[range.location.saturating_add(range.length).min(len)];
// The word comes back out of `text` rather than from the checker, so the string the
// frontend matches against is byte for byte the one it sent.
let word: String = chars[start..end].iter().collect();
// Asked in the checker's own coordinates, because this range indexes into `ns`.
let mut suggestions = Vec::new();
if let Some(guesses) = checker.guessesForWordRange_inString_language_inSpellDocumentWithTag(
range,
&ns,
None,
NO_DOCUMENT,
) {
for guess in guesses.iter() {
suggestions.push(guess.to_string());
if suggestions.len() >= MAX_SUGGESTIONS {
break;
}
}
}
// Reported even with nothing to suggest. NSSpellChecker regularly flags a typo it has
// no guess for, and dropping those because the menu would have no replacements in it
// is how a checker earns a reputation for missing things.
issues.push(SpellIssue {
start,
end,
word,
suggestions,
});
}
issues
})
}
/// Teaches `word` to the system, for every app on this machine and not only for this one.
///
/// That is not a shortcut, it is what a checker borrowed from the OS does: `learnWord:` hands the
/// word to the system spell service, exactly where the "Learn Spelling" item in Mail or Pages puts
/// it, and every app on the machine stops underlining it from then on. This app deliberately keeps
/// no private word list beside that, because a second dictionary the rest of the system cannot see
/// is a word the user has to teach twice.
pub fn learn(word: &str) {
autoreleasepool(|_| {
NSSpellChecker::sharedSpellChecker().learnWord(&NSString::from_str(word));
})
}
/// Undoes a `learn`, for a word taught by a slip of the hand. Also system wide, and the checker
/// treats unlearning a word it was never taught as nothing to do rather than as an error.
pub fn unlearn(word: &str) {
autoreleasepool(|_| {
NSSpellChecker::sharedSpellChecker().unlearnWord(&NSString::from_str(word));
})
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
margin_docs_lib::run()
}
+95
View File
@@ -0,0 +1,95 @@
// The four spelling commands, and the only place in this crate that knows whether the machine has
// a checker at all.
//
// Everything real happens in macspell.rs, which is compiled on macOS alone. This module exists so
// that the frontend gets the same four commands on every platform: it picks a checker at compile
// time and each command below has one body rather than a cfg in the middle of it.
//
// A run with no misspellings and a build with no checker both answer with an empty list, and that
// is deliberate. Returning an error from `spell_check` on a platform without NSSpellChecker would
// put a permanent failure toast in front of a user whose actual situation is "this build cannot
// check spelling", which is not a failure and is not something they can act on. `spell_available`
// is where that fact belongs, because it is the one answer the UI can do something with: it hides
// the underlines and the menu rather than offering a menu that does nothing.
//
// There is no state here and no dictionary file. The system holds the learned words, so there is
// nothing for this module to load at launch, nothing to keep in sync and nothing to migrate.
use crate::dto::SpellIssue;
#[cfg(target_os = "macos")]
use crate::macspell as checker;
#[cfg(not(target_os = "macos"))]
use self::no_checker as checker;
/// Spelling on a platform this app has no system checker for: every call succeeds and does
/// nothing. The alternative is a cfg inside each of the three commands that touch a checker, and
/// three chances to get the non-macOS answer subtly different from each other.
#[cfg(not(target_os = "macos"))]
mod no_checker {
use crate::dto::SpellIssue;
pub fn check(_text: &str) -> Vec<SpellIssue> {
Vec::new()
}
pub fn learn(_word: &str) {}
pub fn unlearn(_word: &str) {}
}
/// Every misspelling in one run of text.
///
/// Offsets are half-open and counted in characters from the start of the run that was passed in,
/// never from the start of a document. The checker is told about a paragraph and answers about that
/// paragraph; it has no idea a document exists, which is what leaves the caller free to send a
/// paragraph, a visible screenful or one sentence, and to add its own base offset afterwards.
///
/// Runs off the main thread. The call reaches the system spell service over XPC and a long
/// paragraph is enough work that a window held still for the length of it would be visible, which
/// matters more here than elsewhere because this is called while the user is typing.
#[tauri::command(async)]
pub fn spell_check(text: String) -> Result<Vec<SpellIssue>, String> {
Ok(checker::check(&text))
}
/// Teaches a word to the system dictionary, for every app on the machine and not only for this
/// one.
///
/// That is the honest behaviour of a checker borrowed from the OS, and it is exactly what the
/// "Learn Spelling" item in every other Mac app does. This app ships no dictionary of its own and
/// keeps no private word list, so there is nowhere else for the word to go and nothing that would
/// need teaching twice.
///
/// A blank word is nothing to learn rather than an error: the frontend takes the word from
/// whatever the user right clicked, and an empty selection is a mis-click, not a failure worth a
/// toast.
#[tauri::command(async)]
pub fn spell_learn(word: String) -> Result<(), String> {
let word = word.trim();
if word.is_empty() {
return Ok(());
}
checker::learn(word);
Ok(())
}
/// Undoes a `spell_learn`, for a word taught by a slip of the hand. System wide in the same way,
/// and unlearning a word that was never learned is nothing to do rather than an error.
#[tauri::command(async)]
pub fn spell_unlearn(word: String) -> Result<(), String> {
let word = word.trim();
if word.is_empty() {
return Ok(());
}
checker::unlearn(word);
Ok(())
}
/// Whether this build has a checker behind it. Answered from the target rather than by asking
/// AppKit anything: NSSpellChecker is part of macOS itself, so on a build that has it there is no
/// failure mode where it is absent, and on any other build there is nothing to ask.
#[tauri::command]
pub fn spell_available() -> Result<bool, String> {
Ok(cfg!(target_os = "macos"))
}
+463
View File
@@ -0,0 +1,463 @@
// One filesystem watcher per open root. Changes never come back as a return value: each debounced
// batch is emitted as a `watch-event`, so a file another program touched reaches the frontend the
// same way whether anything asked for it or not.
//
// Debounced because one logical change is a burst of raw events. A git checkout rewrites a hundred
// files, another editor's atomic save is a create, a rename and a remove for what the user thinks
// of as one save, and a folder copied in arrives file by file. Reacting to raw events would reload
// the open document several times over for a single save somewhere else.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant, SystemTime};
use notify::event::{ModifyKind, RenameMode};
use notify::{EventKind, RecommendedWatcher, RecursiveMode};
use notify_debouncer_full::{
new_debouncer_opt, DebounceEventResult, DebouncedEvent, Debouncer, NoCache,
};
use tauri::{AppHandle, Emitter, Manager, State};
use crate::dto::WatchEvent;
use crate::Roots;
/// Mirrors `WATCH_EVENT` in src/ipc.ts.
const WATCH_EVENT: &str = "watch-event";
/// How long a burst of raw events for one path is allowed to settle before it is reported.
///
/// Long enough that an atomic save arrives as one batch rather than as its create, rename and
/// remove parts, short enough that a file changed by another program shows up while the user is
/// still looking at the window that changed it.
const DEBOUNCE: Duration = Duration::from_millis(300);
/// How long a path stays on the self-written list.
///
/// The event for a write cannot reach the callback sooner than `DEBOUNCE` after the write finishes,
/// and the debouncer's tick is a further `DEBOUNCE / 4`, so nothing under about 375ms would suppress
/// anything at all. The rest is headroom for the write itself: an fsync on a large document on a
/// busy disk can take a good fraction of a second, and the path is registered before the write
/// starts, not after. Two seconds leaves room for that several times over, and the cost of
/// overshooting is bounded and mild.
///
/// What that cost is: an external change to a file the app itself wrote less than two seconds ago is
/// dropped. That is the right answer anyway. The only way to be inside that window is for the user
/// to be typing in that document right now, and a reload mid-keystroke would throw away their
/// unsaved text to show them somebody else's. The next save catches it regardless, because
/// `file_write` compares mtimes and reports a conflict. Erring the other way is not symmetric: a
/// leaked echo of the app's own autosave reloads the editor under the cursor on every save, which
/// makes the app unusable rather than briefly out of date.
///
/// Entries expire on time and are not consumed on the first match, because one atomic save can
/// produce several debounced events for the same path and suppressing only the first would defeat
/// the whole thing.
const SELF_WRITE_WINDOW: Duration = Duration::from_millis(2_000);
/// How long after a change was raised a file may have been born and still count as created by it.
///
/// Covers the write landing, the backend noticing and the timestamp's own granularity. Too tight
/// and a new file is reported as a modification of a file the tree has never heard of; too loose
/// and editing a file made moments ago is reported as making it again.
const BIRTH_SLACK: Duration = Duration::from_millis(250);
/// Paths this app wrote, and when.
///
/// A global rather than managed state because the commands that write files take a path and nothing
/// else: their signatures are the frozen contract, so there is no `State` for them to reach the
/// watcher through. Keyed by the path with its directory resolved, since that is the only form both
/// sides can agree on.
static SELF_WRITES: LazyLock<Mutex<HashMap<PathBuf, Instant>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Records that this app is about to write `path`, so the watcher drops the event that comes back.
///
/// Call it immediately before every write, for every path the write touches. An atomic save touches
/// two, the temp file and the target it is renamed over, and each end raises its own events, so
/// registering only the target lets the temp file's half through on its own. `fs::atomic_write` is
/// the one caller, and every write in the app goes through it.
///
/// Cheap, so a caller unsure whether a path will really be written should register it anyway. The
/// entry expires on its own and registering a path that is never written costs one map slot for two
/// seconds.
pub fn note_self_write<P: AsRef<Path>>(path: P) {
let key = resolve(path.as_ref());
let now = Instant::now();
if let Ok(mut writes) = SELF_WRITES.lock() {
writes.retain(|_, at| now.duration_since(*at) < SELF_WRITE_WINDOW);
writes.insert(key, now);
}
}
/// The live watchers, keyed by root id.
///
/// Dropping a debouncer stops its thread, so both `watch_stop` and closing a folder come down to a
/// remove from this map and nothing else. The map is the only place a watcher is held: a watcher
/// that is not in here is not running.
#[derive(Default)]
pub struct Watchers(pub Mutex<HashMap<String, Debouncer<RecommendedWatcher, NoCache>>>);
/// Starts watching one open root, recursively. Idempotent: starting a watch that is already running
/// is a no-op rather than a second watcher on the same folder.
///
/// Every debounced change is emitted as one `watch-event` carrying the root id, so the frontend can
/// tell which tree to patch without matching path prefixes, and no path is ever the subject of more
/// than one event per batch.
///
/// A rename is two events on macOS and not one: a `removed` for the name that went and a `created`
/// or `modified` for the name that arrived. FSEvents describes the two ends as unrelated changes and
/// nothing here can prove otherwise, so `old_path` stays empty and a frontend that wants to follow a
/// renamed document has to pair them up itself, or rely on `file_rename` for the renames it made.
/// `created` and `modified` are likewise a hint rather than a promise, since the only thing
/// separating them is how recently the file was born: both mean the row should be inserted or
/// refreshed. `removed` is exact, because it is a fact about the disk read at the moment of
/// emitting.
///
/// The app's own writes are filtered out, on the strength of the paths `note_self_write` was told
/// about. The frontend cannot do this itself: by the time it hears about a change it has already
/// been handed a path and a reason to reload, and the mtime it holds cannot tell it apart from a
/// write another program made in the same second.
///
/// The search index reads the same batch, which makes that filter its blind spot: the one document
/// this stream never mentions is the one the user is typing in, because that is the one this app
/// keeps saving. `fs::file_write` tells the index about its own saves for exactly that reason.
///
/// The root going away takes the watcher with it. A folder deleted or moved out from under a
/// running watch emits one `removed` for the root path and then the watcher is dropped, since a
/// watch on a path that no longer exists reports nothing and would sit in the map forever.
#[tauri::command]
pub fn watch_start(
app: AppHandle,
roots: State<'_, Roots>,
watchers: State<'_, Watchers>,
root_id: String,
) -> Result<(), String> {
let root_path = roots.path_for(&root_id)?;
let mut live = watchers.0.lock().map_err(|e| e.to_string())?;
if live.contains_key(&root_id) {
return Ok(());
}
let handle = app.clone();
let dead_root = root_path.clone();
let dead_id = root_id.clone();
let debouncer = spawn_watcher(root_id.clone(), root_path, move |events| {
for event in &events {
handle.emit(WATCH_EVENT, event).ok();
}
// The index reads the same batch the frontend does, so a file another program wrote is
// searchable at the same moment the tree learns about it. It goes here rather than inside
// `spawn_watcher` because that function is also what the tests drive, with a real folder and
// no app at all to hold an index.
crate::index::note_watch_events(&handle, &events);
if events
.iter()
.any(|event| event.kind == "removed" && event.path == dead_root)
{
reap(handle.clone(), dead_id.clone());
}
})?;
live.insert(root_id, debouncer);
Ok(())
}
/// Stops the watcher for one root and drops it. Stopping a watch that is not running is a no-op, so
/// the frontend can close a folder and stop its watcher without having to remember whether it ever
/// started one.
#[tauri::command]
pub fn watch_stop(watchers: State<'_, Watchers>, root_id: String) -> Result<(), String> {
let mut live = watchers.0.lock().map_err(|e| e.to_string())?;
live.remove(&root_id);
Ok(())
}
/// Watches `root_path` recursively and hands each debounced batch to `sink` as `watch-event`
/// payloads. The watch runs until the returned debouncer is dropped.
///
/// `watch_start` is a thin wrapper over this: everything above the Tauri event lives here so the
/// tests can drive a real watcher over a real folder without an app to emit into.
pub fn spawn_watcher<F>(
root_id: String,
root_path: String,
sink: F,
) -> Result<Debouncer<RecommendedWatcher, NoCache>, String>
where
F: Fn(Vec<WatchEvent>) + Send + 'static,
{
let root = PathBuf::from(&root_path);
if !root.is_dir() {
return Err(format!("not a folder: {root_path}"));
}
let canonical = std::fs::canonicalize(&root).map_err(|e| e.to_string())?;
let watched = canonical.clone();
let handler = move |result: DebounceEventResult| {
let batch = match result {
Ok(batch) => batch,
Err(errors) => {
for error in errors {
eprintln!("watch error under {}: {error}", watched.display());
}
return;
}
};
let events = watch_events(&batch, &root_id, &watched, &root);
if !events.is_empty() {
sink(events);
}
};
// Deliberately without the file id cache the crate would otherwise pick. Its whole job is to
// recognise the two halves of a rename by inode, and on macOS it does more harm than good: it
// decides the halves belong together, folds the old name's events into the new name's queue,
// and then throws away the rename event that carried the old name, because FSEvents claims the
// old name was created. What comes out is a modification of the new path and no word at all
// that the old path is gone, which leaves a row in the tree for a file that no longer exists.
// With no cache the two halves stay separate and both ends get reported.
let mut debouncer: Debouncer<RecommendedWatcher, NoCache> = new_debouncer_opt(
DEBOUNCE,
None,
handler,
NoCache::new(),
notify::Config::default(),
)
.map_err(|e| e.to_string())?;
debouncer
.watch(&canonical, RecursiveMode::Recursive)
.map_err(|e| e.to_string())?;
Ok(debouncer)
}
/// Drops a root's watcher from another thread.
///
/// The call site is inside the debouncer's own callback, and dropping a debouncer from the thread it
/// is calling you on is asking for a join on yourself. One short-lived thread is the whole fix.
fn reap(app: AppHandle, root_id: String) {
std::thread::spawn(move || {
if let Some(watchers) = app.try_state::<Watchers>() {
if let Ok(mut live) = watchers.0.lock() {
live.remove(&root_id);
}
}
});
}
/// One debounced batch turned into the events the frontend sees: classified, filtered and reduced
/// to at most one event per path.
fn watch_events(
batch: &[DebouncedEvent],
root_id: &str,
canonical_root: &Path,
root_path: &Path,
) -> Vec<WatchEvent> {
let mut events: Vec<WatchEvent> = Vec::new();
let mut index: HashMap<String, usize> = HashMap::new();
for event in batch {
// The kernel dropped events under load and the backend is telling us so. Nothing in the
// batch describes what was missed, so the honest answer is to report the root as changed
// and let the frontend read the tree again.
let next = if event.need_rescan() {
WatchEvent {
root: root_id.to_string(),
path: root_path.to_string_lossy().into_owned(),
kind: "modified".to_string(),
old_path: None,
}
} else {
let Some((kind, path, old_path)) = classify(event) else {
continue;
};
// The root itself is exempt from the transient rule: a folder called `.notes` is a
// perfectly good root, and its own removal is the one event nothing under it can
// describe.
if was_self_written(&path)
|| old_path.as_deref().is_some_and(was_self_written)
|| (path.as_path() != canonical_root && is_transient(&path))
|| is_hidden_below(&path, canonical_root)
{
continue;
}
WatchEvent {
root: root_id.to_string(),
path: rebase(&path, canonical_root, root_path),
kind: kind.to_string(),
old_path: old_path.map(|path| rebase(&path, canonical_root, root_path)),
}
};
merge(&mut events, &mut index, next);
}
// The root itself going away is the one change nothing under it can describe. macOS does report
// it as an event on the watched path, but a folder moved rather than emptied is a single rename
// this side may never see, so the state of the folder is checked rather than waited for.
if !canonical_root.exists() {
merge(
&mut events,
&mut index,
WatchEvent {
root: root_id.to_string(),
path: root_path.to_string_lossy().into_owned(),
kind: "removed".to_string(),
old_path: None,
},
);
}
events
}
fn merge(events: &mut Vec<WatchEvent>, index: &mut HashMap<String, usize>, next: WatchEvent) {
match index.get(&next.path) {
// A later `modified` says nothing a create or a rename in the same batch has not already
// said, and would lose that event's `old_path`. Anything else supersedes.
Some(_) if next.kind == "modified" => {}
Some(&at) => events[at] = next,
None => {
index.insert(next.path.clone(), events.len());
events.push(next);
}
}
}
/// The kind, the path it happened to and, for a rename, where the file was before.
///
/// Almost nothing here comes from the event's own kind, and that is deliberate. FSEvents does not
/// describe a change, it describes a file: every event it reports for a path carries the union of
/// everything that has ever happened to that path, so `ItemCreated` is set on the modification of a
/// file that was created last week and on the deletion of one created a second ago. Trusting it
/// would report every save as a create and, once the debouncer has folded a create and a remove
/// together, every deletion as a modification.
///
/// So the file itself is asked instead. The event says which path changed, which is the one thing
/// FSEvents is reliable about, and a stat at the moment of emitting says what it changed into. That
/// is also fresher than the flags: by the time a batch comes out it is at least a debounce window
/// old, and what is on disk now is what the frontend is about to go and read.
fn classify(event: &DebouncedEvent) -> Option<(&'static str, PathBuf, Option<PathBuf>)> {
let first = event.paths.first()?.clone();
// The one thing a stat cannot answer afterwards is where a file used to be, so a rename the
// debouncer managed to stitch back together is read from the event. macOS never gets here: it
// reports the two ends of a rename as unrelated events and the pairing is left to the inode
// cache this watcher deliberately does without. A backend that names both ends itself, which
// inotify does through the rename cookie, still arrives whole.
if let EventKind::Modify(ModifyKind::Name(RenameMode::Both)) = &event.kind {
let to = event.paths.get(1)?.clone();
if is_transient(&to) || !to.exists() {
return Some(("removed", first, None));
}
if is_transient(&first) {
// Another editor saving the way this one does: a temp file renamed over the target.
// Reporting the temp name as the document's previous name would have the frontend go
// looking for a tree row that never existed.
return Some((appearance(event, &to), to, None));
}
return Some(("renamed", to, Some(first)));
}
if matches!(&event.kind, EventKind::Access(_) | EventKind::Other) {
return None;
}
Some((appearance(event, &first), first, None))
}
/// What is at the path now: `created`, `modified` or `removed`.
///
/// Created and modified are told apart by the file's birth time, since nothing else survives to
/// here. A file born within a slack of when the event was raised was born by the change the event
/// describes; anything older was only touched by it. The slack covers the gap between the write
/// landing and the backend seeing it, and erring towards `created` is the cheaper mistake: both
/// kinds mean the same thing to a tree that inserts or refreshes a row, and only `removed` means
/// something a frontend must not get wrong.
fn appearance(event: &DebouncedEvent, path: &Path) -> &'static str {
let Ok(meta) = std::fs::symlink_metadata(path) else {
return "removed";
};
let Ok(born) = meta.created() else {
return "modified";
};
let happened = SystemTime::now().checked_sub(event.time.elapsed());
match (born.checked_add(BIRTH_SLACK), happened) {
(Some(fresh_until), Some(happened)) if fresh_until >= happened => "created",
_ => "modified",
}
}
/// A name no tree row will ever carry: an editor's lock file, swap file or backup, or the temp file
/// half of somebody's atomic save.
///
/// Every path in a batch goes through this, not only the two ends of a rename the debouncer managed
/// to stitch together. macOS never reports a rename whole, so the branch in `classify` that used to
/// be the only caller never ran there, and the temp file of every save in the folder was reported to
/// the frontend as a document appearing and then vanishing.
///
/// This app's own temp file, `.<name>.<unique>.tmp`, is caught twice over, by the leading dot and by
/// the extension. That is on purpose: `note_self_write` already covers it, and a save that somehow
/// outran its two second window should still not put a temp name in front of the user.
fn is_transient(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
name.starts_with('.')
|| name.ends_with('~')
|| name.ends_with(".tmp")
|| name.ends_with(".swp")
|| name.ends_with(".swx")
}
/// Whether the path sits under a dot-directory or is a dotfile, counted from the root down.
///
/// The tree hides those, so reporting them would be reporting changes to rows that do not exist:
/// `.git` alone would fire hundreds of times for one checkout. Counted from the root and not from
/// `/` because a root may perfectly well be a folder inside `~/.config`, and that folder's contents
/// are not hidden from anybody.
fn is_hidden_below(path: &Path, root: &Path) -> bool {
let Ok(rel) = path.strip_prefix(root) else {
return false;
};
rel.components()
.any(|part| part.as_os_str().to_string_lossy().starts_with('.'))
}
/// The path as the frontend knows it: under the root exactly as `Roots` spells it.
///
/// FSEvents reports resolved paths, so a root opened as `/tmp/notes` comes back as
/// `/private/tmp/notes` and every path the frontend holds would fail to match.
fn rebase(path: &Path, canonical_root: &Path, root_path: &Path) -> String {
match path.strip_prefix(canonical_root) {
Ok(rel) if rel.as_os_str().is_empty() => root_path.to_string_lossy().into_owned(),
Ok(rel) => root_path.join(rel).to_string_lossy().into_owned(),
Err(_) => path.to_string_lossy().into_owned(),
}
}
/// A path with its directory resolved through any symlink, which is the form event paths arrive in.
///
/// The directory and not the path itself, because the file about to be written may not exist yet and
/// there is nothing to canonicalise. macOS alone makes this necessary: `/tmp` and `/var` are
/// symlinks into `/private`, so a document under either would never match the event describing it.
fn resolve(path: &Path) -> PathBuf {
let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
return path.to_path_buf();
};
match std::fs::canonicalize(parent) {
Ok(dir) => dir.join(name),
Err(_) => path.to_path_buf(),
}
}
/// Event paths arrive already resolved, because the watch is placed on the canonicalised root, so
/// they can be looked up as they are.
fn was_self_written(path: &Path) -> bool {
let Ok(writes) = SELF_WRITES.lock() else {
return false;
};
writes
.get(path)
.is_some_and(|at| Instant::now().duration_since(*at) < SELF_WRITE_WINDOW)
}
+47
View File
@@ -0,0 +1,47 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Margin Docs",
"version": "0.0.1",
"identifier": "studio.margin.docs",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1440",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "",
"width": 1360,
"height": 900,
"minWidth": 880,
"minHeight": 600,
"titleBarStyle": "Overlay"
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data: blob: asset: http://asset.localhost; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; connect-src 'self' ipc: http://ipc.localhost"
}
},
"bundle": {
"active": true,
"targets": [
"app",
"dmg"
],
"category": "Productivity",
"shortDescription": "A folder of markdown documents",
"longDescription": "Margin Docs is an editor for a folder of markdown documents.",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/[email protected]",
"icons/icon.icns",
"icons/icon.ico"
],
"macOS": {
"minimumSystemVersion": "10.15"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"pubkey": "REPLACE_WITH_TAURI_SIGNER_PUBKEY",
"endpoints": [
"https://github.com/priyanshujain/margin-docs/releases/latest/download/latest.json"
]
}
}
}
+597
View File
@@ -0,0 +1,597 @@
// The filesystem layer is the only part of this app that can lose somebody's work, so the tests
// here are about the promises rather than the plumbing: a failed write leaves the old file whole,
// a conflict writes nothing at all, a name is never taken from a file that already has it, a path
// from the frontend cannot reach outside the folders the user opened, and a delete is always
// something Finder can undo.
use std::collections::BTreeSet;
use std::fs;
use std::os::unix::fs::{symlink, PermissionsExt};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::{SystemTime, UNIX_EPOCH};
use margin_docs_lib::dto::FileNode;
use margin_docs_lib::fs::{
atomic_write, create_file, create_folder, duplicate_entry, free_path, move_entry, read_document,
rename_entry, resolve_in_roots, root_id_for, scan_tree, trash_entry, write_asset,
write_document,
};
use tempfile::TempDir;
fn root() -> TempDir {
TempDir::new().expect("a temp dir")
}
fn write(path: &Path, text: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, text).unwrap();
}
fn names(node: &FileNode) -> Vec<String> {
node.children.iter().map(|c| c.name.clone()).collect()
}
fn find<'a>(node: &'a FileNode, name: &str) -> Option<&'a FileNode> {
node.children.iter().find(|c| c.name == name)
}
fn flat(node: &FileNode, into: &mut Vec<String>) {
into.push(node.name.clone());
for child in &node.children {
flat(child, into);
}
}
#[test]
fn a_failed_write_leaves_the_original_whole() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "the original");
// A folder that cannot be written to makes the write fail at its first step, which is the
// moment the original is most at risk. The temp name cannot be blocked from outside any more,
// since it is unique per call, so the folder is what gets taken away instead.
let open = fs::metadata(dir.path()).unwrap().permissions();
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o500)).unwrap();
let result = atomic_write(&doc, b"the replacement");
fs::set_permissions(dir.path(), open).unwrap();
assert!(result.is_err());
assert_eq!(fs::read_to_string(&doc).unwrap(), "the original");
}
/// A `.bak` or a `.tmp` named after a document is a file somebody may have written on purpose, and
/// a save of the document it is named after is not permission to unlink it.
#[test]
fn a_write_leaves_the_users_own_bak_and_tmp_siblings_alone() {
let dir = root();
let doc = dir.path().join("doc.md");
let bak = dir.path().join("doc.md.bak");
let tmp = dir.path().join("doc.md.tmp");
write(&doc, "one");
write(&bak, "a revision the author kept on purpose");
write(&tmp, "a scratch file the author kept on purpose");
atomic_write(&doc, b"two").unwrap();
assert_eq!(fs::read_to_string(&doc).unwrap(), "two");
assert_eq!(
fs::read_to_string(&bak).unwrap(),
"a revision the author kept on purpose",
"the save deleted a backup the user owned"
);
assert_eq!(
fs::read_to_string(&tmp).unwrap(),
"a scratch file the author kept on purpose",
"the save deleted a scratch file the user owned"
);
}
/// Nothing named after the document may appear beside it, not even for the length of one save.
/// Anything else watching the folder, git included, sees whatever is there while the write runs, and
/// a crash halfway through strands it for good.
#[test]
fn a_save_puts_nothing_document_shaped_beside_the_document() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "one");
let watched = dir.path().to_path_buf();
let (stop_tx, stop_rx) = mpsc::channel::<()>();
let poller = std::thread::spawn(move || {
let mut seen: BTreeSet<String> = BTreeSet::new();
while stop_rx.try_recv().is_err() {
if let Ok(entries) = fs::read_dir(&watched) {
for entry in entries.flatten() {
seen.insert(entry.file_name().to_string_lossy().into_owned());
}
}
}
seen
});
// Big enough that the copy, the write and the fsync are a window a poller can see into.
atomic_write(&doc, &vec![b'z'; 32 * 1024 * 1024]).unwrap();
stop_tx.send(()).ok();
let seen = poller.join().unwrap();
let strays: Vec<&String> = seen
.iter()
.filter(|name| name.as_str() != "doc.md")
.filter(|name| !(name.starts_with(".doc.md.") && name.ends_with(".tmp")))
.collect();
assert!(strays.is_empty(), "a save put these beside the document: {strays:?}");
assert!(
seen.iter().any(|name| name.starts_with(".doc.md.")),
"the poller never caught the temp file, so this proved nothing: {seen:?}"
);
let left: Vec<String> = fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(left, vec!["doc.md".to_string()]);
}
/// A debounced autosave and a Cmd+S both in flight are two saves of one document. Naming the temp
/// file after the target made them race on one path, and the loser took the document with it.
#[test]
fn concurrent_saves_of_one_document_all_land_and_none_loses_it() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "the original");
let payloads: Vec<String> = (0..4)
.map(|n| format!("save number {n}\n{}\n", "z".repeat(1024 * 1024)))
.collect();
let writers: Vec<_> = payloads
.iter()
.cloned()
.map(|text| {
let doc = doc.clone();
std::thread::spawn(move || atomic_write(&doc, text.as_bytes()))
})
.collect();
for writer in writers {
writer
.join()
.expect("a writer thread")
.expect("every concurrent save succeeds");
}
let landed = fs::read_to_string(&doc).expect("the document is still there");
assert!(
payloads.contains(&landed),
"the document holds neither writer's text"
);
let left: Vec<String> = fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(left, vec!["doc.md".to_string()]);
}
#[test]
fn a_write_leaves_no_temp_and_no_backup_behind() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "one");
atomic_write(&doc, b"two").unwrap();
assert_eq!(fs::read_to_string(&doc).unwrap(), "two");
let mut left: Vec<String> = fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
left.sort();
assert_eq!(left, vec!["doc.md".to_string()]);
}
#[test]
fn a_write_keeps_the_permissions_the_file_had() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "one");
fs::set_permissions(&doc, fs::Permissions::from_mode(0o600)).unwrap();
atomic_write(&doc, b"two").unwrap();
let mode = fs::metadata(&doc).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn a_conflict_writes_nothing() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "what is on disk");
let seen = read_document(&doc).unwrap();
let result = write_document(&doc, "what the buffer holds", Some(seen.modified_ms - 5_000))
.expect("a conflict is a result and not an error");
assert!(result.conflict);
assert_eq!(fs::read_to_string(&doc).unwrap(), "what is on disk");
}
#[test]
fn the_timestamp_the_caller_saw_lets_the_write_through() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "one");
let seen = read_document(&doc).unwrap();
let result = write_document(&doc, "two", Some(seen.modified_ms)).unwrap();
assert!(!result.conflict);
assert_eq!(fs::read_to_string(&doc).unwrap(), "two");
}
#[test]
fn reading_a_document_writes_nothing() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "# Heading\n");
let before = fs::metadata(&doc).unwrap().modified().unwrap();
let read = read_document(&doc).unwrap();
assert_eq!(read.text, "# Heading\n");
assert_eq!(fs::metadata(&doc).unwrap().modified().unwrap(), before);
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
}
#[test]
fn a_document_that_is_not_utf8_is_an_error_and_not_a_lossy_read() {
let dir = root();
let doc = dir.path().join("doc.md");
fs::write(&doc, [0xff, 0xfe, 0x00, 0x41]).unwrap();
assert!(read_document(&doc).is_err());
}
#[test]
fn untitled_naming_does_not_collide() {
let dir = root();
let first = create_file(dir.path(), "untitled.md").unwrap();
let second = create_file(dir.path(), "untitled.md").unwrap();
let third = create_file(dir.path(), "untitled.md").unwrap();
assert_eq!(first.name, "untitled.md");
assert_eq!(second.name, "untitled-2.md");
assert_eq!(third.name, "untitled-3.md");
assert!(dir.path().join("untitled.md").exists());
assert!(dir.path().join("untitled-2.md").exists());
assert!(dir.path().join("untitled-3.md").exists());
assert!(first.editable);
assert_eq!(first.kind, "markdown");
}
#[test]
fn a_free_name_never_lands_on_a_file_that_is_there() {
let dir = root();
write(&dir.path().join("note.md"), "one");
write(&dir.path().join("note-2.md"), "two");
assert_eq!(free_path(dir.path(), "note.md"), dir.path().join("note-3.md"));
}
#[test]
fn a_new_folder_follows_the_same_rule() {
let dir = root();
let first = create_folder(dir.path(), "untitled").unwrap();
let second = create_folder(dir.path(), "untitled").unwrap();
assert_eq!(first.name, "untitled");
assert_eq!(first.kind, "dir");
assert!(!first.editable);
assert_eq!(second.name, "untitled-2");
}
#[test]
fn the_tree_skips_node_modules_and_the_rest() {
let dir = root();
write(&dir.path().join("note.md"), "note");
write(&dir.path().join("node_modules/left-pad/index.js"), "js");
write(&dir.path().join(".git/config"), "config");
write(&dir.path().join("target/debug/thing"), "binary");
write(&dir.path().join("dist/bundle.js"), "bundle");
let tree = scan_tree(dir.path(), false).unwrap();
assert_eq!(names(&tree), vec!["note.md".to_string()]);
}
#[test]
fn the_tree_respects_a_gitignore() {
let dir = root();
write(&dir.path().join(".gitignore"), "drafts/\nsecret.md\n");
write(&dir.path().join("note.md"), "note");
write(&dir.path().join("secret.md"), "secret");
write(&dir.path().join("drafts/half.md"), "half");
let tree = scan_tree(dir.path(), false).unwrap();
assert_eq!(names(&tree), vec!["note.md".to_string()]);
}
#[test]
fn showing_ignored_files_brings_them_back() {
let dir = root();
write(&dir.path().join(".gitignore"), "secret.md\n");
write(&dir.path().join("note.md"), "note");
write(&dir.path().join("secret.md"), "secret");
write(&dir.path().join("node_modules/left-pad/index.js"), "js");
let tree = scan_tree(dir.path(), true).unwrap();
let mut all = Vec::new();
flat(&tree, &mut all);
assert!(all.contains(&"secret.md".to_string()));
assert!(all.contains(&"node_modules".to_string()));
}
#[test]
fn the_tree_classifies_every_kind_and_sorts_folders_first() {
let dir = root();
write(&dir.path().join("zebra.md"), "md");
write(&dir.path().join("Apple.txt"), "txt");
write(&dir.path().join("photo.png"), "png");
write(&dir.path().join("beta/inner.markdown"), "md");
let tree = scan_tree(dir.path(), false).unwrap();
assert_eq!(
names(&tree),
vec![
"beta".to_string(),
"Apple.txt".to_string(),
"photo.png".to_string(),
"zebra.md".to_string(),
]
);
assert_eq!(find(&tree, "zebra.md").unwrap().kind, "markdown");
assert!(find(&tree, "Apple.txt").unwrap().editable);
assert_eq!(find(&tree, "Apple.txt").unwrap().kind, "text");
assert_eq!(find(&tree, "photo.png").unwrap().kind, "other");
assert!(!find(&tree, "photo.png").unwrap().editable);
assert_eq!(find(&tree, "beta").unwrap().children.len(), 1);
}
#[test]
fn a_symlink_loop_does_not_hang_the_scan() {
let dir = root();
write(&dir.path().join("note.md"), "note");
fs::create_dir(dir.path().join("inner")).unwrap();
symlink(dir.path(), dir.path().join("inner/loop")).unwrap();
let tree = scan_tree(dir.path(), false).unwrap();
let mut all = Vec::new();
flat(&tree, &mut all);
assert!(all.contains(&"note.md".to_string()));
}
#[test]
fn path_validation_rejects_an_escape() {
let dir = root();
let roots = vec![dir.path().to_string_lossy().into_owned()];
let escape = dir.path().join("../escaped.md");
assert!(resolve_in_roots(&roots, &escape.to_string_lossy()).is_err());
assert!(resolve_in_roots(&roots, "/etc/hosts").is_err());
assert!(resolve_in_roots(&roots, "notes/relative.md").is_err());
assert!(resolve_in_roots(&[], &dir.path().join("note.md").to_string_lossy()).is_err());
}
#[test]
fn path_validation_rejects_a_symlink_pointing_out_of_the_root() {
let inside = root();
let outside = root();
let secret = outside.path().join("secret.md");
write(&secret, "secret");
symlink(&secret, inside.path().join("link.md")).unwrap();
let roots = vec![inside.path().to_string_lossy().into_owned()];
let link = inside.path().join("link.md");
assert!(resolve_in_roots(&roots, &link.to_string_lossy()).is_err());
}
#[test]
fn path_validation_does_not_treat_a_sibling_as_a_child() {
let dir = root();
fs::create_dir(dir.path().join("notes")).unwrap();
fs::create_dir(dir.path().join("notes-old")).unwrap();
write(&dir.path().join("notes-old/note.md"), "note");
let roots = vec![dir.path().join("notes").to_string_lossy().into_owned()];
let sibling = dir.path().join("notes-old/note.md");
assert!(resolve_in_roots(&roots, &sibling.to_string_lossy()).is_err());
}
#[test]
fn path_validation_accepts_what_is_really_inside() {
let dir = root();
let doc = dir.path().join("folder/note.md");
write(&doc, "note");
let roots = vec![dir.path().to_string_lossy().into_owned()];
let resolved = resolve_in_roots(&roots, &doc.to_string_lossy()).unwrap();
assert_eq!(resolved, fs::canonicalize(&doc).unwrap());
// A file being created has no canonical path of its own, and it still has to be checked.
let unborn = dir.path().join("folder/new.md");
let resolved = resolve_in_roots(&roots, &unborn.to_string_lossy()).unwrap();
assert_eq!(
resolved,
fs::canonicalize(dir.path().join("folder")).unwrap().join("new.md")
);
}
#[test]
fn trash_does_not_hard_delete() {
let dir = root();
let unique = format!(
"margin-docs-trash-test-{}.md",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let doc = dir.path().join(&unique);
write(&doc, "recoverable");
trash_entry(&doc).unwrap();
assert!(!doc.exists(), "the file is gone from where it was");
let trashed = PathBuf::from(std::env::var("HOME").unwrap())
.join(".Trash")
.join(&unique);
assert!(trashed.exists(), "and it is sitting in the Trash instead");
// Best effort, because the contents of the Trash are not always readable or writable by a
// process that is not Finder. The test above is the assertion; this is only tidying up.
let _ = fs::remove_file(&trashed);
}
#[test]
fn a_rename_will_not_move() {
let dir = root();
let doc = dir.path().join("note.md");
write(&doc, "note");
assert!(rename_entry(&doc, "../elsewhere.md").is_err());
assert!(rename_entry(&doc, "sub/other.md").is_err());
assert!(rename_entry(&doc, " ").is_err());
assert!(doc.exists());
let renamed = rename_entry(&doc, "other.md").unwrap();
assert_eq!(renamed.name, "other.md");
assert!(dir.path().join("other.md").exists());
assert!(!doc.exists());
}
#[test]
fn a_rename_onto_a_name_that_is_taken_is_refused() {
let dir = root();
write(&dir.path().join("one.md"), "one");
write(&dir.path().join("two.md"), "two");
assert!(rename_entry(&dir.path().join("one.md"), "two.md").is_err());
assert_eq!(fs::read_to_string(dir.path().join("two.md")).unwrap(), "two");
}
#[test]
fn a_move_into_the_folder_it_is_already_in_does_nothing() {
let dir = root();
let doc = dir.path().join("note.md");
write(&doc, "note");
let node = move_entry(&doc, dir.path()).unwrap();
assert_eq!(node.name, "note.md");
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
}
#[test]
fn a_move_will_not_put_a_folder_inside_itself() {
let dir = root();
let outer = dir.path().join("outer");
let inner = outer.join("inner");
fs::create_dir_all(&inner).unwrap();
assert!(move_entry(&outer, &inner).is_err());
assert!(move_entry(&outer, &outer).is_err());
}
#[test]
fn a_move_keeps_the_name_unless_it_is_taken() {
let dir = root();
let from = dir.path().join("from");
let to = dir.path().join("to");
fs::create_dir_all(&from).unwrap();
fs::create_dir_all(&to).unwrap();
write(&from.join("note.md"), "moved");
write(&to.join("note.md"), "already here");
let node = move_entry(&from.join("note.md"), &to).unwrap();
assert_eq!(node.name, "note-2.md");
assert_eq!(fs::read_to_string(to.join("note.md")).unwrap(), "already here");
assert_eq!(fs::read_to_string(to.join("note-2.md")).unwrap(), "moved");
}
#[test]
fn duplicating_copies_a_folder_and_everything_under_it() {
let dir = root();
write(&dir.path().join("project/note.md"), "note");
write(&dir.path().join("project/deep/inner.md"), "inner");
let node = duplicate_entry(&dir.path().join("project")).unwrap();
assert_eq!(node.name, "project-2");
assert_eq!(
fs::read_to_string(dir.path().join("project-2/deep/inner.md")).unwrap(),
"inner"
);
assert_eq!(fs::read_to_string(dir.path().join("project/note.md")).unwrap(), "note");
}
#[test]
fn duplicating_a_document_is_byte_for_byte() {
let dir = root();
let doc = dir.path().join("note.md");
write(&doc, "---\ntitle: x\n---\n\n# ragged heading\n");
let node = duplicate_entry(&doc).unwrap();
assert_eq!(node.name, "note-2.md");
assert_eq!(
fs::read_to_string(dir.path().join("note-2.md")).unwrap(),
"---\ntitle: x\n---\n\n# ragged heading\n"
);
}
#[test]
fn a_pasted_image_lands_in_assets_beside_the_document() {
let dir = root();
let doc = dir.path().join("folder/note.md");
write(&doc, "note");
let first = write_asset(&doc, &[0x89, 0x50, 0x4e, 0x47], "image.png").unwrap();
let second = write_asset(&doc, &[0x89, 0x50, 0x4e, 0x47], "image.png").unwrap();
assert_eq!(first.rel_path, "assets/image.png");
assert_eq!(second.rel_path, "assets/image-2.png");
assert_eq!(
fs::read(dir.path().join("folder/assets/image.png")).unwrap(),
vec![0x89, 0x50, 0x4e, 0x47]
);
assert!(Path::new(&first.path).is_absolute());
}
#[test]
fn a_suggested_asset_name_is_a_name_and_not_a_path() {
let dir = root();
let doc = dir.path().join("note.md");
write(&doc, "note");
let result = write_asset(&doc, &[1, 2, 3], "../../../evil.png").unwrap();
assert_eq!(result.rel_path, "assets/evil.png");
assert!(dir.path().join("assets/evil.png").exists());
}
#[test]
fn a_root_id_is_the_same_folder_every_time() {
assert_eq!(root_id_for("/Users/x/notes"), root_id_for("/Users/x/notes"));
assert_ne!(root_id_for("/Users/x/notes"), root_id_for("/Users/x/other"));
assert_eq!(root_id_for("/Users/x/notes").len(), 16);
}
+18
View File
@@ -0,0 +1,18 @@
// Full text search is load bearing for the index in M3, and it arrives through libsqlite3-sys's
// bundled build rather than through a cargo feature we can see in Cargo.toml. A dependency bump
// could drop it silently, so the assumption is asserted rather than assumed.
#[test]
fn fts5_is_compiled_in() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE VIRTUAL TABLE probe USING fts5(path, body);
INSERT INTO probe(path, body) VALUES ('a.md', 'the quick brown fox');
INSERT INTO probe(path, body) VALUES ('b.md', 'lazy dog sleeping');",
)
.expect("fts5 virtual table must be creatable");
let hit: String = conn
.query_row("SELECT path FROM probe WHERE probe MATCH 'brown'", [], |r| r.get(0))
.unwrap();
assert_eq!(hit, "a.md");
}
File diff suppressed because it is too large. Load diff
+428
View File
@@ -0,0 +1,428 @@
// The watcher against a real folder and real filesystem calls, because every interesting thing it
// does is a reaction to what the kernel actually reports rather than to what notify's documentation
// says it reports. FSEvents sets `ItemCreated` on every event it ever emits for a path, describes
// an atomic save as a rename with no relation to the file it replaced, and spells every path
// through /private. None of that is visible from the types.
//
// Waiting is done by writing a probe file and waiting for its event, not by sleeping. Batches are
// delivered oldest first, so the probe's event arriving is proof that everything caused before it
// has already been delivered, which is what makes "nothing was reported" a bounded assertion rather
// than a guess at how long to wait. The one deliberate sleep is in the fixture helper, where a file
// has to be older than the watcher's own idea of newly born for the test to mean anything.
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::mpsc::{self, Receiver};
use std::time::{Duration, Instant};
use margin_docs_lib::dto::WatchEvent;
use margin_docs_lib::fs::write_document;
use margin_docs_lib::watch::{note_self_write, spawn_watcher};
use notify::RecommendedWatcher;
use notify_debouncer_full::{Debouncer, NoCache};
use tempfile::TempDir;
/// How long a test waits for the watcher to prove it is running before calling it broken.
const DEADLINE: Duration = Duration::from_secs(15);
/// The debouncer holds an event for 300ms and ticks every quarter of that, so a batch that has not
/// arrived in this long is not on its way.
const QUIET: Duration = Duration::from_millis(900);
/// Comfortably more than the 250ms within which the watcher counts a file as newly born, so that a
/// fixture written by the test is unambiguously a file that was already there.
const AGE: Duration = Duration::from_millis(600);
const ROOT: &str = "root-1";
struct Harness {
/// Declared first so the watcher stops before the folder it is watching is deleted.
_watcher: Debouncer<RecommendedWatcher, NoCache>,
dir: TempDir,
rx: Receiver<WatchEvent>,
probes: AtomicU32,
}
impl Harness {
fn path(&self, name: &str) -> PathBuf {
self.dir.path().join(name)
}
/// Waits until the watcher is up and throws away whatever it has reported so far.
fn sync(&self) {
self.drain();
}
/// Everything reported up to a fresh probe file, the probe events themselves left out.
fn drain(&self) -> Vec<WatchEvent> {
let give_up = Instant::now() + DEADLINE;
let mut seen = Vec::new();
loop {
let n = self.probes.fetch_add(1, Ordering::Relaxed);
let probe = self.path(&format!("probe-{n}.md"));
fs::write(&probe, format!("probe {n}\n")).unwrap();
let want = probe.to_string_lossy().into_owned();
loop {
match self.rx.recv_timeout(QUIET) {
Ok(event) if event.path == want => return seen,
Ok(event) => {
if !is_probe(&event.path) {
seen.push(event);
}
}
// Nothing is arriving at all, so the watcher was not yet up when the probe was
// written. Write another one.
Err(_) => break,
}
}
assert!(
Instant::now() < give_up,
"the watcher never reported anything"
);
}
}
fn events_for(&self, path: &Path) -> Vec<WatchEvent> {
let want = path.to_string_lossy().into_owned();
self.drain()
.into_iter()
.filter(|event| event.path == want)
.collect()
}
}
/// A watched folder holding `fixtures`, all of them old enough to count as files that were already
/// there when the watch started.
fn harness(fixtures: &[&str]) -> Harness {
let dir = tempfile::tempdir().unwrap();
for name in fixtures {
let path = dir.path().join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, format!("# {name}\n")).unwrap();
}
if !fixtures.is_empty() {
std::thread::sleep(AGE);
}
let (tx, rx) = mpsc::channel();
let watcher = spawn_watcher(
ROOT.to_string(),
dir.path().to_string_lossy().into_owned(),
move |events| {
for event in events {
tx.send(event).ok();
}
},
)
.unwrap();
Harness {
_watcher: watcher,
dir,
rx,
probes: AtomicU32::new(0),
}
}
fn is_probe(path: &str) -> bool {
Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("probe-"))
}
fn one<'a>(events: &'a [WatchEvent], what: &str) -> &'a WatchEvent {
assert_eq!(events.len(), 1, "{what}, got {events:?}");
&events[0]
}
#[test]
fn a_created_file_is_reported_created() {
let harness = harness(&[]);
harness.sync();
let note = harness.path("note.md");
fs::write(&note, "# Note\n").unwrap();
let events = harness.events_for(&note);
let event = one(&events, "a new file is one event");
assert_eq!(event.kind, "created");
assert_eq!(event.root, ROOT);
assert_eq!(event.old_path, None);
assert_eq!(event.path, note.to_string_lossy());
}
#[test]
fn a_modified_file_is_reported_modified() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
harness.sync();
fs::write(&note, "# Note\n\nA second paragraph.\n").unwrap();
let events = harness.events_for(&note);
let event = one(&events, "a write to an existing file is one event");
assert_eq!(event.kind, "modified");
assert_eq!(event.old_path, None);
}
#[test]
fn a_deleted_file_is_reported_removed() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
harness.sync();
fs::remove_file(&note).unwrap();
let events = harness.events_for(&note);
assert_eq!(one(&events, "a delete is one event").kind, "removed");
}
#[test]
fn a_self_write_is_reported_as_nothing() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
harness.sync();
note_self_write(&note);
fs::write(&note, "# Note\n\nWritten by the app itself.\n").unwrap();
let events = harness.events_for(&note);
assert!(events.is_empty(), "the app's own write echoed: {events:?}");
}
#[test]
fn the_apps_own_atomic_save_is_reported_as_nothing() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
let temp = harness.path("note.md.tmp");
harness.sync();
// Both halves have to be registered. The rename names the temp file as well as the document,
// and either name getting through would let the echo through with it.
note_self_write(&temp);
note_self_write(&note);
fs::write(&temp, "# Note\n\nWritten by the app itself.\n").unwrap();
fs::rename(&temp, &note).unwrap();
let events = harness.drain();
assert!(
events.is_empty(),
"the app's own atomic save echoed: {events:?}"
);
}
/// The two tests above register the paths by hand, which proves the mechanism and not that anything
/// uses it. This one goes through the real write path: a save of a document reaches the frontend as
/// nothing at all, neither the document nor the temp file it went through.
#[test]
fn a_real_save_is_reported_as_nothing() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
harness.sync();
write_document(&note, "# Note\n\nSaved by the app itself.\n", None).unwrap();
let events = harness.drain();
assert!(
events.is_empty(),
"the app's own save came back as an external change: {events:?}"
);
}
#[test]
fn a_self_write_stops_suppressing_once_the_window_is_up() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
harness.sync();
note_self_write(&note);
fs::write(&note, "one\n").unwrap();
assert!(harness.events_for(&note).is_empty());
// The suppression is a window and not a switch: a later write to the same path, by the app or
// by anything else, has to come through again once the window is up.
std::thread::sleep(Duration::from_millis(2_100));
fs::write(&note, "two\n").unwrap();
let events = harness.events_for(&note);
assert_eq!(
one(&events, "the write after the window is one event").kind,
"modified"
);
}
#[test]
fn a_burst_of_writes_is_not_a_burst_of_events() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
harness.sync();
for n in 0..10 {
fs::write(&note, format!("# Note\n\nRevision {n}.\n")).unwrap();
}
let events = harness.events_for(&note);
assert!(
(1..=2).contains(&events.len()),
"ten writes should coalesce, got {events:?}"
);
assert!(events.iter().all(|event| event.kind == "modified"));
}
#[test]
fn a_rename_is_reported_at_both_ends() {
let harness = harness(&["before.md"]);
let before = harness.path("before.md");
let after = harness.path("after.md");
harness.sync();
fs::rename(&before, &after).unwrap();
let events = harness.drain();
let gone = events
.iter()
.find(|event| event.path == before.to_string_lossy())
.unwrap_or_else(|| panic!("the old name was not reported: {events:?}"));
assert_eq!(gone.kind, "removed");
// Not `renamed`. FSEvents reports the two ends as unrelated events and marks both of them
// created, which defeats the debouncer's attempt to pair them up, so the honest report is that
// one name went away and another appeared.
let arrived = events
.iter()
.find(|event| event.path == after.to_string_lossy())
.unwrap_or_else(|| panic!("the new name was not reported: {events:?}"));
assert_ne!(arrived.kind, "removed");
if let Some(old) = &arrived.old_path {
assert_eq!(old, &*before.to_string_lossy());
}
}
#[test]
fn another_editors_atomic_save_is_one_event_on_the_document() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
let temp = harness.path(".note.md.tmp");
harness.sync();
fs::write(&temp, "# Note\n\nSaved by something else.\n").unwrap();
fs::rename(&temp, &note).unwrap();
let events = harness.drain();
assert!(
events
.iter()
.all(|event| event.path != temp.to_string_lossy()),
"the temp file was reported as if it were a document: {events:?}"
);
let on_note: Vec<_> = events
.into_iter()
.filter(|event| event.path == note.to_string_lossy())
.collect();
let event = one(&on_note, "an atomic save is one event on the document");
assert_ne!(event.kind, "removed");
assert_eq!(
event.old_path, None,
"the document was reported as renamed from a temp file it never was"
);
}
#[test]
fn hidden_paths_are_not_reported() {
let harness = harness(&[]);
harness.sync();
fs::create_dir_all(harness.path(".git")).unwrap();
fs::write(harness.path(".git/index"), "not a document").unwrap();
fs::write(harness.path(".DS_Store"), "not a document either").unwrap();
let note = harness.path("note.md");
fs::write(&note, "# Note\n").unwrap();
let events = harness.drain();
assert!(
events
.iter()
.all(|event| event.path == note.to_string_lossy()),
"hidden paths reached the frontend: {events:?}"
);
}
#[test]
fn nested_changes_are_reported_under_the_path_the_root_was_opened_as() {
let harness = harness(&["sub/deep.md"]);
let nested = harness.path("sub/deep.md");
harness.sync();
fs::write(&nested, "# Deep\n\nEdited.\n").unwrap();
let events = harness.events_for(&nested);
let event = one(&events, "a nested file is one event");
assert_eq!(event.kind, "modified");
// Not the /private form FSEvents hands out, which nothing else in the app spells that way.
assert!(event
.path
.starts_with(&*harness.dir.path().to_string_lossy()));
}
#[test]
fn a_deleted_root_is_reported_removed() {
let outer = tempfile::tempdir().unwrap();
let root = outer.path().join("notes");
fs::create_dir(&root).unwrap();
fs::write(root.join("note.md"), "# Note\n").unwrap();
let (tx, rx) = mpsc::channel();
let watcher = spawn_watcher(
ROOT.to_string(),
root.to_string_lossy().into_owned(),
move |events| {
for event in events {
tx.send(event).ok();
}
},
)
.unwrap();
let give_up = Instant::now() + DEADLINE;
let mut live = false;
let mut probe = 0;
while !live {
fs::write(root.join(format!("probe-{probe}.md")), "probe\n").unwrap();
probe += 1;
live = rx.recv_timeout(QUIET).is_ok();
assert!(
live || Instant::now() < give_up,
"the watcher never reported anything"
);
}
fs::remove_dir_all(&root).unwrap();
let want = root.to_string_lossy().into_owned();
let give_up = Instant::now() + DEADLINE;
let mut removed = false;
while !removed && Instant::now() < give_up {
match rx.recv_timeout(QUIET) {
Ok(event) => removed = event.path == want && event.kind == "removed",
Err(_) => break,
}
}
assert!(removed, "deleting the root reported nothing");
drop(watcher);
}
#[test]
fn a_watch_on_a_folder_that_is_not_there_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("gone");
let started = spawn_watcher(
ROOT.to_string(),
missing.to_string_lossy().into_owned(),
|_| {},
);
assert!(started.is_err());
}
+214
View File
@@ -0,0 +1,214 @@
// The shape of a `watch-event` as the frontend receives it, rather than as Rust holds it.
//
// src-tauri/tests/watch.rs proves the watcher reports the right things about the right paths, but
// it asserts against `WatchEvent`'s Rust fields, and the frontend never sees those. What crosses
// the IPC boundary is serde's JSON, and the frontend reads `event.payload.oldPath` off it. A
// missing `#[serde(rename_all = "camelCase")]` would leave every Rust test green and hand the
// frontend `old_path`, which reads as `undefined`, which is neither the string nor the null the
// TypeScript type promises. Nothing else in the suite would notice.
//
// So this file drives the same real watcher over a real folder and asserts the serialized object
// exactly: every key, no extra keys, and the literal values the TypeScript union in src/ipc.ts
// lists. Between this and the Playwright suite in tests/external-changes.spec.ts, which feeds
// payloads of this shape through the real Tauri listener into the real UI, both ends of the wire
// are pinned to the same object.
//
// The waiting strategy is the one watch.rs uses and for the same reason: a probe file whose event
// proves everything caused before it has already been delivered.
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::mpsc::{self, Receiver};
use std::time::{Duration, Instant};
use margin_docs_lib::dto::WatchEvent;
use margin_docs_lib::watch::spawn_watcher;
use notify::RecommendedWatcher;
use notify_debouncer_full::{Debouncer, NoCache};
use serde_json::{json, Value};
use tempfile::TempDir;
const DEADLINE: Duration = Duration::from_secs(15);
const QUIET: Duration = Duration::from_millis(900);
const AGE: Duration = Duration::from_millis(600);
const ROOT: &str = "root-1";
struct Harness {
_watcher: Debouncer<RecommendedWatcher, NoCache>,
dir: TempDir,
rx: Receiver<WatchEvent>,
probes: AtomicU32,
}
impl Harness {
fn path(&self, name: &str) -> PathBuf {
self.dir.path().join(name)
}
fn sync(&self) {
self.drain();
}
fn drain(&self) -> Vec<WatchEvent> {
let give_up = Instant::now() + DEADLINE;
let mut seen = Vec::new();
loop {
let n = self.probes.fetch_add(1, Ordering::Relaxed);
let probe = self.path(&format!("probe-{n}.md"));
fs::write(&probe, format!("probe {n}\n")).unwrap();
let want = probe.to_string_lossy().into_owned();
loop {
match self.rx.recv_timeout(QUIET) {
Ok(event) if event.path == want => return seen,
Ok(event) => {
if !is_probe(&event.path) {
seen.push(event);
}
}
Err(_) => break,
}
}
assert!(
Instant::now() < give_up,
"the watcher never reported anything"
);
}
}
/// The one event for `path`, as the JSON object the frontend will be handed.
fn payload_for(&self, path: &Path) -> Value {
let want = path.to_string_lossy().into_owned();
let events: Vec<WatchEvent> = self
.drain()
.into_iter()
.filter(|event| event.path == want)
.collect();
assert_eq!(events.len(), 1, "expected one event, got {events:?}");
serde_json::to_value(&events[0]).unwrap()
}
}
fn harness(fixtures: &[&str]) -> Harness {
let dir = tempfile::tempdir().unwrap();
for name in fixtures {
let path = dir.path().join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, format!("# {name}\n")).unwrap();
}
if !fixtures.is_empty() {
std::thread::sleep(AGE);
}
let (tx, rx) = mpsc::channel();
let watcher = spawn_watcher(
ROOT.to_string(),
dir.path().to_string_lossy().into_owned(),
move |events| {
for event in events {
tx.send(event).ok();
}
},
)
.unwrap();
Harness {
_watcher: watcher,
dir,
rx,
probes: AtomicU32::new(0),
}
}
fn is_probe(path: &str) -> bool {
Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("probe-"))
}
/// Exactly these four keys, spelled the way `WatchEvent` in src/ipc.ts spells them.
fn expect_payload(actual: &Value, path: &Path, kind: &str) {
assert_eq!(
actual,
&json!({
"root": ROOT,
"path": path.to_string_lossy(),
"kind": kind,
"oldPath": Value::Null,
}),
"the payload the frontend receives is not the object it is typed as"
);
}
#[test]
fn an_external_edit_serialises_as_the_frontend_reads_it() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
harness.sync();
fs::write(&note, "# Note\n\nEdited by another program.\n").unwrap();
expect_payload(&harness.payload_for(&note), &note, "modified");
}
#[test]
fn a_created_file_serialises_as_the_frontend_reads_it() {
let harness = harness(&[]);
harness.sync();
let note = harness.path("note.md");
fs::write(&note, "# Note\n").unwrap();
expect_payload(&harness.payload_for(&note), &note, "created");
}
#[test]
fn a_deleted_file_serialises_as_the_frontend_reads_it() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
harness.sync();
fs::remove_file(&note).unwrap();
expect_payload(&harness.payload_for(&note), &note, "removed");
}
/// `old_path` is the one field whose name differs between the two languages, and the only one that
/// is ever anything but a plain string. A rename is where it would be filled in if it ever were,
/// so this is where a wrong spelling would do its damage.
#[test]
fn old_path_is_spelled_the_way_the_frontend_reads_it() {
let renamed = WatchEvent {
root: ROOT.to_string(),
path: "/tmp/after.md".to_string(),
kind: "renamed".to_string(),
old_path: Some("/tmp/before.md".to_string()),
};
assert_eq!(
serde_json::to_value(&renamed).unwrap(),
json!({
"root": ROOT,
"path": "/tmp/after.md",
"kind": "renamed",
"oldPath": "/tmp/before.md",
})
);
}
/// A source-literal check and nothing more: it cannot see a running app. What it does catch is the
/// one silent break the runtime tests on either side cannot, because each side is internally
/// consistent with its own constant. Rename the event on one side and the frontend simply stops
/// hearing anything, with every test still green.
#[test]
fn both_sides_name_the_event_the_same_string() {
assert!(
include_str!("../src/watch.rs").contains(r#"const WATCH_EVENT: &str = "watch-event";"#),
"the backend no longer emits under `watch-event`"
);
assert!(
include_str!("../../src/ipc.ts").contains(r#"export const WATCH_EVENT = "watch-event";"#),
"the frontend no longer listens for `watch-event`"
);
}
+204
View File
@@ -0,0 +1,204 @@
// The window, and the only file that knows what the whole app looks like at once.
//
// Everything here is wiring: which surface is on screen, which of the backend's events the shell
// listens for, and what happens to an unsaved document when the window is asked to close. No
// business logic and no disk access. The stores hold state, their sibling modules do the work, and
// this file decides what is mounted.
//
// One document at a time and no tab bar, so there is exactly one editor in the tree and it is
// either the WYSIWYG surface or the plain text one, never both. A folder of documents can be open
// with nothing chosen out of it, which is why the empty pane is a state and not an error.
import { useEffect, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Backlinks } from "./components/Backlinks";
import { CommandPalette } from "./components/CommandPalette";
import { ConflictDialog } from "./components/ConflictDialog";
import { FindBar } from "./components/FindBar";
import { FindInFiles } from "./components/FindInFiles";
import { ProofPopover } from "./components/ProofPopover";
import { QuickOpen } from "./components/QuickOpen";
import { Recents } from "./components/Recents";
import { Shortcuts } from "./components/Shortcuts";
import { Sidebar } from "./components/Sidebar";
import { Titlebar } from "./components/Titlebar";
import { Toast } from "./components/Toast";
import { flushPendingSave, keepBuffer } from "./document";
import { DocumentEditor, PlainTextEditor, useDocumentFind } from "./editor";
import { Toolbar, type ToolbarSaveState } from "./editor/Toolbar";
import { MENU_ACTION_EVENT, isDesktop, isTauri } from "./ipc";
import { onCommand, type CommandId } from "./keys/commands";
import { useKeymap } from "./keys/keymap";
import { handleMenuAction } from "./keys/menu";
import { openLink } from "./links";
import { documentKindForPath } from "./model/doc";
import { useDocument } from "./store/useDocument";
import { notify } from "./store/useToast";
import { useWorkspace } from "./store/useWorkspace";
import { useCompact, useTouch } from "./useMedia";
import { applyWidth } from "./width";
import { restoreSession, startWorkspaceEvents } from "./workspace";
/**
* Commands whose whole result is a panel that this milestone does not have. They are bound keys
* and native menu rows already, so pressing one has to say something: a key that silently does
* nothing reads as a broken app rather than as an unfinished one. Each line goes when its panel
* arrives.
*/
const UNBUILT: ReadonlyArray<[CommandId, string]> = [
["settings", "There is no settings panel yet. The theme is in the title bar."],
];
const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
function App() {
const roots = useWorkspace((s) => s.roots);
const path = useDocument((s) => s.path);
const openDocument = useDocument((s) => s.document);
const savePhase = useDocument((s) => s.savePhase);
const externalChange = useDocument((s) => s.externalChange);
const setContent = useDocument((s) => s.setContent);
const reloadFromDisk = useDocument((s) => s.reloadFromDisk);
const find = useDocumentFind();
const [resolving, setResolving] = useState(false);
useKeymap();
useCompact();
useTouch();
useEffect(() => {
void restoreSession();
}, []);
// `watch-event` and `index-progress`, both of them landing in the stores that care. The routing
// itself belongs to src/workspace.ts, which is the module that already knows which root a path
// sits under and whether the open document was the file that moved.
useEffect(() => startWorkspaceEvents(), []);
// The native menu emits a command id, so this is a lookup and not a second dispatch table. A
// phone has a menu bar to emit from too, hence isTauri rather than isDesktop.
useEffect(() => {
if (!isTauri) return;
const pending = listen<string>(MENU_ACTION_EVENT, (event) => handleMenuAction(event.payload));
return () => {
void pending.then((stop) => stop()).catch(() => {});
};
}, []);
// Quitting with an edit half a second old must not lose it. The debounce is cancelled and the
// save run to completion before the window is allowed to go, and the close is only intercepted
// when there is actually something to write.
useEffect(() => {
if (!isDesktop) return;
const win = getCurrentWindow();
const pending = win.onCloseRequested(async (event) => {
if (!useDocument.getState().dirty) return;
event.preventDefault();
await flushPendingSave();
void win.destroy();
});
return () => {
void pending.then((stop) => stop()).catch(() => {});
};
}, []);
useEffect(() => {
const stops = [
onCommand("editor-width-narrow", () => applyWidth("narrow")),
onCommand("editor-width-normal", () => applyWidth("normal")),
onCommand("editor-width-wide", () => applyWidth("wide")),
...UNBUILT.map(([id, message]) => onCommand(id, () => notify(message))),
];
return () => {
for (const stop of stops) stop();
};
}, []);
const conflict = externalChange === "changed-on-disk";
// Asked once, when the conflict appears. Dismissing it leaves the warning in the toolbar to
// reopen rather than asking again on the next keystroke.
useEffect(() => {
if (conflict) setResolving(true);
}, [conflict]);
const kind = path === null ? null : documentKindForPath(path);
const saveState: ToolbarSaveState = conflict
? "conflict"
: savePhase === "saving"
? "saving"
: "idle";
return (
<div className="app">
<Titlebar />
<div className="stage">
{roots.length === 0 ? (
<Recents />
) : (
<>
<Sidebar />
<main className="editor-pane">
{openDocument === null || kind === null ? (
<p className="pane-empty">Choose a document from the sidebar.</p>
) : kind === "markdown" ? (
<>
<article className="sheet">
<DocumentEditor
document={openDocument}
onChange={setContent}
onOpenLink={openLink}
editable={!resolving}
/>
<Backlinks />
</article>
<Toolbar
document={openDocument}
saveState={saveState}
onResolveConflict={() => setResolving(true)}
/>
</>
) : (
<article className="sheet">
<PlainTextEditor
document={openDocument}
onChange={setContent}
editable={!resolving}
/>
</article>
)}
</main>
</>
)}
</div>
<FindBar find={find} />
<QuickOpen />
<FindInFiles />
<CommandPalette />
<ProofPopover />
<Shortcuts />
<Toast />
{resolving && conflict && path !== null && (
<ConflictDialog
name={baseName(path)}
onReload={() => {
setResolving(false);
reloadFromDisk().catch((e) => notify(`Could not reload: ${String(e)}`));
}}
onKeep={() => {
setResolving(false);
keepBuffer();
}}
onDismiss={() => setResolving(false)}
/>
)}
</div>
);
}
export default App;
+36
View File
@@ -0,0 +1,36 @@
import { call, type AssetResult, type FileNode, type ReadResult, type WriteResult } from "../ipc";
export const fileRead = (path: string) => call<ReadResult>("file_read", { path });
/**
* Writes through a temporary file and a rename, so a crash mid-write leaves the old document
* whole. Pass the `modifiedMs` the buffer was read at: if the file has moved on since, nothing is
* written and the result comes back with `conflict`.
*/
export const fileWrite = (path: string, text: string, expectedModifiedMs?: number) =>
call<WriteResult>("file_write", { path, text, expectedModifiedMs });
/** `name` is a suggestion. A taken name gets a suffix, and the node returned carries the real one. */
export const fileCreate = (parentPath: string, name: string) =>
call<FileNode>("file_create", { parentPath, name });
export const fileFolderCreate = (parentPath: string, name: string) =>
call<FileNode>("file_folder_create", { parentPath, name });
export const fileRename = (path: string, name: string) =>
call<FileNode>("file_rename", { path, name });
export const fileMove = (path: string, destDir: string) =>
call<FileNode>("file_move", { path, destDir });
export const fileDuplicate = (path: string) => call<FileNode>("file_duplicate", { path });
/** To the system Trash, never an unlink. Deleting a document is undoable in Finder. */
export const fileTrash = (path: string) => call<void>("file_trash", { path });
/**
* A pasted image, into an `assets/` folder beside the document. `bytes` is the clipboard payload
* and `name` the filename it suggested, which is usually `image.png` and usually already taken.
*/
export const assetWrite = (docPath: string, bytes: number[], name: string) =>
call<AssetResult>("asset_write", { docPath, bytes, name });
+16
View File
@@ -0,0 +1,16 @@
import { call, type Backlink, type IndexStatus, type QuickOpenHit, type SearchHit } from "../ipc";
/** Rescans every open root. Progress arrives on the `index-progress` event. */
export const indexRebuild = () => call<IndexStatus>("index_rebuild");
export const indexStatus = () => call<IndexStatus>("index_status");
/** Fuzzy match over paths relative to their root, across every open root. */
export const searchQuickOpen = (query: string, limit: number) =>
call<QuickOpenHit[]>("search_quick_open", { query, limit });
export const searchText = (query: string, limit: number) =>
call<SearchHit[]>("search_text", { query, limit });
/** Which documents link to this one, for the section at the end of the document. */
export const backlinksFor = (path: string) => call<Backlink[]>("backlinks_for", { path });
+16
View File
@@ -0,0 +1,16 @@
import { call, type FileNode, type RootInfo } from "../ipc";
export const rootsList = () => call<RootInfo[]>("roots_list");
/** `path` comes from the native folder picker. Opening a folder never writes anything into it. */
export const rootOpen = (path: string) => call<RootInfo>("root_open", { path });
export const rootClose = (rootId: string) => call<void>("root_close", { rootId });
/** The whole tree for one root, root node included. */
export const treeRead = (rootId: string) => call<FileNode>("tree_read", { rootId });
export const revealInFinder = (path: string) => call<void>("reveal_in_finder", { path });
/** Hands a file to whatever macOS opens it with. The only way to open a non-editable file. */
export const openExternal = (path: string) => call<void>("open_external", { path });
+35
View File
@@ -0,0 +1,35 @@
// Spelling, which is the system's and not this app's.
//
// Everything here goes to NSSpellChecker, the same checker Mail and Notes correct into, so a word
// learned anywhere on the machine is a word this editor does not underline and the user's own
// languages are already configured. Nothing in this app ships a dictionary or has an opinion about
// English.
//
// The checker is asked about a run of text and answers about that run. It has no idea a document
// exists, which is what keeps the caller free to send it a paragraph, a visible screenful or one
// sentence, and to decide for itself what a stale answer is worth.
import { call, type SpellIssue } from "../ipc";
/**
* Every misspelling in one run of text, with offsets in characters counted from the start of that
* run. The caller adds its own base offset; this never sees a document position.
*/
export const spellCheck = (text: string) => call<SpellIssue[]>("spell_check", { text });
/**
* Teaches the word to the system, for every app on the machine and not only this one. That is the
* honest behaviour for a checker borrowed from the OS, and it is what the "Learn Spelling" item in
* every other Mac app does.
*/
export const spellLearn = (word: string) => call<void>("spell_learn", { word });
/** Undoes a `spellLearn`, for a word taught by a slip of the hand. */
export const spellUnlearn = (word: string) => call<void>("spell_unlearn", { word });
/**
* Whether the machine has a checker at all. False on a build that is not macOS, where the answer
* to every check is an empty list rather than an error, and the UI hides itself rather than
* offering a menu that cannot do anything.
*/
export const spellAvailable = () => call<boolean>("spell_available");
+6
View File
@@ -0,0 +1,6 @@
import { call } from "../ipc";
/** Changes arrive on the `watch-event` event, never as a return value. */
export const watchStart = (rootId: string) => call<void>("watch_start", { rootId });
export const watchStop = (rootId: string) => call<void>("watch_stop", { rootId });
+138
View File
@@ -0,0 +1,138 @@
// The "Linked from" section: the documents elsewhere on disk that point at the one on screen.
//
// Nothing here is content. A backlink exists because of bytes in somebody else's file, so it is
// never in the ProseMirror document, never serialized and never written; it is drawn after the last
// block and that is the whole of its existence. Which is why this is a sibling of the editor inside
// the sheet rather than a node at the end of it: it shares the paper and the measure with the
// document and shares nothing else, and a caret cannot land in a section that was never in the
// editable, nor can a select all inside the editor reach it.
//
// Silence is the default and it is the point. No section under a document nothing links to, and no
// section before the index has finished a pass, because "nothing links here" and "I have not looked
// yet" are different facts and only one of them has earned a heading.
//
// The open document and the index are read from their stores rather than passed in, so the mount in
// App.tsx is a bare tag. This component already has to watch the index to know whether its answer
// means anything, so it is subscribed either way, and a prop would only put half of what it needs
// through the shell while the other half went round it.
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { backlinksFor } from "../api";
import type { Backlink } from "../ipc";
import { useDocument } from "../store/useDocument";
import { useIndex } from "../store/useIndex";
import { notify } from "../store/useToast";
interface Answer {
/** The document these were asked for, kept with them so a slow reply about the file that was open
* a moment ago is never drawn under the file that is open now. */
path: string;
/** Null is "asked, and could not be told". It draws the same nothing an empty list does, and that
* is a decision rather than an accident: a writer cannot act on a failed index lookup, and a
* permanent error line under every document costs more attention than the feature is worth. The
* two are still not the same fact, so they are not the same value here, and this is the one place
* that could ever tell them apart. */
links: Backlink[] | null;
}
const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path;
/**
* A snippet is the source line the link sits on, so the one construct every snippet is guaranteed
* to contain is the link that made it a backlink, and an editor whose whole pitch is that markdown
* syntax is never visible should not be the thing putting `](../thing.md)` on screen. The link is
* unwrapped to its text and nothing else is: everything else a line might hold is not certain to be
* there, and unwrapping it would be a second markdown reader living in a view.
*/
const readableSnippet = (snippet: string): string =>
snippet.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").trim();
export function Backlinks() {
const path = useDocument((s) => s.path);
const open = useDocument((s) => s.open);
const phase = useIndex((s) => s.phase);
const [answer, setAnswer] = useState<Answer | null>(null);
const [active, setActive] = useState(0);
const rows = useRef<(HTMLButtonElement | null)[]>([]);
// Two triggers, both of them in the dependencies: a different document to ask about, and a pass of
// the index finishing. The second is what keeps the section true when somebody edits another file
// and the watcher reindexes it, since `index-progress` lands in useIndex and comes out as a phase.
//
// Anything short of a completed pass is not asked at all, and mid-pass the previous answer is left
// on screen: a reindex is not new information about this document, and blanking the section for
// the duration would be a flicker that says something changed when nothing has.
useEffect(() => {
if (path === null || phase !== "ready") return;
let cancelled = false;
backlinksFor(path)
.then((links) => {
if (!cancelled) setAnswer({ path, links });
})
.catch(() => {
if (!cancelled) setAnswer({ path, links: null });
});
return () => {
cancelled = true;
};
}, [path, phase]);
// Matched against the open path at render rather than cleared in an effect, so switching documents
// cannot paint one frame of the last one's links before the effect catches up.
const links = answer !== null && answer.path === path ? answer.links : null;
if (links === null || links.length === 0) return null;
// Roving focus: the section is one stop in the tab order however many rows it has, and the arrows
// move inside it. A row per tab stop would make tabbing out of a well linked document a chore
// through chrome, and taking the rows out of the tab order entirely would leave them mouse only.
const focused = active < links.length ? active : 0;
const move = (delta: number) => {
const next = Math.min(Math.max(focused + delta, 0), links.length - 1);
setActive(next);
rows.current[next]?.focus();
};
const onKeyDown = (event: KeyboardEvent<HTMLUListElement>) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
move(event.key === "ArrowDown" ? 1 : -1);
};
const go = (target: string) => {
open(target).catch((e) => notify(`Could not open ${baseName(target)}: ${String(e)}`));
};
return (
<nav className="backlinks" aria-labelledby="backlinks-heading">
{/* One document at a time and one of these, so a fixed id cannot collide with a second. */}
<h2 className="nav-label" id="backlinks-heading">
Linked from
</h2>
<ul className="backlinks-list" onKeyDown={onKeyDown}>
{links.map((link, index) => (
<li key={link.path}>
<button
className="backlinks-row"
ref={(el) => {
rows.current[index] = el;
}}
tabIndex={index === focused ? 0 : -1}
// The title is a heading or a filename and two documents are allowed to share one, so
// the path is what settles which of them this row is.
title={link.path}
onFocus={() => setActive(index)}
onClick={() => go(link.path)}
>
{/* The index titles a document by its first heading and falls back to its filename,
so this only catches a row that would otherwise be a blank line to click. */}
<span className="backlinks-title">{link.title || baseName(link.path)}</span>
<span className="backlinks-snippet">{readableSnippet(link.snippet)}</span>
</button>
</li>
))}
</ul>
</nav>
);
}
+78
View File
@@ -0,0 +1,78 @@
// Cmd+K: every command the app has, by name.
//
// This is the one palette with nothing behind it. No index, no IPC, no store: the rows are the
// table in src/keys/commands.ts filtered by a subsequence match, so it answers on a build where
// SQLite has fallen over and on the first frame after launch, before a folder is even open. That
// is why src/keys/bindings.ts binds it in the `global` context with a comment saying an overlay may
// not shadow it: whatever is on screen, this is how you get anywhere from inside it, and something
// that reaches into a search index for its own row list would not be able to make that promise.
//
// It lists commands, not bindings, which is why the keys on the right come from `keysFor` and
// `keyLabel` rather than from `bindingLabel`: that one turns a binding into its words, and a
// command with no key at all still belongs in this list.
import { useEffect, useState } from "react";
import { useEscapeLayer } from "../escape";
import { keyLabel, keysFor } from "../keys/bindings";
import { COMMANDS, commandMatches, onCommand, runCommand } from "../keys/commands";
import { useKeyContext } from "../keys/keymap";
import { Palette, type PaletteRow } from "./Palette";
interface CommandRow extends PaletteRow {
label: string;
keys: readonly string[];
}
export function CommandPalette() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
// A toggle, like the shortcuts sheet: the key that opens it is reachable from inside it, so it
// has to mean something the second time it is pressed.
useEffect(
() =>
onCommand("command-palette", () => {
setOpen((wasOpen) => !wasOpen);
setQuery("");
}),
[],
);
useEscapeLayer(open, () => setOpen(false));
useKeyContext("overlay", open);
if (!open) return null;
const rows: CommandRow[] = COMMANDS.filter(
(command) => command.palette && commandMatches(command.label, query),
).map((command) => ({
key: command.id,
label: command.label,
keys: keysFor(command.id),
run: () => runCommand(command.id),
}));
return (
<Palette
label="Command palette"
placeholder="Run a command"
query={query}
onQuery={setQuery}
rows={rows}
status={{ text: "No command by that name." }}
onClose={() => setOpen(false)}
renderRow={(row) => (
<span className="palette-main">
<span className="palette-name">{row.label}</span>
<span className="palette-keys">
{row.keys.map((combo) => (
<kbd key={combo} className="key-cap">
{keyLabel(combo)}
</kbd>
))}
</span>
</span>
)}
/>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useRef, type ReactNode } from "react";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon";
interface ConfirmDialogProps {
title: string;
message: ReactNode;
confirmLabel?: string;
onConfirm: () => void;
onClose: () => void;
}
export function ConfirmDialog({
title,
message,
confirmLabel = "Delete",
onConfirm,
onClose,
}: ConfirmDialogProps) {
const confirmRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
confirmRef.current?.focus();
}, []);
useEscapeLayer(true, onClose);
return (
<div className="overlay" onClick={onClose}>
<div
className="panel panel-confirm"
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
>
<div className="panel-head">
<h2>{title}</h2>
<button className="icon-button" onClick={onClose} title="Close (⎋)" aria-label="Close">
<Icon d="M6 6l12 12M18 6L6 18" />
</button>
</div>
<div className="panel-body">
<p className="confirm-text">{message}</p>
</div>
<div className="panel-foot">
<button className="btn-ghost" onClick={onClose}>
Cancel
</button>
<button ref={confirmRef} className="btn-danger" onClick={onConfirm}>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
// Something outside the app changed the file that is open, and the buffer has an edit in it that
// is not on disk. Both copies are real work and the app does not get to pick, so it asks.
//
// There is no merge and there will not be one: a three way merge of somebody's prose is a thing
// that looks like it worked. The two answers are the two copies, and dismissing the dialog picks
// neither, which leaves the warning in the toolbar and the buffer exactly as it was.
import { useEffect, useRef } from "react";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon";
interface ConflictDialogProps {
/** The file's name, not its path: the path is already in the title bar. */
name: string;
/** Throws the buffer away and takes what is on disk. */
onReload: () => void;
/** Keeps the buffer and lets the next save write over the copy on disk. */
onKeep: () => void;
/** Neither, for now. The document stays unsaved and the toolbar keeps the warning. */
onDismiss: () => void;
}
export function ConflictDialog({ name, onReload, onKeep, onDismiss }: ConflictDialogProps) {
const keepRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
keepRef.current?.focus();
}, []);
useEscapeLayer(true, onDismiss);
return (
<div className="overlay" onClick={onDismiss}>
<div
className="panel panel-conflict"
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
>
<div className="panel-head">
<h2>Changed on disk</h2>
<button className="icon-button" onClick={onDismiss} title="Close (⎋)" aria-label="Close">
<Icon d="M6 6l12 12M18 6L6 18" />
</button>
</div>
<div className="panel-body">
<p className="confirm-text">
Something outside Margin Docs has changed <strong>{name}</strong>, and you have edits
here that are not on disk. Nothing has been written and nothing has been lost yet.
</p>
</div>
<div className="panel-foot">
<button className="btn-ghost" onClick={onDismiss}>
Decide later
</button>
<button className="btn-danger" onClick={onReload}>
Reload from disk
</button>
<button ref={keepRef} className="btn-primary" onClick={onKeep}>
Keep my version
</button>
</div>
</div>
</div>
);
}
+274
View File
@@ -0,0 +1,274 @@
// The recursive half of the sidebar: rows, twisties, indentation and drop indicators, and nothing
// else. Selection, the keyboard, the drag gesture and every action a row can perform live one
// level up in Sidebar.tsx, because all of those span every open root and a recursive renderer only
// ever sees one subtree.
import {
useEffect,
useRef,
type CSSProperties,
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
} from "react";
import { useEscapeLayer } from "../escape";
import { MARKDOWN_EXTENSIONS } from "../model/doc";
import type { TreeNode } from "../store/useWorkspace";
import { Icon } from "./Icon";
import { RowMenu, type RowMenuEntry } from "./RowMenu";
const FOLDER = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z";
const FOLDER_OPEN = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2H7l-4 8z M3 17V7";
const DOCUMENT = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5";
const FOREIGN = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5 M9 17l2.5-3 2 2.2 1.5-1.7";
/**
* The extension a row hides. Markdown is the app's own format and `.md` on every second row is
* noise, but everything else keeps its extension: two rows both reading "notes", for `notes.md`
* and `notes.txt`, would be a worse lie than the clutter it saved.
*/
export function splitExtension(name: string, isDir = false): { base: string; hidden: string } {
if (isDir) return { base: name, hidden: "" };
const dot = name.lastIndexOf(".");
if (dot <= 0) return { base: name, hidden: "" };
const ext = name.slice(dot + 1).toLowerCase();
if (!(MARKDOWN_EXTENSIONS as readonly string[]).includes(ext)) return { base: name, hidden: "" };
return { base: name.slice(0, dot), hidden: name.slice(dot) };
}
export const prettyName = (node: TreeNode): string => splitExtension(node.name, node.isDir).base;
export interface TreeRow {
node: TreeNode;
depth: number;
/** The directory the row sits in, empty for a root. Where a "drop above this row" resolves to. */
parentPath: string;
}
/** The rows the user can actually see, in the order they appear, which is what the arrow keys walk. */
export function flattenTree(
nodes: readonly TreeNode[],
expanded: ReadonlySet<string>,
depth = 0,
parentPath = "",
): TreeRow[] {
const rows: TreeRow[] = [];
for (const node of nodes) {
rows.push({ node, depth, parentPath });
if (node.isDir && expanded.has(node.path) && node.children?.length)
rows.push(...flattenTree(node.children, expanded, depth + 1, node.path));
}
return rows;
}
/**
* Every drop resolves to exactly one destination directory, because a filesystem has no row order
* to insert into. The mode is only how the pointer said it: "into" is the folder under the cursor,
* "before" and "after" are the folder that row already lives in.
*/
export type DropMode = "before" | "after" | "into";
export interface DropTarget {
dir: string;
mode: DropMode;
/** The row the indicator draws on, which for "into" is the destination folder itself. */
row: string;
}
export interface TreeViewState {
expanded: ReadonlySet<string>;
selectedPath: string | null;
/** The one row in the whole sidebar that is in the tab order. */
tabStopPath: string | null;
draggingPath: string | null;
dropTarget: DropTarget | null;
renamingPath: string | null;
/** The document currently open in the editor, which is a different thing from the selected row. */
openPath: string | null;
}
export interface TreeHandlers {
onActivate: (node: TreeNode) => void;
onToggle: (node: TreeNode) => void;
onKeyDown: (e: KeyboardEvent, row: TreeRow) => void;
onPointerDown: (e: PointerEvent, row: TreeRow) => void;
onContextMenu: (e: MouseEvent, row: TreeRow) => void;
onMenuOpenChange: (path: string, open: boolean) => void;
menuItems: (row: TreeRow) => readonly RowMenuEntry[];
onRenameCommit: (node: TreeNode, base: string) => void;
onRenameCancel: () => void;
}
interface TreeProps {
nodes: readonly TreeNode[];
depth: number;
parentPath: string;
state: TreeViewState;
handlers: TreeHandlers;
}
export function FileTree({ nodes, depth, parentPath, state, handlers }: TreeProps) {
return (
<ul className="tree" role={depth === 0 ? "tree" : "group"}>
{nodes.map((node) => (
<TreeItem
key={node.path}
row={{ node, depth, parentPath }}
state={state}
handlers={handlers}
/>
))}
</ul>
);
}
function TreeItem({
row,
state,
handlers,
}: {
row: TreeRow;
state: TreeViewState;
handlers: TreeHandlers;
}) {
const { node, depth } = row;
const { base, hidden } = splitExtension(node.name, node.isDir);
const open = node.isDir && state.expanded.has(node.path);
const renaming = state.renamingPath === node.path;
const drop = state.dropTarget?.row === node.path ? state.dropTarget.mode : null;
const foreign = !node.isDir && !node.editable;
return (
<li className="tree-item">
<div
className="tree-row"
role="treeitem"
style={{ "--tree-depth": depth } as CSSProperties}
data-path={node.path}
data-parent={row.parentPath}
data-dir={node.isDir}
data-root={depth === 0}
data-foreign={foreign}
data-selected={state.selectedPath === node.path}
data-current={state.openPath === node.path}
data-dragging={state.draggingPath === node.path}
data-drop-before={drop === "before"}
data-drop-after={drop === "after"}
data-drop-into={drop === "into"}
aria-expanded={node.isDir ? open : undefined}
aria-selected={state.selectedPath === node.path}
aria-level={depth + 1}
tabIndex={state.tabStopPath === node.path ? 0 : -1}
onClick={() => handlers.onActivate(node)}
onKeyDown={(e) => handlers.onKeyDown(e, row)}
onPointerDown={(e) => handlers.onPointerDown(e, row)}
onContextMenu={(e) => handlers.onContextMenu(e, row)}
>
{node.isDir ? (
<button
className="tree-twisty"
tabIndex={-1}
title={open ? "Collapse" : "Expand"}
aria-label={open ? `Collapse ${node.name}` : `Expand ${node.name}`}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
handlers.onToggle(node);
}}
>
<Icon d={open ? "M6 9l6 6 6-6" : "M9 6l6 6-6 6"} size={13} />
</button>
) : (
<span className="tree-twisty" aria-hidden="true" />
)}
<span className="tree-glyph" aria-hidden="true">
<Icon d={node.isDir ? (open ? FOLDER_OPEN : FOLDER) : foreign ? FOREIGN : DOCUMENT} size={15} />
</span>
{renaming ? (
<RenameField
value={base}
onCommit={(next) => handlers.onRenameCommit(node, next)}
onCancel={handlers.onRenameCancel}
/>
) : (
<span className="tree-name" title={node.name}>
{base}
{hidden && <span className="tree-ext">{hidden}</span>}
</span>
)}
<RowMenu
label={node.isDir ? "Folder options" : "File options"}
items={() => handlers.menuItems(row)}
onOpenChange={(isOpen) => handlers.onMenuOpenChange(node.path, isOpen)}
/>
</div>
{open && node.children?.length ? (
<FileTree
nodes={node.children}
depth={depth + 1}
parentPath={node.path}
state={state}
handlers={handlers}
/>
) : null}
</li>
);
}
/**
* Blur commits, the way Finder does, so the escape layer lives here rather than in the sidebar:
* unmounting a focused input can fire a blur on the way out, and a cancel that arrived from
* outside would otherwise be overtaken by the commit it was trying to avoid.
*/
function RenameField({
value,
onCommit,
onCancel,
}: {
value: string;
onCommit: (next: string) => void;
onCancel: () => void;
}) {
const ref = useRef<HTMLInputElement>(null);
const settled = useRef(false);
useEffect(() => {
const input = ref.current;
if (!input) return;
input.focus();
input.select();
}, []);
const commit = (next: string) => {
if (settled.current) return;
settled.current = true;
onCommit(next);
};
useEscapeLayer(true, () => {
settled.current = true;
onCancel();
});
return (
<input
ref={ref}
className="tree-rename"
defaultValue={value}
spellCheck={false}
autoComplete="off"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onBlur={(e) => commit(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
commit(e.currentTarget.value);
}}
/>
);
}
+190
View File
@@ -0,0 +1,190 @@
// Find and replace inside the open document. Margin's bar, minus the cross-chapter scope: there
// is one document open at a time here, and searching every file is `find-in-files` against the
// SQLite index, which is a different panel with different results.
//
// The matching itself is the editor's, not this bar's. `EditorHandle` in src/editor/index.ts does
// not carry a search surface, so the shape this bar drives is declared here and handed in: a
// component that draws a text field has no business owning a ProseMirror decoration set, and the
// alternative, walking the contenteditable DOM behind the editor's back, is a second
// implementation of matching that would disagree with the first the day either changed.
import { useEffect, useRef, useState } from "react";
import { useEscapeLayer } from "../escape";
import { onCommand } from "../keys/commands";
import { Icon } from "./Icon";
export interface FindOptions {
caseSensitive: boolean;
wholeWord: boolean;
}
export interface FindState {
count: number;
/** Zero based, so `current + 1` is what the "3 of 12" readout shows. */
current: number;
}
/**
* What the editor lane implements for this bar to be usable.
*
* A new object whenever `state` changes, the way `EditorHandle` already works: this bar draws the
* "3 of 12" readout from a prop and has nothing to subscribe to, so a handle mutated in place
* would leave the count stale until something unrelated re-rendered.
*/
export interface DocumentFind {
state: FindState;
setQuery: (query: string, options: FindOptions) => void;
clear: () => void;
next: () => void;
prev: () => void;
replaceCurrent: (text: string) => void;
replaceAll: (text: string) => void;
/** Puts the cursor back in the document, which every replace has to do to be worth anything. */
focus: () => void;
}
export function FindBar({ find }: { find: DocumentFind | null }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [replacement, setReplacement] = useState("");
const [caseSensitive, setCaseSensitive] = useState(false);
const [wholeWord, setWholeWord] = useState(false);
const [expanded, setExpanded] = useState(false);
const findRef = useRef<HTMLInputElement>(null);
useEffect(
() =>
onCommand("find", () => {
setOpen(true);
const input = findRef.current;
input?.focus();
input?.select();
}),
[],
);
useEffect(() => {
if (!open) return;
const input = findRef.current;
input?.focus();
input?.select();
}, [open]);
useEffect(() => {
if (!find) return;
if (open) find.setQuery(query, { caseSensitive, wholeWord });
else find.clear();
}, [find, open, query, caseSensitive, wholeWord]);
useEscapeLayer(open, () => setOpen(false));
if (!open || !find) return null;
const { count, current } = find.state;
const countLabel = !query ? "" : count === 0 ? "No results" : `${current + 1} of ${count}`;
const onFindKey = (e: React.KeyboardEvent) => {
if (e.key !== "Enter") return;
e.preventDefault();
if (e.shiftKey) find.prev();
else find.next();
};
const replaceOne = () => {
find.replaceCurrent(replacement);
find.focus();
};
const replaceEvery = () => {
find.replaceAll(replacement);
find.focus();
};
return (
<div className="find-bar" role="search">
<button
className="find-expand"
data-on={expanded}
title={expanded ? "Hide replace" : "Show replace"}
onClick={() => setExpanded((v) => !v)}
>
<Icon d={expanded ? "M6 9l6 6 6-6" : "M9 6l6 6-6 6"} size={14} />
</button>
<div className="find-stack">
<div className="find-row">
<input
ref={findRef}
className="find-input"
value={query}
placeholder="Find"
spellCheck={false}
aria-label="Find"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onFindKey}
/>
<span className="find-count">{countLabel}</span>
<button className="find-btn" title="Previous (⇧↩)" disabled={!count} onClick={find.prev}>
<Icon d="M6 15l6-6 6 6" size={14} />
</button>
<button className="find-btn" title="Next (↩)" disabled={!count} onClick={find.next}>
<Icon d="M6 9l6 6 6-6" size={14} />
</button>
<button
className="find-toggle"
data-on={caseSensitive}
title="Match case"
onClick={() => setCaseSensitive((v) => !v)}
>
Aa
</button>
<button
className="find-toggle"
data-on={wholeWord}
title="Whole word"
onClick={() => setWholeWord((v) => !v)}
>
<span className="find-ww">ab</span>
</button>
<button className="find-btn" title="Close (⎋)" onClick={() => setOpen(false)}>
<Icon d="M18 6L6 18M6 6l12 12" size={14} />
</button>
</div>
{expanded && (
<div className="find-row">
<input
className="find-input"
value={replacement}
placeholder="Replace"
spellCheck={false}
aria-label="Replace with"
onChange={(e) => setReplacement(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
replaceOne();
}}
/>
<button
className="find-action"
disabled={!count}
onClick={replaceOne}
title="Replace the current match"
>
Replace
</button>
<button
className="find-action"
disabled={!count}
onClick={replaceEvery}
title="Replace every match in this document"
>
Replace All
</button>
</div>
)}
</div>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
// Cmd+Shift+F: the text inside every file in every open root, which is the one question the file
// tree and the find bar between them cannot answer. The bar in src/components/FindBar.tsx searches
// the one document that is open; this searches the ones that are not.
//
// Same debounce and the same reason as quick open, a little longer because a full text query reads
// the whole corpus rather than one column of paths, and the same deliberate absence of a second
// guard around the race: the sequence number in src/store/useSearch.ts already refuses an answer
// that has been overtaken.
//
// A row opens the document it found the line in, and stops there. Putting the caret on the line
// itself would need a way to say "open this file at line 42", and the editor's public surface in
// src/editor/index.ts has no such thing, so the honest version of this today is the file open at
// the top rather than a jump built out of a DOM query behind the editor's back.
import { useEffect, useState } from "react";
import { useEscapeLayer } from "../escape";
import type { MatchRange } from "../ipc";
import { onCommand } from "../keys/commands";
import { useKeyContext } from "../keys/keymap";
import { useDocument } from "../store/useDocument";
import { useIndex } from "../store/useIndex";
import { useSearch } from "../store/useSearch";
import { notify } from "../store/useToast";
import { useWorkspace } from "../store/useWorkspace";
import { Palette, highlight, type PaletteRow, type PaletteStatus } from "./Palette";
/** Longer than quick open's: this one reads the text of every file rather than their paths. */
const DEBOUNCE_MS = 140;
interface HitRow extends PaletteRow {
title: string;
/** One based, and counted over the file as it sits on disk, frontmatter included. */
line: number;
excerpt: string;
ranges: readonly MatchRange[];
path: string;
}
export function FindInFiles() {
const [open, setOpen] = useState(false);
const query = useSearch((s) => s.fullTextQuery);
const setQuery = useSearch((s) => s.setFullTextQuery);
const runFullText = useSearch((s) => s.runFullText);
const hits = useSearch((s) => s.fullTextHits);
const phase = useSearch((s) => s.fullTextPhase);
const error = useSearch((s) => s.fullTextError);
const indexPhase = useIndex((s) => s.phase);
const roots = useWorkspace((s) => s.roots);
const select = useWorkspace((s) => s.select);
const openDocument = useDocument((s) => s.open);
useEffect(
() =>
onCommand("find-in-files", () => {
// Empty field, and last time's rows cleared through the store so its sequence number moves
// with them. See the same lines in QuickOpen.tsx.
setQuery("");
void runFullText("");
setOpen(true);
}),
[setQuery, runFullText],
);
useEscapeLayer(open, () => setOpen(false));
useKeyContext("overlay", open);
useEffect(() => {
if (!open) return;
const timer = window.setTimeout(() => void runFullText(query), DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [open, query, runFullText]);
if (!open) return null;
const choose = (path: string) => {
select(path);
openDocument(path).catch((e) => notify(`Could not open: ${String(e)}`));
};
const searching = query.trim() !== "";
// A file can answer on several lines, so the path alone is not a key.
const rows: HitRow[] = hits.map((hit, index) => ({
key: `${hit.path}:${hit.line}:${index}`,
title: hit.title,
line: hit.line,
excerpt: hit.excerpt,
ranges: hit.ranges,
path: hit.path,
run: () => choose(hit.path),
}));
const status = (): PaletteStatus => {
if (phase === "error") {
return { text: error ?? "The search index could not be read.", error: true };
}
if (!searching) {
return {
text:
roots.length === 0 ? "Open a folder first." : "Type to search every folder that is open.",
};
}
if (phase === "loading") return { text: "Searching…" };
if (indexPhase === "indexing") return { text: "Still indexing. Try again in a moment." };
return { text: "Nothing in these folders says that." };
};
return (
<Palette
label="Find in files"
placeholder="Search every open folder"
query={query}
onQuery={setQuery}
rows={rows}
status={status()}
onClose={() => setOpen(false)}
renderRow={(row) => (
<span className="palette-stack" title={row.path}>
<span className="palette-main">
<span className="palette-name">{row.title}</span>
<span className="palette-line">{row.line}</span>
</span>
<span className="palette-snippet">{highlight(row.excerpt, row.ranges)}</span>
</span>
)}
/>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
interface IconProps {
d?: string;
size?: number;
children?: ReactNode;
}
export function Icon({ d, size = 16, children }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
{children ?? <path d={d} />}
</svg>
);
}
+191
View File
@@ -0,0 +1,191 @@
// The shell behind all three overlay palettes: the backdrop, the one text field, the list under it
// and the keyboard that drives them.
//
// Three sources, one widget. What differs between quick open, find in files and the command palette
// is where the rows come from and what a row does when it is chosen, and that is the whole of what
// the three concrete palettes hand in. Everything a user would call "how the palette behaves", the
// arrow keys, the wrap at the ends, the selection following the mouse, the row scrolling itself
// into view, lives here once so the three cannot drift into three slightly different lists.
//
// Rendered only while its palette is open, never handed a closed flag: mounting is what opens it.
// That is what keeps the selection, the scroll position and the focus fresh on every open without a
// single reset effect, and it leaves the open flag, the Escape layer and the key context in the
// concrete component beside its `onCommand` subscription, which is the shape Shortcuts.tsx already
// has.
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import type { MatchRange } from "../ipc";
export interface PaletteRow {
/** Identity, not position: a path, a command id. React's key and nothing more. */
key: string;
/** What choosing the row does. The palette is already closed by the time this is called. */
run: () => void;
}
/** The single line shown in place of the list. */
export interface PaletteStatus {
text: string;
/** Something failed and this is its message. An empty result is not a failure. */
error?: boolean;
}
interface PaletteProps<Row extends PaletteRow> {
/** Names the dialog, its field and its list for a screen reader. */
label: string;
placeholder: string;
query: string;
onQuery: (query: string) => void;
rows: readonly Row[];
/** Shown only when there are no rows, so an answer that is still in flight keeps the last rows
* on screen rather than flashing "No results" between two keystrokes. */
status: PaletteStatus | null;
renderRow: (row: Row) => ReactNode;
onClose: () => void;
}
export function Palette<Row extends PaletteRow>({
label,
placeholder,
query,
onQuery,
rows,
status,
renderRow,
onClose,
}: PaletteProps<Row>) {
const [selected, setSelected] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const listId = useId();
// Clamped where it is read rather than corrected in an effect. The row count changes with every
// answer the index gives back, and an effect that put the index right afterwards would render one
// frame with a selection pointing past the end of the list first.
const at = Math.min(selected, rows.length - 1);
const current = at >= 0 ? rows[at] : null;
useEffect(() => inputRef.current?.focus(), []);
// A new query is a new list, so the selection goes back to the top. Keyed on the query rather
// than on `rows`, because a palette that filters as it renders hands over a new array every time
// and this would then undo every arrow key the moment it was pressed.
useEffect(() => setSelected(0), [query]);
useEffect(() => {
listRef.current?.children[at]?.scrollIntoView({ block: "nearest" });
}, [at]);
const choose = (row: Row) => {
// Closed before the row runs. A command palette row can put another overlay on screen, and the
// two would otherwise unwind the Escape stack and the key context stack in the wrong order.
onClose();
row.run();
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
// Without this the caret jumps to one end of the field on every step through the list.
e.preventDefault();
if (rows.length === 0) return;
const next = e.key === "ArrowDown" ? at + 1 : at - 1 + rows.length;
setSelected(next % rows.length);
return;
}
if (e.key === "Enter" && current) {
e.preventDefault();
choose(current);
}
};
return (
// Mousedown rather than click: a click closes on the release, so dragging a selection out of
// the field and letting go over the backdrop would dismiss the palette mid-gesture.
<div className="overlay" data-align="top" onMouseDown={onClose}>
<div
className="panel palette"
role="dialog"
aria-modal="true"
aria-label={label}
onMouseDown={(e) => e.stopPropagation()}
>
<input
ref={inputRef}
className="palette-field"
value={query}
placeholder={placeholder}
spellCheck={false}
autoComplete="off"
role="combobox"
aria-label={label}
aria-expanded={rows.length > 0}
aria-controls={rows.length > 0 ? listId : undefined}
aria-activedescendant={current ? `${listId}-${at}` : undefined}
onChange={(e) => onQuery(e.target.value)}
onKeyDown={onKeyDown}
/>
{rows.length > 0 ? (
<ul ref={listRef} id={listId} className="palette-list" role="listbox" aria-label={label}>
{rows.map((row, index) => (
<li
key={row.key}
id={`${listId}-${index}`}
className="palette-row"
role="option"
aria-selected={index === at}
data-selected={index === at}
// Move, not enter. The list re-renders under a still cursor every time the index
// answers, and `mouseenter` would hand the selection to whichever row happened to
// slide under a pointer nobody had touched.
onMouseMove={() => setSelected(index)}
onClick={() => choose(row)}
>
{renderRow(row)}
</li>
))}
</ul>
) : (
status && (
<p className="palette-status" data-error={status.error === true}>
{status.text}
</p>
)
)}
</div>
</div>
);
}
/**
* The matched characters, marked.
*
* `ranges` are half-open offsets into `text` and they come from whatever did the matching, which is
* the only thing that knows where it landed, so neither search palette runs the match a second time
* to find out. Offsets are clamped and taken in order rather than trusted: they are computed on the
* other side of the IPC boundary against a string this side only has a copy of, and one bad pair
* would otherwise slice a row into nonsense.
*/
export function highlight(text: string, ranges: readonly MatchRange[]): ReactNode {
if (ranges.length === 0) return text;
const parts: ReactNode[] = [];
let at = 0;
const ordered = [...ranges].sort((a, b) => a.start - b.start);
ordered.forEach((range, i) => {
const start = Math.max(at, Math.min(range.start, text.length));
const end = Math.max(start, Math.min(range.end, text.length));
if (end === start) return;
if (start > at) parts.push(text.slice(at, start));
parts.push(
<mark key={i} className="palette-mark">
{text.slice(start, end)}
</mark>,
);
at = end;
});
if (at < text.length) parts.push(text.slice(at));
return parts;
}
+150
View File
@@ -0,0 +1,150 @@
// The menu over a misspelled word: what the system thinks was meant, and the two ways of saying it
// was not a mistake.
//
// Mounted once and drawing nothing until src/editor/proofing.ts puts a word in the store, so App.tsx
// holds one line for it rather than a piece of the feature. It renders into a portal because the
// document scrolls inside its own pane and a menu clipped by the pane it belongs to is no menu at
// all, and it is positioned in viewport coordinates because that is what the editor measured the
// word in.
//
// It never takes focus. A left click on a misspelled word is somebody putting the caret in a word
// they are about to fix by hand as often as it is somebody asking what else it could have been, and
// a menu that steals the caret out of the sentence being typed has broken the more common of the
// two. So the caret stays where the click put it, typing goes on into the document and dismisses the
// menu on the way, and the buttons refuse the focus a mousedown would otherwise give them.
//
// "Learn Spelling" is the item that has to be honest about what it does. The checker is
// NSSpellChecker and the dictionary is the Mac's, so learning a word here teaches Mail, Notes and
// every other app on the machine, which is what makes it useful and also what makes it more than
// this app's business to do quietly. The note under the buttons says so in the menu, where the
// decision is being made, rather than in a tooltip nobody reads first.
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { replaceSpelling } from "../editor/proofing";
import { useEscapeLayer } from "../escape";
import { useProofing, type ProofTarget } from "../store/useProofing";
/** Clearance from the word above and from the edges of the window. */
const GAP = 6;
const MARGIN = 8;
export function ProofPopover() {
const target = useProofing((s) => s.target);
if (target === null) return null;
// Keyed so that opening the menu over a second word rebuilds it rather than sliding the first
// one's measurements across.
return <ProofMenu key={`${target.from}:${target.word}`} target={target} />;
}
function ProofMenu({ target }: { target: ProofTarget }) {
const closeMenu = useProofing((s) => s.closeMenu);
const ignoreWord = useProofing((s) => s.ignoreWord);
const learnWord = useProofing((s) => s.learnWord);
const popRef = useRef<HTMLDivElement>(null);
const [at, setAt] = useState({ left: target.left, top: target.bottom + GAP });
useLayoutEffect(() => {
const el = popRef.current;
if (!el) return;
const box = el.getBoundingClientRect();
const left = Math.max(
MARGIN,
Math.min(target.left - box.width / 2, window.innerWidth - box.width - MARGIN),
);
// Under the word, unless the window has no room under it, in which case above it. Never over it:
// the word is what the menu is about and covering it hides the mistake being corrected.
const below = target.bottom + GAP;
const top =
below + box.height + MARGIN <= window.innerHeight
? below
: Math.max(MARGIN, target.top - GAP - box.height);
setAt({ left, top });
}, [target]);
useEscapeLayer(true, closeMenu);
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (popRef.current?.contains(e.target as Node)) return;
closeMenu();
};
const close = () => closeMenu();
document.addEventListener("mousedown", onDown, true);
document.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("mousedown", onDown, true);
document.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
}, [closeMenu]);
// The caret belongs to the document, not to this menu, so a press on any of these buttons is not
// allowed to move it.
const keepFocus = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
};
return createPortal(
<div
ref={popRef}
className="proof-pop"
role="menu"
aria-label={`Spelling suggestions for ${target.word}`}
style={{ left: at.left, top: at.top }}
onContextMenu={(e) => e.preventDefault()}
>
{target.suggestions.length === 0 ? (
<p className="proof-none">No suggestions</p>
) : (
target.suggestions.map((suggestion) => (
<button
key={suggestion}
role="menuitem"
className="proof-suggestion"
onMouseDown={keepFocus}
onClick={() => {
replaceSpelling(target, suggestion);
closeMenu();
}}
>
{suggestion}
</button>
))
)}
<div className="proof-sep" />
<button
role="menuitem"
className="proof-action"
title={`Adds “${target.word}” to the dictionary every app on this Mac shares.`}
onMouseDown={keepFocus}
onClick={() => {
void learnWord(target.word);
closeMenu();
}}
>
Learn Spelling
</button>
<button
role="menuitem"
className="proof-action"
title="Stops underlining this word until the app is next opened."
onMouseDown={keepFocus}
onClick={() => {
ignoreWord(target.word);
closeMenu();
}}
>
Ignore
</button>
<p className="proof-note">Learning a word teaches this Mac, not only Margin Docs.</p>
</div>,
document.body,
);
}
+174
View File
@@ -0,0 +1,174 @@
// Cmd+P: every file the index knows about, across every open root, matched against the path it
// sits at relative to that root.
//
// The query lives in the store rather than here because the store is also what asks SQLite, and the
// two have to be able to disagree for a moment: the field shows the letter that was just typed
// while the index is still answering the word before it. What sits in between is the debounce
// below, so a typist crosses the IPC boundary once for a word rather than once for a letter.
//
// A slow answer landing after a fast one is already handled on the other side of that boundary, by
// the sequence number in src/store/useSearch.ts, and this file deliberately does not grow a second
// guard for the same race: two of them would have to agree forever, and the day they stopped the
// symptom would be a row from a query nobody can see any more.
//
// An empty field is not an empty palette. Cmd+P with nothing typed offers the documents this
// session has already been in, which is the other half of what people reach for the key for.
import { useEffect, useState } from "react";
import { useEscapeLayer } from "../escape";
import type { MatchRange } from "../ipc";
import { onCommand } from "../keys/commands";
import { useKeyContext } from "../keys/keymap";
import { useDocument } from "../store/useDocument";
import { useIndex } from "../store/useIndex";
import { useSearch } from "../store/useSearch";
import { notify } from "../store/useToast";
import { useWorkspace, type WorkspaceRoot } from "../store/useWorkspace";
import { Palette, highlight, type PaletteRow, type PaletteStatus } from "./Palette";
/** Long enough that a word is one query rather than five, short enough that the pause between two
* words already has an answer waiting in it. */
const DEBOUNCE_MS = 90;
/** How many already-visited documents an empty field offers before it stops being a shortlist. */
const RECENT_LIMIT = 8;
interface FileRow extends PaletteRow {
path: string;
name: string;
/** The path relative to its root, whole, because that is the string the index counted its match
* offsets against and a trimmed version of it would highlight the wrong characters. */
where: string;
ranges: readonly MatchRange[];
/** The root's own name, or empty. Filled in only when more than one folder is open and the
* relative path alone would be ambiguous between them, and empty for a root that was closed
* while its answer was still in flight. */
root: string;
}
const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path;
function relativeTo(path: string, roots: readonly WorkspaceRoot[]): string {
for (const root of roots) {
if (path.startsWith(`${root.path}/`)) return path.slice(root.path.length + 1);
}
return path;
}
export function QuickOpen() {
const [open, setOpen] = useState(false);
const query = useSearch((s) => s.quickOpenQuery);
const setQuery = useSearch((s) => s.setQuickOpenQuery);
const runQuickOpen = useSearch((s) => s.runQuickOpen);
const hits = useSearch((s) => s.quickOpenHits);
const phase = useSearch((s) => s.quickOpenPhase);
const error = useSearch((s) => s.quickOpenError);
const indexPhase = useIndex((s) => s.phase);
const roots = useWorkspace((s) => s.roots);
const select = useWorkspace((s) => s.select);
const history = useDocument((s) => s.history);
const openPath = useDocument((s) => s.path);
const openDocument = useDocument((s) => s.open);
useEffect(
() =>
onCommand("quick-open", () => {
// The field starts empty every time and last time's rows go with it. `runQuickOpen("")` is
// what clears them, and going through the store rather than reaching for the array directly
// is also what bumps its sequence number, so an answer to the query this palette was closed
// on cannot arrive inside the one it was just opened for.
setQuery("");
void runQuickOpen("");
setOpen(true);
}),
[setQuery, runQuickOpen],
);
useEscapeLayer(open, () => setOpen(false));
useKeyContext("overlay", open);
useEffect(() => {
if (!open) return;
const timer = window.setTimeout(() => void runQuickOpen(query), DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [open, query, runQuickOpen]);
if (!open) return null;
const choose = (path: string) => {
// Selected as well as opened, so the tree, and every command that acts on the selection, agree
// with the document that is now on screen. Opening a file from here and renaming it with the
// next key otherwise renames whatever was last clicked in the sidebar.
select(path);
openDocument(path).catch((e) => notify(`Could not open: ${String(e)}`));
};
const searching = query.trim() !== "";
const recent = () => {
const seen = new Set<string>();
const rows: FileRow[] = [];
for (let i = history.length - 1; i >= 0 && rows.length < RECENT_LIMIT; i -= 1) {
const path = history[i];
// The document already on screen is not somewhere to go.
if (path === openPath || seen.has(path)) continue;
seen.add(path);
rows.push({
key: path,
path,
name: baseName(path),
where: relativeTo(path, roots),
ranges: [],
root: "",
run: () => choose(path),
});
}
return rows;
};
const rows: FileRow[] = searching
? hits.map((hit) => ({
key: hit.path,
path: hit.path,
name: hit.name,
where: hit.relPath,
ranges: hit.ranges,
root: roots.length > 1 ? baseName(hit.rootPath) : "",
run: () => choose(hit.path),
}))
: recent();
const status = (): PaletteStatus => {
if (phase === "error") {
return { text: error ?? "The search index could not be read.", error: true };
}
if (!searching) {
return {
text: roots.length === 0 ? "Open a folder first." : "Type to find a file by name or path.",
};
}
if (phase === "loading") return { text: "Searching…" };
if (indexPhase === "indexing") return { text: "Still indexing. Try again in a moment." };
return { text: "No file matches." };
};
return (
<Palette
label="Quick open"
placeholder="Go to file"
query={query}
onQuery={setQuery}
rows={rows}
status={status()}
onClose={() => setOpen(false)}
renderRow={(row) => (
<span className="palette-main" title={row.path}>
<span className="palette-name">{row.name}</span>
<span className="palette-where">{highlight(row.where, row.ranges)}</span>
{row.root !== "" && <span className="palette-root">{row.root}</span>}
</span>
)}
/>
);
}
+67
View File
@@ -0,0 +1,67 @@
// The start screen: what the window shows before any folder is open. A quiet list rather than a
// grid of cards, because a folder has no cover and pretending otherwise would just be a row of
// identical rectangles.
import { commandLabel, runCommand } from "../keys/commands";
import { notify } from "../store/useToast";
import { useWorkspace } from "../store/useWorkspace";
import { addRoot } from "../workspace";
import { Icon } from "./Icon";
import { shortcutTitle } from "./Titlebar";
const FOLDER = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z";
const OPEN_FOLDER = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M12 10v6 M9 13h6";
const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path;
const parentOf = (path: string): string => path.slice(0, path.lastIndexOf("/")) || "/";
export function Recents() {
const recentFolders = useWorkspace((s) => s.recentFolders);
const scanPhase = useWorkspace((s) => s.scanPhase);
// `openFolder` is the picker and takes no path, so a folder that is already known is opened
// through the effects module directly rather than by asking the user to find it again.
const open = (path: string) => {
addRoot(path).catch((e) => notify(`Could not open that folder: ${String(e)}`));
};
return (
<div className="start">
<div className="start-drag" data-tauri-drag-region />
<div className="start-body">
<h1 className="start-title">Margin Docs</h1>
<p className="start-line">
Open a folder of markdown files. Nothing is copied, nothing is imported, and nothing is
written until you make an edit.
</p>
<button
className="start-open"
title={shortcutTitle("open-folder")}
disabled={scanPhase === "scanning"}
onClick={() => runCommand("open-folder")}
>
<Icon d={OPEN_FOLDER} size={18} />
{scanPhase === "scanning" ? "Opening…" : commandLabel("open-folder")}
</button>
{recentFolders.length > 0 && (
<div className="start-recent">
<div className="nav-label">Recent</div>
<ul className="start-list">
{recentFolders.map((path) => (
<li key={path}>
<button className="start-row" onClick={() => open(path)} title={path}>
<Icon d={FOLDER} size={15} />
<span className="start-name">{baseName(path)}</span>
<span className="start-path">{parentOf(path)}</span>
</button>
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
// The sidebar's drag edge. The width it writes is the `--pane-sidebar` token itself, set on the
// root element, so the stylesheet keeps owning the layout and this only moves a number.
import { useLayoutEffect, type PointerEvent } from "react";
const VAR = "--pane-sidebar";
const KEY = "margindocs-pane-sidebar";
const DEFAULT = 248;
const MIN = 200;
const MAX = 460;
const clamp = (px: number): number => Math.round(Math.min(MAX, Math.max(MIN, px)));
function currentWidth(): number {
const raw = getComputedStyle(document.documentElement).getPropertyValue(VAR);
const px = parseInt(raw, 10);
return Number.isFinite(px) && px > 0 ? px : DEFAULT;
}
function applyWidth(px: number): void {
const width = clamp(px);
document.documentElement.style.setProperty(VAR, `${width}px`);
try {
localStorage.setItem(KEY, String(width));
} catch {
// A webview with storage denied still gets a working drag, just not a remembered one.
}
}
function resetWidth(): void {
document.documentElement.style.removeProperty(VAR);
try {
localStorage.removeItem(KEY);
} catch {
// See above.
}
}
export function ResizeHandle() {
useLayoutEffect(() => {
try {
const saved = parseInt(localStorage.getItem(KEY) ?? "", 10);
if (Number.isFinite(saved) && saved > 0)
document.documentElement.style.setProperty(VAR, `${clamp(saved)}px`);
} catch {
// See above.
}
}, []);
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
e.preventDefault();
const handle = e.currentTarget;
const startX = e.clientX;
const startWidth = currentWidth();
handle.setPointerCapture(e.pointerId);
document.documentElement.setAttribute("data-resizing", "");
const onMove = (ev: globalThis.PointerEvent) => applyWidth(startWidth + (ev.clientX - startX));
const onUp = () => {
document.documentElement.removeAttribute("data-resizing");
handle.removeEventListener("pointermove", onMove);
handle.removeEventListener("pointerup", onUp);
};
handle.addEventListener("pointermove", onMove);
handle.addEventListener("pointerup", onUp);
};
return (
<div
className="pane-resizer"
role="separator"
aria-orientation="vertical"
onPointerDown={onPointerDown}
onDoubleClick={resetWidth}
title="Drag to resize, double click to reset"
/>
);
}
+187
View File
@@ -0,0 +1,187 @@
// The popup menu a row offers, in two forms over one body: a "..." button that opens it under
// itself, and a bare popup anchored to the point a right click happened. Both render into a portal
// so a menu is never clipped by the scrolling tree it belongs to.
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon";
export interface RowMenuItem {
id: string;
label: string;
/** A Feather-style 24x24 stroke path, the same shape `<Icon d>` takes everywhere else. */
icon: string;
danger?: boolean;
run: () => void;
}
/** A hairline between groups of items. Written inline in the array so the order stays readable. */
export type RowMenuEntry = RowMenuItem | "sep";
const MENU_ITEM = ".row-menu-item";
interface PopProps {
x: number;
y: number;
/** A thunk, not an array: a tree of a thousand rows should not build a thousand menus it will
* never show, and the items a row offers can depend on state that moved since it was drawn. */
items: () => readonly RowMenuEntry[];
onClose: () => void;
/** Where focus goes when the menu closes, so keyboard use does not land back at the document. */
restoreFocus?: () => void;
}
function MenuPop({ x, y, items, onClose, restoreFocus }: PopProps) {
const popRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ left: x, top: y });
useLayoutEffect(() => {
const el = popRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setPos({
left: Math.max(8, Math.min(x, window.innerWidth - rect.width - 8)),
top: Math.max(8, Math.min(y, window.innerHeight - rect.height - 8)),
});
}, [x, y]);
useEffect(() => {
popRef.current?.querySelector<HTMLElement>(MENU_ITEM)?.focus();
}, []);
useEscapeLayer(true, () => {
onClose();
restoreFocus?.();
});
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (popRef.current?.contains(e.target as Node)) return;
onClose();
};
const close = () => onClose();
document.addEventListener("mousedown", onDown, true);
document.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("mousedown", onDown, true);
document.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
}, [onClose]);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const all = Array.from(popRef.current?.querySelectorAll<HTMLElement>(MENU_ITEM) ?? []);
if (!all.length) return;
const at = all.indexOf(document.activeElement as HTMLElement);
const next = e.key === "ArrowDown" ? (at + 1) % all.length : (at - 1 + all.length) % all.length;
all[next]?.focus();
};
const choose = (e: React.MouseEvent, item: RowMenuItem) => {
e.stopPropagation();
onClose();
item.run();
};
return createPortal(
<div
ref={popRef}
className="row-menu-pop"
role="menu"
style={{ top: pos.top, left: pos.left }}
onKeyDown={onKeyDown}
onContextMenu={(e) => e.preventDefault()}
>
{items().map((item, i) =>
item === "sep" ? (
<div key={`sep-${i}`} className="menu-sep" />
) : (
<button
key={item.id}
role="menuitem"
className={item.danger ? "row-menu-item danger" : "row-menu-item"}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => choose(e, item)}
>
<Icon d={item.icon} size={14} />
{item.label}
</button>
),
)}
</div>,
document.body,
);
}
interface RowMenuProps {
label: string;
items: () => readonly RowMenuEntry[];
onOpenChange?: (open: boolean) => void;
className?: string;
}
/** The "..." trigger, for a row that has room to show one. */
export function RowMenu({ label, items, onOpenChange, className = "" }: RowMenuProps) {
const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const wasOpen = useRef(false);
useEffect(() => {
const open = anchor !== null;
if (open === wasOpen.current) return;
wasOpen.current = open;
onOpenChange?.(open);
}, [anchor, onOpenChange]);
const toggle = (e: React.MouseEvent) => {
e.stopPropagation();
if (anchor) {
setAnchor(null);
return;
}
const rect = btnRef.current?.getBoundingClientRect();
if (rect) setAnchor({ x: rect.left, y: rect.bottom + 4 });
};
return (
<>
<button
ref={btnRef}
className={`row-menu-btn ${className}`}
data-open={anchor !== null}
title={label}
aria-label={label}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={toggle}
>
<Icon d="M12 5h.01M12 12h.01M12 19h.01" />
</button>
{anchor && (
<MenuPop
x={anchor.x}
y={anchor.y}
items={items}
onClose={() => setAnchor(null)}
restoreFocus={() => btnRef.current?.focus()}
/>
)}
</>
);
}
interface RowMenuAtProps {
x: number;
y: number;
items: () => readonly RowMenuEntry[];
onClose: () => void;
}
/** The same menu opened at a point, which is what a right click on a row produces. */
export function RowMenuAt({ x, y, items, onClose }: RowMenuAtProps) {
return <MenuPop x={x} y={y} items={items} onClose={onClose} />;
}
+69
View File
@@ -0,0 +1,69 @@
// Every key the app answers to, generated from src/keys/bindings.ts rather than written out here.
// That is the point of the table over there: a binding that exists is a binding this sheet shows,
// so the two cannot drift and there is no list to remember to update.
import { useEffect, useState } from "react";
import { useEscapeLayer } from "../escape";
import { BINDINGS, GROUPS, bindingLabel, keyLabel } from "../keys/bindings";
import { commandLabel, onCommand } from "../keys/commands";
import { useKeyContext } from "../keys/keymap";
import { Icon } from "./Icon";
export function Shortcuts() {
const [open, setOpen] = useState(false);
useEffect(() => onCommand("shortcuts", () => setOpen((v) => !v)), []);
useEscapeLayer(open, () => setOpen(false));
useKeyContext("overlay", open);
if (!open) return null;
return (
<div className="overlay" onClick={() => setOpen(false)}>
<div
className="panel panel-keys"
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts"
onClick={(e) => e.stopPropagation()}
>
<div className="panel-head">
<h2>Keyboard shortcuts</h2>
<button
className="icon-button"
onClick={() => setOpen(false)}
title="Close (⎋)"
aria-label="Close"
>
<Icon d="M6 6l12 12M18 6L6 18" />
</button>
</div>
<div className="panel-body">
{GROUPS.map((group) => {
const rows = BINDINGS.filter((binding) => binding.group === group);
if (!rows.length) return null;
return (
<section key={group} className="key-group">
<div className="nav-label">{group}</div>
<ul className="key-list">
{rows.map((binding) => (
<li key={binding.keys.join("+")} className="key-row">
<span className="key-what">{bindingLabel(binding, commandLabel)}</span>
<span className="key-combos">
{binding.keys.map((combo) => (
<kbd key={combo} className="key-cap">
{keyLabel(combo)}
</kbd>
))}
</span>
</li>
))}
</ul>
</section>
);
})}
</div>
</div>
</div>
);
}
+513
View File
@@ -0,0 +1,513 @@
// Every open folder, one tree each. This component owns everything that spans roots: which row is
// selected, which row holds the tab stop, the arrow-key walk over the visible rows, the drag
// gesture and the menus. FileTree.tsx below it only draws.
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, type PointerEvent } from "react";
import { openExternal } from "../api/roots";
import { commandLabel, runCommand } from "../keys/commands";
import { useDocument } from "../store/useDocument";
import { notify } from "../store/useToast";
import { useWorkspace, type TreeNode } from "../store/useWorkspace";
import { movePath } from "../workspace";
import { ConfirmDialog } from "./ConfirmDialog";
import {
FileTree,
flattenTree,
splitExtension,
type DropTarget,
type TreeHandlers,
type TreeRow,
type TreeViewState,
} from "./FileTree";
import { Icon } from "./Icon";
import { ResizeHandle } from "./ResizeHandle";
import { RowMenuAt, type RowMenuEntry } from "./RowMenu";
import { shortcutTitle } from "./Titlebar";
const EXPANDED_KEY = "margindocs-expanded";
const ROOTS_SEEN_KEY = "margindocs-roots-seen";
/** How far the pointer travels before a press on a row becomes a drag rather than a click. */
const DRAG_SLOP = 4;
/** How long a drag hovers a closed folder before it springs open, the way Finder does. */
const SPRING_MS = 650;
const OPEN_FOLDER = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M12 10v6 M9 13h6";
const NEW_DOC_ICON = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5 M12 12v5 M9.5 14.5h5";
const NEW_FOLDER_ICON = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M12 11v6 M9 14h6";
const RENAME_ICON = "M4 20h4L20 8l-4-4L4 16z M14 6l4 4";
const DUPLICATE_ICON = "M9 9h11v11H9z M6 15V5h9";
const REVEAL_ICON = "M9 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4 M15 3h6v6 M10 14L21 3";
const COPY_PATH_ICON = "M8 4h8a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z M9 2h6v4H9z";
const TRASH_ICON = "M5 7h14M10 7V5h4v2M7 7l1 13h8l1-13M10 11v6M14 11v6";
const CLOSE_ICON = "M18 6L6 18M6 6l12 12";
function readList(key: string): string[] {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(key) ?? "[]");
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : [];
} catch {
return [];
}
}
function writeList(key: string, values: readonly string[]): void {
try {
localStorage.setItem(key, JSON.stringify(values));
} catch {
// A webview with storage denied still works, it just forgets the shape of the tree.
}
}
/**
* Where the pointer says the dragged row should land.
*
* A filesystem has no row order to insert into, so all three answers are one destination
* directory: the middle of a folder means that folder, and the edges of any row mean the folder
* that row already lives in. Reading the answer off the DOM rather than off the row array is what
* keeps this working while the tree scrolls under the pointer.
*/
function dropAt(x: number, y: number): DropTarget | null {
const el = document.elementFromPoint(x, y) as HTMLElement | null;
if (!el) return null;
const row = el.closest<HTMLElement>(".tree-row");
if (row?.dataset.path) {
const path = row.dataset.path;
const parent = row.dataset.parent ?? "";
const rect = row.getBoundingClientRect();
const at = (y - rect.top) / rect.height;
if (row.dataset.dir === "true") {
if (!parent || (at > 0.25 && at < 0.75)) return { dir: path, mode: "into", row: path };
return { dir: parent, mode: at <= 0.25 ? "before" : "after", row: path };
}
if (!parent) return null;
return { dir: parent, mode: at < 0.5 ? "before" : "after", row: path };
}
// The indent gutter of a nested list belongs to the folder that owns the list, not to the root.
const owner = el
.closest<HTMLElement>(".tree-item")
?.querySelector<HTMLElement>(":scope > .tree-row");
if (owner?.dataset.path) return { dir: owner.dataset.path, mode: "into", row: owner.dataset.path };
const section = el.closest<HTMLElement>(".tree-section");
if (section?.dataset.root) return { dir: section.dataset.root, mode: "into", row: section.dataset.root };
return null;
}
/** A drop that would not move anything, or would move a folder inside itself, is not a drop. */
function usableDrop(target: DropTarget | null, path: string, parent: string): DropTarget | null {
if (!target) return null;
if (target.dir === parent) return null;
if (target.dir === path || target.dir.startsWith(`${path}/`)) return null;
return target;
}
const sameDrop = (a: DropTarget | null, b: DropTarget | null): boolean =>
a?.dir === b?.dir && a?.mode === b?.mode && a?.row === b?.row;
export function Sidebar() {
const roots = useWorkspace((s) => s.roots);
const expanded = useWorkspace((s) => s.expanded);
const selectedPath = useWorkspace((s) => s.selectedPath);
const scanPhase = useWorkspace((s) => s.scanPhase);
const select = useWorkspace((s) => s.select);
const toggleExpanded = useWorkspace((s) => s.toggleExpanded);
const newDocument = useWorkspace((s) => s.newDocument);
const newFolder = useWorkspace((s) => s.newFolder);
const renameEntry = useWorkspace((s) => s.renameEntry);
const duplicateEntry = useWorkspace((s) => s.duplicateEntry);
const deleteEntry = useWorkspace((s) => s.deleteEntry);
const revealInFinder = useWorkspace((s) => s.revealInFinder);
const closeFolder = useWorkspace((s) => s.closeFolder);
const openDocument = useDocument((s) => s.open);
const openPath = useDocument((s) => s.path);
const [hydrated, setHydrated] = useState(false);
const [renamingPath, setRenamingPath] = useState<string | null>(null);
const [menuOpenPath, setMenuOpenPath] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; row: TreeRow } | null>(null);
const [pendingDelete, setPendingDelete] = useState<TreeNode | null>(null);
const [dragPath, setDragPath] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const gesture = useRef<{ path: string; parent: string; x: number; y: number; active: boolean } | null>(null);
const suppressClick = useRef(false);
const dropRef = useRef<DropTarget | null>(null);
const spring = useRef<number | null>(null);
useEffect(() => {
const already = useWorkspace.getState().expanded;
for (const path of readList(EXPANDED_KEY)) if (!already.has(path)) toggleExpanded(path);
setHydrated(true);
}, [toggleExpanded]);
useEffect(() => {
if (!hydrated) return;
writeList(EXPANDED_KEY, [...expanded]);
}, [hydrated, expanded]);
// A folder the user has only just opened should show its contents. One that they opened months
// ago and then collapsed should stay collapsed, which is why "seen" is remembered separately
// rather than inferred from an empty expansion set.
useEffect(() => {
if (!hydrated) return;
const seen = readList(ROOTS_SEEN_KEY);
const fresh = roots.filter((r) => !seen.includes(r.path));
if (!fresh.length) return;
const already = useWorkspace.getState().expanded;
for (const root of fresh) if (!already.has(root.path)) toggleExpanded(root.path);
writeList(ROOTS_SEEN_KEY, [...seen, ...fresh.map((r) => r.path)]);
}, [hydrated, roots, toggleExpanded]);
useEffect(
() => () => {
if (spring.current !== null) clearTimeout(spring.current);
},
[],
);
const rootNodes: TreeNode[] = useMemo(
() =>
roots.map((root) => ({
path: root.path,
name: root.name,
isDir: true,
editable: false,
children: root.tree,
})),
[roots],
);
const rows = useMemo(
() => rootNodes.flatMap((node) => flattenTree([node], expanded, 0, "")),
[rootNodes, expanded],
);
const tabStopPath =
rows.find((r) => r.node.path === selectedPath)?.node.path ?? rows[0]?.node.path ?? null;
const focusRow = (path: string) =>
requestAnimationFrame(() => {
document.querySelector<HTMLElement>(`.tree-row[data-path="${CSS.escape(path)}"]`)?.focus();
});
const moveFocus = (row: TreeRow | undefined) => {
if (!row) return;
select(row.node.path);
focusRow(row.node.path);
};
const activate = (node: TreeNode) => {
if (suppressClick.current) {
suppressClick.current = false;
return;
}
select(node.path);
if (node.isDir) {
toggleExpanded(node.path);
return;
}
if (node.editable) {
openDocument(node.path).catch((e) => notify(`Could not open: ${String(e)}`));
return;
}
openExternal(node.path).catch((e) => notify(`Could not open: ${String(e)}`));
};
const onKeyDown = (e: KeyboardEvent, row: TreeRow) => {
const at = rows.findIndex((r) => r.node.path === row.node.path);
switch (e.key) {
case "Enter":
case " ":
e.preventDefault();
activate(row.node);
break;
case "ArrowDown":
e.preventDefault();
moveFocus(rows[at + 1]);
break;
case "ArrowUp":
e.preventDefault();
moveFocus(rows[at - 1]);
break;
case "Home":
e.preventDefault();
moveFocus(rows[0]);
break;
case "End":
e.preventDefault();
moveFocus(rows[rows.length - 1]);
break;
case "ArrowRight":
e.preventDefault();
if (!row.node.isDir) break;
if (!expanded.has(row.node.path)) toggleExpanded(row.node.path);
else moveFocus(rows[at + 1]);
break;
case "ArrowLeft":
e.preventDefault();
if (row.node.isDir && expanded.has(row.node.path)) toggleExpanded(row.node.path);
else if (row.parentPath) moveFocus(rows.find((r) => r.node.path === row.parentPath));
break;
default:
break;
}
};
const setDrop = (target: DropTarget | null) => {
if (sameDrop(dropRef.current, target)) return;
dropRef.current = target;
setDropTarget(target);
if (spring.current !== null) {
clearTimeout(spring.current);
spring.current = null;
}
if (target?.mode !== "into") return;
const dir = target.dir;
if (useWorkspace.getState().expanded.has(dir)) return;
spring.current = window.setTimeout(() => {
spring.current = null;
if (dropRef.current?.dir === dir) useWorkspace.getState().toggleExpanded(dir);
}, SPRING_MS);
};
const onPointerMove = (e: globalThis.PointerEvent) => {
const g = gesture.current;
if (!g) return;
if (!g.active) {
if (Math.abs(e.clientX - g.x) < DRAG_SLOP && Math.abs(e.clientY - g.y) < DRAG_SLOP) return;
g.active = true;
setDragPath(g.path);
}
setDrop(usableDrop(dropAt(e.clientX, e.clientY), g.path, g.parent));
};
// The disk half of a drop. Everything it does now lives in src/workspace.ts beside renamePath,
// which is where it belonged: refreshing both folders, following the selection, and rewriting the
// relative links the move broke. Calling fileMove from here would move the bytes and leave every
// link pointing at the old path.
const moveInto = async (path: string, dir: string) => {
await movePath(path, dir);
};
const onPointerUp = () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
const g = gesture.current;
gesture.current = null;
const target = dropRef.current;
if (g?.active) {
suppressClick.current = true;
if (target) moveInto(g.path, target.dir).catch((e) => notify(`Could not move: ${String(e)}`));
}
setDragPath(null);
setDrop(null);
};
const onPointerDown = (e: PointerEvent, row: TreeRow) => {
suppressClick.current = false;
if (e.button !== 0 || renamingPath === row.node.path || menuOpenPath === row.node.path) return;
// A root is where its folder lives on disk, not a row inside a tree, so it does not move.
if (!row.parentPath) return;
const target = e.target as HTMLElement;
if (target.closest(".row-menu-btn") || target.closest(".tree-twisty")) return;
gesture.current = { path: row.node.path, parent: row.parentPath, x: e.clientX, y: e.clientY, active: false };
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
};
const onContextMenu = (e: MouseEvent, row: TreeRow) => {
e.preventDefault();
e.stopPropagation();
select(row.node.path);
setContextMenu({ x: e.clientX, y: e.clientY, row });
};
// Open first, then offer the rename: the editor takes focus as it mounts, and a rename field
// that opened before it would be blurred out from under the user mid-word.
const createDocument = (dir: string) => {
newDocument(dir)
.then((path) => {
select(path);
return openDocument(path).then(() => setRenamingPath(path));
})
.catch((e) => notify(`Could not create the document: ${String(e)}`));
};
const createFolder = (dir: string) => {
newFolder(dir)
.then((path) => {
select(path);
setRenamingPath(path);
})
.catch((e) => notify(`Could not create the folder: ${String(e)}`));
};
const copyPath = (path: string) => {
navigator.clipboard
.writeText(path)
.then(() => notify("Path copied"))
.catch(() => notify("Could not copy the path"));
};
const menuItems = (row: TreeRow): readonly RowMenuEntry[] => {
const node = row.node;
const dir = node.isDir ? node.path : row.parentPath;
const isRoot = !row.parentPath;
const items: RowMenuEntry[] = [
{ id: "new-doc", label: "New Document", icon: NEW_DOC_ICON, run: () => createDocument(dir) },
{ id: "new-folder", label: "New Folder", icon: NEW_FOLDER_ICON, run: () => createFolder(dir) },
];
if (!isRoot) {
items.push("sep");
items.push({
id: "rename",
label: "Rename",
icon: RENAME_ICON,
run: () => setRenamingPath(node.path),
});
items.push({
id: "duplicate",
label: "Duplicate",
icon: DUPLICATE_ICON,
run: () =>
duplicateEntry(node.path).catch((e) => notify(`Could not duplicate: ${String(e)}`)),
});
}
items.push("sep");
items.push({
id: "reveal",
label: "Reveal in Finder",
icon: REVEAL_ICON,
run: () =>
revealInFinder(node.path).catch((e) => notify(`Could not reveal in Finder: ${String(e)}`)),
});
items.push({ id: "copy-path", label: "Copy Path", icon: COPY_PATH_ICON, run: () => copyPath(node.path) });
items.push("sep");
if (isRoot)
items.push({
id: "close-folder",
label: "Close Folder",
icon: CLOSE_ICON,
run: () => closeFolder(node.path),
});
else
items.push({
id: "delete",
label: "Delete",
icon: TRASH_ICON,
danger: true,
run: () => setPendingDelete(node),
});
return items;
};
const view: TreeViewState = {
expanded,
selectedPath,
tabStopPath,
draggingPath: dragPath,
dropTarget,
renamingPath,
openPath,
};
const handlers: TreeHandlers = {
onActivate: activate,
onToggle: (node) => {
select(node.path);
toggleExpanded(node.path);
},
onKeyDown,
onPointerDown,
onContextMenu,
onMenuOpenChange: (path, open) =>
setMenuOpenPath((current) => (open ? path : current === path ? null : current)),
menuItems,
// The row hides the extension, so the rename has to put back the one it took away rather than
// quietly turning notes.md into a file with no extension at all.
onRenameCommit: (node, typed) => {
setRenamingPath(null);
const { base, hidden } = splitExtension(node.name, node.isDir);
const trimmed = typed.trim();
if (!trimmed || trimmed === base) return;
renameEntry(node.path, `${trimmed}${hidden}`).catch((e) =>
notify(`Could not rename: ${String(e)}`),
);
},
onRenameCancel: () => setRenamingPath(null),
};
return (
<>
<aside className="sidebar" aria-label="Folders">
<div className="sidebar-head">
<span className="nav-label">Folders</span>
<div className="sidebar-actions">
<button
className="icon-button"
title={shortcutTitle("open-folder")}
aria-label={commandLabel("open-folder")}
onClick={() => runCommand("open-folder")}
>
<Icon d={OPEN_FOLDER} />
</button>
<button
className="icon-button"
title={shortcutTitle("new-doc")}
aria-label={commandLabel("new-doc")}
onClick={() => runCommand("new-doc")}
>
<Icon d={NEW_DOC_ICON} />
</button>
</div>
</div>
<div className="nav-scroll">
{rootNodes.map((node) => (
<div key={node.path} className="tree-section" data-root={node.path}>
<FileTree nodes={[node]} depth={0} parentPath="" state={view} handlers={handlers} />
</div>
))}
{!rootNodes.length && (
<p className="sidebar-empty">
{scanPhase === "scanning" ? "Reading the folder…" : "No folder is open."}
</p>
)}
</div>
</aside>
<ResizeHandle />
{contextMenu && (
<RowMenuAt
x={contextMenu.x}
y={contextMenu.y}
items={() => menuItems(contextMenu.row)}
onClose={() => setContextMenu(null)}
/>
)}
{pendingDelete && (
<ConfirmDialog
title={pendingDelete.isDir ? "Delete folder" : "Delete file"}
message={
<>
Move <strong>{pendingDelete.name}</strong> to the Trash? You can put it back from
Finder.
</>
}
confirmLabel="Move to Trash"
onConfirm={() => {
const path = pendingDelete.path;
setPendingDelete(null);
deleteEntry(path).catch((e) => notify(`Could not delete: ${String(e)}`));
}}
onClose={() => setPendingDelete(null)}
/>
)}
</>
);
}
+227
View File
@@ -0,0 +1,227 @@
// The macOS overlay title bar. The traffic lights float over its left end, which is why the row
// itself carries `data-tauri-drag-region` and every control inside it does not: an interactive
// element that also drags the window swallows its own click.
import { useEffect, useRef, useState } from "react";
import { useEscapeLayer } from "../escape";
import { keyLabel, keysFor } from "../keys/bindings";
import { commandLabel, onCommand, runCommand, type CommandId } from "../keys/commands";
import { useDocument } from "../store/useDocument";
import { useTheme } from "../store/useTheme";
import { notify } from "../store/useToast";
import { useWorkspace } from "../store/useWorkspace";
import { splitExtension } from "./FileTree";
import { Icon } from "./Icon";
import { WidthMenu } from "./WidthMenu";
const SIDEBAR_KEY = "margindocs-sidebar";
const SIDEBAR_ICON = "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z M9 3v18";
const NEW_DOC = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5 M12 12v5 M9.5 14.5h5";
const SEARCH = "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14z M20 20l-3.6-3.6";
const SUN = "M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10z M12 1v2 M12 21v2 M4.2 4.2l1.4 1.4 M18.4 18.4l1.4 1.4 M1 12h2 M21 12h2 M4.2 19.8l1.4-1.4 M18.4 5.6l1.4-1.4";
const MOON = "M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z";
const MORE = "M5 12h.01M12 12h.01M19 12h.01";
/** A tooltip that names the action and prints its key in the glyphs the sheet uses. */
export function shortcutTitle(id: CommandId): string {
const keys = keysFor(id);
return keys.length ? `${commandLabel(id)} (${keyLabel(keys[0])})` : commandLabel(id);
}
export function Titlebar() {
const path = useDocument((s) => s.path);
const dirty = useDocument((s) => s.dirty);
const externalChange = useDocument((s) => s.externalChange);
const renameEntry = useWorkspace((s) => s.renameEntry);
const theme = useTheme((s) => s.theme);
const toggleTheme = useTheme((s) => s.toggle);
const [sidebar, setSidebar] = useState(
() => document.documentElement.getAttribute("data-sidebar") !== "false",
);
const [menu, setMenu] = useState(false);
const [renaming, setRenaming] = useState(false);
useEffect(() => {
document.documentElement.setAttribute("data-sidebar", String(sidebar));
try {
localStorage.setItem(SIDEBAR_KEY, String(sidebar));
} catch {
// A webview with storage denied still toggles, it just forgets between launches.
}
}, [sidebar]);
useEffect(() => onCommand("toggle-sidebar", () => setSidebar((v) => !v)), []);
useEffect(() => setRenaming(false), [path]);
useEscapeLayer(menu, () => setMenu(false));
const fileName = path ? path.slice(path.lastIndexOf("/") + 1) : "";
const { base, hidden } = splitExtension(fileName);
// The filename and the document's H1 are unrelated, and this is the place that promise is
// easiest to break. What the title bar shows is the file on disk, and the only thing that ever
// renames it is the user typing here. Editing a heading is a content edit and nothing else: it
// does not move the file, because a path is what git, every other editor and every relative
// link from another document already agreed on.
const commitRename = (next: string) => {
setRenaming(false);
const trimmed = next.trim();
if (!path || !trimmed || trimmed === base) return;
renameEntry(path, `${trimmed}${hidden}`).catch((e) => notify(`Could not rename: ${String(e)}`));
};
const menuItem = (id: CommandId) => (
<button
key={id}
onClick={() => {
setMenu(false);
runCommand(id);
}}
>
{commandLabel(id)}
</button>
);
return (
<header className="titlebar" data-tauri-drag-region>
<div className="lead">
<button
className="icon-button"
data-active={sidebar}
title={shortcutTitle("toggle-sidebar")}
aria-label={commandLabel("toggle-sidebar")}
aria-pressed={sidebar}
onClick={() => setSidebar((v) => !v)}
>
<Icon d={SIDEBAR_ICON} />
</button>
</div>
{path &&
(renaming ? (
<TitleRename value={base} onCommit={commitRename} onCancel={() => setRenaming(false)} />
) : (
<button
className="doc-title"
data-external={externalChange === "changed-on-disk"}
title={
externalChange === "changed-on-disk"
? `${fileName} (changed on disk). Click to rename.`
: `${fileName}. Click to rename.`
}
onClick={() => setRenaming(true)}
>
{base}
{dirty && <span className="dirty-dot" />}
</button>
))}
<div className="actions">
<button
className="icon-button"
title={shortcutTitle("new-doc")}
aria-label={commandLabel("new-doc")}
onClick={() => runCommand("new-doc")}
>
<Icon d={NEW_DOC} />
</button>
<button
className="icon-button"
title={shortcutTitle("quick-open")}
aria-label={commandLabel("quick-open")}
onClick={() => runCommand("quick-open")}
>
<Icon d={SEARCH} />
</button>
<button
className="icon-button"
title={theme === "dark" ? "Light theme" : "Dark theme"}
aria-label={commandLabel("toggle-theme")}
onClick={toggleTheme}
>
<Icon d={theme === "dark" ? SUN : MOON} />
</button>
<WidthMenu />
<div className="menu-wrap">
<button
className="icon-button"
data-active={menu}
title="More"
aria-label="More"
aria-expanded={menu}
onClick={() => setMenu((v) => !v)}
>
<Icon d={MORE} />
</button>
{menu && (
<>
<div className="menu-backdrop" onClick={() => setMenu(false)} />
<div className="menu" role="menu">
{menuItem("open-folder")}
{menuItem("new-folder")}
<div className="menu-sep" />
{menuItem("find-in-files")}
{menuItem("shortcuts")}
{menuItem("settings")}
<div className="menu-sep" />
{menuItem("check-updates")}
{menuItem("report-issue")}
</div>
</>
)}
</div>
</div>
</header>
);
}
function TitleRename({
value,
onCommit,
onCancel,
}: {
value: string;
onCommit: (next: string) => void;
onCancel: () => void;
}) {
const ref = useRef<HTMLInputElement>(null);
const settled = useRef(false);
useEffect(() => {
const input = ref.current;
if (!input) return;
input.focus();
input.select();
}, []);
const commit = (next: string) => {
if (settled.current) return;
settled.current = true;
onCommit(next);
};
useEscapeLayer(true, () => {
settled.current = true;
onCancel();
});
return (
<input
ref={ref}
className="doc-title title-rename"
defaultValue={value}
spellCheck={false}
autoComplete="off"
aria-label="Rename this file"
onBlur={(e) => commit(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
commit(e.currentTarget.value);
}}
/>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect } from "react";
import { useToast } from "../store/useToast";
const DWELL_MS = 4200;
export function Toast() {
const message = useToast((s) => s.message);
const dismiss = useToast((s) => s.dismiss);
useEffect(() => {
if (!message) return;
const timer = setTimeout(dismiss, DWELL_MS);
return () => clearTimeout(timer);
}, [message, dismiss]);
if (!message) return null;
return (
<div className="toast" role="status" title="Dismiss" onClick={dismiss}>
{message}
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
// The width control there was no way to click: a title bar button that shows the applied width and
// opens the three named steps with the current one marked.
//
// It belongs beside the theme toggle rather than in the editor pill. Everything in the pill edits
// the file; this edits the app's view of it and touches no byte on disk, and the title bar already
// holds the other two of exactly that kind, the sidebar and the theme, both persisted under the
// same `margindocs-` prefix and both restored by the same boot script. The pill is also the wrong
// place mechanically: its tools go dead while a save conflict is open, and being unable to widen
// the page because the file moved on disk is nonsense, and the foot of src/styles/toolbar.css
// records that the row is already four pixels over the pane at the app's minimum window.
//
// The DOM is the source of truth and this component's state is a cache of it. `applyWidth` writes
// `data-width` on the root element and index.html's boot script writes it before React exists,
// while the keyboard commands and the native menu both call `applyWidth` without telling anyone,
// so the attribute is read on mount and watched with a MutationObserver. A component that
// remembered the last width it set itself would open showing the wrong one the first time somebody
// reached for the key instead.
//
// Focus is not taken from the document. A mouse press on any button here is prevented, so the
// caret stays in the sentence somebody is in the middle of; only a keyboard open moves focus into
// the menu, and closing puts it back where it came from.
import { useEffect, useId, useRef, useState } from "react";
import { useEscapeLayer } from "../escape";
import { applyWidth, WIDTHS, type EditorWidth } from "../width";
import { Icon } from "./Icon";
const ITEM = ".width-menu-item";
const CHECK_D = "M20 6L9 17l-5-5";
/** The page's two edges with three lines of text between them, so the button says which width is
* applied without spending a word of the title bar on it. The edges never move and only the
* measure does, which is the whole of what the setting changes. The sibling's glyph for this is a
* double headed arrow, and it is not ported: an arrow six units long is a smudge at 16px, which is
* the only size this is ever drawn at. */
const WIDTH_ICON: Record<EditorWidth, string> = {
narrow: "M3 4v16M21 4v16M9 7h6M9 12h6M9 17h6",
normal: "M3 4v16M21 4v16M7 7h10M7 12h10M7 17h10",
wide: "M3 4v16M21 4v16M5 7h14M5 12h14M5 17h14",
};
function isWidth(value: string | null): value is EditorWidth {
return WIDTHS.includes(value as EditorWidth);
}
/** Capitalised for a menu. The names themselves belong to src/width.ts and the command ids. */
function widthLabel(width: EditorWidth): string {
return width.charAt(0).toUpperCase() + width.slice(1);
}
/** No attribute at all is the default, because sheet.css only writes rules for narrow and wide and
* the boot script only sets the attribute when something was saved. */
function appliedWidth(): EditorWidth {
const value = document.documentElement.getAttribute("data-width");
return isWidth(value) ? value : "normal";
}
function useAppliedWidth(): EditorWidth {
const [width, setWidth] = useState(appliedWidth);
useEffect(() => {
const read = () => setWidth(appliedWidth());
const observer = new MutationObserver(read);
observer.observe(document.documentElement, { attributeFilter: ["data-width"] });
// The attribute can have moved between the first render and this effect running.
read();
return () => observer.disconnect();
}, []);
return width;
}
export function WidthMenu() {
const width = useAppliedWidth();
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
/** Where focus was when a keyboard user opened the menu, and null when a mouse user did, since
* that press never moved it. */
const returnTo = useRef<HTMLElement | null>(null);
const focusOnOpen = useRef(false);
const labelId = useId();
const close = (restoreFocus = true) => {
setOpen(false);
const el = returnTo.current;
returnTo.current = null;
if (restoreFocus && el?.isConnected) el.focus();
};
useEscapeLayer(open, () => close());
useEffect(() => {
if (!open || !focusOnOpen.current) return;
focusOnOpen.current = false;
menuRef.current?.querySelector<HTMLElement>(ITEM)?.focus();
}, [open]);
const toggle = (e: React.MouseEvent) => {
if (open) {
close();
return;
}
// `detail` is 0 when Enter or Space activated the button and 1 when a pointer did. A keyboard
// user cannot reach the items unless focus is moved into the menu; a mouse user is mid
// sentence and would lose their caret to a setting that has nothing to do with the text.
const byKeyboard = e.detail === 0;
returnTo.current = byKeyboard ? (document.activeElement as HTMLElement | null) : null;
focusOnOpen.current = byKeyboard;
setOpen(true);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const all = Array.from(menuRef.current?.querySelectorAll<HTMLElement>(ITEM) ?? []);
const at = all.indexOf(document.activeElement as HTMLElement);
const next = e.key === "ArrowDown" ? (at + 1) % all.length : (at - 1 + all.length) % all.length;
all[next]?.focus();
};
const choose = (next: EditorWidth) => {
applyWidth(next);
close();
};
const name = `Editor width: ${widthLabel(width)}`;
return (
<div
className="menu-wrap"
// Tabbing out of an open menu has to leave the menu behind, since nothing here traps focus,
// and focus that has deliberately gone somewhere else is not dragged back.
onBlur={(e) => {
if (!open || e.currentTarget.contains(e.relatedTarget)) return;
close(false);
}}
>
<button
className="icon-button"
data-active={open}
title={name}
aria-label={name}
aria-haspopup="menu"
aria-expanded={open}
onMouseDown={(e) => e.preventDefault()}
onClick={toggle}
>
<Icon d={WIDTH_ICON[width]} />
</button>
{open && (
<>
<div className="menu-backdrop" onClick={() => close()} />
<div
ref={menuRef}
className="menu"
role="menu"
aria-labelledby={labelId}
onKeyDown={onKeyDown}
>
{/* Three words that mean nothing on their own, so the menu says what they are a width
of and then lends the same line to assistive tech as its own name. Presentational
because a menu's children are meant to be its items, and because being announced as
the menu's name and again as a line inside it is the same sentence twice. */}
<div className="menu-label" id={labelId} role="presentation">
Editor width
</div>
{WIDTHS.map((w) => (
<button
key={w}
className="width-menu-item"
role="menuitemradio"
aria-checked={w === width}
data-on={w === width}
onMouseDown={(e) => e.preventDefault()}
onClick={() => choose(w)}
>
<span className="width-menu-check" aria-hidden="true">
<Icon d={CHECK_D} size={14} />
</span>
{widthLabel(w)}
</button>
))}
</div>
</>
)}
</div>
);
}
+290
View File
@@ -0,0 +1,290 @@
// A dev-only folder of documents, held in memory. It exists so the real UI can be opened in a
// plain browser with no Tauri behind it, by a person or by Playwright, without pointing the app at
// anybody's actual files.
//
// It is shaped like a folder somebody would really have rather than three files called test.md,
// because every interesting case in this app is a case the tree has to render: nesting several
// levels deep, a .txt that is editable, a .png that is not, an assets folder beside a document,
// frontmatter, a callout, a table, a toggle, a code block, and relative links between documents
// so backlinks have something to find.
//
// Anchored to the current time at load, so the tree never shows a modified date from last year.
import type { FileKind, RootInfo } from "../ipc";
const MINUTE = 60_000;
const HOUR = 3_600_000;
const DAY = 86_400_000;
const now = Date.now();
/** One file or directory. `text` is empty for a directory and for anything binary. */
export interface DevEntry {
path: string;
dir: boolean;
text: string;
/** Not a text file. Greyed in the tree, opened by the system, refused by `file_read`. */
binary: boolean;
modifiedMs: number;
}
export const HANDBOOK = "/Users/you/Documents/Handbook";
export const SCRATCH = "/Users/you/Documents/Scratch";
/** Stable for a given path, the way the Rust side derives a root id from the folder it opened. */
export const rootIdFor = (path: string): string =>
path
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
export const devRoots: RootInfo[] = [
{ id: rootIdFor(HANDBOOK), path: HANDBOOK, name: "Handbook", openedMs: now - 6 * DAY },
{ id: rootIdFor(SCRATCH), path: SCRATCH, name: "Scratch", openedMs: now - 2 * HOUR },
];
const readme = `---
title: Handbook
updated: 2026-02-11
tags: [team, reference]
---
# Handbook
Everything the team needs, in one folder, in plain markdown. Nothing here is generated and nothing
here needs an account to read.
Start with [Getting started](guides/getting-started.md). Skim [Writing](guides/writing.md) before
you open your first pull request, and keep the
[keyboard reference](reference/keyboard.md) somewhere you can see it.
## What lives where
Guides are the things you read once. The reference is the thing you come back to. Anything under
\`archive/\` is kept because deleting it would lose the argument, not because it is still true.
`;
const gettingStarted = `---
title: Getting started
tags: [onboarding]
---
# Getting started
Clone the repository and run the app once before you change anything. It is much easier to read a
diff when you have seen the thing the diff is about.
\`\`\`sh
git clone [email protected]:example/handbook.git
cd handbook
pnpm install
pnpm dev
\`\`\`
> [!NOTE]
> The first run builds the search index. On a folder this size it takes a second or two, and quick
> open stays empty until it finishes.
## Your editor
Any editor is fine. Two settings are not optional: trim trailing whitespace, and end every file
with a newline. Without them every pull request carries noise nobody wrote.
> [!WARNING]
> Do not edit anything under \`archive/\`. Those documents are kept as a record and a change there
> will not be reviewed.
When something is bound to a key, [the keyboard reference](../reference/keyboard.md) is the list.
`;
const writing = `---
title: Writing
---
# Writing
Short sentences. Say the thing, then stop. If a paragraph is doing two jobs, it is two paragraphs.
## What the editor does with what you type
| You write | On disk | In the editor |
| --- | --- | --- |
| A note | \`> [!NOTE]\` | a tinted block with a title |
| A toggle | \`<details>\` | a disclosure arrow |
| A link | \`[text](path.md)\` | underlined, click to follow |
| A table | pipes and dashes | a real table with a header row |
The file on disk stays plain markdown. Anything the editor cannot model is left exactly as it was
found and shown as a raw block you can still edit.
<details>
<summary>House style, the short version</summary>
No em dashes. No exclamation marks. Do not start a sentence with "Basically". If you catch
yourself writing "simply", delete it and read the sentence again.
</details>
## Before you open a pull request
Read it out loud once. Then read [Getting started](getting-started.md) if you have not, because
half of what gets flagged in review is covered there already, and check the
[handbook index](../README.md) still points at your new page.
`;
const keyboard = `---
title: Keyboard reference
---
# Keyboard reference
| Key | Does |
| --- | --- |
| \`Cmd P\` | Quick open, fuzzy match on the whole path |
| \`Cmd Shift F\` | Search the text of every open folder |
| \`Cmd S\` | Save |
| \`Cmd N\` | New document, in the selected folder |
| \`Cmd O\` | Open a folder |
| \`Cmd \\\` | Show or hide the sidebar |
| \`Cmd B\` | Bold |
| \`Cmd K\` | Command palette |
Nothing here is configurable yet. If a key is wrong for you, say so and it can move.
`;
const retro = `---
title: 2024 retro
archived: true
---
# 2024 retro
Kept for the record. Most of this is out of date and none of it should be edited.
## What went well
Shipping small and often. The three week gap in July is the only stretch nobody enjoyed, and it
was the week the build broke twice.
## What did not
Documentation drifted from the code for most of the second half of the year, which is the reason
this folder exists at all.
`;
const notes = `Scratch notes, not markdown, still editable.
Ask about the archive folder. Nobody seems to know who owns it.
Chase the design review before Thursday.
The index rebuild takes longer than it should on the big folder.
`;
const inbox = `# Inbox
Things that have not found a home yet.
- Move the keyboard reference into the guides folder, or do not, but decide.
- A callout for "deprecated" would be useful.
- Check whether the .txt files should be indexed too.
`;
const todo = `Buy a new keyboard
Reply to the design review thread
Rebuild the index after the folder move
`;
/**
* A one pixel PNG. The point of it is the tree row, not the image: it proves a file the editor
* will not open is greyed and hands itself to the system instead.
*/
export const devPng =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
const dir = (path: string, modifiedMs: number): DevEntry => ({
path,
dir: true,
text: "",
binary: false,
modifiedMs,
});
const file = (path: string, text: string, modifiedMs: number): DevEntry => ({
path,
dir: false,
text,
binary: false,
modifiedMs,
});
export const devEntries: DevEntry[] = [
dir(HANDBOOK, now - 20 * MINUTE),
file(`${HANDBOOK}/README.md`, readme, now - 20 * MINUTE),
dir(`${HANDBOOK}/guides`, now - 3 * HOUR),
file(`${HANDBOOK}/guides/getting-started.md`, gettingStarted, now - 3 * HOUR),
file(`${HANDBOOK}/guides/writing.md`, writing, now - 2 * DAY),
dir(`${HANDBOOK}/reference`, now - 5 * DAY),
file(`${HANDBOOK}/reference/keyboard.md`, keyboard, now - 5 * DAY),
dir(`${HANDBOOK}/reference/assets`, now - 5 * DAY),
{
path: `${HANDBOOK}/reference/assets/diagram.png`,
dir: false,
text: "",
binary: true,
modifiedMs: now - 5 * DAY,
},
dir(`${HANDBOOK}/archive`, now - 200 * DAY),
dir(`${HANDBOOK}/archive/2024`, now - 200 * DAY),
file(`${HANDBOOK}/archive/2024/retro.md`, retro, now - 200 * DAY),
file(`${HANDBOOK}/notes.txt`, notes, now - 45 * MINUTE),
dir(SCRATCH, now - 90 * MINUTE),
file(`${SCRATCH}/inbox.md`, inbox, now - 90 * MINUTE),
file(`${SCRATCH}/todo.txt`, todo, now - 8 * HOUR),
];
export const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
export const dirName = (path: string): string => path.slice(0, path.lastIndexOf("/")) || "/";
export const joinPath = (parent: string, name: string): string =>
parent.endsWith("/") ? `${parent}${name}` : `${parent}/${name}`;
export const extensionOf = (path: string): string => {
const name = baseName(path);
const dot = name.lastIndexOf(".");
return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
};
export function kindOf(entry: DevEntry): FileKind {
if (entry.dir) return "dir";
const ext = extensionOf(entry.path);
if (ext === "md" || ext === "markdown") return "markdown";
if (ext === "txt") return "text";
return "other";
}
export const editableKind = (kind: FileKind): boolean => kind === "markdown" || kind === "text";
/** Frontmatter title first, then the first heading, then the filename. What the Rust index does. */
export function titleOf(path: string, text: string): string {
const front = /^---\n([\s\S]*?)\n---/.exec(text);
const titled = front && /^title:\s*(.+)$/m.exec(front[1]);
if (titled) return titled[1].trim().replace(/^["']|["']$/g, "");
const heading = /^#\s+(.+)$/m.exec(text);
if (heading) return heading[1].trim();
return baseName(path);
}
/** Resolves `](../thing.md)` against the document that wrote it. Null for anything not local. */
export function resolveRelative(fromFile: string, target: string): string | null {
if (!target || /^[a-z]+:/i.test(target) || target.startsWith("#")) return null;
const clean = target.split("#")[0].split("?")[0];
if (!clean) return null;
const parts = clean.startsWith("/") ? clean.split("/") : `${dirName(fromFile)}/${clean}`.split("/");
const out: string[] = [];
for (const part of parts) {
if (part === "" || part === ".") continue;
if (part === "..") out.pop();
else out.push(part);
}
return `/${out.join("/")}`;
}
+564
View File
@@ -0,0 +1,564 @@
// Serves the IPC surface from the dev fixture when the app is opened in a browser rather than in
// Tauri. This exists so the real UI can be driven and looked at, by a person or by Playwright,
// without a build of the Rust side and without pointing the app at real documents.
//
// It is reachable only when `import.meta.env.DEV` is true and `isTauri` is false, so it is absent
// from a production bundle and can never shadow the real backend inside the app.
//
// Writes mutate the fixture for the session, so creating a document and typing into it behaves
// the way it will on disk.
//
// `external` at the bottom is the other half: the world outside the app, for a test that needs a
// file to change while the app is looking at it. It mutates the fixture the way another program
// would, behind the app's back and without going through `file_write`, and hands back the exact
// `WatchEvent` payloads the Rust watcher would have emitted for what it did. Emitting them is the
// caller's job, because emitting means the Tauri event bus and this module has no opinion about
// where that comes from. src-tauri/tests/watch_payload.rs is what keeps those payloads honest.
import type {
AssetResult,
Backlink,
FileNode,
IndexStatus,
MatchRange,
QuickOpenHit,
ReadResult,
RootInfo,
SearchHit,
SpellIssue,
WatchEvent,
WriteResult,
} from "../ipc";
import {
baseName,
devEntries,
devRoots,
dirName,
editableKind,
extensionOf,
joinPath,
kindOf,
resolveRelative,
rootIdFor,
titleOf,
type DevEntry,
} from "./fixture";
const entries = new Map<string, DevEntry>(devEntries.map((e) => [e.path, { ...e }]));
const roots: RootInfo[] = devRoots.map((r) => ({ ...r }));
/** Roots with a watch running, so `external` can refuse to invent an event nobody subscribed to. */
const watching = new Set<string>();
/**
* A modification time that is always newer than the last one handed out. `Date.now()` twice in the
* same millisecond is two writes the app cannot tell apart, and telling them apart is the entire
* mechanism behind conflict detection and the reload guard.
*/
let lastStamp = 0;
function stamp(): number {
lastStamp = Math.max(Date.now(), lastStamp + 1);
return lastStamp;
}
/** Held writes, for a test that needs a buffer to stay dirty while something else touches disk. */
let writeGate: Promise<void> | null = null;
let openGate: (() => void) | null = null;
/**
* A first launch, which the fixture otherwise has no way to show: it is seeded with two open
* folders, so the empty state somebody new actually opens on was the one screen nobody could look
* at. With this set there are no roots and the tree comes back empty.
*/
const firstRun = (): boolean => {
try {
return localStorage.getItem("margindocs-dev-empty") === "1";
} catch {
return false;
}
};
function entryAt(path: string): DevEntry {
const entry = entries.get(path);
if (!entry) throw new Error(`no such file: ${path}`);
return entry;
}
function rootFor(path: string): RootInfo | undefined {
return roots.find((r) => path === r.path || path.startsWith(`${r.path}/`));
}
const relTo = (root: RootInfo, path: string): string => path.slice(root.path.length + 1);
function childrenOf(path: string): DevEntry[] {
const prefix = `${path}/`;
return [...entries.values()]
.filter((e) => e.path.startsWith(prefix) && !e.path.slice(prefix.length).includes("/"))
.sort((a, b) => {
if (a.dir !== b.dir) return a.dir ? -1 : 1;
return a.path.localeCompare(b.path, undefined, { sensitivity: "base" });
});
}
function nodeFor(entry: DevEntry): FileNode {
const kind = kindOf(entry);
return {
path: entry.path,
name: baseName(entry.path),
kind,
editable: editableKind(kind),
modifiedMs: entry.modifiedMs,
children: entry.dir ? childrenOf(entry.path).map(nodeFor) : [],
};
}
/** Every descendant of a directory, the directory itself included, deepest last. */
function subtree(path: string): DevEntry[] {
const prefix = `${path}/`;
return [...entries.values()].filter((e) => e.path === path || e.path.startsWith(prefix));
}
/** `name` is a suggestion. A taken one gets a numbered suffix, the way the Rust side does it. */
function freePath(parent: string, name: string): string {
const candidate = joinPath(parent, name);
if (!entries.has(candidate)) return candidate;
const dot = name.lastIndexOf(".");
const stem = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : "";
for (let n = 2; ; n += 1) {
const next = joinPath(parent, `${stem} ${n}${ext}`);
if (!entries.has(next)) return next;
}
}
function put(entry: DevEntry): DevEntry {
entries.set(entry.path, entry);
return entry;
}
/** Moves an entry and everything under it, which is the same operation for a rename and a move. */
function relocate(from: string, to: string): DevEntry {
for (const entry of subtree(from)) {
entries.delete(entry.path);
entries.set(entry.path === from ? to : to + entry.path.slice(from.length), {
...entry,
path: entry.path === from ? to : to + entry.path.slice(from.length),
});
}
return entryAt(to);
}
const textFiles = (): DevEntry[] =>
[...entries.values()].filter((e) => !e.dir && !e.binary && editableKind(kindOf(e)));
/** Subsequence match, the cheap kind quick open wants: every query character in order. */
function fuzzy(haystack: string, query: string): { score: number; ranges: MatchRange[] } | null {
const lower = haystack.toLowerCase();
const needle = query.toLowerCase().replace(/\s+/g, "");
if (!needle) return { score: 0, ranges: [] };
const ranges: MatchRange[] = [];
let at = 0;
let score = 0;
let previous = -2;
for (const character of needle) {
const found = lower.indexOf(character, at);
if (found < 0) return null;
// Runs read as a word and score far better than the same letters scattered over a path.
score += found === previous + 1 ? 8 : 1;
if (found > lower.lastIndexOf("/")) score += 4;
const last = ranges[ranges.length - 1];
if (last && last.end === found) last.end = found + 1;
else ranges.push({ start: found, end: found + 1 });
previous = found;
at = found + 1;
}
return { score: score - Math.floor(haystack.length / 10), ranges };
}
/** A window of the line around the first match, so a long line does not fill the results list. */
function snippetAround(line: string, start: number, length: number) {
const from = Math.max(0, start - 32);
const head = from > 0 ? "…" : "";
const body = line.slice(from, from + 160);
const tail = from + 160 < line.length ? "…" : "";
return {
snippet: `${head}${body}${tail}`,
range: { start: head.length + (start - from), end: head.length + (start - from) + length },
};
}
/**
* The whole vocabulary of the dev spell checker. Real spelling comes from the system and this is
* only ever a stand-in for a browser, so the list is short on purpose: it holds the words someone
* exercising the feature is likely to type at it and nothing else.
*/
const DEV_MISSPELLINGS: Record<string, string[]> = {
teh: ["the", "then", "tea"],
recieve: ["receive", "relieve"],
seperate: ["separate", "desperate"],
occured: ["occurred"],
definately: ["definitely", "defiantly"],
accomodate: ["accommodate"],
wierd: ["weird", "wired"],
begining: ["beginning"],
neccessary: ["necessary"],
publically: ["publicly"],
writting: ["writing", "written"],
markdwon: ["markdown"],
};
/** Words `spell_learn` was told about this session. The real checker teaches the whole machine. */
const devLearned = new Set<string>();
export async function mockCall<T>(command: string, args?: Record<string, unknown>): Promise<T> {
const a = (args ?? {}) as Record<string, never>;
switch (command) {
case "roots_list":
return (firstRun() ? [] : roots) as unknown as T;
case "root_open": {
const path = a.path as unknown as string;
const existing = roots.find((r) => r.path === path);
if (existing) return existing as unknown as T;
const opened: RootInfo = {
id: rootIdFor(path),
path,
name: baseName(path),
openedMs: Date.now(),
};
roots.push(opened);
if (!entries.has(path)) {
put({ path, dir: true, text: "", binary: false, modifiedMs: Date.now() });
}
return opened as unknown as T;
}
case "root_close": {
const id = a.rootId as unknown as string;
const at = roots.findIndex((r) => r.id === id);
if (at >= 0) roots.splice(at, 1);
return undefined as T;
}
case "tree_read": {
const root = roots.find((r) => r.id === (a.rootId as unknown as string));
if (!root) throw new Error(`no such root: ${a.rootId as unknown as string}`);
return nodeFor(entryAt(root.path)) as unknown as T;
}
case "reveal_in_finder":
case "open_external":
// Nothing to hand a file to in a browser tab, so say so rather than looking broken.
console.info(`dev mock: ${command} ${a.path as unknown as string}`);
return undefined as T;
case "file_read": {
const entry = entryAt(a.path as unknown as string);
if (entry.dir || entry.binary) throw new Error(`not a text file: ${entry.path}`);
return {
path: entry.path,
text: entry.text,
modifiedMs: entry.modifiedMs,
} satisfies ReadResult as unknown as T;
}
case "file_write": {
// Held only when a test has asked for it, so a buffer can be observed dirty while something
// outside the app changes the same file.
if (writeGate) await writeGate;
const entry = entryAt(a.path as unknown as string);
const expected = a.expectedModifiedMs as unknown as number | undefined;
if (typeof expected === "number" && expected !== entry.modifiedMs) {
return {
path: entry.path,
modifiedMs: entry.modifiedMs,
conflict: true,
} satisfies WriteResult as unknown as T;
}
entry.text = a.text as unknown as string;
entry.modifiedMs = stamp();
return {
path: entry.path,
modifiedMs: entry.modifiedMs,
conflict: false,
} satisfies WriteResult as unknown as T;
}
case "file_create": {
const path = freePath(a.parentPath as unknown as string, a.name as unknown as string);
return nodeFor(
put({ path, dir: false, text: "", binary: false, modifiedMs: stamp() }),
) as unknown as T;
}
case "file_folder_create": {
const path = freePath(a.parentPath as unknown as string, a.name as unknown as string);
return nodeFor(
put({ path, dir: true, text: "", binary: false, modifiedMs: stamp() }),
) as unknown as T;
}
case "file_rename": {
const from = a.path as unknown as string;
return nodeFor(
relocate(from, freePath(dirName(from), a.name as unknown as string)),
) as unknown as T;
}
case "file_move": {
const from = a.path as unknown as string;
const to = freePath(a.destDir as unknown as string, baseName(from));
return nodeFor(relocate(from, to)) as unknown as T;
}
case "file_duplicate": {
const source = entryAt(a.path as unknown as string);
const ext = extensionOf(source.path);
const stem = ext ? baseName(source.path).slice(0, -(ext.length + 1)) : baseName(source.path);
const path = freePath(dirName(source.path), ext ? `${stem} copy.${ext}` : `${stem} copy`);
return nodeFor(put({ ...source, path, modifiedMs: stamp() })) as unknown as T;
}
case "file_trash": {
for (const entry of subtree(a.path as unknown as string)) entries.delete(entry.path);
return undefined as T;
}
case "asset_write": {
const folder = joinPath(dirName(a.docPath as unknown as string), "assets");
if (!entries.has(folder)) {
put({ path: folder, dir: true, text: "", binary: false, modifiedMs: stamp() });
}
const path = freePath(folder, (a.name as unknown as string) || "image.png");
put({ path, dir: false, text: "", binary: true, modifiedMs: stamp() });
return {
path,
relPath: `assets/${baseName(path)}`,
} satisfies AssetResult as unknown as T;
}
case "watch_start":
watching.add(a.rootId as unknown as string);
return undefined as T;
case "watch_stop":
watching.delete(a.rootId as unknown as string);
return undefined as T;
case "index_rebuild":
case "index_status": {
const total = textFiles().length;
return {
phase: "idle",
indexed: total,
total,
lastIndexed: Date.now(),
error: null,
message: null,
} satisfies IndexStatus as unknown as T;
}
case "search_quick_open": {
const query = a.query as unknown as string;
const limit = (a.limit as unknown as number) ?? 30;
const hits: QuickOpenHit[] = [];
for (const entry of textFiles()) {
const root = rootFor(entry.path);
if (!root) continue;
const relPath = relTo(root, entry.path);
const match = fuzzy(relPath, query);
if (!match) continue;
hits.push({
path: entry.path,
name: baseName(entry.path),
root: root.id,
relPath,
score: match.score,
ranges: match.ranges,
});
}
return hits.sort((x, y) => y.score - x.score).slice(0, limit) as unknown as T;
}
case "search_text": {
const query = (a.query as unknown as string) ?? "";
const limit = (a.limit as unknown as number) ?? 100;
const needle = query.toLowerCase();
const hits: SearchHit[] = [];
if (!needle.trim()) return [] as unknown as T;
for (const entry of textFiles()) {
const root = rootFor(entry.path);
if (!root) continue;
const title = titleOf(entry.path, entry.text);
entry.text.split("\n").forEach((line, index) => {
const at = line.toLowerCase().indexOf(needle);
if (at < 0 || hits.length >= limit) return;
const { snippet, range } = snippetAround(line, at, needle.length);
hits.push({
path: entry.path,
root: root.id,
title,
line: index + 1,
snippet,
ranges: [range],
});
});
}
return hits.slice(0, limit) as unknown as T;
}
case "backlinks_for": {
const target = a.path as unknown as string;
const found: Backlink[] = [];
for (const entry of textFiles()) {
if (entry.path === target || kindOf(entry) !== "markdown") continue;
const lines = entry.text.split("\n");
const line = lines.find((text) =>
[...text.matchAll(/\]\(([^)\s]+)\)/g)].some(
(m) => resolveRelative(entry.path, m[1]) === target,
),
);
if (line === undefined) continue;
found.push({
path: entry.path,
title: titleOf(entry.path, entry.text),
snippet: line.trim(),
});
}
return found as unknown as T;
}
// Spelling in a browser is not the system checker and cannot be: NSSpellChecker is not
// reachable from a page. What it is instead is a fixed list of misspellings, which is enough to
// put a real underline under a real word and open a real menu of suggestions over it. A dev
// fixture that flagged every word it did not recognise would need a dictionary, and shipping
// one here to exercise a feature whose whole point is not shipping one would be absurd.
case "spell_available":
return true as unknown as T;
case "spell_check": {
const text = (a.text as unknown as string) ?? "";
const issues: SpellIssue[] = [];
for (const match of text.matchAll(/[\p{L}']+/gu)) {
const word = match[0];
const guesses = DEV_MISSPELLINGS[word.toLowerCase()];
if (guesses === undefined || devLearned.has(word.toLowerCase())) continue;
issues.push({
start: match.index,
end: match.index + word.length,
word,
// Matching the case of what was typed, because a suggestion that comes back lower case
// for a word opening a sentence is a correction the user then has to correct.
suggestions: guesses.map((guess) =>
word[0] === word[0].toUpperCase() ? guess[0].toUpperCase() + guess.slice(1) : guess,
),
});
}
return issues as unknown as T;
}
case "spell_learn":
devLearned.add((a.word as unknown as string).toLowerCase());
return undefined as T;
case "spell_unlearn":
devLearned.delete((a.word as unknown as string).toLowerCase());
return undefined as T;
default:
throw new Error(`dev mock has no handler for ${command}`);
}
}
/**
* The world outside the app: what another program does to the folder while it is open.
*
* Every function here mutates the fixture directly rather than going through `mockCall`, which is
* the point. A change made this way has not been through `file_write`, so nothing has registered a
* self-write against it and nothing has told the open document its file moved on: it is a change
* the app can only find out about from a watch event, exactly like a change made by vim or by a
* git checkout.
*
* The return value is the `WatchEvent` list the Rust watcher would have emitted for that change,
* in the order it would have emitted them. A rename is two events and not one, because FSEvents
* describes the two ends as unrelated and src-tauri/src/watch.rs reports what it is told; see
* `a_rename_is_reported_at_both_ends` in src-tauri/tests/watch.rs.
*
* A change to a root with no watch running is an error rather than an event. Nothing outside a
* watched folder is reported to anybody, so a test that gets an event out of this has also proved
* that opening the folder started the watch.
*/
function watchEventFor(
path: string,
kind: WatchEvent["kind"],
oldPath: string | null = null,
): WatchEvent {
const root = rootFor(path);
if (!root) throw new Error(`no open root owns ${path}`);
if (!watching.has(root.id)) throw new Error(`no watch is running on ${root.path}`);
return { root: root.id, path, kind, oldPath };
}
export const external = {
/** Another program rewrites the file. New bytes, new modification time. */
write(path: string, text: string): WatchEvent[] {
const entry = entryAt(path);
entry.text = text;
entry.modifiedMs = stamp();
return [watchEventFor(path, "modified")];
},
/**
* The same bytes, a newer modification time: `touch`, a git checkout that restores what was
* already there, a backup tool. The watcher cannot tell this from a real edit and reports it as
* one, which is why the app compares bytes and not just timestamps.
*/
touch(path: string): WatchEvent[] {
entryAt(path).modifiedMs = stamp();
return [watchEventFor(path, "modified")];
},
/** An event with nothing behind it, for proving what the app does with a change that is not one. */
signal(path: string, kind: WatchEvent["kind"]): WatchEvent[] {
return [watchEventFor(path, kind)];
},
remove(path: string): WatchEvent[] {
entryAt(path);
const event = watchEventFor(path, "removed");
for (const entry of subtree(path)) entries.delete(entry.path);
return [event];
},
rename(from: string, to: string): WatchEvent[] {
relocate(from, to);
return [watchEventFor(from, "removed"), watchEventFor(to, "created")];
},
/** What is actually on disk now, for asserting that the app has written nothing it should not. */
read(path: string): string | null {
const entry = entries.get(path);
return entry && !entry.dir ? entry.text : null;
},
exists(path: string): boolean {
return entries.has(path);
},
/**
* Holds every `file_write` until `resumeWrites`. A save that cannot land is how a test keeps a
* buffer dirty for as long as it needs to, instead of racing the 500ms autosave.
*/
pauseWrites(): void {
if (writeGate) return;
writeGate = new Promise<void>((resolve) => {
openGate = resolve;
});
},
resumeWrites(): void {
openGate?.();
writeGate = null;
openGate = null;
},
};
+487
View File
@@ -0,0 +1,487 @@
// Everything the open document does to the disk, and the only place that decides when. The store
// next door holds what is on screen and the setters a keystroke can settle on its own; this module
// reads the file, hands it to the markdown bridge, and writes it back 500ms after the last edit.
//
// Opening writes nothing. There is exactly one call to `fileWrite` in this file, it sits inside
// `performSave`, and `performSave` returns before reaching it unless the buffer is dirty, which
// only `setContent` can make it. That is the product's first promise and
// src/store/useDocument.test.ts asserts it rather than trusting this paragraph.
//
// `setContent` marks the buffer dirty through `differsFromDisk` below, which is the second half of
// that promise. That question is asked of the document the file was read from and never of the
// keystroke before, so the answer is "is the buffer different" rather than "did something happen":
// a paragraph typed into and then undone is the document that was opened, and it does not put the
// file on the debounce. A transaction that moved something the markdown has no spelling for gets
// the same answer for the same reason, since the file would not show it either. Dragging a table
// column is the whole of that today.
//
// `performSave` also never runs twice at once for the open document: `saveNow` keeps at most one
// call to it on the wire, folding anything that arrives while one is running into a single next
// lap rather than starting a second write alongside the first.
//
// This module and src/store/useDocument.ts import each other: the store's async actions delegate
// down here, and the work down here lands back in the store. Neither touches the other while its
// own module body is still evaluating, so the cycle resolves. Nothing here runs at import time for
// the same reason: the subscription that drives the debounce is installed by `initDocument`, which
// `loadDocument` calls itself so the shell cannot forget to.
import type { Mark, Node as ProseMirrorNode } from "@tiptap/pm/model";
import { fileRead, fileWrite } from "./api/files";
import type { ReadResult, WriteResult } from "./ipc";
import {
parseMarkdown,
parsePlainText,
serializeMarkdown,
serializePlainText,
} from "./markdown";
import { documentKindForPath, type MarkdownDocument } from "./model/doc";
import { useDocument } from "./store/useDocument";
import { notify } from "./store/useToast";
/** Long enough that a sentence is one save, short enough that Cmd+Tab away is already on disk. */
export const SAVE_DEBOUNCE_MS = 500;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let unsubscribe: (() => void) | null = null;
/**
* The file as this module last saw it, either read or written: its bytes, and the tree those bytes
* are the serialization of.
*
* The bytes are here because a watcher fires on a touch, on a git checkout that restores the same
* content and on this app's own save, and without them the buffer would be thrown away and rebuilt
* for all three. The tree is here because the bytes cannot answer whether the buffer still holds
* the document that was read: a hand written file does not serialize to its own bytes, so from the
* moment it opens the two differ for house style reasons that have nothing to do with any edit.
*
* Either can be null on its own, because they answer different questions. Null bytes mean the file
* no longer holds the bytes this module last saw, so there is nothing a write could be compared
* against and `performSave` reads it as "write". A null document means the file holds a document
* this module has never had, somebody else's copy or none at all, so `differsFromDisk` reads it as
* "dirty". Both say the same thing: the one thing worse than an unnecessary write is a skipped
* necessary one.
*/
let diskText: string | null = null;
let diskDoc: ProseMirrorNode | null = null;
/** The only way either of those moves, so that they cannot drift apart into two answers about the
* same file, one of which sends a write and the other of which holds it back. */
function rememberDisk(text: string | null, doc: ProseMirrorNode | null): void {
diskText = text;
diskDoc = doc;
}
/** Markdown and plain text are two different round trips and picking the wrong one mangles a .txt. */
function bridgeFor(path: string) {
const kind = documentKindForPath(path);
if (kind === null) throw new Error(`${path} is not a document this editor opens`);
return kind === "markdown"
? { parse: parseMarkdown, serialize: serializeMarkdown }
: { parse: parsePlainText, serialize: serializePlainText };
}
/**
* The attributes the serializer never reads, by the node that carries them.
*
* `colwidth` is the whole list, and the list was written by going through src/model/schema.ts
* attribute by attribute against src/markdown/serialize.ts. prosemirror-tables puts a width on
* every cell of a column when its edge is dragged and GFM has no column widths, so that drag is a
* real change to the document and no change at all to the file.
*
* Three others were considered and left off. `colspan` and `rowspan` are unreadable to the
* serializer too, but no op this editor offers can move them, and a table that carried one could
* not be written as a table at all, so calling a change to one insignificant would be hiding the
* one case that needs to be seen. `raw.source` is the file's own bytes and is never written to
* after the parse. And `align` is the near miss: the serializer reads it off the table's first row
* only, so a body cell's copy does not reach the file on its own, but that first row is the
* delimiter row and every align op in tables.ts writes the whole column at once. An alignment
* change is always a change to the file.
*/
const UNWRITTEN_ATTRS: Record<string, readonly string[]> = {
tableHeader: ["colwidth"],
tableCell: ["colwidth"],
};
/**
* Why nothing below compares a NodeType or a MarkType, only its name.
*
* The two documents this comparison is given are never built on the same schema. The one the file
* was read from comes off the bridge, which parses against src/model/schema.ts; the one the editor
* hands back is bound to TipTap's own schema, which src/editor/extensions.ts generates from those
* same specs and which src/editor/Editor.tsx rebinds every opened document on to before it can be
* edited. Two `Schema` instances over one set of specs, so every type object in one is a different
* object from its twin in the other, and `a.type !== b.type` was true of every pair this function
* had ever been handed. Everything behind it, the colwidth exemption included, was unreachable.
*
* The name is also the right thing to compare rather than a way around that. src/markdown/
* serialize.ts dispatches on `node.type.name` and `mark.type.name` and reads nothing else off a
* type, so two nodes agreeing on their name, their attributes, their marks, their text and their
* children are two nodes it writes the same bytes for.
*/
const sameType = (a: { name: string }, b: { name: string }): boolean => a.name === b.name;
/** Two nodes of the same type, agreeing on every attribute the serializer would go looking for. */
function sameAttrs(a: ProseMirrorNode, b: ProseMirrorNode): boolean {
const unwritten = UNWRITTEN_ATTRS[a.type.name];
const names = Object.keys(a.attrs);
// An attribute one side carries and the other does not is not provably nothing, and walking a's
// names only ever shows one of the two directions.
if (names.length !== Object.keys(b.attrs).length) return false;
for (const name of names) {
if (a.attrs[name] === b.attrs[name]) continue;
if (unwritten !== undefined && unwritten.includes(name)) continue;
return false;
}
return true;
}
/**
* The marks on one piece of text, in order.
*
* This replaces `Mark.sameSet`, which compares MarkType by object and so answered "different" for
* every span anybody had ever made bold or turned into a link. Order is compared rather than the
* set treated as unordered because ProseMirror keeps a mark set sorted by the schema's own
* declaration order and both schemas declare the same marks in the same order, so a mismatch is
* either a real difference or the two schemas having drifted apart, and both are worth a write.
*/
function sameMarks(a: readonly Mark[], b: readonly Mark[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const one = a[i];
const other = b[i];
if (one === other) continue;
if (!sameType(one.type, other.type)) return false;
const names = Object.keys(one.attrs);
if (names.length !== Object.keys(other.attrs).length) return false;
for (const name of names) if (one.attrs[name] !== other.attrs[name]) return false;
}
return true;
}
function sameToTheSerializer(a: ProseMirrorNode, b: ProseMirrorNode): boolean {
// The whole reason this is cheap enough to run on every keystroke, even against a tree many
// transactions old. A transaction rebuilds only the spine down to what it touched, so every
// subtree no edit since the read has visited is still the same object it was and the walk stops
// dead at it. Only what has actually been typed into is ever compared node by node.
//
// It buys nothing between an open and the first save, because the tree that was read and the
// editor's rebind of it share no object at all, so every keystroke in that window walks the
// whole document. Measured at 0.08ms on a 57kB file of 984 nodes, against a 500ms debounce.
// Once a save has landed, `diskDoc` is the editor's own tree and the sharing is back.
if (a === b) return true;
if (!sameType(a.type, b.type) || a.text !== b.text || a.childCount !== b.childCount) return false;
if (!sameMarks(a.marks, b.marks)) return false;
if (!sameAttrs(a, b)) return false;
for (let i = 0; i < a.childCount; i += 1) {
if (!sameToTheSerializer(a.child(i), b.child(i))) return false;
}
return true;
}
/**
* Whether a tree differs, anywhere the file would show it, from the document on disk.
*
* This is what the dirty flag is, and the whole of it. Asking it of the document that was read
* rather than of the tree a keystroke ago is what makes it a fact about the file instead of a
* count of transactions: a paragraph typed into and then undone comes back false, because the
* buffer is the file again, and a flag that only ever counted up would have had the whole document
* rewritten in house style for an edit that no longer exists.
*
* It is deliberately lopsided: everything counts as a change to the file unless it is provably not
* one. A change wrongly called insignificant is a keystroke that never reaches the disk, which is
* the worst thing in this module; a change wrongly called significant costs one write that
* `performSave` then finds nothing to do.
*/
export function differsFromDisk(next: ProseMirrorNode): boolean {
return diskDoc === null || !sameToTheSerializer(diskDoc, next);
}
/** The same question, asked of whatever the store is holding now. */
function bufferDiffersFromDisk(): boolean {
const now = useDocument.getState().content;
return now !== null && differsFromDisk(now);
}
function apply(read: ReadResult, document: MarkdownDocument): void {
rememberDisk(read.text, document.doc);
useDocument.setState({
path: read.path,
document,
content: document.doc,
modifiedMs: read.modifiedMs,
frontmatter: document.frontmatter,
dirty: false,
savePhase: "idle",
saveError: null,
externalChange: "synced",
});
}
function scheduleSave(): void {
cancelPendingSave();
saveTimer = setTimeout(() => {
saveTimer = null;
saveNow().catch((e) => notify(`Could not save: ${String(e)}`));
}, SAVE_DEBOUNCE_MS);
}
export function cancelPendingSave(): void {
if (saveTimer !== null) clearTimeout(saveTimer);
saveTimer = null;
}
/**
* Starts the debounce. Idempotent, and returning the teardown rather than keeping it private is
* what lets a test run the lifecycle without leaving a timer behind for the next one.
*/
export function initDocument(): () => void {
if (unsubscribe !== null) return unsubscribe;
const stop = useDocument.subscribe((state, previous) => {
if (state.content === previous.content) return;
// Clean is not just "nothing more to schedule". The edit that armed the timer can have been
// undone while it was still counting down, and letting it run out would put the debounce's
// whole point, one write per burst of typing, behind a document nobody changed.
if (state.dirty) scheduleSave();
else cancelPendingSave();
});
unsubscribe = () => {
stop();
unsubscribe = null;
cancelPendingSave();
};
return unsubscribe;
}
/**
* Reads a file and puts it in the store. Reads only: the bridge is pure, nothing here has a path
* to `fileWrite`, and a document that is opened and closed again leaves the file untouched.
*/
export async function loadDocument(path: string): Promise<void> {
initDocument();
const { parse } = bridgeFor(path);
const read = await fileRead(path);
apply(read, parse(read.text, read.path));
}
/** Throws the buffer away and takes what is on disk. The explicit half of a conflict. */
export async function reloadDocument(): Promise<void> {
const path = useDocument.getState().path;
if (path === null) return;
cancelPendingSave();
await loadDocument(path);
}
/** The single write for the open document that is currently on the wire, if any. */
let writeInFlight: Promise<void> | null = null;
/** Set when a save is requested while `writeInFlight` is already running. One flag, not a queue:
* it can only ever mean "write again after this one", never "write N more times". */
let saveAgainRequested = false;
/**
* Serializes and writes, now. Returns having done nothing when the buffer is clean, which is what
* makes Cmd+S on an untouched document a no-op rather than a reformat.
*
* At most one `fileWrite` for the open document is ever in flight at a time. A call that lands
* while one is already running does not start a second: it flags that another save is wanted and
* folds into a single write that goes out the moment the first one lands, picking up whatever is
* newest in the store by then. That is what keeps the backend from ever seeing two writes of the
* same path race each other, and it is also what keeps the last thing the user typed from being
* the one write that never happened: it is either the content already on the wire, or it is
* exactly what the next lap serializes.
*/
export async function saveNow(): Promise<void> {
cancelPendingSave();
if (writeInFlight !== null) {
saveAgainRequested = true;
return writeInFlight;
}
const inFlight = runSaveLoop();
writeInFlight = inFlight;
try {
await inFlight;
} finally {
if (writeInFlight === inFlight) writeInFlight = null;
}
}
/**
* Runs `performSave` once, then again for every save that arrived while it was on the wire,
* collapsed to the single latest one. Stops the moment a lap does not end in a clean write: a
* conflict or a no-op buffer is not something a stacked-up request should cause to be retried.
*/
async function runSaveLoop(): Promise<void> {
for (;;) {
saveAgainRequested = false;
const outcome = await performSave();
if (outcome !== "wrote" || !saveAgainRequested) return;
}
}
async function performSave(): Promise<"wrote" | "conflict" | "skipped"> {
const { path, document, content, dirty, modifiedMs } = useDocument.getState();
if (path === null || document === null || content === null || !dirty) return "skipped";
const { serialize } = bridgeFor(path);
useDocument.setState({ savePhase: "saving", saveError: null });
let text: string;
try {
text = serialize(document, content);
} catch (e) {
useDocument.setState({ savePhase: "error", saveError: String(e) });
throw e;
}
// The second line, not the first. `differsFromDisk` is what keeps a buffer that is not different
// from the file from being dirty at all, and it has to be, because this comparison only catches
// the case where the serialized bytes already match the file: on a document the editor has
// written before they do, and on a hand written one they differ for house style reasons that have
// nothing to do with any edit, so this would let the write through and the whole file would be
// reformatted for a gesture that moved a line on screen. What is left here is everything else
// that can serialize to the bytes already on disk: two different trees that spell the same
// markdown, and a buffer this module cannot vouch for because the file moved under it.
if (text === diskText) {
// Those bytes are on disk and this is a tree that produces them, which is all `diskDoc` has
// ever claimed to be. Nothing was written, so nothing needs to be.
rememberDisk(text, content);
useDocument.setState({
dirty: bufferDiffersFromDisk(),
savePhase: "idle",
saveError: null,
});
return "skipped";
}
let result: WriteResult;
try {
result = await fileWrite(path, text, modifiedMs ?? undefined);
} catch (e) {
if (useDocument.getState().path === path) {
useDocument.setState({ savePhase: "error", saveError: String(e) });
}
throw e;
}
// The document was switched while the write was in flight, so this result belongs to a buffer
// nobody is looking at any more and applying it would stamp the new one's timestamp.
if (useDocument.getState().path !== path) return "skipped";
if (result.conflict) {
// Not an error and not something to retry. Nothing was written, the edit is still only in the
// buffer, and which copy wins is the user's call. What is on disk is somebody else's copy,
// which this module has not read, so it stops claiming to know either the bytes or the
// document: the buffer stays dirty however much of the edit the user takes back, and the
// decision the UI is now asking for is the only thing that clears it.
rememberDisk(null, null);
useDocument.setState({ savePhase: "idle", externalChange: "changed-on-disk" });
return "conflict";
}
rememberDisk(text, content);
const stillDirty = bufferDiffersFromDisk();
useDocument.setState({
modifiedMs: result.modifiedMs,
dirty: stillDirty,
savePhase: "idle",
saveError: null,
externalChange: "synced",
});
// Typed into, or undone, while that write was on the wire. An undo is the case that needs this:
// it went past the subscription at a moment when the buffer and the file did agree, so nothing
// armed the debounce for it, and this write is what has just made it a difference again.
if (stillDirty) scheduleSave();
else cancelPendingSave();
return "wrote";
}
/**
* Gets an unsaved edit onto disk before something else happens to the document: switching away,
* closing it, quitting. Swallows its own error into a toast, because the caller is on its way
* somewhere else and failing that journey over a failed save helps nobody.
*/
export function flushPendingSave(): Promise<void> {
if (!useDocument.getState().dirty) {
cancelPendingSave();
return Promise.resolve();
}
return saveNow().catch((e) => notify(`Could not save: ${String(e)}`));
}
/**
* Resolves a conflict the other way from `reloadDocument`: the buffer wins and the copy on disk is
* the one that goes.
*
* Dropping `modifiedMs` is what makes the next write land. The backend refuses a write whose
* expected timestamp has moved on, which is the whole conflict mechanism, and there is no way to
* say "yes, I know" other than to stop claiming to know what was there. The write itself is the
* ordinary debounced one, so nothing is put on disk here either.
*/
export function keepBuffer(): void {
if (useDocument.getState().path === null) return;
// What is on disk is whatever the other writer put there, which this module has not read, so the
// last bytes it saw are no longer the file's and neither is the document they came from. Saying
// so is what stops `performSave` from deciding this write is unnecessary and leaving the other
// copy in place, which is the opposite of what the user just asked for, and it is what keeps the
// buffer dirty through an undo taken while the banner is up.
rememberDisk(null, null);
// Dirty even if nothing has been typed: the file moved or went, so the buffer and the disk
// disagree, and that is the only thing the flag has ever meant.
useDocument.setState({ modifiedMs: null, externalChange: "synced", dirty: true });
scheduleSave();
}
/**
* Lets go of the open document without writing it, for when the file it came from has just gone.
* `close` on its own flushes, which for a document that was this second sent to the Trash would
* put the file straight back.
*/
export function abandonDocument(): void {
cancelPendingSave();
useDocument.setState({ dirty: false });
useDocument.getState().close();
}
/**
* Something outside the app touched the open document. Clean buffers take the new bytes silently,
* dirty ones are left exactly as they are and the UI is told there is a choice to make.
*/
export async function documentChangedOnDisk(path: string): Promise<void> {
if (useDocument.getState().path !== path) return;
let read: ReadResult;
try {
read = await fileRead(path);
} catch {
// Deleted, renamed out from under us, or unreadable. The buffer is now the only copy there is,
// so it stays put. The bytes go, because there are none left to hold a write back, and the
// document stays, because it is still the last one this module knew the file to hold and it is
// what keeps a buffer nobody has typed into from turning dirty and putting the file back.
// Resurrecting a file the user deleted is `keepBuffer`, and it is the user's word, not a
// side effect of clicking into the editor afterwards.
rememberDisk(null, diskDoc);
useDocument.setState({ externalChange: "changed-on-disk" });
return;
}
const state = useDocument.getState();
if (state.path !== path) return;
if (read.modifiedMs === state.modifiedMs) return;
if (read.text === diskText) {
useDocument.setState({ modifiedMs: read.modifiedMs });
return;
}
if (state.dirty) {
// The buffer stays, but these are the file's bytes now and this module has just read them, so
// it says so rather than going on remembering the ones the other writer replaced. It does not
// parse them: the document on disk is somebody else's and no tree here is it, so the honest
// answer to "is the buffer different from the file" is that we do not know, which is the answer
// that keeps this dirty until the user picks a side.
rememberDisk(read.text, null);
useDocument.setState({ externalChange: "changed-on-disk" });
return;
}
const { parse } = bridgeFor(path);
apply(read, parse(read.text, read.path));
}
+678
View File
@@ -0,0 +1,678 @@
// The document surface, and the only place in the app that holds a TipTap instance.
//
// The autosave contract lives here as much as it does in the store, and it is a contract about
// what this component does NOT do. Opening a document installs a ProseMirror state and nothing
// else: no serializer runs, no write is scheduled, and `onChange` does not fire, because
// `view.updateState` is not a dispatched transaction and TipTap only emits `update` when a
// transaction actually changed the document. A file that is opened, read and closed is never
// written. Once the user types, `onChange` hands out the live ProseMirror node on every keystroke,
// which is cheap; turning that node into markdown happens once, later, on the shell's debounce.
//
// Ported from margin's editor/Editor.tsx. Margin keeps an EditorState per chapter because a book
// is many documents open at once; here there is one document at a time, so that cache collapses
// into a small path-keyed LRU whose only job is making a return to a recent file instant, with its
// undo history and its caret still where they were.
import { useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from "react";
import type { ReactElement } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import { EditorState, TextSelection } from "@tiptap/pm/state";
import type { EditorProps as ProseMirrorProps, EditorView } from "@tiptap/pm/view";
import type { CalloutKind, HeadingLevel, MarkdownDocument } from "../model/doc";
import { marks as markSpecs } from "../model/schema";
import type { MarkName } from "../model/schema";
import { notify } from "../store/useToast";
import { insertMath, insertMermaid, setCodeLanguage, tableCommand } from "./blocks";
import { createEditorExtensions } from "./extensions";
import { change, markable, place, placeable } from "./fits";
import { loadPosition, savePosition, type DocumentPosition } from "./positions";
import { searchStateOf, type SearchOptions } from "./search";
import type {
BlockCommand,
BlockKind,
DocumentFind,
EditorActiveState,
EditorHandle,
EditorProps,
TableOp,
} from "./index";
// How much of the pane the caret is kept out of when the view scrolls it into sight. The toolbar
// pill is 44px tall (32px controls, 5px of padding, a 1px border) and sticks 22px above the bottom
// of the pane, so it covers the last 66px of it; a line of body prose on top of that means the
// caret's whole line clears the glass instead of sitting against it. Nothing overlaps the top,
// where the titlebar is a sibling above the scroller rather than floating over it, so the number
// there is breathing room and nothing more.
const CARET_KEEPOUT = { top: 24, right: 0, bottom: 98, left: 0 };
/** Enough that going back to what you were just looking at is instant, and not a document store. */
const CACHE_LIMIT = 8;
const EMPTY_CONTENT = { type: "doc", content: [{ type: "paragraph" }] };
const MARK_NAMES = Object.keys(markSpecs) as MarkName[];
/** Blocks a cursor can be inside without them being what the toolbar should report. */
const PASS_THROUGH = new Set([
"paragraph",
"listItem",
"taskItem",
"tableRow",
"tableCell",
"tableHeader",
]);
const REPORTED = new Set<string>([
"bulletList",
"orderedList",
"taskList",
"blockquote",
"codeBlock",
"toggle",
"table",
"mathBlock",
"raw",
]);
interface Cached {
state: EditorState;
document: MarkdownDocument;
scroll: number;
}
const listeners = new Set<() => void>();
let currentHandle: EditorHandle | null = null;
let currentFind: DocumentFind | null = null;
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function announce(): void {
for (const listener of listeners) listener();
}
const handleSnapshot = () => currentHandle;
const findSnapshot = () => currentFind;
/** The handle for the document currently on screen, or null when there is none. */
export function useEditorHandle(): EditorHandle | null {
return useSyncExternalStore(subscribe, handleSnapshot, handleSnapshot);
}
/** Find and replace over the open document, for whatever draws the find bar. */
export function useDocumentFind(): DocumentFind | null {
return useSyncExternalStore(subscribe, findSnapshot, findSnapshot);
}
function reportContentError(error: unknown): void {
notify(`Part of this document could not be read into the editor: ${String(error)}`);
}
function scrollerOf(editor: Editor): HTMLElement | null {
const dom = editor.view.dom as HTMLElement;
const pane = dom.closest(".editor-pane");
if (pane instanceof HTMLElement) return pane;
for (let el = dom.parentElement; el; el = el.parentElement) {
const overflow = getComputedStyle(el).overflowY;
if (overflow === "auto" || overflow === "scroll") return el;
}
return null;
}
/** Ticking a box is an edit like any other, so it goes through the view and dirties the buffer. */
function toggleTask(view: EditorView, item: Element): boolean {
const $pos = view.state.doc.resolve(view.posAtDOM(item, 0));
for (let depth = $pos.depth; depth > 0; depth -= 1) {
const node = $pos.node(depth);
if (node.type.name !== "taskItem") continue;
view.dispatch(
view.state.tr.setNodeMarkup($pos.before(depth), undefined, {
...node.attrs,
checked: !node.attrs.checked,
}),
);
return true;
}
return false;
}
/**
* The ProseMirror props this component installs directly on the view, as opposed to the ones an
* extension contributes through `addProseMirrorPlugins`.
*
* Lifted out of the component and exported so that it can be enumerated. It is a third channel into
* the document, alongside the editor handle and the extensions' own plugins, and it was the one
* src/editor/fits.test.ts could not see: that file reads every extension's plugins and every
* extension's keymap, and `editorProps` is neither. A `handlePaste` or a `handleKeyDown` added here
* would be asked before any of them and answer for the whole document, unenumerated. The click
* handler that is here today only flips a checkbox, which is the harmless case; the enumeration is
* for the next one.
*/
export function createEditorProps(context: {
editable: () => boolean;
onOpenLink: (href: string) => void;
}): ProseMirrorProps {
return {
attributes: { class: "prose" },
scrollThreshold: CARET_KEEPOUT,
scrollMargin: CARET_KEEPOUT,
handleClick: (view, _pos, event) => {
const target = event.target as HTMLElement | null;
// The checkbox is drawn by the list item's own ::before, so a click that lands on the item
// itself rather than on the paragraph inside it is a click on the box.
const item = target?.closest(".task-item");
if (item && item === event.target && toggleTask(view, item)) {
event.preventDefault();
return true;
}
const anchor = target?.closest("a[href]");
const href = anchor?.getAttribute("href");
if (!href) return false;
// A plain click puts the caret in the link text, which is the only way to edit it. Opening
// is the modified click, or any click at all while the document is not editable.
if (context.editable() && !event.metaKey && !event.ctrlKey) return false;
event.preventDefault();
context.onOpenLink(href);
return true;
},
};
}
function enclosing(editor: Editor, names: readonly string[]): { name: string; pos: number } | null {
const { $from } = editor.state.selection;
for (let depth = $from.depth; depth > 0; depth -= 1) {
const name = $from.node(depth).type.name;
if (names.includes(name)) return { name, pos: $from.before(depth) };
}
return null;
}
function activeStateOf(editor: Editor): EditorActiveState {
const marks = MARK_NAMES.filter((mark) => editor.isActive(mark));
const { $from } = editor.state.selection;
// Asked separately from the walk below, because that walk stops at the innermost block it has a
// name for and a cell selection's own position is not inside any of them. isActive answers for a
// cursor in a cell, a selection across cells and the table selected whole, alike.
const inTable = editor.isActive("table");
const base = { marks, inTable, codeLanguage: null };
for (let depth = $from.depth; depth > 0; depth -= 1) {
const node = $from.node(depth);
const name = node.type.name;
if (PASS_THROUGH.has(name)) continue;
if (name === "heading") {
return { ...base, block: "heading", headingLevel: node.attrs.level as HeadingLevel, callout: null };
}
if (name === "callout") {
return { ...base, block: "callout", headingLevel: null, callout: node.attrs.kind as CalloutKind };
}
if (name === "codeBlock") {
const language = node.attrs.language as string | null;
return { ...base, block: "codeBlock", headingLevel: null, callout: null, codeLanguage: language };
}
if (REPORTED.has(name)) {
return { ...base, block: name as BlockKind, headingLevel: null, callout: null };
}
}
return { ...base, block: "paragraph", headingLevel: null, callout: null };
}
function sameActive(a: EditorActiveState, b: EditorActiveState): boolean {
return (
a.block === b.block &&
a.headingLevel === b.headingLevel &&
a.callout === b.callout &&
a.inTable === b.inTable &&
a.codeLanguage === b.codeLanguage &&
a.marks.length === b.marks.length &&
a.marks.every((mark, i) => b.marks[i] === mark)
);
}
/**
* The command half of the handle, built once per editor and shared by every published snapshot.
*
* Exported for src/editor/fits.test.ts and for nothing else: the shell gets the handle through
* `useEditorHandle` and has no business building one. That test enumerates the keys of what this
* returns and refuses to pass unless every insert among them has been proved to keep its hands off
* a document it cannot insert into, which is the only way this file's own insert commands and the
* block lanes' are held to the same rule.
*/
export function createCommands(editor: Editor): Omit<EditorHandle, "active"> {
const setCallout = (kind: CalloutKind | null) => {
const found = enclosing(editor, ["callout", "blockquote"]);
if (kind === null) {
if (found?.name !== "callout") return;
change(editor, "unwrap", (chain) =>
chain.command(({ tr, state, dispatch }) => {
if (dispatch) tr.setNodeMarkup(found.pos, state.schema.nodes.blockquote, {});
return true;
}),
);
return;
}
if (found) {
change(editor, "wrap", (chain) =>
chain.command(({ tr, state, dispatch }) => {
if (dispatch) tr.setNodeMarkup(found.pos, state.schema.nodes.callout, { kind });
return true;
}),
);
return;
}
change(editor, "wrap", (chain) => chain.wrapIn("callout", { kind }));
};
return {
focus: () => {
editor.commands.focus();
},
toggleMark: (mark) => {
editor.chain().focus().toggleMark(mark).run();
},
// With a collapsed caret and no link under it the url goes in as TEXT and is marked
// afterwards, and the text lands whether the mark can or not: in a fence or a raw block, where
// the schema allows no marks at all, that is the url typed into somebody's code. Asked of the
// mark rather than of the block, so it is the same question in both places and in whatever
// block comes next. The two chains below only add a mark, which ProseMirror already declines
// to do where the schema says no.
setLink: (href, title = null) => {
if (href === null) {
editor.chain().focus().extendMarkRange("link").unsetMark("link").run();
return;
}
if (editor.state.selection.empty && !editor.isActive("link")) {
if (!markable(editor.state, editor.schema.marks.link)) return;
if (!placeable(editor.state, editor.schema.nodes.text)) return;
editor
.chain()
.focus()
.extendMarkRange("link")
.insertContent({ type: "text", text: href, marks: [{ type: "link", attrs: { href, title } }] })
.run();
return;
}
editor.chain().focus().extendMarkRange("link").setMark("link", { href, title }).run();
},
// The conversions, and the other half of what fits.ts guards. `place` is for a command that
// adds a node; these change or rewrap the block the caret is already in, which is the question
// `change` answers: a raw block refuses all of them, because a conversion writes its preserved
// source back out as escaped markdown and a wrap writes it back out prefixed, and a conversion
// that would take a callout or a toggle away with it is thrown out rather than dispatched. A
// conversion added here that does not go through `change` fails src/editor/fits.test.ts.
setBlock: (block: BlockCommand) => {
switch (block) {
case "paragraph":
change(editor, "convert", (chain) => chain.clearNodes().setNode("paragraph"));
return;
case "bulletList":
change(editor, "wrap", (chain) => chain.toggleList("bulletList", "listItem"));
return;
case "orderedList":
change(editor, "wrap", (chain) => chain.toggleList("orderedList", "listItem"));
return;
case "taskList":
change(editor, "wrap", (chain) => chain.toggleList("taskList", "taskItem"));
return;
case "blockquote":
change(editor, "wrap", (chain) => chain.toggleWrap("blockquote"));
return;
case "codeBlock":
change(editor, "convert", (chain) => chain.toggleNode("codeBlock", "paragraph"));
return;
case "toggle":
// "unwrap" because this is the toggle's own button: pressed inside one it takes that
// toggle away, summary and all, which is what the user pressed it for.
change(editor, "unwrap", (chain) => chain.toggleWrap("toggle"));
}
},
setHeading: (level) => {
if (level === null) change(editor, "convert", (chain) => chain.setNode("paragraph"));
else change(editor, "convert", (chain) => chain.setNode("heading", { level }));
},
setCallout,
// Every one of these goes through `place`, which is the guard and the insert in one call, for
// the reason written out in fits.ts: with the caret in a table cell an unguarded insert splits
// the table around the new node and leaves a row with no cells in it, which is a table the
// serializer writes back as three blank lines, and in a fence or a raw block it cuts the user's
// own bytes in half and writes the remainder out as prose. An insert added here that does not
// go through `place` fails src/editor/fits.test.ts, which is the point of that file.
insertRule: () => {
place(editor, editor.schema.nodes.horizontalRule, (chain) =>
chain.insertContent({ type: "horizontalRule" }),
);
},
// The same guard the insert below runs, asked on its own, because the Insert image tool has to
// write the picture into the assets folder before it has a path to insert and a refusal after
// that write is an orphan file beside somebody's document. src/editor/paste.ts asks this same
// question before it sends any bytes; the toolbar had no way to.
canInsertImage: () => placeable(editor.state, editor.schema.nodes.image),
// And it still says so when it refuses, for the caller that asks afterwards anyway.
insertImage: (src, alt = null) => {
const placed = place(editor, editor.schema.nodes.image, (chain) =>
chain.insertContent({ type: "image", attrs: { src, alt, title: null } }),
);
if (!placed) notify("An image cannot go where the cursor is.");
},
insertTable: (rows, columns) => {
const table = editor.schema.nodes.table;
const cells = (type: string) =>
Array.from({ length: Math.max(1, columns) }, () => ({ type }));
const body = Array.from({ length: Math.max(0, rows - 1) }, () => ({
type: "tableRow",
content: cells("tableCell"),
}));
const searchFrom = Math.max(0, editor.state.selection.$from.pos - 1);
place(editor, table, (chain) =>
chain
.insertContent({
type: "table",
content: [{ type: "tableRow", content: cells("tableHeader") }, ...body],
})
// The insert leaves the caret past the table, so the first thing typed lands under it
// rather than in it, and the toolbar goes on reading active.inTable as false while a
// table is on screen. Same transaction as the insert, so it is one undo and not two.
.command(({ tr, dispatch }) => {
if (!dispatch) return true;
let found: number | null = null;
tr.doc.nodesBetween(searchFrom, tr.doc.content.size, (node, pos) => {
if (found !== null) return false;
if (node.type === table) found = pos;
return found === null;
});
// A table, its first row and its first cell are one position each, so three in is the
// first place text can go.
if (found !== null) tr.setSelection(TextSelection.create(tr.doc, found + 3));
return true;
}),
);
},
// The four below are the block lanes' own commands, and this is the whole of the wiring: each
// one lives with the extension that gives its block behaviour, in src/editor/blocks/, so that
// tables, code, math and mermaid are worked on without four hands in this file. They return
// false where they have nothing to act on, which is a button pressed in the wrong place and
// means nothing happens.
tableCommand: (op: TableOp) => {
tableCommand(editor, op);
},
insertMath: (display: boolean) => {
insertMath(editor, display);
},
insertMermaid: () => {
insertMermaid(editor);
},
setCodeLanguage: (language: string | null) => {
setCodeLanguage(editor, language);
},
};
}
/**
* The find bar's own handle, which is the second surface this file publishes and the other place a
* command can reach the document from outside the editor layer.
*
* Exported for src/editor/fits.test.ts for the same reason `createCommands` is: two of these seven
* write to the document, and a handle nothing enumerates is a handle a method gets added to without
* anybody saying where it may run.
*/
export function createFind(editor: Editor): Omit<DocumentFind, "state"> {
return {
setQuery: (query: string, options: SearchOptions) => {
editor.commands.setSearch(query, options);
},
clear: () => {
editor.commands.clearSearch();
},
next: () => {
editor.commands.findNext();
},
prev: () => {
editor.commands.findPrev();
},
replaceCurrent: (text: string) => {
editor.commands.replaceCurrent(text);
},
replaceAll: (text: string) => {
editor.commands.replaceAllInDocument(text);
},
focus: () => {
editor.commands.focus();
},
};
}
export function DocumentEditor({
document,
onChange,
onOpenLink,
editable = true,
}: EditorProps): ReactElement {
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onOpenLinkRef = useRef(onOpenLink);
onOpenLinkRef.current = onOpenLink;
const documentRef = useRef(document);
documentRef.current = document;
const editableRef = useRef(editable);
editableRef.current = editable;
const cache = useRef(new Map<string, Cached>());
const host = useRef<Editor | null>(null);
const installed = useRef<MarkdownDocument | null>(null);
const position = useRef<DocumentPosition | null>(null);
const scrollToken = useRef(0);
const publish = useRef<(() => void) | null>(null);
const extensions = useMemo(
() =>
createEditorExtensions({
documentPath: () => documentRef.current.path,
onError: notify,
}),
[],
);
const editor = useEditor({
extensions,
// Empty on purpose: the layout effect below installs the document through the one code path
// that also restores the caret and reports a document the schema cannot hold.
content: EMPTY_CONTENT,
editable,
immediatelyRender: false,
enableContentCheck: true,
editorProps: createEditorProps({
editable: () => editableRef.current,
onOpenLink: (href) => onOpenLinkRef.current(href),
}),
onContentError: ({ error }) => reportContentError(error),
onUpdate: ({ editor }) => onChangeRef.current(editor.state.doc),
});
const applyScroll = (ed: Editor, top: number) => {
const scroller = scrollerOf(ed);
if (!scroller) return;
const token = (scrollToken.current += 1);
const apply = () => {
if (scrollToken.current === token) scroller.scrollTop = top;
};
apply();
requestAnimationFrame(apply);
// Web fonts land after the first paint and change every line's height under the caret with
// them, so the offset that was right a moment ago is wrong once Literata arrives.
window.document.fonts?.ready.then(apply).catch(() => {});
};
const buildState = (ed: Editor, source: MarkdownDocument): EditorState => {
const base = ed.view.state;
try {
// The bridge builds its tree against src/model/schema.ts and TipTap builds an identical one
// of its own from the same specs, so the node has to be rebound before it can be edited.
const doc = ed.schema.nodeFromJSON(source.doc.toJSON());
doc.check();
return EditorState.create({ doc, plugins: base.plugins });
} catch (error) {
reportContentError(error);
return EditorState.create({ schema: base.schema, plugins: base.plugins });
}
};
const remember = (path: string, entry: Cached) => {
cache.current.delete(path);
cache.current.set(path, entry);
for (const stale of Array.from(cache.current.keys()).slice(0, cache.current.size - CACHE_LIMIT)) {
cache.current.delete(stale);
}
};
const stash = (ed: Editor, source: MarkdownDocument) => {
const scroll = scrollerOf(ed)?.scrollTop ?? 0;
const { from, to } = ed.state.selection;
remember(source.path, { state: ed.view.state, document: source, scroll });
savePosition(source.path, { from, to, scroll });
};
const install = (ed: Editor, source: MarkdownDocument, focus: boolean) => {
const cached = cache.current.get(source.path);
// Reusable when it is the same object, or a fresh read of a file whose bytes have not moved
// since that state was built. Anything else and the file is the newer copy, so the cached
// tree goes: an instant reopen is not worth showing somebody yesterday's document.
const entry =
cached && (cached.document === source || cached.document.source === source.source)
? cached
: null;
if (entry) {
ed.view.updateState(entry.state);
if (focus) ed.commands.focus(undefined, { scrollIntoView: false });
applyScroll(ed, entry.scroll);
} else {
ed.view.updateState(buildState(ed, source));
const saved = loadPosition(source.path);
const size = ed.state.doc.content.size;
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
const chain = ed.chain().setTextSelection(selection);
if (focus) chain.focus(undefined, { scrollIntoView: false });
chain.run();
remember(source.path, { state: ed.view.state, document: source, scroll: saved?.scroll ?? 0 });
applyScroll(ed, saved?.scroll ?? 0);
}
position.current = null;
publish.current?.();
};
useLayoutEffect(() => {
if (!editor) return;
// A new editor instance, which React's strict double mount produces, cannot be handed states
// built against the old one's plugins, and it has nothing installed in it yet whatever the
// last document was.
const carried = host.current === editor;
if (!carried) cache.current.clear();
const previous = carried ? installed.current : null;
if (previous === document) return;
if (previous) stash(editor, previous);
install(editor, document, !previous || previous.path !== document.path);
installed.current = document;
host.current = editor;
// The document is installed by identity, not by field: a new object is a different file or a
// reload from disk, and a keystroke is neither.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, document]);
useEffect(() => {
if (!editor) return;
// Not setEditable(value): its default emits an `update` with no transaction behind it, which
// would reach onChange and mark a document dirty that nobody has typed into.
editor.setEditable(editable, false);
}, [editor, editable]);
useEffect(() => {
if (!editor) return;
const commands = createCommands(editor);
const find = createFind(editor);
let active = activeStateOf(editor);
currentHandle = { active, ...commands };
currentFind = { state: { count: 0, current: 0 }, ...find };
const push = () => {
const next = activeStateOf(editor);
if (!sameActive(active, next)) {
active = next;
currentHandle = { active, ...commands };
}
const search = searchStateOf(editor.state);
const count = search?.matches.length ?? 0;
const current = search?.current ?? 0;
if (currentFind === null || currentFind.state.count !== count || currentFind.state.current !== current) {
currentFind = { state: { count, current }, ...find };
}
announce();
};
publish.current = push;
push();
editor.on("transaction", push);
return () => {
editor.off("transaction", push);
publish.current = null;
currentHandle = null;
currentFind = null;
announce();
};
}, [editor]);
useEffect(() => {
if (!editor) return;
const scroller = scrollerOf(editor);
let timer: ReturnType<typeof setTimeout>;
const write = () => {
const source = installed.current;
if (source && position.current) savePosition(source.path, position.current);
};
const persist = () => {
const { from, to } = editor.state.selection;
const scroll = scroller?.scrollTop ?? 0;
position.current = { from, to, scroll };
const entry = installed.current ? cache.current.get(installed.current.path) : undefined;
if (entry) entry.scroll = scroll;
clearTimeout(timer);
timer = setTimeout(write, 400);
};
editor.on("selectionUpdate", persist);
scroller?.addEventListener("scroll", persist, { passive: true });
return () => {
clearTimeout(timer);
editor.off("selectionUpdate", persist);
scroller?.removeEventListener("scroll", persist);
write();
};
}, [editor]);
return <EditorContent editor={editor} className="editor-host" />;
}
+321
View File
@@ -0,0 +1,321 @@
// A .txt file. Not markdown, so nothing here parses any: a line reading "# heading" is that
// literal text on screen and those literal bytes on disk, and the only thing between the two is a
// textarea.
//
// The document shape mirrors the bridge's `parsePlainText` exactly, one line to one paragraph,
// which is what makes splitting and joining on the newline exact inverses and the round trip byte
// identical down to a missing final newline.
//
// Find has no ProseMirror decorations to draw here, since a textarea has no tree to decorate. What
// it has is a native selection, which is the one highlight a textarea can show, so a match is
// found by selecting it and scrolling it into view rather than by painting a span around it. The
// state that search.ts keeps in a plugin lives in a ref instead, published through the same
// `DocumentFind` shape src/editor/index.ts declares for the markdown surface, so FindBar.tsx never
// has to know which editor it is talking to.
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import type { ReactElement } from "react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { schema } from "../model/schema";
import {
EMPTY_PLAIN_SEARCH,
recomputePlainSearch,
replaceAllMatches,
replaceMatch,
sameQuery,
type PlainSearchState,
type SearchMatch,
} from "./plainFind";
import type { DocumentFind, PlainTextProps } from "./index";
function textOf(doc: ProseMirrorNode): string {
const lines: string[] = [];
doc.forEach((block) => lines.push(block.textContent));
return lines.join("\n");
}
function docOf(text: string): ProseMirrorNode {
const lines = text.split("\n");
const blocks = lines.map((line) =>
schema.nodes.paragraph.create(null, line ? schema.text(line) : null),
);
return schema.nodes.doc.create(null, blocks);
}
/** The properties a mirror div needs to copy for its line wrapping, and so the vertical position
* it measures, to match the textarea's own. Only what wrapping and line height depend on: nothing
* about colour or the caret. */
function copyWrappingStyle(mirror: HTMLDivElement, field: HTMLTextAreaElement): void {
const style = getComputedStyle(field);
mirror.style.position = "absolute";
mirror.style.visibility = "hidden";
mirror.style.top = "0";
mirror.style.left = "-9999px";
mirror.style.width = `${field.clientWidth}px`;
mirror.style.fontFamily = style.fontFamily;
mirror.style.fontSize = style.fontSize;
mirror.style.fontWeight = style.fontWeight;
mirror.style.fontStyle = style.fontStyle;
mirror.style.letterSpacing = style.letterSpacing;
mirror.style.lineHeight = style.lineHeight;
mirror.style.textTransform = style.textTransform;
mirror.style.wordSpacing = style.wordSpacing;
mirror.style.tabSize = style.tabSize;
mirror.style.whiteSpace = style.whiteSpace;
mirror.style.wordBreak = style.wordBreak;
mirror.style.overflowWrap = style.overflowWrap;
}
/** Where a character offset lands inside the textarea's own box, found the only way a plain
* textarea allows: rendering the same text in an invisible twin under the same font and width and
* reading back where a marker after it fell. There is no scroll of its own to subtract, since the
* field is always exactly as tall as its text (see the layout effect below). */
function caretOffset(field: HTMLTextAreaElement, index: number): { top: number; height: number } {
const mirror = window.document.createElement("div");
copyWrappingStyle(mirror, field);
mirror.textContent = field.value.slice(0, index);
const marker = window.document.createElement("span");
marker.textContent = field.value.slice(index, index + 1) || ".";
mirror.appendChild(marker);
window.document.body.appendChild(mirror);
const top = marker.offsetTop;
const height = marker.offsetHeight;
window.document.body.removeChild(mirror);
return { top, height };
}
function scrollerOf(field: HTMLTextAreaElement): HTMLElement | null {
for (let node = field.parentElement; node; node = node.parentElement) {
if (node.classList.contains("editor-pane")) return node;
}
return null;
}
/** The textarea has no scrollbar of its own, so bringing a match into view means scrolling the
* pane around it instead, the same "roughly centred" placement search.ts asks the pane for. */
function scrollMatchIntoView(field: HTMLTextAreaElement, match: SearchMatch): void {
const pane = scrollerOf(field);
if (!pane) return;
const { top, height } = caretOffset(field, match.from);
const fieldTop = field.getBoundingClientRect().top - pane.getBoundingClientRect().top + pane.scrollTop;
const target = fieldTop + top - pane.clientHeight / 2 + height / 2;
pane.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
}
const findListeners = new Set<() => void>();
let currentPlainFind: DocumentFind | null = null;
function subscribePlainFind(listener: () => void): () => void {
findListeners.add(listener);
return () => {
findListeners.delete(listener);
};
}
function announcePlainFind(): void {
for (const listener of findListeners) listener();
}
const plainFindSnapshot = () => currentPlainFind;
/**
* Find and replace for the .txt surface, published the same way Editor.tsx publishes the markdown
* one, so src/editor/index.ts can hand `useDocumentFind` whichever of the two is actually on
* screen without either side knowing the other exists.
*/
export function usePlainTextFind(): DocumentFind | null {
return useSyncExternalStore(subscribePlainFind, plainFindSnapshot, plainFindSnapshot);
}
export function PlainTextEditor({
document,
onChange,
editable = true,
}: PlainTextProps): ReactElement {
const field = useRef<HTMLTextAreaElement>(null);
const [text, setText] = useState(() => textOf(document.doc));
const source = useRef(document);
const textRef = useRef(text);
textRef.current = text;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const search = useRef<PlainSearchState>(EMPTY_PLAIN_SEARCH);
const publishRef = useRef<() => void>(() => {});
// Set when the open file is replaced by a newer read of itself, which is what an external edit
// to a clean buffer looks like from here. Editor.tsx stashes and restores the ProseMirror
// selection across the same event; a textarea has no selection of its own to survive a value
// change, so it has to be carried by hand or the caret lands at the end of the file.
const carry = useRef<{ start: number; end: number } | null>(null);
if (source.current !== document) {
const reload = source.current.path === document.path;
const el = field.current;
carry.current = reload && el ? { start: el.selectionStart, end: el.selectionEnd } : null;
source.current = document;
const next = textOf(document.doc);
setText(next);
textRef.current = next;
// A new file has nothing to do with whatever was being searched for in the last one, and its
// matches would point at the wrong offsets anyway. A reload of the same file is the same
// story: the offsets are against text that has just been replaced.
search.current = EMPTY_PLAIN_SEARCH;
}
const methods = useRef<Omit<DocumentFind, "state"> | null>(null);
// Only a new object when the count or the position in it actually moved, the same guard
// Editor.tsx's own `push` keeps: FindBar re-issues `setQuery`/`clear` on every render it is open
// for, and a new object on every one of those, whether anything changed or not, is what a
// `useSyncExternalStore` subscriber reads as new state to render, which is what that repeated
// call turns into an infinite loop rather than the no-op it is meant to be.
const publish = () => {
const count = search.current.matches.length;
const current = search.current.current;
if (!currentPlainFind || currentPlainFind.state.count !== count || currentPlainFind.state.current !== current) {
currentPlainFind = { state: { count, current }, ...methods.current! };
}
announcePlainFind();
};
publishRef.current = publish;
if (!methods.current) {
// Collapses whatever is selected without moving the caret: the closest a textarea has to
// "no decoration", for the moment a query stops matching anything or find closes altogether.
const deselect = () => {
const el = field.current;
if (!el) return;
el.setSelectionRange(el.selectionStart, el.selectionStart);
};
const land = (next: PlainSearchState) => {
search.current = next;
const match = next.matches[next.current];
if (match) {
const el = field.current;
if (el) {
el.setSelectionRange(match.from, match.to);
scrollMatchIntoView(el, match);
}
} else {
deselect();
}
publishRef.current();
};
methods.current = {
setQuery: (query, options) => {
if (sameQuery(search.current, query, options)) return;
land(recomputePlainSearch(textRef.current, query, options, 0));
},
clear: () => {
search.current = EMPTY_PLAIN_SEARCH;
deselect();
publishRef.current();
},
next: () => {
const s = search.current;
if (!s.matches.length) return;
land({ ...s, current: (s.current + 1) % s.matches.length });
},
prev: () => {
const s = search.current;
if (!s.matches.length) return;
land({ ...s, current: (s.current - 1 + s.matches.length) % s.matches.length });
},
replaceCurrent: (replacement) => {
const s = search.current;
if (!s.matches.length) return;
const match = s.matches[s.current];
const nextText = replaceMatch(textRef.current, match, replacement);
textRef.current = nextText;
setText(nextText);
onChangeRef.current(docOf(nextText));
land(recomputePlainSearch(nextText, s.query, s.options, s.current));
},
replaceAll: (replacement) => {
const s = search.current;
if (!s.matches.length) return;
const nextText = replaceAllMatches(textRef.current, s.matches, replacement);
textRef.current = nextText;
setText(nextText);
onChangeRef.current(docOf(nextText));
land(recomputePlainSearch(nextText, s.query, s.options, s.current));
},
focus: () => {
field.current?.focus();
},
};
}
useEffect(() => {
field.current?.focus();
}, [document.path]);
// Republished after every render that changed which document is open, which is what carries the
// reset above (a new file means no active search) out to whatever is drawing the find bar. The
// functions themselves close over refs and read them fresh on every call, so nothing here needs
// rebuilding when only the text or the search changes, just the announcing of it.
useLayoutEffect(() => {
publishRef.current();
}, [document]);
// Unregistering is a real unmount only, not a document change: switching files keeps this
// component and its handle in place, and only leaving the plain text surface entirely (the
// document closes, or a markdown file replaces it) should hand `useDocumentFind` back to null.
useEffect(() => {
return () => {
currentPlainFind = null;
announcePlainFind();
};
}, []);
// The pane is the scroller for every other document, and a textarea with its own scrollbar
// inside that pane would be two of them, one of which puts the last line under the chrome with
// no way to scroll it clear. So the field is always exactly as tall as its text.
useLayoutEffect(() => {
const el = field.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
// Clamped, because the edit that arrived from outside may well be shorter than what was on
// screen. Landing at the end of a file that shrank under you is the same complaint as landing
// at the end of one that grew.
const want = carry.current;
carry.current = null;
if (!want) return;
const end = Math.min(want.end, el.value.length);
el.setSelectionRange(Math.min(want.start, end), end);
}, [text]);
return (
<textarea
ref={field}
className="plain-text"
value={text}
readOnly={!editable}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
onChange={(event) => {
const next = event.target.value;
setText(next);
textRef.current = next;
onChange(docOf(next));
// A search still running when the text under it changes stays running, against the new
// text, the same way search.ts recomputes on every transaction that changes the document.
if (search.current.query) {
search.current = recomputePlainSearch(
next,
search.current.query,
search.current.options,
search.current.current,
);
publishRef.current();
}
}}
/>
);
}
+851
View File
@@ -0,0 +1,851 @@
// The app's only formatting surface: a sticky glass pill at the bottom of the editor pane. There
// is no slash menu and there are no drag handles, by product decision, so every control a document
// can be shaped with lives here.
//
// This component drives itself off `useEditorHandle()`, the declared surface in `./index`, and
// nothing else about the editor: no TipTap instance, no ProseMirror import, no reach into
// extensions.ts. Save state does not come from a store here on purpose: what the pill has to show
// is a conflict, and a conflict is `useDocument`'s `externalChange` rather than its `savePhase`,
// so reading one field would report the wrong thing half the time. `saveState` and `document`
// arrive as props instead, the same way `editor` arrived as a prop in the margin version this is
// ported from, and src/App.tsx is the one place the two fields are folded into one answer.
//
// Ported from ../../../margin/src/editor/FloatingToolbar.tsx: the tool() helper, the onMouseDown
// preventDefault on every button (without it, a click steals the selection before the command that
// reads it runs), the .tool-wrap plus conditional backdrop plus popover idiom, and a
// useEscapeLayer per popover so Escape unwinds them in order. The forceUpdate-on-"transaction"
// subscription from that version is not ported: `useEditorHandle()` is a hook that itself returns
// a new `active` object on every relevant change, per its own doc comment, so calling it is the
// replacement for that subscription, not an addition to it.
//
// M2 brought four block families and one hard constraint: the pill is one row and it has to fit
// the app's 880px minimum window beside a 248px sidebar, which leaves 632px of pane. Three tools
// are added, which is what M1 left room for, and everything else expands out of one of them: the
// table tool's popover is a size picker outside a table and the twelve table ops inside one, the
// callout tool's is the five kinds, the insert tool's is math and mermaid, and the language for a
// fence hangs off the code block tool that was already there, since a control the cursor has to be
// inside a fence to want is a control the pill cannot afford to carry permanently.
//
// The toggle tool is the one added since, and it is a plain button rather than a fourth popover for
// the reason the quote button is: everything a toggle needs beyond existing, its summary and
// whether it is open, is edited on the block itself. It does cost the row its last 32px at the
// minimum window, where the pill was already 4px over and living on the tightened separators in
// toolbar.css.
import { useEffect, useRef, useState, type ReactElement, type ReactNode } from "react";
import { Icon } from "../components/Icon";
import { useEscapeLayer } from "../escape";
import { assetWrite } from "../api/files";
import { notify } from "../store/useToast";
import { CALLOUT_KINDS, type MarkdownDocument } from "../model/doc";
import { HEADING_LEVELS } from "../model/schema";
import { useEditorHandle, type EditorActiveState, type TableOp } from "./index";
const DEFAULT_ACTIVE: EditorActiveState = {
marks: [],
block: "paragraph",
headingLevel: null,
callout: null,
inTable: false,
codeLanguage: null,
};
/** The size picker's ceiling. Anything bigger is a table nobody builds from a grid of squares. */
const PICKER_ROWS = 6;
const PICKER_COLUMNS = 8;
/**
* The languages worth one click. Deliberately short and deliberately not the highlighter's list:
* an info string is free text, so the input beside these takes anything, and a fence the file
* already carried keeps whatever it says whether it appears here or not.
*/
const LANGUAGES = [
"javascript",
"typescript",
"python",
"rust",
"go",
"json",
"yaml",
"bash",
"sql",
"html",
"css",
"markdown",
"mermaid",
];
export type ToolbarSaveState = "idle" | "saving" | "conflict";
export interface ToolbarProps {
/** The open document, needed only for the path a pasted image is written beside. Asset writes
* are disabled while this is null. */
document: MarkdownDocument | null;
/** Idle shows nothing on the right of the pill, saving shows a quiet pulse, conflict shows a
* small clickable warning. Defaults to idle so the pill renders sensibly before whoever owns the
* document store has a real phase to report. */
saveState?: ToolbarSaveState;
/** Only read while saveState is "conflict". */
onResolveConflict?: () => void;
}
function normalizeUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed) return "";
if (/^(https?:\/\/|mailto:|tel:|#|\/)/i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
/**
* A fence's info string is a language and then `meta`, the user's own text after it, which this
* editor has no model for and never invents. Anything typed past the first space would be written
* into the fence and read back as meta, so the picker keeps the first word and leaves the rest of
* the info string to whatever the file already said.
*/
function normalizeLanguage(value: string): string | null {
const first = value.trim().split(/\s+/)[0] ?? "";
return first || null;
}
/** Capitalised for a menu. The label on disk is upper case and belongs to the serializer. */
function calloutLabel(kind: string): string {
return kind.charAt(0).toUpperCase() + kind.slice(1);
}
/**
* Whether the caret is in a toggle's title, which is chrome rather than content.
*
* The title is a node view's own editable island, so ProseMirror's selection stays wherever it was
* in the document the whole time somebody is typing in there. src/editor/blocks/toggle.ts refuses
* every document-changing transaction while that is true, because otherwise a button pressed here
* edits a paragraph the user is not looking at. That refusal is the safety net; this is the half
* that has to agree with it, since a tool that draws itself live and then does nothing is the pill
* lying about what it can do.
*
* Asked of the page rather than of a subscription, because clicking into a title dispatches no
* transaction and there is nothing in the editor's own state to watch. Focus events bubble, so one
* pair on the window covers every toggle on screen and every one added later.
*/
function useCaretInToggleTitle(): boolean {
const [inTitle, setInTitle] = useState(false);
useEffect(() => {
const read = () => {
const active = window.document.activeElement;
setInTitle(active instanceof Element && active.closest("[data-toggle-summary]") !== null);
};
read();
window.addEventListener("focusin", read);
window.addEventListener("focusout", read);
return () => {
window.removeEventListener("focusin", read);
window.removeEventListener("focusout", read);
};
}, []);
return inTitle;
}
function tool(
active: boolean,
onClick: () => void,
title: string,
content: ReactNode,
disabled = false,
): ReactElement {
return (
<button
className="tool"
data-on={active}
title={title}
disabled={disabled}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
>
{content}
</button>
);
}
const BOLD_D = "M7 5v14M7 5h5.5a3.5 3.5 0 0 1 0 7H7M7 12h6a3.5 3.5 0 0 1 0 7H7";
const ITALIC_D = "M10 5h6M6 19h6M13 5l-4 14";
const STRIKETHROUGH_D =
"M5 12h14M8 7.5c0-1.5 1.6-2.5 4-2.5s4 1 4 2.5M8 16.5c0 1.5 1.6 2.5 4 2.5s4-1 4-2.5";
const CODE_D = "M9 6l-5 6 5 6M15 6l5 6-5 6";
const HEADING_D = "M5 5v14M5 12h8M13 5v14";
const BULLET_LIST_D = "M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01";
const ORDERED_LIST_D =
"M10 6h11M10 12h11M10 18h11M4 4v4M3 4h2M4 10.5h1.5a1 1 0 1 1 0 2H4h1.5a1 1 0 1 1 0 2H4M4 20.5l1.4-1.7a1 1 0 1 0-1.4-1.6";
const TASK_LIST_D = "M4 5h4v4H4zM5.5 7l1 1 2-2M4 15h4v4H4zM5 17l1 1 2-2M11 7h9M11 17h9M11 12h9";
const BLOCKQUOTE_D = "M7 8h4v4a4 4 0 0 1-4 4M14 8h4v4a4 4 0 0 1-4 4";
const TOGGLE_D = "M5 7l4 5-4 5M12 9h7M12 15h7";
const CODE_BLOCK_D = "M4 6h16v12H4zM7 10l3 2-3 2";
const LINK_D =
"M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1";
const REMOVE_D = "M18 6L6 18M6 6l12 12";
const HR_D = "M5 12h5M14 12h5";
const IMAGE_D = "M4 5h16v14H4zM4 16l4.5-4.5 3 3L16 10l4 4";
const ALERT_D = "M12 3l10 18H2zM12 9v5M12 17h.01";
const CALLOUT_D = "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M12 11v5M12 8h.01";
const TABLE_D = "M4 5h16v14H4zM4 10h16M10 10v9M15 10v9";
const INSERT_D = "M12 5v14M5 12h14";
export function Toolbar({ document, saveState = "idle", onResolveConflict }: ToolbarProps): ReactElement {
const editor = useEditorHandle();
const active = editor?.active ?? DEFAULT_ACTIVE;
const inTitle = useCaretInToggleTitle();
const disabled = !editor || saveState === "conflict" || inTitle;
const [headingOpen, setHeadingOpen] = useState(false);
const [linkOpen, setLinkOpen] = useState(false);
const [calloutOpen, setCalloutOpen] = useState(false);
const [tableOpen, setTableOpen] = useState(false);
const [insertOpen, setInsertOpen] = useState(false);
const [languageOpen, setLanguageOpen] = useState(false);
const [linkValue, setLinkValue] = useState("");
const [languageValue, setLanguageValue] = useState("");
// What the size grid is hovering over, which is the picker's whole state: nothing is inserted
// until a square is clicked.
const [size, setSize] = useState<{ rows: number; columns: number } | null>(null);
const linkInputRef = useRef<HTMLInputElement>(null);
const languageInputRef = useRef<HTMLInputElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
// Every popover in the pill is mutually exclusive already, since each one lays a fixed backdrop
// over the other tools, so opening one closes the rest rather than letting a second live menu
// exist behind an invisible sheet.
const closePopovers = () => {
setHeadingOpen(false);
setLinkOpen(false);
setCalloutOpen(false);
setTableOpen(false);
setInsertOpen(false);
setLanguageOpen(false);
};
useEffect(() => {
if (linkOpen) linkInputRef.current?.focus();
}, [linkOpen]);
useEffect(() => {
if (languageOpen) languageInputRef.current?.focus();
}, [languageOpen]);
// The backdrop cannot be the whole click-away story here the way it is for the titlebar's menu.
// .editor-toolbar carries a backdrop-filter, and that makes it the containing block for a fixed
// position child, so `inset: 0` on the backdrop resolves to the pill and not to the window: it
// covers the other tools, which is what makes a second press of the open one close it, and
// nothing else. A click in the document went behind it and left the menu standing, which was
// survivable with two small popovers and is not with six. Nothing is prevented, so the click
// still lands where it was aimed.
useEffect(() => {
if (!(headingOpen || linkOpen || calloutOpen || tableOpen || insertOpen || languageOpen)) return;
const onDown = (e: MouseEvent) => {
const target = e.target instanceof Element ? e.target : null;
if (target?.closest(".editor-toolbar")) return;
closePopovers();
};
window.addEventListener("mousedown", onDown, true);
return () => window.removeEventListener("mousedown", onDown, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [headingOpen, linkOpen, calloutOpen, tableOpen, insertOpen, languageOpen]);
// A conflict disables every tool, and a popover left open over a disabled pill would still have
// live items in it. The file on disk has already moved by then, so nothing here gets to write to
// the buffer until the user has said which copy wins. Only the six setState functions are read,
// and those are stable, so the closure this captures is never the stale one.
useEffect(() => {
if (disabled) closePopovers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [disabled]);
// Ported Mod-K handling. Note for whoever wires the pane together: cmd+k is already bound
// globally to "command-palette" in src/keys/bindings.ts with allowInInput: true, and that
// listener is installed at app boot, before this component ever mounts, so it always sees the
// keydown first. The defaultPrevented check below means this never double-fires on top of it,
// but it also means this shortcut is inert until a document-context override for cmd+k exists.
// Clicking the link tool still opens the popover either way.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.isComposing || e.defaultPrevented) return;
if (e.key.toLowerCase() !== "k" || !(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return;
if (!editor && !linkOpen) return;
e.preventDefault();
e.stopPropagation();
if (linkOpen) {
setLinkOpen(false);
} else {
setLinkValue("");
setLinkOpen(true);
}
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [editor, linkOpen]);
useEscapeLayer(linkOpen, () => {
setLinkOpen(false);
editor?.focus();
});
useEscapeLayer(headingOpen, () => setHeadingOpen(false));
useEscapeLayer(calloutOpen, () => setCalloutOpen(false));
useEscapeLayer(tableOpen, () => setTableOpen(false));
useEscapeLayer(insertOpen, () => setInsertOpen(false));
useEscapeLayer(languageOpen, () => {
setLanguageOpen(false);
editor?.focus();
});
const applyLink = () => {
if (!editor) return;
const href = normalizeUrl(linkValue);
editor.setLink(href || null);
setLinkOpen(false);
editor.focus();
};
const removeLink = () => {
if (!editor) return;
editor.setLink(null);
setLinkOpen(false);
editor.focus();
};
const openLink = () => {
const wasOpen = linkOpen;
closePopovers();
if (wasOpen) return;
setLinkValue("");
setLinkOpen(true);
};
// The code block tool converts into a fence from outside one and configures the fence from
// inside it. Turning one back into a paragraph moves into the foot of that popover rather than
// staying on a second press of the tool, because the pill has no room for a language control of
// its own and the tool for the block you are in is where you would look for one anyway.
const onCodeBlock = () => {
if (active.block !== "codeBlock") {
closePopovers();
editor?.setBlock("codeBlock");
return;
}
const wasOpen = languageOpen;
closePopovers();
if (wasOpen) return;
setLanguageValue(active.codeLanguage ?? "");
setLanguageOpen(true);
};
const setLanguage = (language: string | null) => {
if (!editor) return;
editor.setCodeLanguage(language);
setLanguageOpen(false);
editor.focus();
};
// Row and column ops leave the popover up: adding three rows is three clicks in the same place,
// and the command has already put the cursor back in the table by the time the next one runs.
const runTable = (op: TableOp) => {
editor?.tableCommand(op);
};
const onImageChosen = async (file: File) => {
if (!editor || !document) return;
// Asked before the bytes go anywhere. The picture has to be on disk before there is a path to
// put in the document, so a cursor in a table cell or a fenced block, where the insert is
// refused, would otherwise leave a file in the user's assets folder that nothing refers to.
if (!editor.canInsertImage()) {
notify("An image cannot go where the cursor is.");
return;
}
try {
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
const result = await assetWrite(document.path, bytes, file.name || "image.png");
const alt = file.name.replace(/\.[^./]+$/, "") || null;
editor.insertImage(result.relPath, alt);
editor.focus();
} catch (e) {
notify(String(e));
}
};
return (
<div className="editor-toolbar">
{tool(active.marks.includes("strong"), () => editor?.toggleMark("strong"), "Bold", <Icon d={BOLD_D} />, disabled)}
{tool(active.marks.includes("em"), () => editor?.toggleMark("em"), "Italic", <Icon d={ITALIC_D} />, disabled)}
{tool(
active.marks.includes("strikethrough"),
() => editor?.toggleMark("strikethrough"),
"Strikethrough",
<Icon d={STRIKETHROUGH_D} />,
disabled,
)}
{tool(active.marks.includes("code"), () => editor?.toggleMark("code"), "Inline code", <Icon d={CODE_D} />, disabled)}
<span className="tool-sep" />
<span className="tool-wrap">
{tool(
headingOpen || active.block === "heading",
() => {
const wasOpen = headingOpen;
closePopovers();
if (!wasOpen) setHeadingOpen(true);
},
"Heading",
<Icon d={HEADING_D} />,
disabled,
)}
{headingOpen && (
<>
<div className="link-pop-backdrop" onMouseDown={() => setHeadingOpen(false)} />
<div className="heading-pop" onMouseDown={(e) => e.stopPropagation()}>
<button
className="pop-item"
data-on={active.block === "paragraph"}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
editor?.setHeading(null);
setHeadingOpen(false);
editor?.focus();
}}
>
Paragraph
</button>
{HEADING_LEVELS.map((level) => (
<button
key={level}
className="pop-item"
data-on={active.block === "heading" && active.headingLevel === level}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
editor?.setHeading(level);
setHeadingOpen(false);
editor?.focus();
}}
>
{`Heading ${level}`}
</button>
))}
</div>
</>
)}
</span>
<span className="tool-sep" />
{tool(active.block === "bulletList", () => editor?.setBlock("bulletList"), "Bulleted list", <Icon d={BULLET_LIST_D} />, disabled)}
{tool(active.block === "orderedList", () => editor?.setBlock("orderedList"), "Numbered list", <Icon d={ORDERED_LIST_D} />, disabled)}
{tool(active.block === "taskList", () => editor?.setBlock("taskList"), "Task list", <Icon d={TASK_LIST_D} />, disabled)}
{tool(active.block === "blockquote", () => editor?.setBlock("blockquote"), "Quote", <Icon d={BLOCKQUOTE_D} />, disabled)}
{/* The third of the three wrapping commands, beside the two it behaves like: one press puts
the block inside a <details>, a second takes it back out. The summary is typed into the
toggle itself rather than asked for here, because it is the one part of a block in this
pill that is a piece of the document and not a setting. */}
{tool(active.block === "toggle", () => editor?.setBlock("toggle"), "Toggle", <Icon d={TOGGLE_D} />, disabled)}
<span className="tool-wrap">
{tool(
calloutOpen || active.block === "callout",
() => {
const wasOpen = calloutOpen;
closePopovers();
if (!wasOpen) setCalloutOpen(true);
},
"Callout",
<Icon d={CALLOUT_D} />,
disabled,
)}
{calloutOpen && (
<>
<div className="link-pop-backdrop" onMouseDown={() => setCalloutOpen(false)} />
<div className="callout-pop" onMouseDown={(e) => e.stopPropagation()}>
{CALLOUT_KINDS.map((kind) => (
<button
key={kind}
className="pop-item"
data-on={active.callout === kind}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
editor?.setCallout(kind);
setCalloutOpen(false);
editor?.focus();
}}
>
{calloutLabel(kind)}
</button>
))}
{active.block === "callout" && (
<button
className="pop-item"
title="Leave the blockquote it is on disk, without the marker"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
editor?.setCallout(null);
setCalloutOpen(false);
editor?.focus();
}}
>
Plain quote
</button>
)}
</div>
</>
)}
</span>
<span className="tool-wrap">
{tool(
languageOpen || active.block === "codeBlock",
onCodeBlock,
active.block === "codeBlock"
? `Code block: ${active.codeLanguage ?? "no language"}`
: "Code block",
<Icon d={CODE_BLOCK_D} />,
disabled,
)}
{languageOpen && (
<>
<div className="link-pop-backdrop" onMouseDown={() => setLanguageOpen(false)} />
<div className="lang-pop" onMouseDown={(e) => e.stopPropagation()}>
<div className="lang-row">
<input
ref={languageInputRef}
className="link-input"
value={languageValue}
placeholder="Language"
spellCheck={false}
onChange={(e) => setLanguageValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
setLanguage(normalizeLanguage(languageValue));
}
}}
/>
<button
className="link-btn"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setLanguage(normalizeLanguage(languageValue))}
>
Apply
</button>
{active.codeLanguage !== null && (
<button
className="link-btn ghost"
title="Leave a bare fence"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setLanguage(null)}
>
<Icon d={REMOVE_D} size={14} />
</button>
)}
</div>
<div className="lang-chips">
{LANGUAGES.map((name) => (
<button
key={name}
className="lang-chip"
data-on={active.codeLanguage === name}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setLanguage(name)}
>
{name}
</button>
))}
</div>
<button
className="pop-item"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
editor?.setBlock("codeBlock");
setLanguageOpen(false);
editor?.focus();
}}
>
Turn into a paragraph
</button>
</div>
</>
)}
</span>
<span className="tool-sep" />
<span className="tool-wrap">
{tool(active.marks.includes("link") || linkOpen, openLink, "Link (⌘K)", <Icon d={LINK_D} />, disabled)}
{linkOpen && (
<>
<div className="link-pop-backdrop" onMouseDown={() => setLinkOpen(false)} />
<div className="link-pop" onMouseDown={(e) => e.stopPropagation()}>
<input
ref={linkInputRef}
className="link-input"
value={linkValue}
placeholder="https://..."
spellCheck={false}
onChange={(e) => setLinkValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyLink();
}
}}
/>
<button className="link-btn" onMouseDown={(e) => e.preventDefault()} onClick={applyLink}>
Apply
</button>
{active.marks.includes("link") && (
<button
className="link-btn ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={removeLink}
title="Remove link"
>
<Icon d={REMOVE_D} size={14} />
</button>
)}
</div>
</>
)}
</span>
<span className="tool-sep" />
{tool(false, () => editor?.insertRule(), "Horizontal rule", <Icon d={HR_D} />, disabled)}
{tool(false, () => fileRef.current?.click(), "Insert image", <Icon d={IMAGE_D} />, disabled || !document)}
<input
ref={fileRef}
type="file"
accept="image/*"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void onImageChosen(file);
e.currentTarget.value = "";
}}
/>
<span className="tool-sep" />
<span className="tool-wrap">
{tool(
tableOpen || active.inTable,
() => {
const wasOpen = tableOpen;
closePopovers();
if (wasOpen) return;
setSize(null);
setTableOpen(true);
},
active.inTable ? "Table" : "Insert table",
<Icon d={TABLE_D} />,
disabled,
)}
{tableOpen && (
<>
<div className="link-pop-backdrop" onMouseDown={() => setTableOpen(false)} />
<div
className="table-pop"
data-mode={active.inTable ? "edit" : "insert"}
onMouseDown={(e) => e.stopPropagation()}
>
{active.inTable ? (
<>
<span className="pop-label">Row</span>
<div className="pop-row">
<button
className="pop-btn"
title="Insert a row above this one"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("addRowBefore")}
>
Above
</button>
<button
className="pop-btn"
title="Insert a row below this one"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("addRowAfter")}
>
Below
</button>
<button
className="pop-btn danger"
title="Delete this row"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("deleteRow")}
>
Delete
</button>
</div>
<span className="pop-label">Column</span>
<div className="pop-row">
<button
className="pop-btn"
title="Insert a column to the left"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("addColumnBefore")}
>
Left
</button>
<button
className="pop-btn"
title="Insert a column to the right"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("addColumnAfter")}
>
Right
</button>
<button
className="pop-btn danger"
title="Delete this column"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("deleteColumn")}
>
Delete
</button>
</div>
{/* Markdown has no per cell alignment, so these set the whole column the cursor
is in, which is what the delimiter row on disk can say. */}
<span className="pop-label">Align column</span>
<div className="pop-row">
<button
className="pop-btn"
title="Align this column left"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("alignLeft")}
>
Left
</button>
<button
className="pop-btn"
title="Align this column centre"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("alignCenter")}
>
Centre
</button>
<button
className="pop-btn"
title="Align this column right"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("alignRight")}
>
Right
</button>
<button
className="pop-btn"
title="Leave this column unaligned"
onMouseDown={(e) => e.preventDefault()}
onClick={() => runTable("alignClear")}
>
None
</button>
</div>
{/* No header row control. A GFM table has one header row, it is the first one,
and there is no spelling for a table without one, so the button would offer an
edit the file cannot hold. See TableOp in src/editor/index.ts. */}
<span className="pop-label">Table</span>
<button
className="pop-item danger"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
runTable("deleteTable");
setTableOpen(false);
}}
>
Delete table
</button>
</>
) : (
<>
<div className="size-grid" onMouseLeave={() => setSize(null)}>
{Array.from({ length: PICKER_ROWS * PICKER_COLUMNS }, (_, i) => {
const rows = Math.floor(i / PICKER_COLUMNS) + 1;
const columns = (i % PICKER_COLUMNS) + 1;
return (
<button
key={i}
className="size-cell"
data-on={size !== null && rows <= size.rows && columns <= size.columns}
title={`${rows} by ${columns} table`}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setSize({ rows, columns })}
onFocus={() => setSize({ rows, columns })}
onClick={() => {
editor?.insertTable(rows, columns);
setTableOpen(false);
editor?.focus();
}}
/>
);
})}
</div>
<span className="size-label">
{size === null ? "Pick a size" : `${size.rows} x ${size.columns}`}
</span>
</>
)}
</div>
</>
)}
</span>
<span className="tool-wrap">
{tool(
insertOpen,
() => {
const wasOpen = insertOpen;
closePopovers();
if (!wasOpen) setInsertOpen(true);
},
"Insert",
<Icon d={INSERT_D} />,
disabled,
)}
{insertOpen && (
<>
<div className="link-pop-backdrop" onMouseDown={() => setInsertOpen(false)} />
<div className="insert-pop" onMouseDown={(e) => e.stopPropagation()}>
<button
className="pop-item"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
editor?.insertMath(false);
setInsertOpen(false);
editor?.focus();
}}
>
Inline formula
</button>
<button
className="pop-item"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
editor?.insertMath(true);
setInsertOpen(false);
editor?.focus();
}}
>
Display formula
</button>
<button
className="pop-item"
title="A fenced block with mermaid as its language"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
editor?.insertMermaid();
setInsertOpen(false);
editor?.focus();
}}
>
Mermaid diagram
</button>
</div>
</>
)}
</span>
<div className="toolbar-status" data-phase={saveState}>
{saveState === "saving" && <span className="toolbar-status-dot" aria-hidden="true" />}
{saveState === "conflict" && (
<button
className="toolbar-status-conflict"
title="This file changed on disk. Click to resolve."
onMouseDown={(e) => e.preventDefault()}
onClick={() => onResolveConflict?.()}
>
<Icon d={ALERT_D} size={14} />
</button>
)}
</div>
</div>
);
}
+396
View File
@@ -0,0 +1,396 @@
// Highlighting is paint, and the tests that matter are the ones that prove it stayed paint: the
// text of a fence, the attributes on it and the bytes it serializes to are the same whether or not
// a grammar was ever run over it. The rest is about the two ways a highlighter goes wrong in a real
// editor. It can throw or misalign on input it did not expect, which is answered here by fences
// nobody has a grammar for, and it can be slow, which is answered by counting how much of the
// document it walks when one character is typed.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Editor } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { EditorState } from "@tiptap/pm/state";
import type { Plugin } from "@tiptap/pm/state";
import type { Decoration, DecorationSet } from "@tiptap/pm/view";
// The highlighter is private to code.ts, deliberately, so the only place left to watch how often it
// runs is underneath it. Everything the real lowlight does still happens; the wrapper only records
// the text it was handed.
const { highlighted } = vi.hoisted(() => ({ highlighted: [] as string[] }));
vi.mock("lowlight", async (importOriginal) => {
const actual = await importOriginal<typeof import("lowlight")>();
return {
...actual,
createLowlight(...created: Parameters<typeof actual.createLowlight>) {
const instance = actual.createLowlight(...created);
return {
...instance,
highlight(...call: Parameters<typeof instance.highlight>) {
highlighted.push(call[1]);
return instance.highlight(...call);
},
};
},
};
});
const { createEditorExtensions } = await import("../extensions");
const { setCodeLanguage } = await import("./code");
const { parseMarkdown, serializeMarkdown } = await import("../../markdown");
const extensions = () =>
createEditorExtensions({ documentPath: () => "/notes/a.md", onError: () => {} });
function editorFor(source: string): Editor {
return new Editor({
element: null,
injectCSS: false,
extensions: extensions(),
content: parseMarkdown(source, "/notes/a.md").doc.toJSON(),
});
}
/**
* The document with the highlighting plugin over it, and nothing else.
*
* An editor cannot be mounted without a DOM and an unmounted one's state carries no plugins at all,
* so the plugin is lifted out of the extension manager and given a state of its own. What it sees
* there is what it sees in the app: a real state over a real parsed document, and transactions
* applied to it one at a time. Only the view is missing, and a decoration is computed without one.
*/
function stateFor(source: string): EditorState {
const editor = editorFor(source);
const plugin = editor.extensionManager.plugins.find((candidate: Plugin) =>
String((candidate as unknown as { key: string }).key).startsWith("codeHighlighting"),
);
if (!plugin) throw new Error("the code highlighting plugin is not in the extension list");
const state = EditorState.create({ doc: editor.state.doc, plugins: [plugin] });
editor.destroy();
return state;
}
interface Block {
pos: number;
node: ProseMirrorNode;
}
function codeBlocks(doc: ProseMirrorNode): Block[] {
const found: Block[] = [];
doc.descendants((node, pos) => {
if (node.type.name !== "codeBlock") return true;
found.push({ pos, node });
return false;
});
return found;
}
function decorations(state: EditorState): Decoration[] {
for (const plugin of state.plugins) {
const set = plugin.getState(state) as DecorationSet | undefined;
if (set) return set.find();
}
return [];
}
function classOf(decoration: Decoration): string {
return (decoration as unknown as { type: { attrs: { class: string } } }).type.attrs.class;
}
/** The text a span was cut from, which is the only thing that says it landed in the right place. */
function textOf(state: EditorState, decoration: Decoration): string {
return state.doc.textBetween(decoration.from, decoration.to);
}
function spanWith(state: EditorState, className: string): string | undefined {
const found = decorations(state).find((decoration) => classOf(decoration).includes(className));
return found && textOf(state, found);
}
function fence(language: string, ...lines: string[]): string {
return ["```" + language, ...lines, "```", ""].join("\n");
}
beforeEach(() => {
highlighted.length = 0;
});
describe("the highlighter", () => {
it("colours the four languages this repo's own docs are written in", () => {
const sources: Record<string, string> = {
rust: fence("rust", "fn main() {}"),
toml: fence("toml", "[package]", 'name = "margin-docs"'),
swift: fence("swift", "let x = 1"),
kotlin: fence("kotlin", "val x = 1"),
};
for (const [language, source] of Object.entries(sources)) {
const state = stateFor(source);
expect([language, decorations(state).length > 0]).toEqual([language, true]);
}
});
it("puts every span over the characters it was cut from", () => {
const state = stateFor(fence("rust", 'fn main() { let x = "hi"; }', "// a comment"));
const [block] = codeBlocks(state.doc);
const from = block.pos + 1;
const to = from + block.node.content.size;
const found = decorations(state);
expect(found.length).toBeGreaterThan(3);
for (const decoration of found) {
expect(decoration.from).toBeGreaterThanOrEqual(from);
expect(decoration.to).toBeLessThanOrEqual(to);
expect(decoration.from).toBeLessThan(decoration.to);
}
expect(spanWith(state, "hljs-keyword")).toBe("fn");
expect(spanWith(state, "hljs-string")).toBe('"hi"');
expect(spanWith(state, "hljs-comment")).toBe("// a comment");
});
it("keeps its offsets over text that is not one code unit per character", () => {
const state = stateFor(fence("ts", 'const flag = "🇬🇧 ok";', "\tconst tabbed = 1;"));
expect(codeBlocks(state.doc)[0].node.textContent).toBe(
'const flag = "🇬🇧 ok";\n\tconst tabbed = 1;',
);
expect(spanWith(state, "hljs-string")).toBe('"🇬🇧 ok"');
});
it("leaves a fence tagged with a language nobody has plain, and loses nothing", () => {
const source = fence("nosuchlanguage", "this is not code in any language", " indented ");
const parsed = parseMarkdown(source, "/notes/a.md");
const state = stateFor(source);
expect(decorations(state)).toEqual([]);
expect(codeBlocks(state.doc)[0].node.textContent).toBe(
"this is not code in any language\n indented ",
);
expect(serializeMarkdown(parsed, state.doc)).toBe(source);
});
it("leaves a bare fence plain", () => {
const state = stateFor(fence("", "just some text"));
expect(codeBlocks(state.doc)[0].node.attrs.language).toBe(null);
expect(decorations(state)).toEqual([]);
expect(highlighted).toEqual([]);
});
it("leaves a mermaid fence to the lane that draws it", () => {
const state = stateFor(fence("mermaid", "graph TD;", " a-->b;"));
expect(decorations(state)).toEqual([]);
expect(highlighted).toEqual([]);
});
it("does not throw on code that is broken in its own language", () => {
const source = fence("json", "{ this is not, json: ]]", '"neither" is "this"');
const parsed = parseMarkdown(source, "/notes/a.md");
const state = stateFor(source);
expect(codeBlocks(state.doc)[0].node.textContent).toBe(
'{ this is not, json: ]]\n"neither" is "this"',
);
expect(serializeMarkdown(parsed, state.doc)).toBe(source);
for (const decoration of decorations(state)) {
expect(textOf(state, decoration).length).toBeGreaterThan(0);
}
});
it("leaves a fence too long to be read as code plain, and keeps every character", () => {
const line = "const x = 1; // a line of code that is being repeated a great many times\n";
const long = line.repeat(1000);
expect(long.length).toBeGreaterThan(50_000);
const state = stateFor(fence("ts", long.trimEnd()));
expect(decorations(state)).toEqual([]);
expect(highlighted).toEqual([]);
expect(codeBlocks(state.doc)[0].node.textContent).toBe(long.trimEnd());
});
it("does not write to the document it is painting over", () => {
const source = ["# Notes", "", fence("rust twoslash", "fn main() {}"), "Prose.", ""].join("\n");
const parsed = parseMarkdown(source, "/notes/a.md");
const state = stateFor(source);
expect(state.doc.toJSON()).toEqual(parsed.doc.toJSON());
expect(codeBlocks(state.doc)[0].node.attrs).toEqual({ language: "rust", meta: "twoslash" });
expect(serializeMarkdown(parsed, state.doc)).toBe(source);
});
});
describe("what a keystroke costs", () => {
const twoBlocks = () =>
stateFor(
[fence("ts", "const first = 1;"), fence("ts", "const second = 2;"), "Prose.", ""].join("\n"),
);
const endOf = (block: Block) => block.pos + 1 + block.node.content.size;
it("re-highlights only the block the edit landed in", () => {
const state = twoBlocks();
const blocks = codeBlocks(state.doc);
expect(blocks).toHaveLength(2);
expect(highlighted).toEqual(["const first = 1;", "const second = 2;"]);
highlighted.length = 0;
state.apply(state.tr.insertText("2", endOf(blocks[1])));
expect(highlighted).toEqual(["const second = 2;2"]);
});
it("carries the untouched block's spans forward, still over their own characters", () => {
const before = twoBlocks();
const state = before.apply(before.tr.insertText("// ", codeBlocks(before.doc)[0].pos + 1));
const second = codeBlocks(state.doc)[1];
const inSecond = decorations(state).filter((decoration) => decoration.from > second.pos);
expect(inSecond.length).toBeGreaterThan(0);
for (const decoration of inSecond) {
expect("const second = 2;").toContain(textOf(state, decoration));
}
const keyword = decorations(state).find(
(decoration) =>
decoration.from > second.pos && classOf(decoration).includes("hljs-keyword"),
);
expect(keyword && textOf(state, keyword)).toBe("const");
});
it("re-highlights a block whose language changed, and no other", () => {
const before = twoBlocks();
const block = codeBlocks(before.doc)[0];
highlighted.length = 0;
const state = before.apply(
before.tr.setNodeMarkup(block.pos, null, { ...block.node.attrs, language: "rust" }),
);
expect(highlighted).toEqual(["const first = 1;"]);
expect(spanWith(state, "hljs-keyword")).toBe("const");
});
it("drops the spans of a block that was deleted, and highlights nothing again", () => {
const before = twoBlocks();
const block = codeBlocks(before.doc)[1];
highlighted.length = 0;
const state = before.apply(
before.tr.delete(block.pos, block.pos + block.node.nodeSize),
);
expect(highlighted).toEqual([]);
expect(codeBlocks(state.doc)).toHaveLength(1);
for (const decoration of decorations(state)) {
expect("const first = 1;").toContain(textOf(state, decoration));
}
});
it("highlights a block that was inserted after the document loaded, and no other", () => {
const before = twoBlocks();
const at = codeBlocks(before.doc)[1].pos;
highlighted.length = 0;
const state = before.apply(
before.tr.insert(
at,
before.doc.type.schema.nodes.codeBlock.create({ language: "rust", meta: null }, [
before.doc.type.schema.text("fn main() {}"),
]),
),
);
expect(highlighted).toEqual(["fn main() {}"]);
expect(codeBlocks(state.doc)).toHaveLength(3);
expect(spanWith(state, "hljs-keyword")).toBe("const");
});
it("does not run at all for a transaction that changed no text", () => {
const before = twoBlocks();
highlighted.length = 0;
const state = before.apply(before.tr.setMeta("nothing", true));
expect(highlighted).toEqual([]);
expect(decorations(state).length).toBeGreaterThan(0);
});
});
describe("setCodeLanguage", () => {
function editorAtFence(source: string): Editor {
const editor = editorFor(source);
editor.commands.setTextSelection(codeBlocks(editor.state.doc)[0].pos + 1);
return editor;
}
it("writes the language and leaves the meta the user wrote alone", () => {
const source = fence("ts twoslash", "const x = 1;");
const parsed = parseMarkdown(source, "/notes/a.md");
const editor = editorAtFence(source);
expect(setCodeLanguage(editor, "rust")).toBe(true);
expect(codeBlocks(editor.state.doc)[0].node.attrs).toEqual({
language: "rust",
meta: "twoslash",
});
const written = serializeMarkdown(parsed, editor.state.doc);
expect(written).toBe(fence("rust twoslash", "const x = 1;"));
editor.destroy();
});
it("clears the fence back to a bare one", () => {
const source = fence("ts", "const x = 1;");
const parsed = parseMarkdown(source, "/notes/a.md");
const editor = editorAtFence(source);
expect(setCodeLanguage(editor, null)).toBe(true);
expect(codeBlocks(editor.state.doc)[0].node.attrs.language).toBe(null);
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(fence("", "const x = 1;"));
editor.destroy();
});
it("treats a blank language as a bare fence", () => {
const editor = editorAtFence(fence("ts", "const x = 1;"));
expect(setCodeLanguage(editor, " ")).toBe(true);
expect(codeBlocks(editor.state.doc)[0].node.attrs.language).toBe(null);
editor.destroy();
});
it("refuses a language with a space in it, which the fence would read back as two things", () => {
const source = fence("ts", "const x = 1;");
const parsed = parseMarkdown(source, "/notes/a.md");
const editor = editorAtFence(source);
expect(setCodeLanguage(editor, "ts twoslash")).toBe(false);
expect(codeBlocks(editor.state.doc)[0].node.attrs).toEqual({ language: "ts", meta: null });
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(source);
editor.destroy();
});
it("does nothing when the block already says that, so nothing is dirtied", () => {
const editor = editorAtFence(fence("ts", "const x = 1;"));
const before = editor.state.doc.toJSON();
expect(setCodeLanguage(editor, "ts")).toBe(false);
expect(editor.state.doc.toJSON()).toEqual(before);
editor.destroy();
});
it("does nothing outside a code block", () => {
const editor = editorFor(["Just a paragraph.", ""].join("\n"));
const before = editor.state.doc.toJSON();
expect(setCodeLanguage(editor, "rust")).toBe(false);
expect(editor.state.doc.toJSON()).toEqual(before);
editor.destroy();
});
});
+245
View File
@@ -0,0 +1,245 @@
// Code block behaviour: syntax highlighting, and the language on the fence.
//
// Highlighting is decorations over the block's own text and never an edit to it. The spans belong
// to the view, nothing they do reaches the tree, and a highlighted block therefore serializes back
// to exactly the fence it was read from. lowlight rather than shiki, because a decoration set has
// to be rebuilt synchronously inside the plugin and shiki highlights asynchronously.
//
// `language` and `meta` are already attributes on the schema's codeBlock, so setting a language is
// an ordinary attribute edit and does change the document, which is the point: the fence on disk
// changes with it. `meta` is never touched. It is whatever the user wrote after the language on
// their own opening fence, this editor has no model for it, and it rides along untouched.
//
// The decoration set is rebuilt per code block rather than per document. A document is autosaved
// half a second after the last keystroke, so the typing path is the hot one, and re-running a
// grammar over every fence in a long file on every character typed is the obvious way to make this
// editor feel slow. A transaction says which ranges it touched; only the code blocks those ranges
// land in are highlighted again, and the rest are carried over by mapping the old set forward.
//
// A codeBlock whose language is mermaid belongs to the mermaid lane, which draws it as a diagram
// through a node view. Nothing here decorates one.
import { Extension } from "@tiptap/core";
import type { Editor } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { Transaction } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import type { LanguageFn } from "highlight.js";
import ini from "highlight.js/lib/languages/ini";
import kotlin from "highlight.js/lib/languages/kotlin";
import rust from "highlight.js/lib/languages/rust";
import swift from "highlight.js/lib/languages/swift";
import { common, createLowlight } from "lowlight";
const lowlight = createLowlight(common);
/**
* The four languages this app's own docs folder is written in, registered by hand.
*
* lowlight's common set carries all four today, toml as an alias of ini, so the loop below does
* nothing on this version. It is here because "common" is somebody else's list and it has been
* trimmed before: if one of these ever falls out of it, the app's own documentation is the first
* thing that stops highlighting, and that is a silly way to find out.
*/
const REQUIRED: ReadonlyArray<readonly [string, LanguageFn]> = [
["rust", rust],
["toml", ini],
["swift", swift],
["kotlin", kotlin],
];
for (const [name, grammar] of REQUIRED) {
if (!lowlight.registered(name)) lowlight.register(name, grammar);
}
/**
* Past this many characters a fence is left plain.
*
* A block this long is a pasted file rather than code anyone is reading, and a grammar walking it
* again on every keystroke is a stutter the user cannot explain. Nothing is lost by not colouring
* it: the text is the document's, the decorations were only ever paint.
*/
const MAX_HIGHLIGHT_CHARS = 50_000;
type HighlightRoot = ReturnType<ReturnType<typeof createLowlight>["highlight"]>;
type HighlightChild = HighlightRoot["children"][number];
const codeHighlightKey = new PluginKey<DecorationSet>("codeHighlighting");
/** The class names lowlight put on one span, as ProseMirror wants them: one string. */
function classNameOf(properties: Record<string, unknown> | undefined): string {
const value = properties?.className;
if (typeof value === "string") return value;
if (Array.isArray(value)) {
return value.filter((name): name is string => typeof name === "string").join(" ");
}
return "";
}
/**
* The language to highlight this block with, or null to leave it plain.
*
* A fence's info string is the user's text, not a menu selection: it can be blank, it can name a
* language nobody has a grammar for, and it can be a typo. All three are plain text and none of
* them is an error, so an unregistered name is answered here rather than by letting the highlighter
* throw. Guessing is not on the list either: highlightAuto would colour a paragraph of prose as
* whichever language it happened to resemble.
*/
function highlightableLanguage(node: ProseMirrorNode): string | null {
const language = typeof node.attrs.language === "string" ? node.attrs.language.trim() : "";
if (!language) return null;
// Matched loosely, unlike the mermaid lane's own exact test, because the two must not both draw
// the same block and the safe direction to be wrong in is leaving a block plain.
if (language.toLowerCase() === "mermaid") return null;
return lowlight.registered(language) ? language : null;
}
/** `base` is the position of the block's first character, so `pos + 1` for the node at `pos`. */
function decorationsFor(node: ProseMirrorNode, base: number): Decoration[] {
const language = highlightableLanguage(node);
if (!language) return [];
const text = node.textContent;
if (!text || text.length > MAX_HIGHLIGHT_CHARS) return [];
let tree: HighlightRoot;
try {
tree = lowlight.highlight(language, text);
} catch {
// A grammar that throws on somebody's file is a highlighter's problem and never the document's.
return [];
}
const decorations: Decoration[] = [];
let offset = 0;
const walk = (children: readonly HighlightChild[]): void => {
for (const child of children) {
if (child.type === "text") {
offset += child.value.length;
} else if (child.type === "element") {
const from = offset;
walk(child.children);
const className = classNameOf(child.properties);
if (className && offset > from) {
decorations.push(Decoration.inline(base + from, base + offset, { class: className }));
}
}
}
};
walk(tree.children);
// The highlighter is a third party walking the user's text, and a decoration that runs past the
// end of the block throws inside the view rather than merely looking wrong. If what came back
// does not measure the same as what went in, the offsets cannot be trusted and the block stays
// plain.
return offset === text.length ? decorations : [];
}
function highlightWholeDoc(doc: ProseMirrorNode): DecorationSet {
const decorations: Decoration[] = [];
doc.descendants((node, pos) => {
if (node.type.name !== "codeBlock") return true;
decorations.push(...decorationsFor(node, pos + 1));
return false;
});
return DecorationSet.create(doc, decorations);
}
/**
* The code blocks a transaction landed in, by position in the new document.
*
* Every step carries a map of the ranges it replaced. Mapping a step's range through the steps that
* came after it puts it in the final document's coordinates, where the blocks it overlaps are the
* ones whose text or attributes could have changed. An attribute-only edit counts: setting the
* language rewrites the node, which shows up here as a touched range around it, which is what makes
* the fence recolour the moment its language changes.
*/
function touchedCodeBlocks(tr: Transaction, doc: ProseMirrorNode): Map<number, ProseMirrorNode> {
const blocks = new Map<number, ProseMirrorNode>();
const end = doc.content.size;
tr.mapping.maps.forEach((stepMap, index) => {
const rest = tr.mapping.slice(index + 1);
stepMap.forEach((_oldFrom, _oldTo, newFrom, newTo) => {
const from = Math.max(0, Math.min(end, rest.map(newFrom, -1)));
const to = Math.max(from, Math.min(end, rest.map(newTo, 1)));
doc.nodesBetween(from, to, (node, pos) => {
if (node.type.name !== "codeBlock") return true;
blocks.set(pos, node);
return false;
});
});
});
return blocks;
}
export const CodeHighlighting = Extension.create({
name: "codeHighlighting",
addProseMirrorPlugins() {
return [
new Plugin<DecorationSet>({
key: codeHighlightKey,
state: {
init: (_config, state) => highlightWholeDoc(state.doc),
apply(tr, value) {
if (!tr.docChanged) return value;
const doc = tr.doc;
const touched = touchedCodeBlocks(tr, doc);
let next = value.map(tr.mapping, doc);
if (!touched.size) return next;
const added: Decoration[] = [];
for (const [pos, node] of touched) {
const from = pos + 1;
const to = from + node.content.size;
// The spans mapped forward through the edit are the ones this block had before it,
// stretched over text the grammar has not seen. They go before the new ones do.
const stale = next.find(from, to);
if (stale.length) next = next.remove(stale);
added.push(...decorationsFor(node, from));
}
return added.length ? next.add(doc, added) : next;
},
},
props: {
decorations: (state) => codeHighlightKey.getState(state) ?? DecorationSet.empty,
},
}),
];
},
});
/** null clears the fence back to a bare ```. False when the cursor is not in a code block. */
export function setCodeLanguage(editor: Editor, language: string | null): boolean {
if (!editor.isActive("codeBlock")) return false;
const next = language === null ? null : language.trim() || null;
// A fence's info string is one word of language and everything after it is `meta`, so a language
// with a space in it would be read back off disk as a different language plus a meta the user
// never wrote. Refusing leaves the file saying what it already says.
if (next !== null && /\s/.test(next)) return false;
// Setting the language a block already has would dirty the document and spend an autosave
// rewriting the file the user is looking at, for no change at all.
const current = (editor.getAttributes("codeBlock").language as string | null | undefined) ?? null;
if (current === next) return false;
// Read out of the chain rather than off run(), because focus answers a different question, and
// answers it with false whenever there is no view to focus.
let changed = false;
editor
.chain()
.focus()
.command(({ commands }) => {
changed = commands.updateAttributes("codeBlock", { language: next });
return changed;
})
.run();
return changed;
}
+54
View File
@@ -0,0 +1,54 @@
// The seam the block lanes plug into, and the only file that knows all five of them exist.
//
// extensions.ts generates one TipTap extension per entry in the frozen schema, which covers what a
// node IS. What a node DOES, its ProseMirror plugins, its node views, its keymap and its input
// rules, has no place on a mechanically generated extension, and five unrelated blocks sharing one
// file would mean five reasons to edit it and five chances to break somebody else's block while
// doing so. So each lane is one Extension in one file, listed here once. extensions.ts spreads this
// array without knowing what is in it, and nothing else imports the lane files.
//
// A lane may add plugins, node views, keyboard shortcuts and input rules. It may not add, remove or
// alter a node or a mark. The schema is src/model/schema.ts, the markdown bridge is written against
// it, and an editor whose schema has drifted from the contract is an editor that cannot hold a
// document the bridge just parsed, which is somebody's file lost on the next save.
import type { Extensions } from "@tiptap/core";
import { CodeHighlighting, setCodeLanguage } from "./code";
import { MathRendering, insertMath } from "./math";
import { MermaidRendering, insertMermaid } from "./mermaid";
import { Tables, tableCommand } from "./tables";
import { Toggles } from "./toggle";
/**
* In precedence order, lowest first. TipTap reverses the extension list before it collects
* ProseMirror plugins, so the last entry here contributes the first plugin the view asks, and for a
* node view the first plugin asked is the one that gets the node. Mermaid is last because a mermaid
* diagram is a codeBlock: it and the highlighter are looking at the same node type, and the diagram
* is the more specific of the two.
*
* Position is the weaker of the two levers, and it is worth knowing which because the stronger one
* is now in use. TipTap sorts the reversed list by each extension's `priority` before it collects
* anything, so a higher priority beats any position in this array; every lane here leaves it at the
* default and is ordered by position alone. src/editor/paste.ts is the one that does not, and it
* says why: its handlers guard the document against every other plugin's, so being ahead of the
* table plugins below cannot be left to where two arrays happen to put it.
*
* Toggles goes first rather than beside the lane it reads most like. The toggle node is claimed by
* nobody else, so where it sits changes nothing about which plugin gets that node view, and the
* four below it are in an order that was argued over: put anywhere else it would move one of them
* and leave the sentence above no longer true of the array under it.
*/
export const BLOCK_EXTENSIONS: Extensions = [
Toggles,
Tables,
MathRendering,
CodeHighlighting,
MermaidRendering,
];
/**
* What the editor handle's block commands delegate to. Each returns false when it has nothing to
* act on where the cursor is, which is what a toolbar button pressed in the wrong place should do,
* and each is responsible for its own focus the way every other command in the handle is.
*/
export { insertMath, insertMermaid, setCodeLanguage, tableCommand };
+343
View File
@@ -0,0 +1,343 @@
// The math lane's tests, which stop at the edge of the DOM.
//
// vite.config.ts runs vitest in the node environment, so there is no document for a view to mount
// in and the node view in math.ts cannot be built from here. What a formula looks like on screen,
// and the field that opens on it when it is selected, belong to the Playwright suite. What belongs
// here is the half that touches the document, because that is the half that can cost somebody a
// file: the two ways a formula gets made, and the LaTeX already in the document surviving both of
// them character for character.
//
// The last group tests KaTeX rather than this app. It is here because the node view rests on two
// promises the library makes and could quietly stop keeping on an upgrade: that it does not throw
// under these options, and that what it hands back when it cannot parse something still contains
// the source it was given.
import { describe, expect, it } from "vitest";
import { Editor } from "@tiptap/core";
import type { JSONContent } from "@tiptap/core";
import { EditorState, NodeSelection } from "@tiptap/pm/state";
import { CellSelection, TableMap } from "@tiptap/pm/tables";
import katex from "katex";
import { parseMarkdown, serializeMarkdown } from "../../markdown";
import { createEditorExtensions } from "../extensions";
import { insertMath } from "./math";
const extensions = () =>
createEditorExtensions({ documentPath: () => "/notes/a.md", onError: () => {} });
function editorWith(content: JSONContent): Editor {
return new Editor({ element: null, injectCSS: false, extensions: extensions(), content });
}
/**
* The same editor with the extensions' ProseMirror plugins actually installed, which TipTap only
* does when it mounts a view and there is no DOM here to mount into. src/editor/Editor.tsx swaps a
* state built this way in for every document it opens, so this is what the app runs minus the
* screen. Only the table tests need it, because a cell selection is prosemirror-tables' own.
*/
function editorWithPlugins(content: JSONContent): Editor {
const editor = editorWith(content);
editor.view.updateState(
EditorState.create({ doc: editor.state.doc, plugins: editor.extensionManager.plugins }),
);
return editor;
}
/** A file, opened, with the bytes it would be written back as. */
function open(source: string) {
const parsed = parseMarkdown(source, "/notes/a.md");
const editor = editorWithPlugins(parsed.doc.toJSON());
return { editor, written: () => serializeMarkdown(parsed, editor.state.doc) };
}
/** The document position just inside a cell, for a table that is the document's first block. */
function inCell(editor: Editor, row: number, column: number): number {
const table = editor.state.doc.firstChild!;
return 1 + TableMap.get(table).positionAt(row, column, table) + 1;
}
/**
* Enter, through the keymap the extension really installs rather than through a function this file
* reached into. A headless editor has no view to take a key event, so the plugins' own handlers are
* called in the order ProseMirror would call them, with the proxy view TipTap answers with and the
* two things prosemirror-keymap reads off an event: the key name, and the four modifier flags.
*/
function pressEnter(editor: Editor): void {
const event = {
key: "Enter",
keyCode: 13,
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
} as unknown as KeyboardEvent;
for (const plugin of editor.extensionManager.plugins) {
// Called through the plugin, which is the "this" ProseMirror types the prop as wanting.
if (plugin.props.handleKeyDown?.call(plugin, editor.view, event)) return;
}
}
const CODE = {
type: "doc",
content: [
{
type: "codeBlock",
attrs: { language: "ts", meta: null },
content: [{ type: "text", text: "const x = 1;" }],
},
],
};
const RAW = {
type: "doc",
content: [
{
type: "raw",
attrs: { source: "<figure><img src='x.png'></figure>" },
content: [{ type: "text", text: "<figure><img src='x.png'></figure>" }],
},
],
};
describe("insertMath", () => {
it("puts an empty display equation in and selects it", () => {
const editor = editorWith({ type: "doc", content: [{ type: "paragraph" }] });
expect(insertMath(editor, true)).toBe(true);
const block = editor.state.doc.firstChild;
expect(block?.type.name).toBe("mathBlock");
// Not empty. This assertion used to read "" and that was the bug: an empty formula is a box on
// screen the file has no way to spell, and inline it went out as $$$$ and came back as text, so
// the first autosave took it away without telling anybody. A new formula is made with the
// placeholder in it, which is a formula that survives being written.
expect(block?.attrs.latex).toBe("\\square");
// Selected is what opens the field on it, so it is the half of the command that matters.
expect(editor.state.selection instanceof NodeSelection).toBe(true);
expect((editor.state.selection as NodeSelection).node.type.name).toBe("mathBlock");
editor.destroy();
});
// The caret mid paragraph is the case the first version of this got wrong. A display formula
// dropped there splits the paragraph and lands between the halves, so the position the insert was
// asked for is not the position the formula ends up at, and selecting the wrong one means a
// formula on screen with no way into its field. The end of a paragraph is the one place the two
// answers agree, which is why the test above did not catch it.
it("selects the display equation even when the caret was in the middle of a paragraph", () => {
const editor = editorWith({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "before after" }] }],
});
editor.commands.setTextSelection(8);
expect(insertMath(editor, true)).toBe(true);
expect(editor.state.doc.child(0).textContent).toBe("before ");
expect(editor.state.doc.child(1).type.name).toBe("mathBlock");
expect(editor.state.doc.child(2).textContent).toBe("after");
expect(editor.state.selection instanceof NodeSelection).toBe(true);
expect((editor.state.selection as NodeSelection).node.type.name).toBe("mathBlock");
editor.destroy();
});
it("puts an inline formula in without disturbing the text around it", () => {
const editor = editorWith({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "ab" }] }],
});
editor.commands.setTextSelection(2);
expect(insertMath(editor, false)).toBe(true);
const paragraph = editor.state.doc.firstChild;
expect(paragraph?.type.name).toBe("paragraph");
expect([...Array(paragraph?.childCount ?? 0)].map((_, i) => paragraph?.child(i).type.name)).toEqual([
"text",
"mathInline",
"text",
]);
expect(paragraph?.textContent).toBe("ab");
editor.destroy();
});
it("refuses inside a code block, and leaves the fence exactly as it was", () => {
const editor = editorWith(CODE);
const before = editor.state.doc.toJSON();
expect(insertMath(editor, false)).toBe(false);
expect(insertMath(editor, true)).toBe(false);
expect(editor.state.doc.toJSON()).toEqual(before);
editor.destroy();
});
it("refuses inside a raw block, which is somebody's bytes and not a place for a formula", () => {
const editor = editorWith(RAW);
const before = editor.state.doc.toJSON();
expect(insertMath(editor, false)).toBe(false);
expect(insertMath(editor, true)).toBe(false);
expect(editor.state.doc.toJSON()).toEqual(before);
editor.destroy();
});
// The three below are one bug each, reproduced from the bytes they cost. All three were live in a
// build whose table, rule and diagram inserts were already guarded: this command had a private
// copy of the guard that had never been given the isolating rule, and a private guard is a guard
// that is only as good as the last person who remembered it existed. It is gone, and this command
// now asks the one in src/editor/fits.ts that every other insert asks.
it("refuses with the caret in a table cell, and leaves the file byte identical", () => {
const source = "| h1 | h2 |\n| - | - |\n| a | b |\n";
const { editor, written } = open(source);
editor.commands.setTextSelection(inCell(editor, 1, 0));
// What this used to do: split the table around the formula, leave the body row empty and the
// moved cells in a second table with no header, and hand the autosave
// "| h1 | h2 |\n| - | - |\n| | |\n\n$$\n$$\n\n| a | b |\n| - | - |\n" half a second later.
expect(insertMath(editor, true)).toBe(false);
expect(written()).toBe(source);
editor.destroy();
});
it("refuses over a dragged cell selection, and every cell keeps its text", () => {
const source = "| h1 | h2 |\n| - | - |\n| a | b |\n| c | d |\n";
const { editor, written } = open(source);
const map = TableMap.get(editor.state.doc.firstChild!);
const table = editor.state.doc.firstChild!;
editor.view.dispatch(
editor.state.tr.setSelection(
CellSelection.create(editor.state.doc, 1 + map.positionAt(0, 0, table), 1 + map.positionAt(2, 1, table)),
),
);
// An inline formula fits in a cell perfectly well, which is why the position rule alone let
// this through: over a rectangle of cells the insert does not go in a cell, it replaces the
// content of all six of them at once. Six cells of somebody's text for one empty formula.
expect(insertMath(editor, false)).toBe(false);
expect(insertMath(editor, true)).toBe(false);
expect(written()).toBe(source);
editor.destroy();
});
it("makes a formula the save can keep, which an empty one is not", () => {
const { editor, written } = open("hello\n");
editor.commands.setTextSelection(6);
expect(insertMath(editor, false)).toBe(true);
const file = written();
expect(file).toBe("hello$$\\square$$\n");
// The whole point of the placeholder, asserted the only way that means anything: the file goes
// back through the parser and there is still a formula in it. An empty one wrote "hello$$$$",
// which comes back as four dollar signs of literal text, and the box the user was looking at
// was gone with nobody told.
const reopened = parseMarkdown(file, "/notes/a.md");
const found: string[] = [];
reopened.doc.descendants((node) => {
if (node.type.name === "mathInline") found.push(node.attrs.latex as string);
});
expect(found).toEqual(["\\square"]);
editor.destroy();
});
});
describe("the fence rule", () => {
it("turns a paragraph holding just $$ into a math block, selected", () => {
const editor = editorWith({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "$$" }] }],
});
editor.commands.setTextSelection(3);
pressEnter(editor);
expect(editor.state.doc.childCount).toBe(1);
expect(editor.state.doc.firstChild?.type.name).toBe("mathBlock");
// The placeholder here too, for the reason on the insertMath test above: a formula made by
// typing a fence has the same claim to still being there after a save as one made by a button.
expect(editor.state.doc.firstChild?.attrs.latex).toBe("\\square");
expect(editor.state.selection instanceof NodeSelection).toBe(true);
editor.destroy();
});
it("leaves a paragraph that says anything else alone", () => {
for (const text of ["$$x", "a $$", "$", "$5 and $10"]) {
const editor = editorWith({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
});
editor.commands.setTextSelection(text.length + 1);
pressEnter(editor);
// Enter still splits the paragraph, which is the base keymap's business and not this lane's.
// What is asserted is only that no formula was made out of somebody's prose.
let found = false;
editor.state.doc.descendants((node) => {
if (node.type.name === "mathBlock" || node.type.name === "mathInline") found = true;
});
expect([text, found]).toEqual([text, false]);
editor.destroy();
}
});
});
describe("the LaTeX already in the document", () => {
const source = [
"Before.",
"",
"$$",
"\\frac{a}{b} = \\sum_{i=0}^{n} x_i",
"$$",
"",
"After $$x^2$$ here.",
"",
].join("\n");
it("comes back byte for byte after a formula is inserted somewhere else", () => {
const parsed = parseMarkdown(source, "/notes/a.md");
const editor = editorWith(parsed.doc.toJSON());
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(source);
// Into the first paragraph, which is the one place in the file this is allowed to change.
editor.commands.setTextSelection(4);
expect(insertMath(editor, false)).toBe(true);
const written = serializeMarkdown(parsed, editor.state.doc);
expect(written).toContain("$$\n\\frac{a}{b} = \\sum_{i=0}^{n} x_i\n$$");
expect(written).toContain("After $$x^2$$ here.");
editor.destroy();
});
});
describe("KaTeX under the options this lane renders with", () => {
// The same object math.ts builds its render call from. Repeated rather than exported, because
// what is being pinned here is the library's behaviour under them and not their spelling.
const options = {
throwOnError: false,
strict: false,
trust: false,
errorColor: "var(--danger)",
} as const;
it("draws a formula, with the source it was given still in the markup", () => {
const markup = katex.renderToString("\\frac{a}{b}", options);
expect(markup).toContain("katex");
expect(markup).toContain("\\frac{a}{b}");
});
it("does not throw on LaTeX it cannot parse, and shows the source in the error colour", () => {
// Two shapes, both of them the source and the colour. LaTeX KaTeX cannot get through the
// parser at all comes back as one .katex-error span holding the whole expression; a command it
// parses and has never heard of is drawn as the text of the command, in the same colour.
for (const broken of ["\\frac{", "\\notacommand", "^", "\\begin{matrix}", "\\sqrt{}}{"]) {
const markup = katex.renderToString(broken, options);
expect([broken, markup.includes(broken)]).toEqual([broken, true]);
expect([broken, markup.includes("var(--danger)")]).toEqual([broken, true]);
}
});
it("writes the error colour through as the custom property it was handed", () => {
// KaTeX puts the colour in an attribute on the element it draws, where a stylesheet cannot
// reach it, so the token has to survive the trip out through the markup exactly as written.
expect(katex.renderToString("\\frac{", options)).toContain("var(--danger)");
});
});
+473
View File
@@ -0,0 +1,473 @@
// Math: KaTeX over the mathInline and mathBlock nodes the bridge produces.
//
// Both are atoms carrying their LaTeX as an attribute, so rendering one is a node view drawing an
// attribute and editing one is that same node view handing the source back. Nothing here parses,
// normalises or rewrites the LaTeX: what round trips to disk is the attribute exactly as it was
// read, and KaTeX only ever gets a copy of it.
//
// LaTeX KaTeX cannot render is shown as the source with the error beside it, never as an empty
// box and never dropped. A formula this editor fails to draw is still the user's formula, and it
// has to survive being opened and saved by an editor that could not display it.
//
// An atom has no editable text of its own, so the field the source is typed into is this file's to
// draw and this file's to write back. It appears while the node is selected, which is what both a
// click on a formula and an arrow key into one produce, and every keystroke in it is a transaction
// like any other. The document is therefore never holding a formula the field has already moved
// past: an autosave that lands mid edit writes what is on screen, and closing the file does not
// take the last few characters with it.
import { Extension } from "@tiptap/core";
import type { Editor } from "@tiptap/core";
import type { Node as ProseMirrorNode, NodeType } from "@tiptap/pm/model";
import { NodeSelection, Plugin, PluginKey, Selection } from "@tiptap/pm/state";
import type { Command, Transaction } from "@tiptap/pm/state";
import type { EditorView, NodeView } from "@tiptap/pm/view";
import katex from "katex";
import { place } from "../fits";
// KaTeX's stylesheet, and the twenty faces it names, are pulled into the bundle from here rather
// than from main.tsx alongside the app's own sheets, because this is the file that cannot work
// without them. The app runs under a CSP of font-src 'self', so a font fetched from KaTeX's CDN
// never arrives and every formula is drawn in a fallback face at metrics the layout was not
// measured for. Importing the sheet is what makes Vite emit the woff2 files as local assets and
// rewrite the URLs on to them, so this import is load bearing and is not a stray dependency.
import "katex/dist/katex.min.css";
/** A paragraph holding exactly this becomes a math block when Enter is pressed in it. */
const FENCE = "$$";
/**
* What a new formula is made with, since a new formula is never made empty.
*
* An empty formula is a box on screen that the file has no way to spell. Inline, it goes out as
* `$$$$`, which is not math to anything that reads it back, so the box the user is looking at is
* gone the next time the document is opened and they were never told. That is the failure this
* editor exists not to have: something on screen that the save quietly does not keep.
*
* Three ways out of it were on the table. Refusing to insert until there is content cannot work,
* because the insert is how the content gets typed. Keeping the node out of the saved document
* until it has LaTeX is the same disappearance one layer down, since an autosave then writes a file
* without a formula the user can see. So the node is created with content: `\square` is the glyph
* mathematics already uses for the term that has not been written yet, KaTeX draws it, and it round
* trips as `$$\square$$` like any other formula. Nothing vanishes, because there is nothing empty.
*
* The field opens with it selected, so typing over it is the same keystroke it would have been in
* an empty box, and the user who walks away is left with a formula they can see rather than one
* they cannot.
*/
const PLACEHOLDER = "\\square";
/** Past these the field scrolls rather than growing. A formula this long is not being read. */
const MAX_ROWS = 16;
const MAX_COLS = 64;
const MIN_COLS = 4;
/**
* KaTeX is never allowed to throw, and never allowed to be the reason a formula is not on screen.
*
* `throwOnError` false is what turns a parse failure into markup: KaTeX draws the source it could
* not read in the error colour with the reason on the element's title, which is the whole of the
* error state for LaTeX it understands well enough to refuse. `strict` false is the same bargain
* one level down, for the LaTeX it can read and would rather complain about, a unicode letter in
* math mode being the usual one; the alternative is a console full of warnings about somebody's
* own file. `trust` stays off because the markup goes into the page with innerHTML, and it is what
* decides whether \href in a document that arrived from somewhere else becomes a link.
*
* The error colour is a custom property rather than a hex value because KaTeX writes it into a
* style attribute on the element it draws, and an inline style is not something a stylesheet can
* take back.
*/
const KATEX_OPTIONS = {
throwOnError: false,
strict: false,
trust: false,
errorColor: "var(--danger)",
} as const;
/**
* Where a node of this type ended up, looked for in the ranges the steps from `since` on wrote.
*
* Asked this way rather than by mapping the insertion point forward, which is the obvious move and
* is wrong. A block formula dropped into the middle of a paragraph splits it, and the position it
* was asked for stays with the first half, several places short of the formula. Mapping it forward
* then finds no formula there and nothing gets selected, which is a formula on screen with no way
* into its field. The end of a paragraph is the one place the two answers agree, which is why
* every test that put the caret there passed.
*/
function placedAt(tr: Transaction, since: number, type: NodeType): number | null {
let found: number | null = null;
for (let step = since; step < tr.steps.length && found === null; step += 1) {
const forward = tr.mapping.slice(step + 1);
tr.mapping.maps[step].forEach((_from, _to, newFrom, newTo) => {
if (found !== null) return;
const size = tr.doc.content.size;
const from = Math.min(size, Math.max(0, forward.map(newFrom, -1)));
const to = Math.min(size, Math.max(from, forward.map(newTo, 1)));
tr.doc.nodesBetween(from, to, (node, pos) => {
if (found === null && node.type === type) found = pos;
return found === null;
});
});
}
return found;
}
/**
* A placeholder formula where the cursor is, selected so that its field opens on it.
*
* Whether it can go there at all is `place`'s question and is asked before this runs, which is why
* there is no check of its own here. There used to be one, a private copy of the walk in fits.ts
* that had never been given the isolating rule, and it answered yes with the caret in a table cell:
* the insert then split the table around the formula and emptied the row it had been in, and the
* autosave wrote that to the user's file half a second later with no keystroke behind it.
*/
function placeMath(type: NodeType): Command {
return (state, dispatch) => {
if (dispatch) {
const tr = state.tr;
const before = tr.steps.length;
// Marks carry on to an inline formula, since **$x$** is a thing the file can say and the
// bridge already reads and writes. A block one is in a part of the document where no mark
// can go, and handing it the marks under the cursor would make it unplaceable.
tr.replaceSelectionWith(type.create({ latex: PLACEHOLDER }), type.isInline);
// Selecting it is what opens its field, on the placeholder, which the field selects whole so
// the first thing typed replaces it.
const placed = placedAt(tr, before, type);
if (placed !== null) tr.setSelection(NodeSelection.create(tr.doc, placed));
dispatch(tr.scrollIntoView());
}
return true;
};
}
/**
* `$$` alone in a paragraph, then Enter.
*
* Not an input rule, though it reads like one: an input rule fires on text input and Enter is not
* text, so there would be nothing to run it. There is deliberately no rule for `$…$` either. A
* dollar sign is money or a shell prompt far more often than it is mathematics, and turning
* "$5 and $10" into an equation as somebody types is exactly the unasked for rewrite this editor
* does not do. The bridge takes the same line one layer down, where single dollar math is off in
* the parser.
*/
const openMathBlock: Command = (state, dispatch) => {
const { $from, empty } = state.selection;
if (!empty) return false;
if ($from.parent.type.name !== "paragraph" || $from.parent.textContent !== FENCE) return false;
const type = state.schema.nodes.mathBlock;
const depth = $from.depth;
const index = $from.index(depth - 1);
if (!type || !$from.node(depth - 1).canReplaceWith(index, index + 1, type)) return false;
if (dispatch) {
const from = $from.before(depth);
// The placeholder, for the reason written on it: this is the other way a formula is made, and
// a formula made by typing a fence has the same claim to still being there after a save as one
// made from the toolbar.
const tr = state.tr.replaceWith(from, $from.after(depth), type.create({ latex: PLACEHOLDER }));
tr.setSelection(NodeSelection.create(tr.doc, from));
dispatch(tr.scrollIntoView());
}
return true;
};
/**
* One formula: what KaTeX drew, and the field the LaTeX behind it is typed into.
*
* The two are siblings inside the element the node's own toDOM describes, and which of them is on
* screen is a data attribute the stylesheet reads. Neither is content in ProseMirror's sense: the
* node is an atom, there is no contentDOM, and every mutation inside here is declared to be this
* file's own so that nothing KaTeX draws can be read back into the document.
*/
class MathView implements NodeView {
readonly dom: HTMLElement;
private readonly view: EditorView;
private readonly getPos: () => number | undefined;
private readonly display: boolean;
private readonly render: HTMLElement;
private readonly field: HTMLTextAreaElement;
private node: ProseMirrorNode;
private editing = false;
constructor(
node: ProseMirrorNode,
view: EditorView,
getPos: () => number | undefined,
display: boolean,
) {
this.node = node;
this.view = view;
this.getPos = getPos;
this.display = display;
const owner = view.dom.ownerDocument;
this.dom = owner.createElement(display ? "div" : "span");
this.dom.className = display ? "math-block" : "math-inline";
if (display) this.dom.setAttribute("data-math-block", "");
this.render = owner.createElement(display ? "div" : "span");
this.render.className = "math-render";
this.dom.appendChild(this.render);
this.field = owner.createElement("textarea");
this.field.className = "math-source";
this.field.spellcheck = false;
this.field.setAttribute("aria-label", display ? "Display equation source" : "Inline math source");
this.field.addEventListener("input", this.onInput);
this.field.addEventListener("keydown", this.onKeyDown);
this.dom.appendChild(this.field);
this.draw();
}
private get latex(): string {
const value = this.node.attrs.latex;
return typeof value === "string" ? value : "";
}
update(node: ProseMirrorNode): boolean {
// A node of another type is another node view; ProseMirror builds a fresh one rather than
// asking this one to become something it was not written to be.
if (node.type !== this.node.type) return false;
this.node = node;
this.draw();
return true;
}
selectNode(): void {
// A document being looked at rather than edited gets no field, so it gets the outline
// ProseMirror would have drawn on its own: it says the formula is selected without offering to
// change it. Node views that define this one are asked instead of that outline, not as well.
if (!this.view.editable) {
this.dom.classList.add("ProseMirror-selectednode");
return;
}
if (this.editing) return;
this.editing = true;
this.draw();
this.take();
}
deselectNode(): void {
this.dom.classList.remove("ProseMirror-selectednode");
if (!this.editing) return;
this.editing = false;
this.draw();
}
/**
* Everything that lands inside the field is the field's own. ProseMirror handling the mousedown
* that opens it would put a node selection where the caret was going, and the field would never
* take focus at all.
*/
stopEvent(event: Event): boolean {
const target = event.target;
return target instanceof HTMLElement && this.field.contains(target);
}
/** The element is this file's from end to end, so nothing read off it is news to the document. */
ignoreMutation(): boolean {
return true;
}
destroy(): void {
// Also what cancels the frame `take` queued: the element is on its way out, and focusing it
// then would put the caret at a position the document no longer has.
this.editing = false;
this.field.removeEventListener("input", this.onInput);
this.field.removeEventListener("keydown", this.onKeyDown);
}
/** Everything on the element that depends on the node or on whether it is being edited. */
private draw(): void {
const latex = this.latex;
// Mirrored on to the element the way the node's own toDOM writes it, so that anything reading
// the page back, a copy, a drag, a mutation ProseMirror decides to re-parse after all, takes
// the source out of the attribute the parse rule names rather than out of what KaTeX drew.
this.dom.setAttribute("data-latex", latex);
// The empty string and nothing else, because this flag is what tells the user the formula has
// no spelling and will not be saved, and a formula of one space is saved: the writer drops an
// equation only when its latex is empty. `paint` below asks a different question, which is
// whether KaTeX has anything to draw, and whitespace is a fair no to that one.
this.flag("data-math-empty", latex === "");
this.flag("data-editing", this.editing);
// The field is the source of truth while it is being typed in. Writing to it here would take
// the caret to the end of a formula the user is in the middle of.
if (!this.editing) {
this.field.value = latex;
this.size();
}
this.paint(latex);
}
private flag(name: string, on: boolean): void {
if (on) this.dom.setAttribute(name, "");
else this.dom.removeAttribute(name);
}
private paint(latex: string): void {
if (latex.trim() === "") {
this.render.textContent = "";
this.render.removeAttribute("data-math-error");
this.render.removeAttribute("title");
return;
}
try {
this.render.innerHTML = katex.renderToString(latex, {
...KATEX_OPTIONS,
displayMode: this.display,
});
this.render.removeAttribute("data-math-error");
this.render.removeAttribute("title");
} catch (error) {
// throwOnError covers the LaTeX KaTeX parses and then refuses to typeset. This is the rest of
// it: input it never expected, on which it throws something that is not a parse error. What
// goes on the page is the source as it stands, because that is what the file holds and what
// there is to fix.
this.render.textContent = latex;
this.render.setAttribute("data-math-error", "");
this.render.title = String(error);
}
}
/** Sized by the textarea's own rows and cols, so nothing here measures anything or sets a style. */
private size(): void {
const lines = this.field.value.split("\n");
const widest = lines.reduce((most, line) => Math.max(most, line.length), 0);
this.field.rows = Math.min(MAX_ROWS, lines.length);
this.field.cols = Math.min(MAX_COLS, Math.max(MIN_COLS, widest + 1));
}
/**
* Focus, on the next frame rather than now.
*
* ProseMirror is part way through drawing the selection this call came from and finishes it by
* putting the document's own selection around the node, and TipTap's focus command may have a
* frame of its own already queued in front of that. Either would take the caret straight back
* out of the field.
*/
private take(): void {
requestAnimationFrame(() => {
if (!this.editing) return;
// Already in it, which is what a keystroke that rewrote the node looks like from here. Moving
// the caret then would jump it to the end of a formula being edited in the middle.
if (this.field.ownerDocument.activeElement === this.field) return;
this.field.focus({ preventScroll: true });
const end = this.field.value.length;
// A formula that is still nothing but the placeholder is one nobody has typed into yet, so
// the placeholder is selected and the first keystroke replaces it. Anything else gets the
// caret at the end, because it is somebody's formula and a keystroke must not wipe it.
const start = this.field.value === PLACEHOLDER ? 0 : end;
this.field.setSelectionRange(start, end);
});
}
private readonly onInput = (): void => {
this.size();
this.commit(this.field.value);
};
private readonly onKeyDown = (event: KeyboardEvent): void => {
// A display equation is written over several lines often enough that Enter has to be a newline
// inside one, so it is inline math that Enter leaves and a block that needs the modifier.
const leaving =
event.key === "Escape" ||
(event.key === "Enter" && (!this.display || event.metaKey || event.ctrlKey));
if (leaving) {
event.preventDefault();
this.leave();
return;
}
if ((event.key === "Backspace" || event.key === "Delete") && this.field.value === "") {
event.preventDefault();
this.discard();
}
};
/**
* The field's text on to the node, as a transaction like any other keystroke in the document.
*
* Not held back until the field is left. A formula the field is holding and the document is not
* is one an autosave writes the previous version of and a switch to another file loses outright,
* and neither is worth the tidier undo history that batching it would buy.
*/
private commit(latex: string): void {
const pos = this.getPos();
if (pos === undefined) return;
const { state } = this.view;
const node = state.doc.nodeAt(pos);
if (!node || node.type !== this.node.type || node.attrs.latex === latex) return;
// Null for the type and nothing for the marks, so an inline formula inside a bold run comes
// back out of this still bold. setNodeMarkup keeps the marks it was not given new ones for.
this.view.dispatch(state.tr.setNodeMarkup(pos, null, { ...node.attrs, latex }));
}
/** Puts the caret back in the document just past the node, which is what re-renders it. */
private leave(): void {
const pos = this.getPos();
const { state } = this.view;
if (pos !== undefined) {
const after = Math.min(pos + this.node.nodeSize, state.doc.content.size);
this.view.dispatch(state.tr.setSelection(Selection.near(state.doc.resolve(after), 1)));
}
this.view.focus();
}
/** Backspace in an empty field takes the formula with it, the field being all there is of it. */
private discard(): void {
const pos = this.getPos();
const { state } = this.view;
if (pos === undefined) return;
if (!(state.selection instanceof NodeSelection) || state.selection.from !== pos) return;
this.view.dispatch(state.tr.deleteSelection().scrollIntoView());
this.view.focus();
}
}
export const MathRendering = Extension.create({
name: "mathRendering",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("mathViews"),
props: {
nodeViews: {
mathInline: (node, view, getPos) => new MathView(node, view, getPos, false),
mathBlock: (node, view, getPos) => new MathView(node, view, getPos, true),
},
},
}),
];
},
addKeyboardShortcuts() {
const editor = this.editor;
// ProseMirror's own calling convention rather than editor.commands.command, which dispatches
// its transaction whatever the command answered. Enter is pressed everywhere in the document
// and a key that did nothing here has to leave nothing at all behind it.
return {
Enter: () => openMathBlock(editor.state, editor.view.dispatch),
};
},
});
/**
* `display` picks mathBlock over mathInline. False where neither can be placed, which is a toolbar
* button pressed somewhere a formula cannot go and means nothing happens.
*
* The guard is `place`'s and is the same one every other insert in the editor asks, deliberately:
* this command had a private one and it was the private one that was missing a rule.
*/
export function insertMath(editor: Editor, display: boolean): boolean {
const type = editor.schema.nodes[display ? "mathBlock" : "mathInline"];
if (!type) return false;
return place(editor, type, (chain) =>
chain.command(({ state, dispatch }) => placeMath(type)(state, dispatch)),
);
}
+324
View File
@@ -0,0 +1,324 @@
// What can be asserted about a mermaid block without a browser, which is most of what matters.
//
// The node view itself needs a DOM and a real ProseMirror view, and this suite runs in node, so the
// drawing is not what is tested here. What is tested is everything the drawing is not allowed to
// disturb: that the extension adds nothing to the schema, that a ```mermaid fence is still an
// ordinary code block that round trips byte for byte through the editor, that exactly one plugin in
// the whole build claims the code block node view, and that the decoration telling a block the caret
// is inside it lands on the right blocks and only those.
import { describe, expect, it } from "vitest";
import { Editor } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
import type { Plugin } from "@tiptap/pm/state";
import type { DecorationSet } from "@tiptap/pm/view";
import { createEditorExtensions } from "../extensions";
import { parseMarkdown, serializeMarkdown } from "../../markdown";
import { MermaidRendering, insertMermaid } from "./mermaid";
const PATH = "/notes/diagrams.md";
const FENCE = "```";
const extensions = () => createEditorExtensions({ documentPath: () => PATH, onError: () => {} });
function makeEditor(content?: object): Editor {
return new Editor({
element: null,
injectCSS: false,
extensions: extensions(),
content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
});
}
/** An editor holding what the bridge made of `source`, which is how a document really arrives. */
function editorFor(source: string): Editor {
return makeEditor(parseMarkdown(source, PATH).doc.toJSON());
}
function blockAt(editor: Editor, index: number): { node: ProseMirrorNode; pos: number } {
const doc = editor.state.doc;
let pos = 0;
for (let i = 0; i < index; i += 1) pos += doc.child(i).nodeSize;
return { node: doc.child(index), pos };
}
/**
* The one plugin that draws diagrams, found the way the view finds it: by what it offers.
*
* The plugins are asked of the extensions rather than of the state, because TipTap only installs
* them when it mounts a view and there is no DOM here to mount one in.
*/
function nodeViewPlugins(editor: Editor): Plugin[] {
return editor.extensionManager.plugins.filter(
(plugin) => plugin.props.nodeViews?.codeBlock !== undefined,
);
}
function cursorDecorations(editor: Editor): DecorationSet | null {
const [plugin] = nodeViewPlugins(editor);
// `this` matters: ProseMirror calls a props function with the plugin as its receiver.
const found = plugin.props.decorations?.call(plugin, editor.state);
return (found as DecorationSet | null | undefined) ?? null;
}
function decoratedRanges(editor: Editor): Array<[number, number]> {
const set = cursorDecorations(editor);
if (!set) return [];
return set.find().map((decoration) => [decoration.from, decoration.to]);
}
const DIAGRAM = [
"# Diagrams",
"",
`${FENCE}mermaid`,
"graph TD;",
" A-->B;",
FENCE,
"",
`${FENCE}ts`,
"const x = 1;",
FENCE,
"",
"After.",
"",
].join("\n");
describe("the mermaid extension", () => {
it("is the one the registry names", () => {
expect(MermaidRendering.name).toBe("mermaidRendering");
});
it("adds no node and no mark, so the bridge and the editor still agree", () => {
const plain = new Editor({
element: null,
injectCSS: false,
extensions: extensions().filter((extension) => extension.name !== "mermaidRendering"),
content: { type: "doc", content: [{ type: "paragraph" }] },
});
const withMermaid = makeEditor();
expect(Object.keys(withMermaid.schema.nodes)).toEqual(Object.keys(plain.schema.nodes));
expect(Object.keys(withMermaid.schema.marks)).toEqual(Object.keys(plain.schema.marks));
plain.destroy();
withMermaid.destroy();
});
it("is the only plugin in the build that claims the code block node view", () => {
const editor = makeEditor();
// Two plugins offering a node view for one node is a silent bug: ProseMirror takes the first
// one asked and the other never runs. The code lane leaves this to mermaid on purpose.
expect(nodeViewPlugins(editor)).toHaveLength(1);
editor.destroy();
});
});
describe("a ```mermaid fence in a document", () => {
it("is an ordinary code block carrying its own language", () => {
const editor = editorFor(DIAGRAM);
const { node } = blockAt(editor, 1);
expect(node.type.name).toBe("codeBlock");
expect(node.attrs.language).toBe("mermaid");
expect(node.textContent).toBe("graph TD;\n A-->B;");
editor.destroy();
});
it("round trips byte for byte through the editor", () => {
const parsed = parseMarkdown(DIAGRAM, PATH);
const editor = makeEditor(parsed.doc.toJSON());
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(DIAGRAM);
editor.destroy();
});
it("keeps its indentation, its blank lines and its meta on the way back", () => {
const source = [
`${FENCE}mermaid theme=forest`,
"sequenceDiagram",
" Alice->>John: Hello",
"",
" John-->>Alice: Hi",
FENCE,
"",
].join("\n");
const parsed = parseMarkdown(source, PATH);
const editor = makeEditor(parsed.doc.toJSON());
const { node } = blockAt(editor, 0);
expect(node.attrs.meta).toBe("theme=forest");
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(source);
editor.destroy();
});
});
describe("the decoration that says the caret is inside", () => {
it("is absent while the cursor is somewhere else", () => {
const editor = editorFor(DIAGRAM);
editor.commands.setTextSelection(1);
expect(decoratedRanges(editor)).toEqual([]);
editor.destroy();
});
it("covers the block the cursor is in, and nothing else", () => {
const editor = editorFor(DIAGRAM);
const { node, pos } = blockAt(editor, 1);
editor.commands.setTextSelection(pos + 1);
expect(decoratedRanges(editor)).toEqual([[pos, pos + node.nodeSize]]);
editor.destroy();
});
it("ignores a code block that is not a diagram", () => {
const editor = editorFor(DIAGRAM);
const { pos } = blockAt(editor, 2);
editor.commands.setTextSelection(pos + 1);
expect(decoratedRanges(editor)).toEqual([]);
editor.destroy();
});
it("does not fire on the paragraph that follows the fence", () => {
const editor = editorFor(DIAGRAM);
const { pos } = blockAt(editor, 3);
editor.commands.setTextSelection(pos + 1);
expect(decoratedRanges(editor)).toEqual([]);
editor.destroy();
});
it("covers the block when it is selected whole rather than typed in", () => {
const editor = editorFor(DIAGRAM);
const { node, pos } = blockAt(editor, 1);
editor.view.dispatch(
editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, pos)),
);
expect(decoratedRanges(editor)).toEqual([[pos, pos + node.nodeSize]]);
editor.destroy();
});
it("covers every diagram a whole document selection touches", () => {
const source = [
`${FENCE}mermaid`,
"graph TD;",
FENCE,
"",
"Between.",
"",
`${FENCE}mermaid`,
"graph LR;",
FENCE,
"",
].join("\n");
const editor = editorFor(source);
editor.commands.selectAll();
expect(decoratedRanges(editor)).toHaveLength(2);
editor.destroy();
});
it("leaves a fence whose language only looks like mermaid alone", () => {
// The code lane leaves this block plain too, so a capitalised info string is neither drawn nor
// coloured. It stays the text the user wrote, which is the safe way for the two to disagree.
const source = [`${FENCE}Mermaid`, "graph TD;", FENCE, ""].join("\n");
const editor = editorFor(source);
editor.commands.setTextSelection(1);
expect(blockAt(editor, 0).node.attrs.language).toBe("Mermaid");
expect(decoratedRanges(editor)).toEqual([]);
editor.destroy();
});
});
describe("insertMermaid", () => {
it("turns the empty paragraph the cursor is on into an empty fence", () => {
const editor = makeEditor();
expect(insertMermaid(editor)).toBe(true);
expect(editor.state.doc.childCount).toBe(1);
const { node } = blockAt(editor, 0);
expect(node.type.name).toBe("codeBlock");
expect(node.attrs.language).toBe("mermaid");
expect(node.attrs.meta).toBe(null);
expect(node.textContent).toBe("");
editor.destroy();
});
it("writes a ```mermaid fence and nothing else", () => {
const parsed = parseMarkdown("", PATH);
const editor = makeEditor(parsed.doc.toJSON());
insertMermaid(editor);
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(`${FENCE}mermaid\n${FENCE}\n`);
editor.destroy();
});
it("splits the paragraph it was called from without losing a character of it", () => {
// The same thing the toolbar's rule and table buttons have always done with a caret in the
// middle of a line. What matters is that the words are all still there, on both sides of it.
const editor = editorFor("Some prose.\n");
editor.commands.setTextSelection(3);
expect(insertMermaid(editor)).toBe(true);
expect(editor.state.doc.childCount).toBe(3);
expect(editor.state.doc.child(0).textContent).toBe("So");
expect(editor.state.doc.child(1).attrs.language).toBe("mermaid");
expect(editor.state.doc.child(2).textContent).toBe("me prose.");
editor.destroy();
});
it("writes something the bridge can read back, even inside a list", () => {
const parsed = parseMarkdown("- one\n- two\n", PATH);
const editor = makeEditor(parsed.doc.toJSON());
editor.commands.setTextSelection(4);
insertMermaid(editor);
const written = serializeMarkdown(parsed, editor.state.doc);
const reread = parseMarkdown(written, PATH);
expect(written).toContain(`${FENCE}mermaid`);
expect(serializeMarkdown(reread, reread.doc)).toBe(written);
editor.destroy();
});
it("does nothing, and says so, in a table cell", () => {
// Left to itself ProseMirror would split the table in two around the block and leave a row with
// no cells in it, which is a table the serializer has nothing to write.
const editor = editorFor("| a | b |\n| - | - |\n| 1 | 2 |\n");
expect(editor.state.doc.child(0).type.name).toBe("table");
editor.view.dispatch(
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3)),
);
const before = editor.state.doc;
expect(insertMermaid(editor)).toBe(false);
expect(editor.state.doc).toBe(before);
editor.destroy();
});
it("does nothing, and says so, inside another fence", () => {
const editor = editorFor(`${FENCE}ts\nconst x = 1;\n${FENCE}\n`);
editor.commands.setTextSelection(3);
const before = editor.state.doc;
expect(insertMermaid(editor)).toBe(false);
expect(editor.state.doc).toBe(before);
editor.destroy();
});
it("does nothing, and says so, inside a raw block", () => {
const editor = editorFor("<figure><img src='x.png'></figure>\n");
expect(editor.state.doc.child(0).type.name).toBe("raw");
editor.commands.setTextSelection(3);
const before = editor.state.doc;
expect(insertMermaid(editor)).toBe(false);
expect(editor.state.doc).toBe(before);
editor.destroy();
});
});
+519
View File
@@ -0,0 +1,519 @@
// Mermaid diagrams, which are a fenced code block whose language is `mermaid` and nothing else.
//
// There is no mermaid node in the schema and there will not be one. On disk a diagram is ```mermaid
// and the bridge reads it as a codeBlock like any other fence, so every byte of it round trips as
// that block's text whether or not it draws. This lane only changes how such a block is shown.
//
// Mermaid renders asynchronously, which a ProseMirror view update is not, so the node view draws
// the fence first and swaps the SVG in when it arrives. A diagram that fails to parse stays as the
// code the user wrote, with the error beside it: a broken diagram is a typo to fix, not a block to
// hide.
//
// Nothing below ever writes to the document. The one transaction this file dispatches sets a text
// selection, which is a caret move and not an edit, and it is what makes clicking a drawn diagram
// put the cursor in the source that drew it. A render result is painted into DOM that sits outside
// contentDOM and is declared to ProseMirror as not the document's, so a picture mermaid hands back
// can never be read into the tree and saved over somebody's fence.
//
// ProseMirror resolves node views by node name and the first plugin asked wins, so this file is
// handed every code block in the document, not only the mermaid ones. The other kind gets a node
// view built from the schema's own toDOM, which is the same `pre > code` a code block had before
// this lane existed: the same element prose.css styles and the same one the code lane's decorations
// land on.
import { Extension } from "@tiptap/core";
import type { Editor } from "@tiptap/core";
import { DOMSerializer } from "@tiptap/pm/model";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import type { EditorState } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import type { EditorView, NodeView, ViewMutationRecord } from "@tiptap/pm/view";
import type { Mermaid, MermaidConfig } from "mermaid";
import { place } from "../fits";
/**
* The one info string that draws. Matched exactly, case included.
*
* The code lane matches the same word case insensitively when it decides what to leave plain, so
* ```Mermaid is highlighted by nobody and drawn by nobody: it stays the fence the user typed. That
* is the safe direction for the two lanes to disagree in. Both drawing it and colouring it would
* mean two plugins fighting over one block.
*/
const LANGUAGE = "mermaid";
const DRAWING = "Drawing diagram…";
const FAILED = "Mermaid could not draw this diagram.";
const mermaidKey = new PluginKey("mermaidRendering");
/** On a node decoration, this marks the code block the selection is currently inside. */
const CURSOR_INSIDE = { mermaidCursor: true };
function isDiagram(node: ProseMirrorNode): boolean {
return node.type.name === "codeBlock" && node.attrs.language === LANGUAGE;
}
// ------------------------------------------------------------------------------------------------
// The library, loaded once and only if a diagram is ever drawn
// ------------------------------------------------------------------------------------------------
let loading: Promise<Mermaid> | null = null;
let configured: string | null = null;
let drawings = 0;
/**
* Mermaid is several megabytes and a dependency graph to match, so this is the only place it is
* mentioned outside a type position and the import is dynamic. The bundler gives it a chunk of its
* own, and a user who never writes a diagram never fetches it.
*
* A failed load clears the promise rather than keeping it, so a chunk that did not arrive once is
* asked for again by the next block instead of poisoning every diagram in the app.
*/
function load(): Promise<Mermaid> {
if (!loading) {
loading = import("mermaid")
.then((module) => module.default)
.catch((error) => {
loading = null;
throw error;
});
}
return loading;
}
function themeName(): string {
return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
}
/**
* The app's palette, handed to mermaid as its own theme variables.
*
* `base` is the one mermaid theme meant to be recoloured; the others are fixed palettes that would
* put somebody else's lavender and yellow in the middle of this page. The values are read off the
* token layer at render time rather than named here, so a diagram is drawn in the same ink as the
* document around it and follows tokens.css when that changes.
*/
function themeVariables(): Record<string, string | boolean> {
const style = getComputedStyle(document.documentElement);
const variables: Record<string, string | boolean> = {
darkMode: themeName() === "dark",
fontFamily: "var(--font-ui)",
};
const palette: ReadonlyArray<readonly [string, string]> = [
["background", "--paper"],
["primaryColor", "--code-surface"],
["primaryTextColor", "--ink"],
["primaryBorderColor", "--doc-rule-strong"],
["secondaryColor", "--shell"],
["tertiaryColor", "--raised"],
["lineColor", "--ink-soft"],
["textColor", "--ink"],
// The card a label on an arrow sits on. Left to itself the base theme picks near black for it
// in dark mode, which puts a hole in the middle of the diagram.
["edgeLabelBackground", "--code-surface"],
];
for (const [variable, token] of palette) {
const value = style.getPropertyValue(token).trim();
// An empty custom property means the stylesheet is not loaded yet. Mermaid derives its shades
// from these by colour arithmetic, and "" is not a colour, so a missing token is left to the
// theme's own default rather than passed on.
if (value) variables[variable] = value;
}
return variables;
}
function configFor(): MermaidConfig {
return {
// The whole point of this file: nothing scans the page for diagrams, every render is asked for
// by a node view that knows which block it belongs to.
startOnLoad: false,
// Strict is mermaid's own default and the right one here. The text being drawn came out of a
// file on disk, so it is sanitised and its click handlers are dropped.
securityLevel: "strict",
// Without this a parse failure leaves mermaid's own error diagram behind in the page and a
// stray temporary div in the body. The error belongs in this block, drawn by the code below.
suppressErrorRendering: true,
theme: "base",
fontFamily: "var(--font-ui)",
themeVariables: themeVariables(),
};
}
/**
* One diagram, as an SVG string. Throws whatever mermaid threw.
*
* The id has to be unique per diagram: mermaid scopes the stylesheet it puts inside each SVG with
* `#id`, so two diagrams sharing one would style each other.
*/
async function toSvg(text: string): Promise<string> {
const mermaid = await load();
const theme = themeName();
if (theme !== configured) {
mermaid.initialize(configFor());
configured = theme;
}
// Parsing first keeps a syntax error away from the renderer entirely, which is the difference
// between an error this file can show and a half drawn diagram.
await mermaid.parse(text);
const { svg } = await mermaid.render(`mermaid-diagram-${(drawings += 1)}`, text);
return svg;
}
function messageOf(error: unknown): string {
if (error instanceof Error) return error.message;
if (error && typeof error === "object") {
// Mermaid's parse errors are plain objects carrying the offending line under `str`.
const detail = error as { str?: unknown; message?: unknown };
if (typeof detail.str === "string") return detail.str;
if (typeof detail.message === "string") return detail.message;
}
return String(error);
}
// ------------------------------------------------------------------------------------------------
// The theme watch
// ------------------------------------------------------------------------------------------------
const live = new Set<DiagramView>();
let watcher: MutationObserver | null = null;
let watched: string | null = null;
/**
* A drawn diagram is a picture with the palette baked into it, so the theme changing under it is
* the one event that invalidates a render nothing else touched. One observer serves every block,
* and it exists only while there is a diagram on screen to redraw.
*/
function watchTheme(): void {
if (watcher) return;
watched = themeName();
watcher = new MutationObserver(() => {
const theme = themeName();
if (theme === watched) return;
watched = theme;
configured = null;
for (const view of live) view.redraw();
});
watcher.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
}
function unwatchTheme(): void {
if (!watcher || live.size > 0) return;
watcher.disconnect();
watcher = null;
}
// ------------------------------------------------------------------------------------------------
// The node views
// ------------------------------------------------------------------------------------------------
/**
* Every code block that is not a diagram, rendered by the schema rather than by hand.
*
* Going through the node's own serializer is what makes this a no-op: the DOM here is the DOM
* ProseMirror would have built for a code block if this file did not exist, down to whether
* `data-language` is written at all, so nothing about an ordinary fence changes because the mermaid
* lane happens to be installed.
*/
class SourceView implements NodeView {
readonly dom: HTMLElement;
readonly contentDOM: HTMLElement | null;
private node: ProseMirrorNode;
constructor(node: ProseMirrorNode) {
const serializer = DOMSerializer.fromSchema(node.type.schema);
const rendered = DOMSerializer.renderSpec(document, serializer.nodes[node.type.name](node));
this.dom = rendered.dom;
this.contentDOM = rendered.contentDOM ?? null;
this.node = node;
}
update(next: ProseMirrorNode): boolean {
// Same markup means the same element, so ProseMirror updates the text inside contentDOM and
// this view stands. Anything else is a rebuild, the language crossing into mermaid included,
// and a rebuild is what the standard node view would have done with the same change.
if (!next.sameMarkup(this.node)) return false;
this.node = next;
return true;
}
}
type DiagramState = "empty" | "source" | "pending" | "diagram" | "error";
/**
* One mermaid block: the picture, and the source that made it.
*
* Both are always in the DOM and which one is shown is a CSS state, because the source is the
* document's own content and hiding it by removing it would be an edit. The cursor being inside
* the block arrives as a node decoration from the plugin below rather than being asked for here,
* since a node view is only told about the selection when something else redraws it.
*/
class DiagramView implements NodeView {
readonly dom: HTMLElement;
readonly contentDOM: HTMLElement;
private readonly figure: HTMLElement;
private readonly drawing: HTMLElement;
private readonly note: HTMLElement;
private readonly view: EditorView;
private readonly getPos: () => number | undefined;
private node: ProseMirrorNode;
private inside: boolean;
/** Bumped by anything that makes a render in flight the answer to a question nobody asked. */
private token = 0;
/** The text the picture on screen was drawn from, or null when there is no picture. */
private drawn: string | null = null;
private failure: string | null = null;
private gone = false;
constructor(
node: ProseMirrorNode,
view: EditorView,
getPos: () => number | undefined,
decorations: readonly Decoration[],
) {
this.node = node;
this.view = view;
this.getPos = getPos;
this.inside = hasCursor(decorations);
this.dom = document.createElement("div");
this.dom.className = "mermaid-block";
this.figure = document.createElement("div");
this.figure.className = "mermaid-figure";
// Not part of the document, so the caret has no business in it and ProseMirror is told as much
// here as well as through ignoreMutation below.
this.figure.contentEditable = "false";
this.drawing = document.createElement("div");
this.drawing.className = "mermaid-drawing";
this.note = document.createElement("div");
this.note.className = "mermaid-note";
this.figure.append(this.drawing, this.note);
const source = document.createElement("pre");
source.className = "mermaid-source";
source.setAttribute("data-language", LANGUAGE);
this.contentDOM = document.createElement("code");
source.appendChild(this.contentDOM);
this.dom.append(this.figure, source);
this.figure.addEventListener("mousedown", this.enter);
live.add(this);
watchTheme();
this.apply();
}
update(next: ProseMirrorNode, decorations: readonly Decoration[]): boolean {
// The language leaving mermaid is a different kind of block with different DOM, so this view is
// finished and ProseMirror builds the plain one in its place.
if (!isDiagram(next)) return false;
const edited = next.textContent !== this.node.textContent;
this.node = next;
this.inside = hasCursor(decorations);
if (edited) {
// Whatever is being drawn was drawn from text that is no longer in this block, and the error
// on screen, if there is one, is about a line the user may have just fixed.
this.token += 1;
this.failure = null;
}
this.apply();
return true;
}
/** The theme changed, so the picture is right about the diagram and wrong about the ink. */
redraw(): void {
this.failure = null;
this.drawn = null;
this.apply();
}
destroy(): void {
this.gone = true;
this.figure.removeEventListener("mousedown", this.enter);
live.delete(this);
unwatchTheme();
}
/**
* The figure is the view's own drawing, not the document. Reading an SVG mermaid just handed over
* back into the tree would replace the user's fence with a transcription of its own picture, so
* every mutation outside contentDOM is none of ProseMirror's business.
*/
ignoreMutation(mutation: ViewMutationRecord): boolean {
return !this.contentDOM.contains(mutation.target);
}
stopEvent(event: Event): boolean {
const target = event.target;
return target instanceof Node ? !this.contentDOM.contains(target) : false;
}
/** Clicking the picture puts the caret in the source that drew it, which is how a diagram is edited. */
private enter = (event: MouseEvent): void => {
const pos = this.getPos();
if (pos === undefined) return;
event.preventDefault();
const { state } = this.view;
const inside = Math.min(pos + 1, state.doc.content.size);
this.view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, inside)));
this.view.focus();
};
/** What should be on screen for the block as it is now, and a render if that is not known yet. */
private apply(): void {
const text = this.node.textContent;
if (!text.trim()) {
this.show("empty");
return;
}
// Shown whether or not the caret is in the block, because a diagram that will not draw is a
// line to go and fix and the message is how anybody knows which line.
if (this.failure !== null) {
this.note.textContent = `${FAILED}\n\n${this.failure}`;
this.show("error");
return;
}
if (this.inside) {
this.show("source");
return;
}
if (this.drawn === text) {
this.show("diagram");
return;
}
this.draw(text);
}
private draw(text: string): void {
const token = (this.token += 1);
// A diagram already on screen stays there while the next one is drawn, so a theme change or a
// finished edit does not blink the block out of the page and back into it.
if (this.drawing.firstChild) {
this.show("diagram");
this.dom.setAttribute("data-busy", "");
} else {
this.note.textContent = DRAWING;
this.show("pending");
}
toSvg(text).then(
(svg) => {
if (this.stale(token)) return;
this.dom.removeAttribute("data-busy");
// Mermaid sanitises what it returns, and a script arriving through innerHTML does not run
// in any case, so the SVG goes in as markup and the error below never does.
this.drawing.innerHTML = svg;
this.drawn = text;
this.failure = null;
this.apply();
},
(error: unknown) => {
if (this.stale(token)) return;
this.dom.removeAttribute("data-busy");
this.drawing.textContent = "";
this.drawn = null;
this.failure = messageOf(error);
this.apply();
},
);
}
/**
* Mermaid answers whenever it answers, and by then the block may have been edited, the document
* may have been closed and this view may have been thrown away. The token covers every one of
* those: it is bumped by an edit, by a theme change and by destroy, so an answer to a question
* nobody is asking any more is dropped rather than painted somewhere it no longer belongs.
*/
private stale(token: number): boolean {
return this.gone || token !== this.token || this.view.isDestroyed;
}
private show(state: DiagramState): void {
this.dom.setAttribute("data-state", state);
}
}
function hasCursor(decorations: readonly Decoration[]): boolean {
return decorations.some((decoration) => decoration.spec?.mermaidCursor === true);
}
// ------------------------------------------------------------------------------------------------
// The plugin
// ------------------------------------------------------------------------------------------------
/**
* A node decoration on every mermaid block the selection touches.
*
* This is how a node view is told the caret is inside it. A decoration changing is one of the two
* things that make ProseMirror ask a node view to update, and the selection moving on its own is
* not the other, so without this a block would keep drawing the diagram with the cursor in it.
*/
function cursorDecorations(state: EditorState): DecorationSet | null {
const { from, to } = state.selection;
const found: Decoration[] = [];
state.doc.nodesBetween(from, to, (node, pos) => {
if (node.type.name !== "codeBlock") return true;
if (isDiagram(node)) found.push(Decoration.node(pos, pos + node.nodeSize, {}, CURSOR_INSIDE));
return false;
});
return found.length > 0 ? DecorationSet.create(state.doc, found) : null;
}
export const MermaidRendering = Extension.create({
name: "mermaidRendering",
addProseMirrorPlugins() {
return [
new Plugin({
key: mermaidKey,
props: {
nodeViews: {
codeBlock: (node, view, getPos, decorations) =>
isDiagram(node)
? new DiagramView(node, view, getPos, decorations)
: new SourceView(node),
},
decorations: cursorDecorations,
},
}),
];
},
});
/**
* Inserts an empty ```mermaid fence. False where a code block cannot go.
*
* Through `place` rather than asking `fits` about each end itself, which is what this did while it
* was the only insert in its own file. Both spellings refuse the same things today, but only one of
* them refuses the next thing the guard learns: `fits` gained a cell selection rule after a drag
* across a table lost six cells to an insert that had asked it the older way, and a caller holding
* its own copy of the question is a caller that does not get told. There is one gate and every
* insert goes through it.
*/
export function insertMermaid(editor: Editor): boolean {
return place(editor, editor.schema.nodes.codeBlock, (chain) =>
chain.insertContent({ type: "codeBlock", attrs: { language: LANGUAGE, meta: null } }),
);
}
+755
View File
@@ -0,0 +1,755 @@
// A table is the one block in this editor whose behaviour is a library's rather than this app's,
// which makes it the one block where "it works" is easy to assume and easy to be wrong about. Two
// things are asserted here that a passing prosemirror-tables would not give for free.
//
// The first is alignment, which is this file's own and not the library's. GFM keeps alignment in
// the delimiter row, one entry per column, so the test that matters is not what the attribute says
// but what the serializer writes: a column whose cells disagree is a table the file cannot hold.
//
// The second is the header row, for the same reason from the other end. GFM has exactly one and it
// is the first row, so an edit that leaves body cells in row zero is an edit whose result the file
// cannot spell, and the screen would go on showing it until the file was next opened.
import { describe, expect, it } from "vitest";
import { Editor } from "@tiptap/core";
import type { JSONContent } from "@tiptap/core";
import { EditorState } from "@tiptap/pm/state";
import type { Transaction } from "@tiptap/pm/state";
import { Fragment, Slice } from "@tiptap/pm/model";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { EditorView } from "@tiptap/pm/view";
import { CellSelection, TableMap } from "@tiptap/pm/tables";
import { createEditorExtensions } from "../extensions";
import { serializeMarkdown } from "../../markdown";
import { tableCommand, typingKey } from "./tables";
const EMPTY: JSONContent = { type: "doc", content: [{ type: "paragraph" }] };
function makeEditor(content: JSONContent = EMPTY): Editor {
const editor = new Editor({
element: null,
injectCSS: false,
extensions: createEditorExtensions({ documentPath: () => "/notes/a.md", onError: () => {} }),
content,
});
// TipTap only installs the extensions' ProseMirror plugins when it mounts a view, and there is no
// DOM here to mount into. Swapping in a state built with them is what src/editor/Editor.tsx does
// on every document it installs, so this is the same editor the app runs, minus the screen.
editor.view.updateState(
EditorState.create({ doc: editor.state.doc, plugins: editor.extensionManager.plugins }),
);
return editor;
}
/**
* One key, offered to the plugins in the order the view would offer it and stopping at the first
* that claims it. Which plugin answered is the whole question in half these tests, so the walk is
* the real one rather than a call into the binding this file happens to be about.
*/
function press(editor: Editor, key: string, shift = false): boolean {
const event = {
key,
keyCode: key === "Tab" ? 9 : 8,
shiftKey: shift,
ctrlKey: false,
altKey: false,
metaKey: false,
preventDefault: () => {},
} as unknown as KeyboardEvent;
const view = editor.view as unknown as EditorView;
for (const plugin of editor.state.plugins) {
const handler = plugin.props?.handleKeyDown;
if (handler && handler.call(plugin, view, event)) return true;
}
return false;
}
/**
* One printable character, offered to the plugins the way the view offers one and, when nobody
* claims it, put in the way the view would put it. True when a plugin claimed it.
*
* `tr.insertText` with no range is `Selection.replace`, and over a cell selection that replaces
* every range in the selection: the character goes into the last cell of the rectangle and the
* rest are emptied. That fallback is the bug, so it is run here rather than described.
*/
function type(editor: Editor, character: string): boolean {
const view = editor.view as unknown as EditorView;
const { $from, $to } = editor.state.selection;
const deflt = () => editor.state.tr.insertText(character).scrollIntoView();
for (const plugin of editor.state.plugins) {
const handler = plugin.props?.handleTextInput;
if (handler && handler.call(plugin, view, $from.pos, $to.pos, character, deflt)) return true;
}
editor.view.dispatch(deflt());
return false;
}
/** A table alone in a document. The first row is header cells, as every GFM table's is. */
function tableDoc(rows: string[][]): JSONContent {
return {
type: "doc",
content: [
{
type: "table",
content: rows.map((cells, row) => ({
type: "tableRow",
content: cells.map((text) => ({
type: row === 0 ? "tableHeader" : "tableCell",
...(text ? { content: [{ type: "text", text }] } : {}),
})),
})),
},
],
};
}
/** The document's first table and where it starts, wherever in the tree it happens to sit. */
function tableAt(editor: Editor): { node: ProseMirrorNode; pos: number } {
let found: { node: ProseMirrorNode; pos: number } | null = null;
editor.state.doc.descendants((node, pos) => {
if (found !== null) return false;
if (node.type.name === "table") found = { node, pos };
return found === null;
});
if (found === null) throw new Error("this document has no table in it");
return found;
}
const table = (editor: Editor) => tableAt(editor).node;
/** The document position just inside a cell. */
function inCell(editor: Editor, row: number, column: number): number {
const { node, pos } = tableAt(editor);
return pos + 1 + TableMap.get(node).positionAt(row, column, node) + 1;
}
function cursorIn(editor: Editor, row: number, column: number): void {
editor.commands.setTextSelection(inCell(editor, row, column));
}
function selectCells(editor: Editor, from: [number, number], to: [number, number]): void {
const anchor = inCell(editor, from[0], from[1]) - 1;
const head = inCell(editor, to[0], to[1]) - 1;
editor.view.dispatch(
editor.state.tr.setSelection(CellSelection.create(editor.state.doc, anchor, head)),
);
}
/** The table as a grid of whatever `of` reads off a cell. */
function grid<T>(editor: Editor, of: (cell: ProseMirrorNode) => T): T[][] {
const out: T[][] = [];
table(editor).forEach((row) => {
const cells: T[] = [];
row.forEach((cell) => cells.push(of(cell)));
out.push(cells);
});
return out;
}
const shape = (editor: Editor) => grid(editor, (cell) => cell.textContent);
const kinds = (editor: Editor) => grid(editor, (cell) => cell.type.name);
const aligns = (editor: Editor) => grid(editor, (cell) => cell.attrs.align as string | null);
/** A clipboard carrying one word of plain text, which is what a slice with something in it is. */
function textSlice(editor: Editor, text: string): Slice {
return new Slice(Fragment.from(editor.schema.text(text)), 0, 0);
}
/** What the bridge would write for this document, in a file that holds nothing else. */
function written(editor: Editor): string {
const doc = editor.state.doc;
return serializeMarkdown({ frontmatter: null, doc, source: "", path: "/notes/a.md" }, doc);
}
const GRID = [
["a", "b", "c"],
["1", "2", "3"],
["4", "5", "6"],
];
describe("Tab in a table", () => {
it("moves to the next cell and wraps on to the next row", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 0, 0);
expect(press(editor, "Tab")).toBe(true);
expect(editor.state.selection.from).toBe(inCell(editor, 0, 1));
expect(press(editor, "Tab")).toBe(true);
expect(press(editor, "Tab")).toBe(true);
expect(editor.state.selection.from).toBe(inCell(editor, 1, 0));
editor.destroy();
});
it("moves back on Shift-Tab, and stops at the first cell", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 1, 0);
expect(press(editor, "Tab", true)).toBe(true);
expect(editor.state.selection.from).toBe(inCell(editor, 0, 2));
// Nowhere to go, so the binding declines and the key falls through untouched by it.
cursorIn(editor, 0, 0);
press(editor, "Tab", true);
expect(shape(editor)).toEqual(GRID);
expect(kinds(editor)[0]).toEqual(["tableHeader", "tableHeader", "tableHeader"]);
editor.destroy();
});
// The precedence assertion. shortcuts.ts also binds Tab, for sinking a list item, and it is the
// only other binding on the key: a fourth row here is proof that this lane's is asked first and
// that the list one does not answer inside a table.
it("grows the table out of the last cell, into a body row", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 2, 2);
expect(press(editor, "Tab")).toBe(true);
expect(shape(editor)).toEqual([...GRID, ["", "", ""]]);
expect(kinds(editor)[3]).toEqual(["tableCell", "tableCell", "tableCell"]);
expect(editor.state.selection.from).toBe(inCell(editor, 3, 0));
editor.destroy();
});
it("grows a table that is nothing but its header row", () => {
const editor = makeEditor(tableDoc([["a", "b"]]));
cursorIn(editor, 0, 1);
expect(press(editor, "Tab")).toBe(true);
expect(kinds(editor)).toEqual([
["tableHeader", "tableHeader"],
["tableCell", "tableCell"],
]);
editor.destroy();
});
it("leaves Tab alone outside a table", () => {
const editor = makeEditor({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "x" }] }],
});
editor.commands.setTextSelection(2);
expect(press(editor, "Tab")).toBe(false);
expect(editor.state.doc.textContent).toBe("x");
editor.destroy();
});
});
describe("Backspace over a cell selection", () => {
it("empties the cells and keeps the table the shape it was", () => {
const editor = makeEditor(tableDoc(GRID));
selectCells(editor, [1, 0], [2, 1]);
expect(press(editor, "Backspace")).toBe(true);
expect(shape(editor)).toEqual([
["a", "b", "c"],
["", "", "3"],
["", "", "6"],
]);
editor.destroy();
});
});
// A printable character over the same rectangle, which until this lane claimed it did what
// Backspace does and then wrote the character into the corner of the wreckage.
//
// Reported and reproduced in Chromium: a real mouse drag from (0,0) to (1,1) of a 2x2 body and the
// three keystrokes "zqx" took "| 1 | 2 |\n| 3 | 4 |" to four empty cells with "zqx" in the last
// one. Four cells of somebody's table for three letters, none of them the cell the drag started
// in. It is the destruction src/editor/paste.ts refuses for a Cmd+V that lands on the same
// selection, arriving by the one route with no guard on it.
describe("typing over a cell selection", () => {
it("leaves every cell in the rectangle exactly as it was", () => {
const editor = makeEditor(tableDoc(GRID));
const before = written(editor);
selectCells(editor, [1, 0], [2, 1]);
expect(type(editor, "z")).toBe(true);
expect(shape(editor)).toEqual(GRID);
expect(written(editor)).toBe(before);
// And the rectangle is still selected, so Backspace, the toolbar and a click into one cell are
// all still where the user left them.
expect(editor.state.selection instanceof CellSelection).toBe(true);
editor.destroy();
});
// The other half, twice, because a guard that claimed every character everywhere would pass the
// test above and would be an editor nobody can type in.
it("still types into a single cell, and still types outside a table", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 1, 0);
expect(type(editor, "z")).toBe(false);
expect(shape(editor)[1][0]).toBe("z1");
editor.destroy();
const prose = makeEditor();
expect(type(prose, "z")).toBe(false);
expect(prose.state.doc.textContent).toBe("z");
prose.destroy();
});
// And the half this project keeps getting wrong: an answer nothing asks for is not an answer.
// ProseMirror stops at the first plugin that claims a character, so a guard behind the plugin
// that would have destroyed the cells is a guard the running editor never reaches. TipTap's own
// input rules claim handleTextInput too, which is what this is measured against.
it("is the first plugin in the list that claims a typed character", () => {
const editor = makeEditor(tableDoc(GRID));
const claimants = editor.state.plugins.flatMap((plugin, index) =>
plugin.props?.handleTextInput ? [index] : [],
);
const guard = editor.state.plugins.findIndex((plugin) => plugin.spec.key === typingKey);
expect(guard).toBeGreaterThanOrEqual(0);
expect(claimants[0]).toBe(guard);
expect(claimants.length).toBeGreaterThan(1);
editor.destroy();
});
});
describe("the row and column ops", () => {
it("adds and removes rows where the cursor is", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 1, 0);
expect(tableCommand(editor, "addRowAfter")).toBe(true);
expect(shape(editor)).toEqual([["a", "b", "c"], ["1", "2", "3"], ["", "", ""], ["4", "5", "6"]]);
cursorIn(editor, 2, 0);
expect(tableCommand(editor, "deleteRow")).toBe(true);
expect(shape(editor)).toEqual(GRID);
cursorIn(editor, 1, 0);
expect(tableCommand(editor, "addRowBefore")).toBe(true);
expect(shape(editor)).toEqual([["a", "b", "c"], ["", "", ""], ["1", "2", "3"], ["4", "5", "6"]]);
editor.destroy();
});
it("adds and removes columns where the cursor is", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 0, 1);
expect(tableCommand(editor, "addColumnAfter")).toBe(true);
expect(shape(editor)[0]).toEqual(["a", "b", "", "c"]);
expect(kinds(editor)[0]).toEqual(["tableHeader", "tableHeader", "tableHeader", "tableHeader"]);
cursorIn(editor, 0, 2);
expect(tableCommand(editor, "deleteColumn")).toBe(true);
expect(shape(editor)).toEqual(GRID);
cursorIn(editor, 0, 0);
expect(tableCommand(editor, "addColumnBefore")).toBe(true);
expect(shape(editor)[1]).toEqual(["", "1", "2", "3"]);
editor.destroy();
});
it("takes the whole table out, leaving a document behind", () => {
const editor = makeEditor({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "before" }] }, ...tableDoc(GRID).content!],
});
editor.commands.setTextSelection(editor.state.doc.content.size - 4);
expect(tableCommand(editor, "deleteTable")).toBe(true);
expect(editor.state.doc.childCount).toBe(1);
expect(editor.state.doc.textContent).toBe("before");
editor.destroy();
});
it("takes a table that is the whole document out, leaving something behind", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 0, 0);
expect(tableCommand(editor, "deleteTable")).toBe(true);
expect(editor.state.doc.childCount).toBe(1);
expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
expect(editor.state.doc.textContent).toBe("");
editor.destroy();
});
// prosemirror-tables builds a new cell from the one it is standing beside, so all three of these
// leave a table whose first row is not the row the serializer will write as the header. What is
// on screen after the edit has to be what the file says, or the edit is taken back the next time
// the document is opened.
it("keeps the header first when a row goes in above it", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 0, 0);
expect(tableCommand(editor, "addRowBefore")).toBe(true);
expect(kinds(editor)).toEqual([
["tableHeader", "tableHeader", "tableHeader"],
["tableCell", "tableCell", "tableCell"],
["tableCell", "tableCell", "tableCell"],
["tableCell", "tableCell", "tableCell"],
]);
editor.destroy();
});
it("keeps the header first when a column goes in in front of it", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 0, 0);
expect(tableCommand(editor, "addColumnBefore")).toBe(true);
expect(kinds(editor)[0]).toEqual(["tableHeader", "tableHeader", "tableHeader", "tableHeader"]);
expect(kinds(editor)[1]).toEqual(["tableCell", "tableCell", "tableCell", "tableCell"]);
editor.destroy();
});
// The last row and the last column decline rather than emptying the table out, which is
// prosemirror-tables' own answer and the right one: a table with no rows is not something GFM can
// write, and Delete table is the op that means what this would have meant.
it("will not take the last row or the last column", () => {
for (const op of ["deleteRow", "deleteColumn"] as const) {
const editor = makeEditor(tableDoc([["a"]]));
cursorIn(editor, 0, 0);
const before = editor.state.doc.toJSON();
expect([op, tableCommand(editor, op)]).toEqual([op, false]);
expect([op, editor.state.doc.toJSON()]).toEqual([op, before]);
editor.destroy();
}
});
it("keeps a header row when the header row is the one deleted", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 0, 0);
expect(tableCommand(editor, "deleteRow")).toBe(true);
expect(shape(editor)).toEqual([GRID[1], GRID[2]]);
expect(kinds(editor)[0]).toEqual(["tableHeader", "tableHeader", "tableHeader"]);
expect(kinds(editor)[1]).toEqual(["tableCell", "tableCell", "tableCell"]);
editor.destroy();
});
it("does nothing at all with the cursor outside a table", () => {
const editor = makeEditor({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "x" }] }],
});
editor.commands.setTextSelection(2);
const before = editor.state.doc.toJSON();
for (const op of ["addRowAfter", "deleteRow", "addColumnAfter", "deleteColumn", "deleteTable", "alignCenter"] as const) {
expect([op, tableCommand(editor, op)]).toEqual([op, false]);
}
expect(editor.state.doc.toJSON()).toEqual(before);
editor.destroy();
});
});
describe("alignment", () => {
it("writes the whole column, header included, and nothing beside it", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 2, 1);
expect(tableCommand(editor, "alignCenter")).toBe(true);
expect(aligns(editor)).toEqual([
[null, "center", null],
[null, "center", null],
[null, "center", null],
]);
editor.destroy();
});
it("reaches the serializer's delimiter row, and comes back off it", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 1, 0);
tableCommand(editor, "alignRight");
cursorIn(editor, 1, 2);
tableCommand(editor, "alignCenter");
expect(written(editor)).toBe(
["| a | b | c |", "| -: | - | :-: |", "| 1 | 2 | 3 |", "| 4 | 5 | 6 |", ""].join("\n"),
);
cursorIn(editor, 1, 0);
expect(tableCommand(editor, "alignClear")).toBe(true);
cursorIn(editor, 1, 2);
expect(tableCommand(editor, "alignClear")).toBe(true);
expect(written(editor)).toBe(
["| a | b | c |", "| - | - | - |", "| 1 | 2 | 3 |", "| 4 | 5 | 6 |", ""].join("\n"),
);
editor.destroy();
});
it("covers every column a cell selection touches", () => {
const editor = makeEditor(tableDoc(GRID));
selectCells(editor, [0, 0], [1, 1]);
expect(tableCommand(editor, "alignLeft")).toBe(true);
expect(aligns(editor)).toEqual([
["left", "left", null],
["left", "left", null],
["left", "left", null],
]);
editor.destroy();
});
it("declines a column that already reads that way", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 0, 0);
expect(tableCommand(editor, "alignLeft")).toBe(true);
expect(tableCommand(editor, "alignLeft")).toBe(false);
expect(tableCommand(editor, "alignClear")).toBe(true);
expect(tableCommand(editor, "alignClear")).toBe(false);
editor.destroy();
});
it("survives a row added above the header row", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 1, 2);
tableCommand(editor, "alignRight");
cursorIn(editor, 0, 0);
expect(tableCommand(editor, "addRowBefore")).toBe(true);
expect(aligns(editor)).toEqual([
[null, null, "right"],
[null, null, "right"],
[null, null, "right"],
[null, null, "right"],
]);
expect(written(editor).split("\n")[1]).toBe("| - | - | -: |");
editor.destroy();
});
it("stays with its own column when a column beside it goes", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 1, 2);
tableCommand(editor, "alignCenter");
cursorIn(editor, 1, 0);
expect(tableCommand(editor, "deleteColumn")).toBe(true);
expect(aligns(editor)[0]).toEqual([null, "center"]);
expect(written(editor).split("\n")[1]).toBe("| - | :-: |");
editor.destroy();
});
it("does not follow a new column in beside it", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 1, 0);
tableCommand(editor, "alignCenter");
expect(tableCommand(editor, "addColumnAfter")).toBe(true);
expect(aligns(editor)[0]).toEqual(["center", null, null, null]);
editor.destroy();
});
// prosemirror-tables builds a new cell from the attribute's default, so every one of these would
// leave a column disagreeing with itself. The delimiter row is written off the first row, which
// makes the row added above it the one that would take the whole table's alignment off.
it("survives a row added under it", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 1, 1);
tableCommand(editor, "alignCenter");
cursorIn(editor, 2, 2);
press(editor, "Tab");
expect(aligns(editor)[3]).toEqual([null, "center", null]);
editor.destroy();
});
});
// Dragging a column edge is the one edit the file has nowhere to put. It is asserted rather than
// assumed because the failure is invisible: the markdown is identical, so a resize looks saved and
// is gone the next time the document is opened. See the note at the top of tables.ts.
describe("a resized column", () => {
it("changes the document without changing a byte of the markdown", () => {
const editor = makeEditor(tableDoc(GRID));
const before = written(editor);
const pos = inCell(editor, 0, 0) - 1;
const cell = editor.state.doc.nodeAt(pos)!;
editor.view.dispatch(
editor.state.tr.setNodeMarkup(pos, null, { ...cell.attrs, colwidth: [180] }),
);
expect(table(editor).firstChild!.firstChild!.attrs.colwidth).toEqual([180]);
expect(written(editor)).toBe(before);
editor.destroy();
});
});
// A table indented under a bullet, which is the one place a table has structure around it that
// another extension's keys will act on. Every key this lane binds is asked here, because what is
// behind each of them is a list command that reshapes the list rather than the table: a key that
// falls through from inside a cell can take the bullet out from under the table the cursor is in.
const NESTED: JSONContent = {
type: "doc",
content: [
{ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "T" }] },
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "item" }] },
...tableDoc([["a", "b"], ["c", "d"]]).content!,
],
},
{
type: "listItem",
content: [{ type: "paragraph", content: [{ type: "text", text: "next" }] }],
},
],
},
],
};
/** The node names from the document down to the cursor, which is what a lifted list item loses. */
function path(editor: Editor): string[] {
const { $from } = editor.state.selection;
const names: string[] = [];
for (let depth = 1; depth <= $from.depth; depth += 1) names.push($from.node(depth).type.name);
return names;
}
describe("a table nested in a list item", () => {
it("stays where it is when Shift-Tab is pressed in the first cell", () => {
const editor = makeEditor(NESTED);
cursorIn(editor, 0, 0);
const before = editor.state.doc.toJSON();
const at = editor.state.selection.from;
expect(path(editor)).toEqual(["bulletList", "listItem", "table", "tableRow", "tableHeader"]);
press(editor, "Tab", true);
// Nowhere to go, so nothing moves. What must not happen is the key reaching the list command
// behind this lane's binding, which lifts the item and dissolves the list around the table.
expect(editor.state.doc.toJSON()).toEqual(before);
expect(editor.state.selection.from).toBe(at);
expect(path(editor)).toEqual(["bulletList", "listItem", "table", "tableRow", "tableHeader"]);
editor.destroy();
});
it("keeps its list when Backspace is pressed at the start of the first cell", () => {
const editor = makeEditor(NESTED);
cursorIn(editor, 0, 0);
const before = editor.state.doc.toJSON();
press(editor, "Backspace");
expect(editor.state.doc.toJSON()).toEqual(before);
editor.destroy();
});
it("keeps its list when Delete is pressed at the end of the last cell", () => {
const editor = makeEditor(NESTED);
const map = TableMap.get(table(editor));
const row = map.height - 1;
const column = map.width - 1;
const cell = table(editor).nodeAt(map.map[row * map.width + column])!;
editor.commands.setTextSelection(inCell(editor, row, column) + cell.content.size);
const before = editor.state.doc.toJSON();
press(editor, "Delete");
expect(editor.state.doc.toJSON()).toEqual(before);
editor.destroy();
});
});
// A paste is not this lane's feature and prosemirror-tables claims one, which is exactly why the
// assertion is here: this file installs that plugin, so what it does with a paste is this file's
// answer to give. Over a rectangle of dragged cells the library replaces the content of every cell
// in the rectangle with the slice, whatever the slice is. One word replaced six cells. An image on
// the clipboard carries no HTML and no text, so the slice ProseMirror hands along is the empty one
// and six cells were emptied by a paste that put nothing anywhere.
//
// Both were reproduced against the plugin order this file used to run: pasting the word `z` over
// a two by three rectangle gave [["z","z","z"],["z","z","z"],["4","5","6"]], and Slice.empty over
// the same rectangle gave [["","",""],["","",""],["4","5","6"]]. The app's clipboard plugin now
// sits in front of the library's and refuses both, and stands aside for the one paste the library
// does better, which is cells over cells.
//
// The walk below is EditorView.prototype.someProp rather than a loop over `editor.state.plugins`
// written here. They agree today, and the point is that this asks the question the running editor
// asks instead of a modelled one: the props the component puts on the view come first, then the
// direct plugins, then the state's, and a guard written in the wrong one of those three answers
// nothing. Four guards in this project have been shipped and never reached, and every test that
// missed one was a test that called the handler itself.
describe("a paste over a dragged rectangle of cells", () => {
const paste = (editor: Editor, slice: Slice): boolean => {
const event = { preventDefault: () => {} } as unknown as ClipboardEvent;
const view = {
get state() {
return editor.state;
},
dispatch: (tr: Transaction) => editor.view.dispatch(tr),
directPlugins: [],
_props: {},
someProp: EditorView.prototype.someProp,
focus: () => {},
dom: null,
composing: false,
dragging: null,
editable: true,
} as unknown as EditorView;
return view.someProp("handlePaste", (f) => f(view, event, slice)) === true;
};
/** The clipboard a real copy out of a table puts there, which is the one shape to stand aside for. */
const cellSlice = (editor: Editor, from: [number, number], to: [number, number]): Slice => {
selectCells(editor, from, to);
return editor.state.selection.content();
};
it("leaves every cell alone when the clipboard carried nothing", () => {
const editor = makeEditor(tableDoc(GRID));
const before = written(editor);
selectCells(editor, [0, 0], [1, 2]);
expect(paste(editor, Slice.empty)).toBe(true);
expect(shape(editor)).toEqual(GRID);
expect(written(editor)).toBe(before);
editor.destroy();
});
it("leaves every cell alone when the clipboard carried a word", () => {
const editor = makeEditor(tableDoc(GRID));
const before = written(editor);
selectCells(editor, [0, 0], [1, 2]);
expect(paste(editor, textSlice(editor, "z"))).toBe(true);
// Six cells for one word is not a paste anybody meant, and it is not undoable in the file: the
// save lands half a second later whether or not the user has noticed yet.
expect(shape(editor)).toEqual(GRID);
expect(written(editor)).toBe(before);
editor.destroy();
});
it("still lays out a rectangle of cells copied out of a table", () => {
const editor = makeEditor(tableDoc(GRID));
const copied = cellSlice(editor, [1, 0], [1, 1]);
selectCells(editor, [2, 0], [2, 1]);
expect(paste(editor, copied)).toBe(true);
// The library's own edit, kept because it is better than anything this app would do with it: it
// lays the cells out over the rectangle and keeps every boundary the user copied. Refusing this
// would be the guard destroying a paste in order to guard it.
expect(shape(editor)).toEqual([
["a", "b", "c"],
["1", "2", "3"],
["1", "2", "6"],
]);
editor.destroy();
});
it("puts a word into the one cell the caret is in", () => {
const editor = makeEditor(tableDoc(GRID));
cursorIn(editor, 1, 1);
editor.commands.setTextSelection(inCell(editor, 1, 1) + 1);
// Nobody claims it, so ProseMirror's own handler runs and does the ordinary thing. Refusing a
// paste at a caret in a cell would have been this guard overreaching in the other direction.
expect(paste(editor, textSlice(editor, "z"))).toBe(false);
editor.destroy();
});
});
+385
View File
@@ -0,0 +1,385 @@
// Table behaviour: everything about editing a GFM table that is not its shape.
//
// The shape is already in src/model/schema.ts and generated into an extension by extensions.ts, so
// nothing here declares a node. What is missing is the behaviour prosemirror-tables carries: the
// cell selection, Tab between cells, and the row and column edits a toolbar asks for by name. That
// library ships inside @tiptap/pm/tables and reads the `tableRole` extensions.ts already puts on
// each spec, so it plugs in whole rather than being reimplemented.
//
// Alignment is the one thing the library has no idea about, and it runs through everything below.
// `align` is a cell attribute the bridge reads back out of the GFM delimiter row, and that row is
// per column: markdown cannot say that one cell is centred and the rest of its column is not. So an
// align op writes the whole column, and a row added into a column has to be told what that column
// says, because prosemirror-tables builds its new cells from the attribute's default. The
// serializer reads the delimiter row off the table's first row, which makes a row added above the
// first one the worst case: left alone it would take the whole table's alignment off the next time
// the file was written.
//
// The other thing markdown cannot follow is the shape of the header. A GFM table has exactly one
// header row, it is the first one, and there is no spelling for a table without one, so the ops
// here keep the document to that shape rather than offering edits the file cannot hold. That is
// also why there is no header row toggle: both directions of it are a change the next open of the
// file silently takes back.
//
// One thing the library does that the file cannot follow either: dragging a column edge writes
// `colwidth` on to every cell in that column, and GFM has no column widths for the serializer to
// put them in. The drag is a real document change all the same, and src/document.ts is where it
// stops being one: a transaction that only moved something the markdown cannot spell does not mark
// the buffer dirty, so the drag never reaches the debounce and no save is scheduled behind it.
import { Extension } from "@tiptap/core";
import type { Editor } from "@tiptap/core";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import type { Command, Transaction } from "@tiptap/pm/state";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import {
TableMap,
addColumnAfter,
addColumnBefore,
addRow,
columnResizing,
deleteCellSelection,
deleteColumn,
deleteRow,
deleteTable,
goToNextCell,
isInTable,
selectedRect,
tableEditing,
} from "@tiptap/pm/tables";
import type { TableRect } from "@tiptap/pm/tables";
import type { ColumnAlign } from "../../model/doc";
import { overCells } from "../fits";
import type { TableOp } from "../index";
/**
* The typing guard, named so that a test can find it in the plugin list and say where in that list
* it sits. Being right about a rectangle of cells is worth nothing if something else is asked
* first, which is the mistake this lane has already made once with a paste.
*/
export const typingKey = new PluginKey("tableTyping");
/** What each column says, read where the serializer reads it: the table's first row. */
function columnAlignments(table: ProseMirrorNode): ColumnAlign[] {
const map = TableMap.get(table);
return Array.from(
{ length: map.width },
(_unused, column) => (table.nodeAt(map.map[column])?.attrs.align ?? null) as ColumnAlign,
);
}
/** Every cell of every column made to agree with `alignment`, whatever the edit left behind. */
function restoreAlignments(tr: Transaction, tablePos: number, alignment: ColumnAlign[]): void {
const table = tr.doc.nodeAt(tablePos);
if (!table) return;
const map = TableMap.get(table);
const columns = Math.min(map.width, alignment.length);
const done = new Set<number>();
for (let row = 0; row < map.height; row += 1) {
for (let column = 0; column < columns; column += 1) {
const pos = map.map[row * map.width + column];
if (done.has(pos)) continue;
done.add(pos);
const cell = table.nodeAt(pos);
// Null for the type: a header cell that is centred is still a header cell, and setNodeMarkup
// is the only way to change an attribute and keep both the type and the content.
if (cell && cell.attrs.align !== alignment[column]) {
tr.setNodeMarkup(tablePos + 1 + pos, null, { ...cell.attrs, align: alignment[column] });
}
}
}
}
/**
* Row zero holding header cells and every other row holding body cells, whatever the edit left.
*
* GFM has one shape for a table: the first row is the header and the delimiter row under it is what
* makes the block a table at all. serialize.ts writes the first row as the header whichever kind of
* cell it is holding, so a table that says otherwise on screen is a table that comes back different
* the next time the file is opened. prosemirror-tables copies the type of the cell it is building
* beside, which is how a row added above the header, or a column added in front of it, leaves body
* cells in row zero.
*/
function normaliseHeaderRow(tr: Transaction, tablePos: number): void {
const table = tr.doc.nodeAt(tablePos);
if (!table || table.type.name !== "table") return;
const map = TableMap.get(table);
const types = table.type.schema.nodes;
const done = new Set<number>();
for (let row = 0; row < map.height; row += 1) {
const want = row === 0 ? types.tableHeader : types.tableCell;
for (let column = 0; column < map.width; column += 1) {
const pos = map.map[row * map.width + column];
if (done.has(pos)) continue;
done.add(pos);
const cell = table.nodeAt(pos);
// Both cell types hold inline content, so this changes the type and keeps the text and the
// alignment that were in it.
if (cell && cell.type !== want) tr.setNodeMarkup(tablePos + 1 + pos, want, cell.attrs);
}
}
}
/** Tab out of the last cell should land in the row it has just made, not stay where it was. */
function cursorIntoLastRow(tr: Transaction, tablePos: number): void {
const table = tr.doc.nodeAt(tablePos);
if (!table) return;
const map = TableMap.get(table);
const cell = tablePos + 1 + map.positionAt(map.height - 1, 0, table);
tr.setSelection(TextSelection.near(tr.doc.resolve(cell + 1))).scrollIntoView();
}
/**
* A row added where `at` says, with the alignments the table already had put back over it.
*
* One transaction rather than a command each, so that Tab out of the last cell is one Cmd+Z rather
* than two, and so that the document is never momentarily a table whose column disagrees with its
* own delimiter row.
*/
function insertRow(
at: (rect: TableRect) => number,
then?: (tr: Transaction, tablePos: number) => void,
): Command {
return (state, dispatch) => {
if (!isInTable(state)) return false;
if (dispatch) {
const rect = selectedRect(state);
const alignment = columnAlignments(rect.table);
const tr = addRow(state.tr, rect, at(rect));
// tableStart is the position just inside the table, so one before it is the table itself,
// and every row went in after that point rather than before it.
const tablePos = rect.tableStart - 1;
restoreAlignments(tr, tablePos, alignment);
normaliseHeaderRow(tr, tablePos);
if (then) then(tr, tablePos);
dispatch(tr);
}
return true;
};
}
const addRowAbove = insertRow((rect) => rect.top);
const addRowBelow = insertRow((rect) => rect.bottom);
const addRowAtEnd = insertRow((rect) => rect.map.height, cursorIntoLastRow);
/** Tab: the next cell along, or the row that has to be made first when there is no next cell. */
const nextCellOrNewRow: Command = (state, dispatch) =>
goToNextCell(1)(state, dispatch) || addRowAtEnd(state, dispatch);
/**
* One of prosemirror-tables' own structural commands, with the header row put back over whatever it
* produced, in the transaction the command built rather than a second one behind it.
*
* The library is asked with a dispatch that only catches the transaction, so a command that answers
* false still leaves nothing behind, and a table this edit removed outright is a table
* `normaliseHeaderRow` declines to find.
*/
function normalising(command: Command): Command {
return (state, dispatch, view) => {
if (!isInTable(state)) return false;
if (!dispatch) return command(state, undefined, view);
const tablePos = selectedRect(state).tableStart - 1;
let caught: Transaction | null = null;
const acted = command(
state,
(tr) => {
caught = tr;
},
view,
);
if (!acted || caught === null) return acted;
normaliseHeaderRow(caught, tablePos);
dispatch(caught);
return true;
};
}
/**
* The whole column the selection covers, header cell included.
*
* Setting only the cell under the cursor would show an alignment on screen that the next save
* silently takes back off, and leaving the header out would lose the alignment outright, since the
* first row is the one the delimiter row is written from.
*/
function alignColumn(align: ColumnAlign): Command {
return (state, dispatch) => {
if (!isInTable(state)) return false;
const { left, right, map, table, tableStart } = selectedRect(state);
// Positions relative to the table, and a set because a cell that spans columns appears in the
// map once per column it covers.
const cells = new Set<number>();
for (let row = 0; row < map.height; row += 1) {
for (let column = left; column < right; column += 1) {
const pos = map.map[row * map.width + column];
if (table.nodeAt(pos)?.attrs.align !== align) cells.add(pos);
}
}
if (cells.size === 0) return false;
if (dispatch) {
const tr = state.tr;
for (const pos of cells) {
const cell = table.nodeAt(pos);
if (cell) tr.setNodeMarkup(tableStart + pos, null, { ...cell.attrs, align });
}
dispatch(tr);
}
return true;
};
}
/**
* The command, with the key claimed for as long as the cursor is in a table, whether or not the
* command found anything to do with it.
*
* A binding that answers false hands the key on to whatever is bound behind it, and behind this
* lane's Tab and Shift-Tab is shortcuts.ts's list pair, which reshapes the list around the table
* rather than anything inside it. A table indented under a bullet is an ordinary thing to write,
* and Shift-Tab in its first cell has nowhere to go: the answer to that is the cursor staying where
* it is, not the item being lifted out and the list dissolved by a key pressed to move back a cell.
*/
function claimedInTable(command: Command): Command {
return (state, dispatch, view) => {
if (!isInTable(state)) return false;
command(state, dispatch, view);
return true;
};
}
/**
* Every op the handle can name, as the ProseMirror command that performs it.
*
* There is no header row op. GFM writes the first row of a table as its header and has no spelling
* for a table without one or for a second one, so both directions of a toggle are an edit the
* serializer cannot carry and the next open of the file does not show. An op the file cannot hold
* is an op that is not offered.
*/
const TABLE_OPS: { [op in TableOp]: Command } = {
addRowBefore: addRowAbove,
addRowAfter: addRowBelow,
deleteRow: normalising(deleteRow),
addColumnBefore: normalising(addColumnBefore),
addColumnAfter: normalising(addColumnAfter),
deleteColumn: normalising(deleteColumn),
deleteTable,
alignLeft: alignColumn("left"),
alignCenter: alignColumn("center"),
alignRight: alignColumn("right"),
alignClear: alignColumn(null),
};
/**
* A printable character typed over a rectangle of dragged cells, which does nothing.
*
* ProseMirror offers a character to `handleTextInput` whenever the selection is not an ordinary one
* inside a single textblock, and when nobody claims it the character goes in through
* `tr.insertText`, which is `Selection.replace`. A cell selection replaces every range it holds:
* the character lands in the LAST cell of the rectangle and the other cells are emptied. Measured,
* in a browser, on the table this lane was reported against: a drag across a 2x2 body and the three
* keystrokes "zqx" took "| 1 | 2 |\n| 3 | 4 |" to four empty cells with "zqx" sitting in the last
* of them. Four cells of somebody's table for three characters, and none of them the cell the drag
* started in.
*
* That is the destruction src/editor/paste.ts refuses for a Cmd+V arriving at the same selection,
* and it arrived here by the one route with no guard on it at all.
*
* Emptying the cells is what Backspace over a rectangle does, in the keymap at the end of this
* file, and that is right: delete is the verb that was pressed and the rows and the columns survive
* it. A letter is not that verb. A rectangle is a selection of whole cells rather than of text, so
* there is no text for a character to replace and no one cell it belongs in: putting it in the
* first cell or the last one both throw away cells the user never aimed at, and neither is what
* they asked for. So nothing happens, the key is claimed so that nothing else does it either, and
* the rectangle stays selected, which leaves Backspace, the toolbar and a click into one cell all
* exactly where they were.
*/
const typing = new Plugin({
key: typingKey,
props: {
handleTextInput: (view) => overCells(view.state),
},
});
export const Tables = Extension.create({
name: "tables",
addProseMirrorPlugins() {
// There was a third plugin in front of these two once, and it answered one paste: an empty
// slice over a rectangle of cells, which is what a clipboard holding only an image looks like
// by the time it reaches a handler. `tableEditing` takes that empty slice and empties every
// cell in the rectangle, so a PNG pasted over a dragged table deleted the table's text.
//
// It was here because src/editor/paste.ts already refused exactly that and was never asked:
// these plugins came ninth in the list and that one came fifteenth. The guard sitting in front
// of the plugin it guards was the right instinct and the wrong fix, because it only covered
// the empty slice, and the same ordering handed the library every non-empty paste over a cell
// selection too. One word pasted over four dragged cells replaced all four.
//
// So the paste ordering is fixed instead: paste.ts asks for the highest priority in the editor,
// is asked first for every paste, and stands aside only for cells pasted into a table, which is
// the one paste those two are better at. `typing` below is not a second copy of that question,
// it is a different event: nothing in this editor claimed a typed character, and a typed
// character over a rectangle is the same destruction arriving by the one route nobody guarded.
return [
typing,
// columnResizing before tableEditing, which takes mousedown for the cell selection drag: a
// press on a column edge is a resize, and the plugin that decides that has to be asked first.
columnResizing(),
tableEditing(),
];
},
addKeyboardShortcuts() {
const editor = this.editor;
// ProseMirror's own calling convention rather than editor.commands.command, which dispatches
// its transaction whatever the command answered. These four are asked on every Tab and every
// Backspace in the document, and a key pressed outside a table has to leave nothing behind.
const run = (command: Command) => () =>
command(editor.state, editor.view.dispatch, editor.view);
return {
// Tab out of the last cell grows the table, which is the only way to add a row without
// reaching for the toolbar. Shift-Tab has no matching gesture: there is no row before the
// first one to make, so in the first cell it moves nothing and answers for the key anyway.
// Both are claimed the same way so that neither can be handed on to a list command; see
// claimedInTable above for what that costs the document when it is.
Tab: run(claimedInTable(nextCellOrNewRow)),
"Shift-Tab": run(claimedInTable(goToNextCell(-1))),
// Said here rather than left to tableEditing's own binding further down the plugin list.
// A cell selection has to be emptied and not removed: deleting it as a selection would take
// the rows and columns the cells were in along with the text that was in them.
//
// Not claimed the way the two above are: with a plain cursor in a cell there is nothing to
// empty, and a Backspace that stopped here would be a Backspace that never deletes a
// character. What is behind these is StarterKit's list keymap, which reads the cursor's
// parent as its list item and finds a table row instead, so it declines from inside a cell
// and the key reaches the editing it was pressed for. src/editor/blocks/tables.test.ts holds
// that assertion, since it is the library's behaviour rather than this file's.
Backspace: run(deleteCellSelection),
Delete: run(deleteCellSelection),
};
},
});
/** False when the cursor is not in a table, or the op has nothing to act on where it is. */
export function tableCommand(editor: Editor, op: TableOp): boolean {
const command = TABLE_OPS[op];
// Read out of the chain rather than off the chain's own result, because focus is in the chain too
// and answers a different question, with a false of its own whenever there is no view to focus.
let acted = false;
editor
.chain()
.focus()
.command(({ state, dispatch }) => {
acted = command(state, dispatch);
return acted;
})
.run();
return acted;
}
+773
View File
@@ -0,0 +1,773 @@
// What can be asserted about a toggle without a browser, which is the half that costs somebody a
// file.
//
// vite.config.ts runs vitest in the node environment, so there is no page here and nothing a
// browser does with one is on trial: the arrow's drawing, the caret's travel through a title and
// the placeholder on an untitled one belong to the Playwright suite. What belongs here is
// everything that reaches disk. The two attributes a toggle carries are the two things this lane
// writes, and both of them go out through an html block that the bridge will only read back if it
// is spelled one exact way, so a title the editor mangles on the way in is a `<details>` that opens
// as a raw block the next time the file is looked at.
//
// The node view is built here all the same, against the page written out at the bottom of this
// file, because two of the things it decides reach disk as surely as the attributes do: which
// clicks on the summary write `open`, and which commands are allowed to run while the caret is
// somewhere the document's selection is not. Both were bugs a green suite did not see, and neither
// is a question about a browser. They are questions about what these handlers do with what a
// browser sends them.
//
// The entity group is the one to keep. A summary holding `&`, `<` or `>` is escaped by the
// serializer and unescaped by the parser, and the parser refuses to model any toggle those two do
// not agree about character for character. An extra round of escaping would not throw, would not
// fail a type check and would not lose the document: it would grow another `amp;` in somebody's
// heading on every save.
import { describe, expect, it } from "vitest";
import { Editor } from "@tiptap/core";
import type { JSONContent } from "@tiptap/core";
import { EditorState, TextSelection } from "@tiptap/pm/state";
import type { Plugin, Transaction } from "@tiptap/pm/state";
import { DecorationSet } from "@tiptap/pm/view";
import type { EditorView } from "@tiptap/pm/view";
import { createEditorExtensions } from "../extensions";
import { parseMarkdown, serializeMarkdown } from "../../markdown";
import { Toggles, setToggleOpen, setToggleSummary } from "./toggle";
const PATH = "/notes/writing.md";
const extensions = () => createEditorExtensions({ documentPath: () => PATH, onError: () => {} });
const EMPTY: JSONContent = { type: "doc", content: [{ type: "paragraph" }] };
/**
* An editor with the lanes' plugins in its state.
*
* TipTap only installs them when it mounts a view and there is no DOM here to mount into, so the
* state is rebuilt with them the way src/editor/Editor.tsx installs every document it opens. It
* matters more here than it does for a keymap: this lane's plugin also appends a transaction, and a
* plugin that is not in the state is never asked to.
*/
function makeEditor(content: JSONContent = EMPTY): Editor {
const editor = new Editor({ element: null, injectCSS: false, extensions: extensions(), content });
editor.view.updateState(
EditorState.create({ doc: editor.state.doc, plugins: editor.extensionManager.plugins }),
);
return editor;
}
function editorFor(source: string): Editor {
return makeEditor(parseMarkdown(source, PATH).doc.toJSON());
}
/** What the bridge would write for the document as it stands now. */
function written(source: string, editor: Editor): string {
return serializeMarkdown(parseMarkdown(source, PATH), editor.state.doc);
}
/** Where the first toggle in the document is, and what it holds. */
function toggleIn(editor: Editor): { pos: number; open: boolean; summary: string; body: string } {
let found: { pos: number; open: boolean; summary: string; body: string } | null = null;
editor.state.doc.descendants((node, pos) => {
if (found || node.type.name !== "toggle") return !found;
found = {
pos,
open: node.attrs.open as boolean,
summary: node.attrs.summary as string,
body: node.textContent,
};
return false;
});
if (!found) throw new Error("no toggle in the document");
return found;
}
/** The plugins offering a node view for the toggle node, found the way the view finds them. */
function nodeViewPlugins(editor: Editor): Plugin[] {
return editor.extensionManager.plugins.filter(
(plugin) => plugin.props.nodeViews?.toggle !== undefined,
);
}
const SUMMARY = "Tom &amp; Jerry &lt;3&gt;";
const PLAIN = "Tom & Jerry <3>";
const DOC = [
"# Writing",
"",
"<details>",
`<summary>${SUMMARY}</summary>`,
"",
"No em dashes.",
"",
"</details>",
"",
"After.",
"",
].join("\n");
describe("the toggle extension", () => {
it("is the one the registry names", () => {
expect(Toggles.name).toBe("toggles");
});
it("adds no node and no mark, so the bridge and the editor still agree", () => {
const plain = new Editor({
element: null,
injectCSS: false,
extensions: extensions().filter((extension) => extension.name !== "toggles"),
content: EMPTY,
});
const withToggles = makeEditor();
expect(Object.keys(withToggles.schema.nodes)).toEqual(Object.keys(plain.schema.nodes));
expect(Object.keys(withToggles.schema.marks)).toEqual(Object.keys(plain.schema.marks));
plain.destroy();
withToggles.destroy();
});
// The node view is the whole feature: without one the browser's own disclosure takes the click,
// the open attribute never moves and a keystroke aimed at the title lands in the body. Two
// plugins claiming the node would be the same bug from the other end, since ProseMirror takes the
// first one asked and the other never runs.
it("is the only plugin in the build that claims the toggle node view", () => {
const editor = makeEditor();
expect(nodeViewPlugins(editor)).toHaveLength(1);
editor.destroy();
});
});
describe("a <details> read off disk", () => {
it("arrives as a toggle carrying its summary as text, not as entities", () => {
const editor = editorFor(DOC);
const toggle = toggleIn(editor);
expect(toggle.summary).toBe(PLAIN);
expect(toggle.open).toBe(false);
expect(toggle.body).toBe("No em dashes.");
editor.destroy();
});
it("is written back byte for byte when nothing was edited", () => {
const editor = editorFor(DOC);
expect(written(DOC, editor)).toBe(DOC);
editor.destroy();
});
it("keeps the whole file byte identical when only the summary is edited", () => {
const editor = editorFor(DOC);
const { pos } = toggleIn(editor);
expect(setToggleSummary(pos, "Tom & Jerry <4>")(editor.state, editor.view.dispatch)).toBe(true);
expect(written(DOC, editor)).toBe(DOC.replace("&lt;3&gt;", "&lt;4&gt;"));
editor.destroy();
});
// The failure this is shaped to catch is invisible: an editor that put the escaped form on the
// node would write `&amp;amp;` here, the file would still parse, and the title would grow a word
// every time the document was saved.
it("does not escape the ampersand it already escaped once", () => {
const editor = editorFor(DOC);
const { pos } = toggleIn(editor);
// The one keystroke: a character on the end of a title that already holds all three of the
// characters the serializer has to spell as entities.
setToggleSummary(pos, `${PLAIN}!`)(editor.state, editor.view.dispatch);
const once = written(DOC, editor);
expect(once).toBe(DOC.replace(SUMMARY, `${SUMMARY}!`));
// And back through the bridge, which is where a second round of escaping would show up.
const again = makeEditor(parseMarkdown(once, PATH).doc.toJSON());
expect(toggleIn(again).summary).toBe(`${PLAIN}!`);
expect(written(once, again)).toBe(once);
again.destroy();
editor.destroy();
});
it("writes the open marker on to the tag, and takes it off again", () => {
const editor = editorFor(DOC);
const { pos } = toggleIn(editor);
expect(setToggleOpen(pos, true)(editor.state, editor.view.dispatch)).toBe(true);
expect(written(DOC, editor)).toBe(DOC.replace("<details>", "<details open>"));
expect(setToggleOpen(pos, false)(editor.state, editor.view.dispatch)).toBe(true);
expect(written(DOC, editor)).toBe(DOC);
editor.destroy();
});
it("leaves the body alone whatever happens to the two attributes", () => {
const editor = editorFor(DOC);
const { pos, body } = toggleIn(editor);
setToggleSummary(pos, "Something else")(editor.state, editor.view.dispatch);
setToggleOpen(pos, true)(editor.state, editor.view.dispatch);
expect(toggleIn(editor).body).toBe(body);
expect(editor.state.doc.textContent).toBe(editorFor(DOC).state.doc.textContent);
editor.destroy();
});
});
describe("the two commands", () => {
it("decline a position that is not a toggle, and one that already reads that way", () => {
const editor = editorFor(DOC);
const { pos, summary } = toggleIn(editor);
const before = editor.state.doc.toJSON();
expect(setToggleOpen(0, true)(editor.state, editor.view.dispatch)).toBe(false);
expect(setToggleSummary(0, "x")(editor.state, editor.view.dispatch)).toBe(false);
expect(setToggleOpen(pos, false)(editor.state, editor.view.dispatch)).toBe(false);
expect(setToggleSummary(pos, summary)(editor.state, editor.view.dispatch)).toBe(false);
expect(editor.state.doc.toJSON()).toEqual(before);
editor.destroy();
});
// A closed <details> does not draw its children, so a caret left in one is a caret nobody can see
// and the next keystroke goes somewhere invisible.
it("bring the caret out of a body that is being closed", () => {
const editor = editorFor(DOC);
const { pos } = toggleIn(editor);
setToggleOpen(pos, true)(editor.state, editor.view.dispatch);
const inside = pos + 2;
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, inside)));
expect(editor.state.selection.from).toBe(inside);
setToggleOpen(pos, false)(editor.state, editor.view.dispatch);
const node = editor.state.doc.nodeAt(pos)!;
expect(editor.state.selection.from >= pos + node.nodeSize).toBe(true);
editor.destroy();
});
it("leave the caret where it is when it was never inside", () => {
const editor = editorFor(DOC);
const { pos } = toggleIn(editor);
setToggleOpen(pos, true)(editor.state, editor.view.dispatch);
editor.commands.setTextSelection(2);
setToggleOpen(pos, false)(editor.state, editor.view.dispatch);
expect(editor.state.selection.from).toBe(2);
editor.destroy();
});
});
// What the pill's Toggle button runs, which is `toggleWrap("toggle")` in setBlock in
// src/editor/Editor.tsx. A toggle takes the attribute's own default, which is closed, so without
// the plugin's appended transaction the paragraph the user just wrapped is behind an arrow and
// reads as a deletion.
describe("wrapping a block in a toggle", () => {
const PROSE: JSONContent = {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "Keep this visible." }] }],
};
it("leaves it open, with the block still on screen inside it", () => {
const editor = makeEditor(PROSE);
editor.commands.setTextSelection(2);
expect(editor.chain().toggleWrap("toggle").run()).toBe(true);
const toggle = toggleIn(editor);
expect(toggle.open).toBe(true);
expect(toggle.summary).toBe("");
expect(toggle.body).toBe("Keep this visible.");
editor.destroy();
});
it("makes something the bridge writes and reads back as the same toggle", () => {
const editor = makeEditor(PROSE);
editor.commands.setTextSelection(2);
editor.chain().toggleWrap("toggle").run();
setToggleSummary(toggleIn(editor).pos, "House style")(editor.state, editor.view.dispatch);
const source = written("Keep this visible.\n", editor);
expect(source).toBe(
["<details open>", "<summary>House style</summary>", "", "Keep this visible.", "", "</details>", ""].join("\n"),
);
const reopened = makeEditor(parseMarkdown(source, PATH).doc.toJSON());
const toggle = toggleIn(reopened);
expect([toggle.open, toggle.summary, toggle.body]).toEqual([true, "House style", "Keep this visible."]);
reopened.destroy();
editor.destroy();
});
it("takes it back out on a second press, which is what the same button means", () => {
const editor = makeEditor(PROSE);
editor.commands.setTextSelection(2);
editor.chain().toggleWrap("toggle").run();
expect(editor.chain().toggleWrap("toggle").run()).toBe(true);
expect(editor.state.doc.toJSON()).toEqual(PROSE);
editor.destroy();
});
});
// The guard on the appended transaction, and the reason it is written the way it is. Opening a
// document restores the caret it was last left at, and that is a selection with no edit behind it:
// a toggle opened by one would be a byte written into a file nobody has touched.
describe("a caret that moves without an edit", () => {
it("never opens the toggle it lands in, and never dirties the document", () => {
const editor = editorFor(DOC);
const { pos } = toggleIn(editor);
const before = editor.state.doc.toJSON();
editor.view.dispatch(
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos + 2)),
);
expect(toggleIn(editor).open).toBe(false);
expect(editor.state.doc.toJSON()).toEqual(before);
expect(written(DOC, editor)).toBe(DOC);
editor.destroy();
});
});
/**
* The few parts of a page the node view reaches for, written out rather than depended on.
*
* A DOM implementation is not a dependency this project has, and the handful of calls the node view
* makes into one does not earn it: it creates elements, hangs listeners on them, asks which one the
* page thinks has the caret, and asks for a frame. What a browser does with an element is
* Playwright's question. What the handlers in toggle.ts do with what a browser sends them is this
* file's, and that is the whole of what these model.
*/
interface PageEvent {
type: string;
target: PageElement;
key?: string;
isComposing?: boolean;
defaultPrevented: boolean;
preventDefault: () => void;
}
class PageElement {
className = "";
contentEditable = "inherit";
textContent = "";
parent: PageElement | null = null;
private readonly attributes = new Set<string>();
private readonly listeners = new Map<string, ((event: PageEvent) => void)[]>();
constructor(
readonly tagName: string,
readonly ownerDocument: Page,
) {}
/** Only ever asked whether there is one, which is how a leftover <br> in a title is found. */
get firstChild(): object | null {
return this.textContent === "" ? null : { nodeName: "#text" };
}
setAttribute(name: string, _value: string): void {
this.attributes.add(name);
}
hasAttribute(name: string): boolean {
return this.attributes.has(name);
}
toggleAttribute(name: string, on: boolean): void {
if (on) this.attributes.add(name);
else this.attributes.delete(name);
}
appendChild(child: PageElement): void {
child.parent = this;
}
append(...children: PageElement[]): void {
for (const child of children) this.appendChild(child);
}
contains(other: PageElement | null): boolean {
for (let element = other; element; element = element.parent) if (element === this) return true;
return false;
}
focus(): void {
this.ownerDocument.activeElement = this;
this.fire("focus");
}
blur(): void {
if (this.ownerDocument.activeElement === this) this.ownerDocument.activeElement = null;
this.fire("blur");
}
addEventListener(type: string, listener: (event: PageEvent) => void): void {
const list = this.listeners.get(type) ?? [];
list.push(listener);
this.listeners.set(type, list);
}
removeEventListener(type: string, listener: (event: PageEvent) => void): void {
this.listeners.set(type, (this.listeners.get(type) ?? []).filter((one) => one !== listener));
}
/** Bubbling, because the row's listeners are what an event on the title inside it reaches. */
fire(type: string, extra: Partial<PageEvent> = {}): PageEvent {
const event: PageEvent = {
type,
target: this,
defaultPrevented: false,
preventDefault: () => {
event.defaultPrevented = true;
},
...extra,
};
for (let element: PageElement | null = this; element; element = element.parent) {
for (const listener of element.listeners.get(type) ?? []) listener(event);
}
return event;
}
}
class Page {
activeElement: PageElement | null = null;
readonly created: PageElement[] = [];
private readonly frames: (() => void)[] = [];
readonly defaultView = {
requestAnimationFrame: (run: () => void): number => this.frames.push(run),
};
createElement(tagName: string): PageElement {
const element = new PageElement(tagName, this);
this.created.push(element);
return element;
}
/** Nothing here models a caret inside a title, only which element holds one. */
getSelection(): null {
return null;
}
/** The frame a browser would run next, which is also where TipTap's own focus call lands. */
runFrames(): number {
const queued = this.frames.splice(0);
for (const run of queued) run();
return queued.length;
}
}
interface Mounted {
page: Page;
details: PageElement;
summary: PageElement;
title: PageElement;
destroy: () => void;
}
/** The node view for the toggle at `pos`, built the way the editor's view builds one. */
function mount(editor: Editor, pos: number): Mounted {
const page = new Page();
const view = {
dom: page.createElement("div"),
get state(): EditorState {
return editor.state;
},
dispatch: (tr: Transaction) => editor.view.dispatch(tr),
editable: true,
focus: () => {},
} as unknown as EditorView;
const build = nodeViewPlugins(editor)[0]?.props.nodeViews?.toggle;
const node = editor.state.doc.nodeAt(pos);
if (!build || !node) throw new Error("nothing to build a node view from");
const nodeView = build(node, view, () => pos, [], DecorationSet.empty);
const find = (match: (element: PageElement) => boolean): PageElement => {
const element = page.created.find(match);
if (!element) throw new Error("the node view did not build that element");
return element;
};
return {
page,
details: find((element) => element.tagName === "details"),
summary: find((element) => element.tagName === "summary"),
title: find((element) => element.hasAttribute("data-toggle-summary")),
destroy: () => nodeView.destroy?.(),
};
}
/** Where the text of this block ends, which is where a click in it leaves the caret. */
function endOf(editor: Editor, text: string): number {
let found: number | null = null;
editor.state.doc.descendants((node, pos) => {
if (found !== null) return false;
if (node.isTextblock && node.textContent === text) found = pos + 1 + node.content.size;
return found === null;
});
if (found === null) throw new Error(`no block reading ${text}`);
return found;
}
const PAGE_DOC = [
"# Doc",
"",
"First paragraph.",
"",
"Second paragraph.",
"",
"<details open>",
"<summary>My title</summary>",
"",
"Toggle body.",
"",
"</details>",
"",
].join("\n");
// A `<details>` is a disclosure control and the browser works one from the keyboard whether this
// file wants it to or not: a space typed anywhere inside the summary sends the row a click of its
// own. Taken as a press, that closed the toggle under the caret on every other space of a title and
// wrote the flip to the file each time.
describe("a space typed in a title", () => {
it("leaves the toggle as it was, and the space in the title", () => {
const editor = editorFor(PAGE_DOC);
const ui = mount(editor, toggleIn(editor).pos);
ui.title.focus();
ui.title.fire("keydown", { key: " " });
// The character the browser puts in the element, and then the click it sends the row after it.
ui.title.textContent = "My title ";
ui.title.fire("input");
ui.summary.fire("click");
const toggle = toggleIn(editor);
expect(toggle.open).toBe(true);
expect(toggle.summary).toBe("My title ");
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC.replace("My title", "My title "));
ui.destroy();
editor.destroy();
});
it("does not find a press that never became a click waiting for it", () => {
const editor = editorFor(PAGE_DOC);
const ui = mount(editor, toggleIn(editor).pos);
// Pressed on the row, and then the pointer left and no click ever came of it.
ui.summary.fire("mousedown");
ui.title.focus();
ui.title.fire("keydown", { key: " " });
ui.summary.fire("click");
expect(toggleIn(editor).open).toBe(true);
ui.destroy();
editor.destroy();
});
});
describe("a press on the summary row", () => {
it("still flips the toggle and writes it, which is what the arrow is for", () => {
const editor = editorFor(PAGE_DOC);
const ui = mount(editor, toggleIn(editor).pos);
ui.summary.fire("mousedown");
ui.summary.fire("click");
expect(toggleIn(editor).open).toBe(false);
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC.replace("<details open>", "<details>"));
ui.destroy();
editor.destroy();
});
it("flips it from the keyboard when the row itself is what has focus", () => {
const editor = editorFor(PAGE_DOC);
const ui = mount(editor, toggleIn(editor).pos);
ui.page.activeElement = ui.summary;
ui.summary.fire("click");
expect(toggleIn(editor).open).toBe(false);
ui.destroy();
editor.destroy();
});
});
// The caret in a title is not in the document: ProseMirror's selection is still wherever it was
// when the caret went in there, so a toolbar button pressed while a title is being typed runs its
// command against a paragraph the user is not looking at.
describe("a command aimed at the document while the caret is in a title", () => {
it("changes nothing, and the file with it", () => {
const editor = editorFor(PAGE_DOC);
const ui = mount(editor, toggleIn(editor).pos);
editor.commands.setTextSelection(endOf(editor, "Second paragraph."));
ui.title.focus();
const before = editor.state.doc.toJSON();
// What the pill's Horizontal rule runs, less the focus() in front of it, which wants a browser.
editor.commands.insertContent({ type: "horizontalRule" });
expect(editor.state.doc.toJSON()).toEqual(before);
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC);
ui.destroy();
editor.destroy();
});
it("leaves the caret in the title, so a second press is refused like the first", () => {
const editor = editorFor(PAGE_DOC);
const ui = mount(editor, toggleIn(editor).pos);
editor.commands.setTextSelection(endOf(editor, "Second paragraph."));
ui.title.focus();
const before = editor.state.doc.toJSON();
editor.commands.insertContent({ type: "horizontalRule" });
// TipTap's focus command queues a view.focus() for the next frame, and it lands behind the
// refusal: without the caret being put back, the second press of the same button has a caret in
// the document again and edits the place the first press was refused for.
ui.title.blur();
expect(ui.page.runFrames()).toBe(1);
expect(ui.page.activeElement).toBe(ui.title);
editor.commands.insertContent({ type: "horizontalRule" });
expect(editor.state.doc.toJSON()).toEqual(before);
ui.destroy();
editor.destroy();
});
it("is taken back the moment the caret leaves the title", () => {
const editor = editorFor(PAGE_DOC);
const ui = mount(editor, toggleIn(editor).pos);
editor.commands.setTextSelection(endOf(editor, "Second paragraph."));
ui.title.focus();
ui.title.blur();
const before = editor.state.doc.toJSON();
editor.commands.insertContent({ type: "horizontalRule" });
expect(editor.state.doc.toJSON()).not.toEqual(before);
ui.destroy();
editor.destroy();
});
it("is never the title's own writing, which is the one edit that is where the caret is", () => {
const editor = editorFor(PAGE_DOC);
const ui = mount(editor, toggleIn(editor).pos);
ui.title.focus();
ui.title.textContent = "Renamed";
ui.title.fire("input");
expect(toggleIn(editor).summary).toBe("Renamed");
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC.replace("My title", "Renamed"));
ui.destroy();
editor.destroy();
});
});
const TWO_TOGGLES = [
"<details>",
"<summary>Closed</summary>",
"",
"Hidden body.",
"",
"</details>",
"",
"<details open>",
"<summary>My title</summary>",
"",
"Toggle body.",
"",
"</details>",
"",
].join("\n");
/** Every toggle in the document, in the order they are written. */
function togglesIn(editor: Editor): { pos: number; open: boolean; summary: string }[] {
const found: { pos: number; open: boolean; summary: string }[] = [];
editor.state.doc.descendants((node, pos) => {
if (node.type.name !== "toggle") return true;
found.push({ pos, open: node.attrs.open as boolean, summary: node.attrs.summary as string });
return true;
});
return found;
}
// The plugin opens the collapsed toggle the caret ends an edit inside, because a caret behind a
// closed arrow is one nobody can see. The caret in a title is not in the document at all, so the
// selection that rule reads is stale and the toggle it names is one nobody is in.
describe("typing in a title while the selection is left inside another toggle", () => {
it("does not open the toggle it is left in, and writes no byte into that one", () => {
const editor = editorFor(TWO_TOGGLES);
const [closed, titled] = togglesIn(editor);
const ui = mount(editor, titled.pos);
editor.view.dispatch(
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, closed.pos + 3)),
);
ui.title.focus();
ui.title.textContent = "Renamed";
ui.title.fire("input");
expect(togglesIn(editor)[0].open).toBe(false);
expect(written(TWO_TOGGLES, editor)).toBe(TWO_TOGGLES.replace("My title", "Renamed"));
ui.destroy();
editor.destroy();
});
});
// src/markdown/parse.ts pairs `<details>` among the root's own children and nowhere else, so a
// toggle anywhere but the top level of the document goes to disk as something the next open of the
// file reads back as one raw block: the bytes are kept and both constructs stop being editable.
describe("a toggle the bridge could not read back", () => {
const CALLOUT = "> [!NOTE]\n> Callout body.\n";
it("is not made inside a callout", () => {
const editor = editorFor(CALLOUT);
editor.commands.setTextSelection(endOf(editor, "Callout body."));
const before = editor.state.doc.toJSON();
editor.chain().toggleWrap("toggle").run();
expect(editor.state.doc.toJSON()).toEqual(before);
expect(written(CALLOUT, editor)).toBe(CALLOUT);
editor.destroy();
});
it("is not made inside another toggle", () => {
const editor = editorFor(PAGE_DOC);
editor.commands.setTextSelection(endOf(editor, "Toggle body."));
const before = editor.state.doc.toJSON();
// Attributes the toggle already there does not carry, because that is the call that nests:
// TipTap's isNodeActive wants them to match before it takes a second press as taking one out,
// so anything else wraps a second toggle around the inside of the first.
editor.chain().toggleWrap("toggle", { open: false }).run();
expect(editor.state.doc.toJSON()).toEqual(before);
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC);
editor.destroy();
});
// The rule is about a toggle being put somewhere, and an edit inside one that is already there
// reaches into the same node without moving it. Reading that as a toggle being nested would
// refuse every keystroke in every toggle body in the document.
it("does not stand in the way of an edit inside a toggle that is already there", () => {
const editor = editorFor(PAGE_DOC);
editor.commands.setTextSelection(endOf(editor, "Toggle body."));
editor.commands.insertContent({ type: "text", text: " More." });
expect(toggleIn(editor).body).toBe("Toggle body. More.");
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC.replace("Toggle body.", "Toggle body. More."));
editor.destroy();
});
it("is still made at the top level, where it is read back as itself", () => {
const editor = editorFor(PAGE_DOC);
editor.commands.setTextSelection(endOf(editor, "First paragraph."));
expect(editor.chain().toggleWrap("toggle").run()).toBe(true);
expect(togglesIn(editor)).toHaveLength(2);
const source = written(PAGE_DOC, editor);
expect(parseMarkdown(source, PATH).doc.childCount).toBe(editor.state.doc.childCount);
editor.destroy();
});
});
Loaded 100 of 252 files, more files were not shown because too many files have changed in this diff. Show more