features
No files matched your search
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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 `
` and `
`. 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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 491 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 685 B |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 829 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1022 B |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
@@ -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 |
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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(¬e, "# Note\n").unwrap();
|
||||||
|
|
||||||
|
let events = harness.events_for(¬e);
|
||||||
|
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(¬e, "# Note\n\nA second paragraph.\n").unwrap();
|
||||||
|
|
||||||
|
let events = harness.events_for(¬e);
|
||||||
|
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(¬e).unwrap();
|
||||||
|
|
||||||
|
let events = harness.events_for(¬e);
|
||||||
|
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(¬e);
|
||||||
|
fs::write(¬e, "# Note\n\nWritten by the app itself.\n").unwrap();
|
||||||
|
|
||||||
|
let events = harness.events_for(¬e);
|
||||||
|
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(¬e);
|
||||||
|
fs::write(&temp, "# Note\n\nWritten by the app itself.\n").unwrap();
|
||||||
|
fs::rename(&temp, ¬e).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(¬e, "# 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(¬e);
|
||||||
|
fs::write(¬e, "one\n").unwrap();
|
||||||
|
assert!(harness.events_for(¬e).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(¬e, "two\n").unwrap();
|
||||||
|
|
||||||
|
let events = harness.events_for(¬e);
|
||||||
|
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(¬e, format!("# Note\n\nRevision {n}.\n")).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let events = harness.events_for(¬e);
|
||||||
|
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, ¬e).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(¬e, "# 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());
|
||||||
|
}
|
||||||
@@ -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(¬e, "# Note\n\nEdited by another program.\n").unwrap();
|
||||||
|
|
||||||
|
expect_payload(&harness.payload_for(¬e), ¬e, "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(¬e, "# Note\n").unwrap();
|
||||||
|
|
||||||
|
expect_payload(&harness.payload_for(¬e), ¬e, "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(¬e).unwrap();
|
||||||
|
|
||||||
|
expect_payload(&harness.payload_for(¬e), ¬e, "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`"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// The window, and the only file that knows what the whole app looks like at once.
|
||||||
|
//
|
||||||
|
// Everything here is wiring: which surface is on screen, which of the backend's events the shell
|
||||||
|
// listens for, and what happens to an unsaved document when the window is asked to close. No
|
||||||
|
// business logic and no disk access. The stores hold state, their sibling modules do the work, and
|
||||||
|
// this file decides what is mounted.
|
||||||
|
//
|
||||||
|
// One document at a time and no tab bar, so there is exactly one editor in the tree and it is
|
||||||
|
// either the WYSIWYG surface or the plain text one, never both. A folder of documents can be open
|
||||||
|
// with nothing chosen out of it, which is why the empty pane is a state and not an error.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { Backlinks } from "./components/Backlinks";
|
||||||
|
import { CommandPalette } from "./components/CommandPalette";
|
||||||
|
import { ConflictDialog } from "./components/ConflictDialog";
|
||||||
|
import { FindBar } from "./components/FindBar";
|
||||||
|
import { FindInFiles } from "./components/FindInFiles";
|
||||||
|
import { ProofPopover } from "./components/ProofPopover";
|
||||||
|
import { QuickOpen } from "./components/QuickOpen";
|
||||||
|
import { Recents } from "./components/Recents";
|
||||||
|
import { Shortcuts } from "./components/Shortcuts";
|
||||||
|
import { Sidebar } from "./components/Sidebar";
|
||||||
|
import { Titlebar } from "./components/Titlebar";
|
||||||
|
import { Toast } from "./components/Toast";
|
||||||
|
import { flushPendingSave, keepBuffer } from "./document";
|
||||||
|
import { DocumentEditor, PlainTextEditor, useDocumentFind } from "./editor";
|
||||||
|
import { Toolbar, type ToolbarSaveState } from "./editor/Toolbar";
|
||||||
|
import { MENU_ACTION_EVENT, isDesktop, isTauri } from "./ipc";
|
||||||
|
import { onCommand, type CommandId } from "./keys/commands";
|
||||||
|
import { useKeymap } from "./keys/keymap";
|
||||||
|
import { handleMenuAction } from "./keys/menu";
|
||||||
|
import { openLink } from "./links";
|
||||||
|
import { documentKindForPath } from "./model/doc";
|
||||||
|
import { useDocument } from "./store/useDocument";
|
||||||
|
import { notify } from "./store/useToast";
|
||||||
|
import { useWorkspace } from "./store/useWorkspace";
|
||||||
|
import { useCompact, useTouch } from "./useMedia";
|
||||||
|
import { applyWidth } from "./width";
|
||||||
|
import { restoreSession, startWorkspaceEvents } from "./workspace";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commands whose whole result is a panel that this milestone does not have. They are bound keys
|
||||||
|
* and native menu rows already, so pressing one has to say something: a key that silently does
|
||||||
|
* nothing reads as a broken app rather than as an unfinished one. Each line goes when its panel
|
||||||
|
* arrives.
|
||||||
|
*/
|
||||||
|
const UNBUILT: ReadonlyArray<[CommandId, string]> = [
|
||||||
|
["settings", "There is no settings panel yet. The theme is in the title bar."],
|
||||||
|
];
|
||||||
|
|
||||||
|
const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const roots = useWorkspace((s) => s.roots);
|
||||||
|
const path = useDocument((s) => s.path);
|
||||||
|
const openDocument = useDocument((s) => s.document);
|
||||||
|
const savePhase = useDocument((s) => s.savePhase);
|
||||||
|
const externalChange = useDocument((s) => s.externalChange);
|
||||||
|
const setContent = useDocument((s) => s.setContent);
|
||||||
|
const reloadFromDisk = useDocument((s) => s.reloadFromDisk);
|
||||||
|
const find = useDocumentFind();
|
||||||
|
|
||||||
|
const [resolving, setResolving] = useState(false);
|
||||||
|
|
||||||
|
useKeymap();
|
||||||
|
useCompact();
|
||||||
|
useTouch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void restoreSession();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// `watch-event` and `index-progress`, both of them landing in the stores that care. The routing
|
||||||
|
// itself belongs to src/workspace.ts, which is the module that already knows which root a path
|
||||||
|
// sits under and whether the open document was the file that moved.
|
||||||
|
useEffect(() => startWorkspaceEvents(), []);
|
||||||
|
|
||||||
|
// The native menu emits a command id, so this is a lookup and not a second dispatch table. A
|
||||||
|
// phone has a menu bar to emit from too, hence isTauri rather than isDesktop.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauri) return;
|
||||||
|
const pending = listen<string>(MENU_ACTION_EVENT, (event) => handleMenuAction(event.payload));
|
||||||
|
return () => {
|
||||||
|
void pending.then((stop) => stop()).catch(() => {});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Quitting with an edit half a second old must not lose it. The debounce is cancelled and the
|
||||||
|
// save run to completion before the window is allowed to go, and the close is only intercepted
|
||||||
|
// when there is actually something to write.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDesktop) return;
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
const pending = win.onCloseRequested(async (event) => {
|
||||||
|
if (!useDocument.getState().dirty) return;
|
||||||
|
event.preventDefault();
|
||||||
|
await flushPendingSave();
|
||||||
|
void win.destroy();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
void pending.then((stop) => stop()).catch(() => {});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stops = [
|
||||||
|
onCommand("editor-width-narrow", () => applyWidth("narrow")),
|
||||||
|
onCommand("editor-width-normal", () => applyWidth("normal")),
|
||||||
|
onCommand("editor-width-wide", () => applyWidth("wide")),
|
||||||
|
...UNBUILT.map(([id, message]) => onCommand(id, () => notify(message))),
|
||||||
|
];
|
||||||
|
return () => {
|
||||||
|
for (const stop of stops) stop();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const conflict = externalChange === "changed-on-disk";
|
||||||
|
|
||||||
|
// Asked once, when the conflict appears. Dismissing it leaves the warning in the toolbar to
|
||||||
|
// reopen rather than asking again on the next keystroke.
|
||||||
|
useEffect(() => {
|
||||||
|
if (conflict) setResolving(true);
|
||||||
|
}, [conflict]);
|
||||||
|
|
||||||
|
const kind = path === null ? null : documentKindForPath(path);
|
||||||
|
const saveState: ToolbarSaveState = conflict
|
||||||
|
? "conflict"
|
||||||
|
: savePhase === "saving"
|
||||||
|
? "saving"
|
||||||
|
: "idle";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<Titlebar />
|
||||||
|
|
||||||
|
<div className="stage">
|
||||||
|
{roots.length === 0 ? (
|
||||||
|
<Recents />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sidebar />
|
||||||
|
<main className="editor-pane">
|
||||||
|
{openDocument === null || kind === null ? (
|
||||||
|
<p className="pane-empty">Choose a document from the sidebar.</p>
|
||||||
|
) : kind === "markdown" ? (
|
||||||
|
<>
|
||||||
|
<article className="sheet">
|
||||||
|
<DocumentEditor
|
||||||
|
document={openDocument}
|
||||||
|
onChange={setContent}
|
||||||
|
onOpenLink={openLink}
|
||||||
|
editable={!resolving}
|
||||||
|
/>
|
||||||
|
<Backlinks />
|
||||||
|
</article>
|
||||||
|
<Toolbar
|
||||||
|
document={openDocument}
|
||||||
|
saveState={saveState}
|
||||||
|
onResolveConflict={() => setResolving(true)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<article className="sheet">
|
||||||
|
<PlainTextEditor
|
||||||
|
document={openDocument}
|
||||||
|
onChange={setContent}
|
||||||
|
editable={!resolving}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FindBar find={find} />
|
||||||
|
<QuickOpen />
|
||||||
|
<FindInFiles />
|
||||||
|
<CommandPalette />
|
||||||
|
<ProofPopover />
|
||||||
|
<Shortcuts />
|
||||||
|
<Toast />
|
||||||
|
|
||||||
|
{resolving && conflict && path !== null && (
|
||||||
|
<ConflictDialog
|
||||||
|
name={baseName(path)}
|
||||||
|
onReload={() => {
|
||||||
|
setResolving(false);
|
||||||
|
reloadFromDisk().catch((e) => notify(`Could not reload: ${String(e)}`));
|
||||||
|
}}
|
||||||
|
onKeep={() => {
|
||||||
|
setResolving(false);
|
||||||
|
keepBuffer();
|
||||||
|
}}
|
||||||
|
onDismiss={() => setResolving(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { call, type AssetResult, type FileNode, type ReadResult, type WriteResult } from "../ipc";
|
||||||
|
|
||||||
|
export const fileRead = (path: string) => call<ReadResult>("file_read", { path });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes through a temporary file and a rename, so a crash mid-write leaves the old document
|
||||||
|
* whole. Pass the `modifiedMs` the buffer was read at: if the file has moved on since, nothing is
|
||||||
|
* written and the result comes back with `conflict`.
|
||||||
|
*/
|
||||||
|
export const fileWrite = (path: string, text: string, expectedModifiedMs?: number) =>
|
||||||
|
call<WriteResult>("file_write", { path, text, expectedModifiedMs });
|
||||||
|
|
||||||
|
/** `name` is a suggestion. A taken name gets a suffix, and the node returned carries the real one. */
|
||||||
|
export const fileCreate = (parentPath: string, name: string) =>
|
||||||
|
call<FileNode>("file_create", { parentPath, name });
|
||||||
|
|
||||||
|
export const fileFolderCreate = (parentPath: string, name: string) =>
|
||||||
|
call<FileNode>("file_folder_create", { parentPath, name });
|
||||||
|
|
||||||
|
export const fileRename = (path: string, name: string) =>
|
||||||
|
call<FileNode>("file_rename", { path, name });
|
||||||
|
|
||||||
|
export const fileMove = (path: string, destDir: string) =>
|
||||||
|
call<FileNode>("file_move", { path, destDir });
|
||||||
|
|
||||||
|
export const fileDuplicate = (path: string) => call<FileNode>("file_duplicate", { path });
|
||||||
|
|
||||||
|
/** To the system Trash, never an unlink. Deleting a document is undoable in Finder. */
|
||||||
|
export const fileTrash = (path: string) => call<void>("file_trash", { path });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pasted image, into an `assets/` folder beside the document. `bytes` is the clipboard payload
|
||||||
|
* and `name` the filename it suggested, which is usually `image.png` and usually already taken.
|
||||||
|
*/
|
||||||
|
export const assetWrite = (docPath: string, bytes: number[], name: string) =>
|
||||||
|
call<AssetResult>("asset_write", { docPath, bytes, name });
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { call, type Backlink, type IndexStatus, type QuickOpenHit, type SearchHit } from "../ipc";
|
||||||
|
|
||||||
|
/** Rescans every open root. Progress arrives on the `index-progress` event. */
|
||||||
|
export const indexRebuild = () => call<IndexStatus>("index_rebuild");
|
||||||
|
|
||||||
|
export const indexStatus = () => call<IndexStatus>("index_status");
|
||||||
|
|
||||||
|
/** Fuzzy match over paths relative to their root, across every open root. */
|
||||||
|
export const searchQuickOpen = (query: string, limit: number) =>
|
||||||
|
call<QuickOpenHit[]>("search_quick_open", { query, limit });
|
||||||
|
|
||||||
|
export const searchText = (query: string, limit: number) =>
|
||||||
|
call<SearchHit[]>("search_text", { query, limit });
|
||||||
|
|
||||||
|
/** Which documents link to this one, for the section at the end of the document. */
|
||||||
|
export const backlinksFor = (path: string) => call<Backlink[]>("backlinks_for", { path });
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { call, type FileNode, type RootInfo } from "../ipc";
|
||||||
|
|
||||||
|
export const rootsList = () => call<RootInfo[]>("roots_list");
|
||||||
|
|
||||||
|
/** `path` comes from the native folder picker. Opening a folder never writes anything into it. */
|
||||||
|
export const rootOpen = (path: string) => call<RootInfo>("root_open", { path });
|
||||||
|
|
||||||
|
export const rootClose = (rootId: string) => call<void>("root_close", { rootId });
|
||||||
|
|
||||||
|
/** The whole tree for one root, root node included. */
|
||||||
|
export const treeRead = (rootId: string) => call<FileNode>("tree_read", { rootId });
|
||||||
|
|
||||||
|
export const revealInFinder = (path: string) => call<void>("reveal_in_finder", { path });
|
||||||
|
|
||||||
|
/** Hands a file to whatever macOS opens it with. The only way to open a non-editable file. */
|
||||||
|
export const openExternal = (path: string) => call<void>("open_external", { path });
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Spelling, which is the system's and not this app's.
|
||||||
|
//
|
||||||
|
// Everything here goes to NSSpellChecker, the same checker Mail and Notes correct into, so a word
|
||||||
|
// learned anywhere on the machine is a word this editor does not underline and the user's own
|
||||||
|
// languages are already configured. Nothing in this app ships a dictionary or has an opinion about
|
||||||
|
// English.
|
||||||
|
//
|
||||||
|
// The checker is asked about a run of text and answers about that run. It has no idea a document
|
||||||
|
// exists, which is what keeps the caller free to send it a paragraph, a visible screenful or one
|
||||||
|
// sentence, and to decide for itself what a stale answer is worth.
|
||||||
|
|
||||||
|
import { call, type SpellIssue } from "../ipc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every misspelling in one run of text, with offsets in characters counted from the start of that
|
||||||
|
* run. The caller adds its own base offset; this never sees a document position.
|
||||||
|
*/
|
||||||
|
export const spellCheck = (text: string) => call<SpellIssue[]>("spell_check", { text });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teaches the word to the system, for every app on the machine and not only this one. That is the
|
||||||
|
* honest behaviour for a checker borrowed from the OS, and it is what the "Learn Spelling" item in
|
||||||
|
* every other Mac app does.
|
||||||
|
*/
|
||||||
|
export const spellLearn = (word: string) => call<void>("spell_learn", { word });
|
||||||
|
|
||||||
|
/** Undoes a `spellLearn`, for a word taught by a slip of the hand. */
|
||||||
|
export const spellUnlearn = (word: string) => call<void>("spell_unlearn", { word });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the machine has a checker at all. False on a build that is not macOS, where the answer
|
||||||
|
* to every check is an empty list rather than an error, and the UI hides itself rather than
|
||||||
|
* offering a menu that cannot do anything.
|
||||||
|
*/
|
||||||
|
export const spellAvailable = () => call<boolean>("spell_available");
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { call } from "../ipc";
|
||||||
|
|
||||||
|
/** Changes arrive on the `watch-event` event, never as a return value. */
|
||||||
|
export const watchStart = (rootId: string) => call<void>("watch_start", { rootId });
|
||||||
|
|
||||||
|
export const watchStop = (rootId: string) => call<void>("watch_stop", { rootId });
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// The "Linked from" section: the documents elsewhere on disk that point at the one on screen.
|
||||||
|
//
|
||||||
|
// Nothing here is content. A backlink exists because of bytes in somebody else's file, so it is
|
||||||
|
// never in the ProseMirror document, never serialized and never written; it is drawn after the last
|
||||||
|
// block and that is the whole of its existence. Which is why this is a sibling of the editor inside
|
||||||
|
// the sheet rather than a node at the end of it: it shares the paper and the measure with the
|
||||||
|
// document and shares nothing else, and a caret cannot land in a section that was never in the
|
||||||
|
// editable, nor can a select all inside the editor reach it.
|
||||||
|
//
|
||||||
|
// Silence is the default and it is the point. No section under a document nothing links to, and no
|
||||||
|
// section before the index has finished a pass, because "nothing links here" and "I have not looked
|
||||||
|
// yet" are different facts and only one of them has earned a heading.
|
||||||
|
//
|
||||||
|
// The open document and the index are read from their stores rather than passed in, so the mount in
|
||||||
|
// App.tsx is a bare tag. This component already has to watch the index to know whether its answer
|
||||||
|
// means anything, so it is subscribed either way, and a prop would only put half of what it needs
|
||||||
|
// through the shell while the other half went round it.
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||||
|
import { backlinksFor } from "../api";
|
||||||
|
import type { Backlink } from "../ipc";
|
||||||
|
import { useDocument } from "../store/useDocument";
|
||||||
|
import { useIndex } from "../store/useIndex";
|
||||||
|
import { notify } from "../store/useToast";
|
||||||
|
|
||||||
|
interface Answer {
|
||||||
|
/** The document these were asked for, kept with them so a slow reply about the file that was open
|
||||||
|
* a moment ago is never drawn under the file that is open now. */
|
||||||
|
path: string;
|
||||||
|
/** Null is "asked, and could not be told". It draws the same nothing an empty list does, and that
|
||||||
|
* is a decision rather than an accident: a writer cannot act on a failed index lookup, and a
|
||||||
|
* permanent error line under every document costs more attention than the feature is worth. The
|
||||||
|
* two are still not the same fact, so they are not the same value here, and this is the one place
|
||||||
|
* that could ever tell them apart. */
|
||||||
|
links: Backlink[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A snippet is the source line the link sits on, so the one construct every snippet is guaranteed
|
||||||
|
* to contain is the link that made it a backlink, and an editor whose whole pitch is that markdown
|
||||||
|
* syntax is never visible should not be the thing putting `](../thing.md)` on screen. The link is
|
||||||
|
* unwrapped to its text and nothing else is: everything else a line might hold is not certain to be
|
||||||
|
* there, and unwrapping it would be a second markdown reader living in a view.
|
||||||
|
*/
|
||||||
|
const readableSnippet = (snippet: string): string =>
|
||||||
|
snippet.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").trim();
|
||||||
|
|
||||||
|
export function Backlinks() {
|
||||||
|
const path = useDocument((s) => s.path);
|
||||||
|
const open = useDocument((s) => s.open);
|
||||||
|
const phase = useIndex((s) => s.phase);
|
||||||
|
|
||||||
|
const [answer, setAnswer] = useState<Answer | null>(null);
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const rows = useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
|
|
||||||
|
// Two triggers, both of them in the dependencies: a different document to ask about, and a pass of
|
||||||
|
// the index finishing. The second is what keeps the section true when somebody edits another file
|
||||||
|
// and the watcher reindexes it, since `index-progress` lands in useIndex and comes out as a phase.
|
||||||
|
//
|
||||||
|
// Anything short of a completed pass is not asked at all, and mid-pass the previous answer is left
|
||||||
|
// on screen: a reindex is not new information about this document, and blanking the section for
|
||||||
|
// the duration would be a flicker that says something changed when nothing has.
|
||||||
|
useEffect(() => {
|
||||||
|
if (path === null || phase !== "ready") return;
|
||||||
|
let cancelled = false;
|
||||||
|
backlinksFor(path)
|
||||||
|
.then((links) => {
|
||||||
|
if (!cancelled) setAnswer({ path, links });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setAnswer({ path, links: null });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [path, phase]);
|
||||||
|
|
||||||
|
// Matched against the open path at render rather than cleared in an effect, so switching documents
|
||||||
|
// cannot paint one frame of the last one's links before the effect catches up.
|
||||||
|
const links = answer !== null && answer.path === path ? answer.links : null;
|
||||||
|
if (links === null || links.length === 0) return null;
|
||||||
|
|
||||||
|
// Roving focus: the section is one stop in the tab order however many rows it has, and the arrows
|
||||||
|
// move inside it. A row per tab stop would make tabbing out of a well linked document a chore
|
||||||
|
// through chrome, and taking the rows out of the tab order entirely would leave them mouse only.
|
||||||
|
const focused = active < links.length ? active : 0;
|
||||||
|
|
||||||
|
const move = (delta: number) => {
|
||||||
|
const next = Math.min(Math.max(focused + delta, 0), links.length - 1);
|
||||||
|
setActive(next);
|
||||||
|
rows.current[next]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent<HTMLUListElement>) => {
|
||||||
|
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
||||||
|
event.preventDefault();
|
||||||
|
move(event.key === "ArrowDown" ? 1 : -1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const go = (target: string) => {
|
||||||
|
open(target).catch((e) => notify(`Could not open ${baseName(target)}: ${String(e)}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="backlinks" aria-labelledby="backlinks-heading">
|
||||||
|
{/* One document at a time and one of these, so a fixed id cannot collide with a second. */}
|
||||||
|
<h2 className="nav-label" id="backlinks-heading">
|
||||||
|
Linked from
|
||||||
|
</h2>
|
||||||
|
<ul className="backlinks-list" onKeyDown={onKeyDown}>
|
||||||
|
{links.map((link, index) => (
|
||||||
|
<li key={link.path}>
|
||||||
|
<button
|
||||||
|
className="backlinks-row"
|
||||||
|
ref={(el) => {
|
||||||
|
rows.current[index] = el;
|
||||||
|
}}
|
||||||
|
tabIndex={index === focused ? 0 : -1}
|
||||||
|
// The title is a heading or a filename and two documents are allowed to share one, so
|
||||||
|
// the path is what settles which of them this row is.
|
||||||
|
title={link.path}
|
||||||
|
onFocus={() => setActive(index)}
|
||||||
|
onClick={() => go(link.path)}
|
||||||
|
>
|
||||||
|
{/* The index titles a document by its first heading and falls back to its filename,
|
||||||
|
so this only catches a row that would otherwise be a blank line to click. */}
|
||||||
|
<span className="backlinks-title">{link.title || baseName(link.path)}</span>
|
||||||
|
<span className="backlinks-snippet">{readableSnippet(link.snippet)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Cmd+K: every command the app has, by name.
|
||||||
|
//
|
||||||
|
// This is the one palette with nothing behind it. No index, no IPC, no store: the rows are the
|
||||||
|
// table in src/keys/commands.ts filtered by a subsequence match, so it answers on a build where
|
||||||
|
// SQLite has fallen over and on the first frame after launch, before a folder is even open. That
|
||||||
|
// is why src/keys/bindings.ts binds it in the `global` context with a comment saying an overlay may
|
||||||
|
// not shadow it: whatever is on screen, this is how you get anywhere from inside it, and something
|
||||||
|
// that reaches into a search index for its own row list would not be able to make that promise.
|
||||||
|
//
|
||||||
|
// It lists commands, not bindings, which is why the keys on the right come from `keysFor` and
|
||||||
|
// `keyLabel` rather than from `bindingLabel`: that one turns a binding into its words, and a
|
||||||
|
// command with no key at all still belongs in this list.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { keyLabel, keysFor } from "../keys/bindings";
|
||||||
|
import { COMMANDS, commandMatches, onCommand, runCommand } from "../keys/commands";
|
||||||
|
import { useKeyContext } from "../keys/keymap";
|
||||||
|
import { Palette, type PaletteRow } from "./Palette";
|
||||||
|
|
||||||
|
interface CommandRow extends PaletteRow {
|
||||||
|
label: string;
|
||||||
|
keys: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPalette() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
// A toggle, like the shortcuts sheet: the key that opens it is reachable from inside it, so it
|
||||||
|
// has to mean something the second time it is pressed.
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
onCommand("command-palette", () => {
|
||||||
|
setOpen((wasOpen) => !wasOpen);
|
||||||
|
setQuery("");
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEscapeLayer(open, () => setOpen(false));
|
||||||
|
useKeyContext("overlay", open);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const rows: CommandRow[] = COMMANDS.filter(
|
||||||
|
(command) => command.palette && commandMatches(command.label, query),
|
||||||
|
).map((command) => ({
|
||||||
|
key: command.id,
|
||||||
|
label: command.label,
|
||||||
|
keys: keysFor(command.id),
|
||||||
|
run: () => runCommand(command.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Palette
|
||||||
|
label="Command palette"
|
||||||
|
placeholder="Run a command"
|
||||||
|
query={query}
|
||||||
|
onQuery={setQuery}
|
||||||
|
rows={rows}
|
||||||
|
status={{ text: "No command by that name." }}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
renderRow={(row) => (
|
||||||
|
<span className="palette-main">
|
||||||
|
<span className="palette-name">{row.label}</span>
|
||||||
|
<span className="palette-keys">
|
||||||
|
{row.keys.map((combo) => (
|
||||||
|
<kbd key={combo} className="key-cap">
|
||||||
|
{keyLabel(combo)}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
title: string;
|
||||||
|
message: ReactNode;
|
||||||
|
confirmLabel?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = "Delete",
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
confirmRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEscapeLayer(true, onClose);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overlay" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="panel panel-confirm"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="panel-head">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<button className="icon-button" onClick={onClose} title="Close (⎋)" aria-label="Close">
|
||||||
|
<Icon d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="panel-body">
|
||||||
|
<p className="confirm-text">{message}</p>
|
||||||
|
</div>
|
||||||
|
<div className="panel-foot">
|
||||||
|
<button className="btn-ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button ref={confirmRef} className="btn-danger" onClick={onConfirm}>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Something outside the app changed the file that is open, and the buffer has an edit in it that
|
||||||
|
// is not on disk. Both copies are real work and the app does not get to pick, so it asks.
|
||||||
|
//
|
||||||
|
// There is no merge and there will not be one: a three way merge of somebody's prose is a thing
|
||||||
|
// that looks like it worked. The two answers are the two copies, and dismissing the dialog picks
|
||||||
|
// neither, which leaves the warning in the toolbar and the buffer exactly as it was.
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
interface ConflictDialogProps {
|
||||||
|
/** The file's name, not its path: the path is already in the title bar. */
|
||||||
|
name: string;
|
||||||
|
/** Throws the buffer away and takes what is on disk. */
|
||||||
|
onReload: () => void;
|
||||||
|
/** Keeps the buffer and lets the next save write over the copy on disk. */
|
||||||
|
onKeep: () => void;
|
||||||
|
/** Neither, for now. The document stays unsaved and the toolbar keeps the warning. */
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConflictDialog({ name, onReload, onKeep, onDismiss }: ConflictDialogProps) {
|
||||||
|
const keepRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
keepRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEscapeLayer(true, onDismiss);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overlay" onClick={onDismiss}>
|
||||||
|
<div
|
||||||
|
className="panel panel-conflict"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="panel-head">
|
||||||
|
<h2>Changed on disk</h2>
|
||||||
|
<button className="icon-button" onClick={onDismiss} title="Close (⎋)" aria-label="Close">
|
||||||
|
<Icon d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="panel-body">
|
||||||
|
<p className="confirm-text">
|
||||||
|
Something outside Margin Docs has changed <strong>{name}</strong>, and you have edits
|
||||||
|
here that are not on disk. Nothing has been written and nothing has been lost yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="panel-foot">
|
||||||
|
<button className="btn-ghost" onClick={onDismiss}>
|
||||||
|
Decide later
|
||||||
|
</button>
|
||||||
|
<button className="btn-danger" onClick={onReload}>
|
||||||
|
Reload from disk
|
||||||
|
</button>
|
||||||
|
<button ref={keepRef} className="btn-primary" onClick={onKeep}>
|
||||||
|
Keep my version
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
// The recursive half of the sidebar: rows, twisties, indentation and drop indicators, and nothing
|
||||||
|
// else. Selection, the keyboard, the drag gesture and every action a row can perform live one
|
||||||
|
// level up in Sidebar.tsx, because all of those span every open root and a recursive renderer only
|
||||||
|
// ever sees one subtree.
|
||||||
|
|
||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
type CSSProperties,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type MouseEvent,
|
||||||
|
type PointerEvent,
|
||||||
|
} from "react";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { MARKDOWN_EXTENSIONS } from "../model/doc";
|
||||||
|
import type { TreeNode } from "../store/useWorkspace";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
import { RowMenu, type RowMenuEntry } from "./RowMenu";
|
||||||
|
|
||||||
|
const FOLDER = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z";
|
||||||
|
const FOLDER_OPEN = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2H7l-4 8z M3 17V7";
|
||||||
|
const DOCUMENT = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5";
|
||||||
|
const FOREIGN = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5 M9 17l2.5-3 2 2.2 1.5-1.7";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The extension a row hides. Markdown is the app's own format and `.md` on every second row is
|
||||||
|
* noise, but everything else keeps its extension: two rows both reading "notes", for `notes.md`
|
||||||
|
* and `notes.txt`, would be a worse lie than the clutter it saved.
|
||||||
|
*/
|
||||||
|
export function splitExtension(name: string, isDir = false): { base: string; hidden: string } {
|
||||||
|
if (isDir) return { base: name, hidden: "" };
|
||||||
|
const dot = name.lastIndexOf(".");
|
||||||
|
if (dot <= 0) return { base: name, hidden: "" };
|
||||||
|
const ext = name.slice(dot + 1).toLowerCase();
|
||||||
|
if (!(MARKDOWN_EXTENSIONS as readonly string[]).includes(ext)) return { base: name, hidden: "" };
|
||||||
|
return { base: name.slice(0, dot), hidden: name.slice(dot) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prettyName = (node: TreeNode): string => splitExtension(node.name, node.isDir).base;
|
||||||
|
|
||||||
|
export interface TreeRow {
|
||||||
|
node: TreeNode;
|
||||||
|
depth: number;
|
||||||
|
/** The directory the row sits in, empty for a root. Where a "drop above this row" resolves to. */
|
||||||
|
parentPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rows the user can actually see, in the order they appear, which is what the arrow keys walk. */
|
||||||
|
export function flattenTree(
|
||||||
|
nodes: readonly TreeNode[],
|
||||||
|
expanded: ReadonlySet<string>,
|
||||||
|
depth = 0,
|
||||||
|
parentPath = "",
|
||||||
|
): TreeRow[] {
|
||||||
|
const rows: TreeRow[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
rows.push({ node, depth, parentPath });
|
||||||
|
if (node.isDir && expanded.has(node.path) && node.children?.length)
|
||||||
|
rows.push(...flattenTree(node.children, expanded, depth + 1, node.path));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every drop resolves to exactly one destination directory, because a filesystem has no row order
|
||||||
|
* to insert into. The mode is only how the pointer said it: "into" is the folder under the cursor,
|
||||||
|
* "before" and "after" are the folder that row already lives in.
|
||||||
|
*/
|
||||||
|
export type DropMode = "before" | "after" | "into";
|
||||||
|
|
||||||
|
export interface DropTarget {
|
||||||
|
dir: string;
|
||||||
|
mode: DropMode;
|
||||||
|
/** The row the indicator draws on, which for "into" is the destination folder itself. */
|
||||||
|
row: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeViewState {
|
||||||
|
expanded: ReadonlySet<string>;
|
||||||
|
selectedPath: string | null;
|
||||||
|
/** The one row in the whole sidebar that is in the tab order. */
|
||||||
|
tabStopPath: string | null;
|
||||||
|
draggingPath: string | null;
|
||||||
|
dropTarget: DropTarget | null;
|
||||||
|
renamingPath: string | null;
|
||||||
|
/** The document currently open in the editor, which is a different thing from the selected row. */
|
||||||
|
openPath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeHandlers {
|
||||||
|
onActivate: (node: TreeNode) => void;
|
||||||
|
onToggle: (node: TreeNode) => void;
|
||||||
|
onKeyDown: (e: KeyboardEvent, row: TreeRow) => void;
|
||||||
|
onPointerDown: (e: PointerEvent, row: TreeRow) => void;
|
||||||
|
onContextMenu: (e: MouseEvent, row: TreeRow) => void;
|
||||||
|
onMenuOpenChange: (path: string, open: boolean) => void;
|
||||||
|
menuItems: (row: TreeRow) => readonly RowMenuEntry[];
|
||||||
|
onRenameCommit: (node: TreeNode, base: string) => void;
|
||||||
|
onRenameCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeProps {
|
||||||
|
nodes: readonly TreeNode[];
|
||||||
|
depth: number;
|
||||||
|
parentPath: string;
|
||||||
|
state: TreeViewState;
|
||||||
|
handlers: TreeHandlers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileTree({ nodes, depth, parentPath, state, handlers }: TreeProps) {
|
||||||
|
return (
|
||||||
|
<ul className="tree" role={depth === 0 ? "tree" : "group"}>
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<TreeItem
|
||||||
|
key={node.path}
|
||||||
|
row={{ node, depth, parentPath }}
|
||||||
|
state={state}
|
||||||
|
handlers={handlers}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeItem({
|
||||||
|
row,
|
||||||
|
state,
|
||||||
|
handlers,
|
||||||
|
}: {
|
||||||
|
row: TreeRow;
|
||||||
|
state: TreeViewState;
|
||||||
|
handlers: TreeHandlers;
|
||||||
|
}) {
|
||||||
|
const { node, depth } = row;
|
||||||
|
const { base, hidden } = splitExtension(node.name, node.isDir);
|
||||||
|
const open = node.isDir && state.expanded.has(node.path);
|
||||||
|
const renaming = state.renamingPath === node.path;
|
||||||
|
const drop = state.dropTarget?.row === node.path ? state.dropTarget.mode : null;
|
||||||
|
const foreign = !node.isDir && !node.editable;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="tree-item">
|
||||||
|
<div
|
||||||
|
className="tree-row"
|
||||||
|
role="treeitem"
|
||||||
|
style={{ "--tree-depth": depth } as CSSProperties}
|
||||||
|
data-path={node.path}
|
||||||
|
data-parent={row.parentPath}
|
||||||
|
data-dir={node.isDir}
|
||||||
|
data-root={depth === 0}
|
||||||
|
data-foreign={foreign}
|
||||||
|
data-selected={state.selectedPath === node.path}
|
||||||
|
data-current={state.openPath === node.path}
|
||||||
|
data-dragging={state.draggingPath === node.path}
|
||||||
|
data-drop-before={drop === "before"}
|
||||||
|
data-drop-after={drop === "after"}
|
||||||
|
data-drop-into={drop === "into"}
|
||||||
|
aria-expanded={node.isDir ? open : undefined}
|
||||||
|
aria-selected={state.selectedPath === node.path}
|
||||||
|
aria-level={depth + 1}
|
||||||
|
tabIndex={state.tabStopPath === node.path ? 0 : -1}
|
||||||
|
onClick={() => handlers.onActivate(node)}
|
||||||
|
onKeyDown={(e) => handlers.onKeyDown(e, row)}
|
||||||
|
onPointerDown={(e) => handlers.onPointerDown(e, row)}
|
||||||
|
onContextMenu={(e) => handlers.onContextMenu(e, row)}
|
||||||
|
>
|
||||||
|
{node.isDir ? (
|
||||||
|
<button
|
||||||
|
className="tree-twisty"
|
||||||
|
tabIndex={-1}
|
||||||
|
title={open ? "Collapse" : "Expand"}
|
||||||
|
aria-label={open ? `Collapse ${node.name}` : `Expand ${node.name}`}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlers.onToggle(node);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon d={open ? "M6 9l6 6 6-6" : "M9 6l6 6-6 6"} size={13} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="tree-twisty" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="tree-glyph" aria-hidden="true">
|
||||||
|
<Icon d={node.isDir ? (open ? FOLDER_OPEN : FOLDER) : foreign ? FOREIGN : DOCUMENT} size={15} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{renaming ? (
|
||||||
|
<RenameField
|
||||||
|
value={base}
|
||||||
|
onCommit={(next) => handlers.onRenameCommit(node, next)}
|
||||||
|
onCancel={handlers.onRenameCancel}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="tree-name" title={node.name}>
|
||||||
|
{base}
|
||||||
|
{hidden && <span className="tree-ext">{hidden}</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RowMenu
|
||||||
|
label={node.isDir ? "Folder options" : "File options"}
|
||||||
|
items={() => handlers.menuItems(row)}
|
||||||
|
onOpenChange={(isOpen) => handlers.onMenuOpenChange(node.path, isOpen)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && node.children?.length ? (
|
||||||
|
<FileTree
|
||||||
|
nodes={node.children}
|
||||||
|
depth={depth + 1}
|
||||||
|
parentPath={node.path}
|
||||||
|
state={state}
|
||||||
|
handlers={handlers}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blur commits, the way Finder does, so the escape layer lives here rather than in the sidebar:
|
||||||
|
* unmounting a focused input can fire a blur on the way out, and a cancel that arrived from
|
||||||
|
* outside would otherwise be overtaken by the commit it was trying to avoid.
|
||||||
|
*/
|
||||||
|
function RenameField({
|
||||||
|
value,
|
||||||
|
onCommit,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onCommit: (next: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
const settled = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const input = ref.current;
|
||||||
|
if (!input) return;
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const commit = (next: string) => {
|
||||||
|
if (settled.current) return;
|
||||||
|
settled.current = true;
|
||||||
|
onCommit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEscapeLayer(true, () => {
|
||||||
|
settled.current = true;
|
||||||
|
onCancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className="tree-rename"
|
||||||
|
defaultValue={value}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onBlur={(e) => commit(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Enter") return;
|
||||||
|
e.preventDefault();
|
||||||
|
commit(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
// Find and replace inside the open document. Margin's bar, minus the cross-chapter scope: there
|
||||||
|
// is one document open at a time here, and searching every file is `find-in-files` against the
|
||||||
|
// SQLite index, which is a different panel with different results.
|
||||||
|
//
|
||||||
|
// The matching itself is the editor's, not this bar's. `EditorHandle` in src/editor/index.ts does
|
||||||
|
// not carry a search surface, so the shape this bar drives is declared here and handed in: a
|
||||||
|
// component that draws a text field has no business owning a ProseMirror decoration set, and the
|
||||||
|
// alternative, walking the contenteditable DOM behind the editor's back, is a second
|
||||||
|
// implementation of matching that would disagree with the first the day either changed.
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { onCommand } from "../keys/commands";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
export interface FindOptions {
|
||||||
|
caseSensitive: boolean;
|
||||||
|
wholeWord: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FindState {
|
||||||
|
count: number;
|
||||||
|
/** Zero based, so `current + 1` is what the "3 of 12" readout shows. */
|
||||||
|
current: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the editor lane implements for this bar to be usable.
|
||||||
|
*
|
||||||
|
* A new object whenever `state` changes, the way `EditorHandle` already works: this bar draws the
|
||||||
|
* "3 of 12" readout from a prop and has nothing to subscribe to, so a handle mutated in place
|
||||||
|
* would leave the count stale until something unrelated re-rendered.
|
||||||
|
*/
|
||||||
|
export interface DocumentFind {
|
||||||
|
state: FindState;
|
||||||
|
setQuery: (query: string, options: FindOptions) => void;
|
||||||
|
clear: () => void;
|
||||||
|
next: () => void;
|
||||||
|
prev: () => void;
|
||||||
|
replaceCurrent: (text: string) => void;
|
||||||
|
replaceAll: (text: string) => void;
|
||||||
|
/** Puts the cursor back in the document, which every replace has to do to be worth anything. */
|
||||||
|
focus: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FindBar({ find }: { find: DocumentFind | null }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [replacement, setReplacement] = useState("");
|
||||||
|
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||||
|
const [wholeWord, setWholeWord] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const findRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
onCommand("find", () => {
|
||||||
|
setOpen(true);
|
||||||
|
const input = findRef.current;
|
||||||
|
input?.focus();
|
||||||
|
input?.select();
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const input = findRef.current;
|
||||||
|
input?.focus();
|
||||||
|
input?.select();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!find) return;
|
||||||
|
if (open) find.setQuery(query, { caseSensitive, wholeWord });
|
||||||
|
else find.clear();
|
||||||
|
}, [find, open, query, caseSensitive, wholeWord]);
|
||||||
|
|
||||||
|
useEscapeLayer(open, () => setOpen(false));
|
||||||
|
|
||||||
|
if (!open || !find) return null;
|
||||||
|
|
||||||
|
const { count, current } = find.state;
|
||||||
|
const countLabel = !query ? "" : count === 0 ? "No results" : `${current + 1} of ${count}`;
|
||||||
|
|
||||||
|
const onFindKey = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key !== "Enter") return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) find.prev();
|
||||||
|
else find.next();
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceOne = () => {
|
||||||
|
find.replaceCurrent(replacement);
|
||||||
|
find.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceEvery = () => {
|
||||||
|
find.replaceAll(replacement);
|
||||||
|
find.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="find-bar" role="search">
|
||||||
|
<button
|
||||||
|
className="find-expand"
|
||||||
|
data-on={expanded}
|
||||||
|
title={expanded ? "Hide replace" : "Show replace"}
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
<Icon d={expanded ? "M6 9l6 6 6-6" : "M9 6l6 6-6 6"} size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="find-stack">
|
||||||
|
<div className="find-row">
|
||||||
|
<input
|
||||||
|
ref={findRef}
|
||||||
|
className="find-input"
|
||||||
|
value={query}
|
||||||
|
placeholder="Find"
|
||||||
|
spellCheck={false}
|
||||||
|
aria-label="Find"
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={onFindKey}
|
||||||
|
/>
|
||||||
|
<span className="find-count">{countLabel}</span>
|
||||||
|
<button className="find-btn" title="Previous (⇧↩)" disabled={!count} onClick={find.prev}>
|
||||||
|
<Icon d="M6 15l6-6 6 6" size={14} />
|
||||||
|
</button>
|
||||||
|
<button className="find-btn" title="Next (↩)" disabled={!count} onClick={find.next}>
|
||||||
|
<Icon d="M6 9l6 6 6-6" size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="find-toggle"
|
||||||
|
data-on={caseSensitive}
|
||||||
|
title="Match case"
|
||||||
|
onClick={() => setCaseSensitive((v) => !v)}
|
||||||
|
>
|
||||||
|
Aa
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="find-toggle"
|
||||||
|
data-on={wholeWord}
|
||||||
|
title="Whole word"
|
||||||
|
onClick={() => setWholeWord((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className="find-ww">ab</span>
|
||||||
|
</button>
|
||||||
|
<button className="find-btn" title="Close (⎋)" onClick={() => setOpen(false)}>
|
||||||
|
<Icon d="M18 6L6 18M6 6l12 12" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="find-row">
|
||||||
|
<input
|
||||||
|
className="find-input"
|
||||||
|
value={replacement}
|
||||||
|
placeholder="Replace"
|
||||||
|
spellCheck={false}
|
||||||
|
aria-label="Replace with"
|
||||||
|
onChange={(e) => setReplacement(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Enter") return;
|
||||||
|
e.preventDefault();
|
||||||
|
replaceOne();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="find-action"
|
||||||
|
disabled={!count}
|
||||||
|
onClick={replaceOne}
|
||||||
|
title="Replace the current match"
|
||||||
|
>
|
||||||
|
Replace
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="find-action"
|
||||||
|
disabled={!count}
|
||||||
|
onClick={replaceEvery}
|
||||||
|
title="Replace every match in this document"
|
||||||
|
>
|
||||||
|
Replace All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Cmd+Shift+F: the text inside every file in every open root, which is the one question the file
|
||||||
|
// tree and the find bar between them cannot answer. The bar in src/components/FindBar.tsx searches
|
||||||
|
// the one document that is open; this searches the ones that are not.
|
||||||
|
//
|
||||||
|
// Same debounce and the same reason as quick open, a little longer because a full text query reads
|
||||||
|
// the whole corpus rather than one column of paths, and the same deliberate absence of a second
|
||||||
|
// guard around the race: the sequence number in src/store/useSearch.ts already refuses an answer
|
||||||
|
// that has been overtaken.
|
||||||
|
//
|
||||||
|
// A row opens the document it found the line in, and stops there. Putting the caret on the line
|
||||||
|
// itself would need a way to say "open this file at line 42", and the editor's public surface in
|
||||||
|
// src/editor/index.ts has no such thing, so the honest version of this today is the file open at
|
||||||
|
// the top rather than a jump built out of a DOM query behind the editor's back.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import type { MatchRange } from "../ipc";
|
||||||
|
import { onCommand } from "../keys/commands";
|
||||||
|
import { useKeyContext } from "../keys/keymap";
|
||||||
|
import { useDocument } from "../store/useDocument";
|
||||||
|
import { useIndex } from "../store/useIndex";
|
||||||
|
import { useSearch } from "../store/useSearch";
|
||||||
|
import { notify } from "../store/useToast";
|
||||||
|
import { useWorkspace } from "../store/useWorkspace";
|
||||||
|
import { Palette, highlight, type PaletteRow, type PaletteStatus } from "./Palette";
|
||||||
|
|
||||||
|
/** Longer than quick open's: this one reads the text of every file rather than their paths. */
|
||||||
|
const DEBOUNCE_MS = 140;
|
||||||
|
|
||||||
|
interface HitRow extends PaletteRow {
|
||||||
|
title: string;
|
||||||
|
/** One based, and counted over the file as it sits on disk, frontmatter included. */
|
||||||
|
line: number;
|
||||||
|
excerpt: string;
|
||||||
|
ranges: readonly MatchRange[];
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FindInFiles() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const query = useSearch((s) => s.fullTextQuery);
|
||||||
|
const setQuery = useSearch((s) => s.setFullTextQuery);
|
||||||
|
const runFullText = useSearch((s) => s.runFullText);
|
||||||
|
const hits = useSearch((s) => s.fullTextHits);
|
||||||
|
const phase = useSearch((s) => s.fullTextPhase);
|
||||||
|
const error = useSearch((s) => s.fullTextError);
|
||||||
|
const indexPhase = useIndex((s) => s.phase);
|
||||||
|
const roots = useWorkspace((s) => s.roots);
|
||||||
|
const select = useWorkspace((s) => s.select);
|
||||||
|
const openDocument = useDocument((s) => s.open);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
onCommand("find-in-files", () => {
|
||||||
|
// Empty field, and last time's rows cleared through the store so its sequence number moves
|
||||||
|
// with them. See the same lines in QuickOpen.tsx.
|
||||||
|
setQuery("");
|
||||||
|
void runFullText("");
|
||||||
|
setOpen(true);
|
||||||
|
}),
|
||||||
|
[setQuery, runFullText],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEscapeLayer(open, () => setOpen(false));
|
||||||
|
useKeyContext("overlay", open);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const timer = window.setTimeout(() => void runFullText(query), DEBOUNCE_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [open, query, runFullText]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const choose = (path: string) => {
|
||||||
|
select(path);
|
||||||
|
openDocument(path).catch((e) => notify(`Could not open: ${String(e)}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const searching = query.trim() !== "";
|
||||||
|
|
||||||
|
// A file can answer on several lines, so the path alone is not a key.
|
||||||
|
const rows: HitRow[] = hits.map((hit, index) => ({
|
||||||
|
key: `${hit.path}:${hit.line}:${index}`,
|
||||||
|
title: hit.title,
|
||||||
|
line: hit.line,
|
||||||
|
excerpt: hit.excerpt,
|
||||||
|
ranges: hit.ranges,
|
||||||
|
path: hit.path,
|
||||||
|
run: () => choose(hit.path),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const status = (): PaletteStatus => {
|
||||||
|
if (phase === "error") {
|
||||||
|
return { text: error ?? "The search index could not be read.", error: true };
|
||||||
|
}
|
||||||
|
if (!searching) {
|
||||||
|
return {
|
||||||
|
text:
|
||||||
|
roots.length === 0 ? "Open a folder first." : "Type to search every folder that is open.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (phase === "loading") return { text: "Searching…" };
|
||||||
|
if (indexPhase === "indexing") return { text: "Still indexing. Try again in a moment." };
|
||||||
|
return { text: "Nothing in these folders says that." };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Palette
|
||||||
|
label="Find in files"
|
||||||
|
placeholder="Search every open folder"
|
||||||
|
query={query}
|
||||||
|
onQuery={setQuery}
|
||||||
|
rows={rows}
|
||||||
|
status={status()}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
renderRow={(row) => (
|
||||||
|
<span className="palette-stack" title={row.path}>
|
||||||
|
<span className="palette-main">
|
||||||
|
<span className="palette-name">{row.title}</span>
|
||||||
|
<span className="palette-line">{row.line}</span>
|
||||||
|
</span>
|
||||||
|
<span className="palette-snippet">{highlight(row.excerpt, row.ranges)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
|
d?: string;
|
||||||
|
size?: number;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Icon({ d, size = 16, children }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
{children ?? <path d={d} />}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// The shell behind all three overlay palettes: the backdrop, the one text field, the list under it
|
||||||
|
// and the keyboard that drives them.
|
||||||
|
//
|
||||||
|
// Three sources, one widget. What differs between quick open, find in files and the command palette
|
||||||
|
// is where the rows come from and what a row does when it is chosen, and that is the whole of what
|
||||||
|
// the three concrete palettes hand in. Everything a user would call "how the palette behaves", the
|
||||||
|
// arrow keys, the wrap at the ends, the selection following the mouse, the row scrolling itself
|
||||||
|
// into view, lives here once so the three cannot drift into three slightly different lists.
|
||||||
|
//
|
||||||
|
// Rendered only while its palette is open, never handed a closed flag: mounting is what opens it.
|
||||||
|
// That is what keeps the selection, the scroll position and the focus fresh on every open without a
|
||||||
|
// single reset effect, and it leaves the open flag, the Escape layer and the key context in the
|
||||||
|
// concrete component beside its `onCommand` subscription, which is the shape Shortcuts.tsx already
|
||||||
|
// has.
|
||||||
|
|
||||||
|
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||||
|
import type { MatchRange } from "../ipc";
|
||||||
|
|
||||||
|
export interface PaletteRow {
|
||||||
|
/** Identity, not position: a path, a command id. React's key and nothing more. */
|
||||||
|
key: string;
|
||||||
|
/** What choosing the row does. The palette is already closed by the time this is called. */
|
||||||
|
run: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single line shown in place of the list. */
|
||||||
|
export interface PaletteStatus {
|
||||||
|
text: string;
|
||||||
|
/** Something failed and this is its message. An empty result is not a failure. */
|
||||||
|
error?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaletteProps<Row extends PaletteRow> {
|
||||||
|
/** Names the dialog, its field and its list for a screen reader. */
|
||||||
|
label: string;
|
||||||
|
placeholder: string;
|
||||||
|
query: string;
|
||||||
|
onQuery: (query: string) => void;
|
||||||
|
rows: readonly Row[];
|
||||||
|
/** Shown only when there are no rows, so an answer that is still in flight keeps the last rows
|
||||||
|
* on screen rather than flashing "No results" between two keystrokes. */
|
||||||
|
status: PaletteStatus | null;
|
||||||
|
renderRow: (row: Row) => ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Palette<Row extends PaletteRow>({
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
query,
|
||||||
|
onQuery,
|
||||||
|
rows,
|
||||||
|
status,
|
||||||
|
renderRow,
|
||||||
|
onClose,
|
||||||
|
}: PaletteProps<Row>) {
|
||||||
|
const [selected, setSelected] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
const listId = useId();
|
||||||
|
|
||||||
|
// Clamped where it is read rather than corrected in an effect. The row count changes with every
|
||||||
|
// answer the index gives back, and an effect that put the index right afterwards would render one
|
||||||
|
// frame with a selection pointing past the end of the list first.
|
||||||
|
const at = Math.min(selected, rows.length - 1);
|
||||||
|
const current = at >= 0 ? rows[at] : null;
|
||||||
|
|
||||||
|
useEffect(() => inputRef.current?.focus(), []);
|
||||||
|
|
||||||
|
// A new query is a new list, so the selection goes back to the top. Keyed on the query rather
|
||||||
|
// than on `rows`, because a palette that filters as it renders hands over a new array every time
|
||||||
|
// and this would then undo every arrow key the moment it was pressed.
|
||||||
|
useEffect(() => setSelected(0), [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listRef.current?.children[at]?.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [at]);
|
||||||
|
|
||||||
|
const choose = (row: Row) => {
|
||||||
|
// Closed before the row runs. A command palette row can put another overlay on screen, and the
|
||||||
|
// two would otherwise unwind the Escape stack and the key context stack in the wrong order.
|
||||||
|
onClose();
|
||||||
|
row.run();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
|
// Without this the caret jumps to one end of the field on every step through the list.
|
||||||
|
e.preventDefault();
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
const next = e.key === "ArrowDown" ? at + 1 : at - 1 + rows.length;
|
||||||
|
setSelected(next % rows.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Enter" && current) {
|
||||||
|
e.preventDefault();
|
||||||
|
choose(current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Mousedown rather than click: a click closes on the release, so dragging a selection out of
|
||||||
|
// the field and letting go over the backdrop would dismiss the palette mid-gesture.
|
||||||
|
<div className="overlay" data-align="top" onMouseDown={onClose}>
|
||||||
|
<div
|
||||||
|
className="panel palette"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={label}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="palette-field"
|
||||||
|
value={query}
|
||||||
|
placeholder={placeholder}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
role="combobox"
|
||||||
|
aria-label={label}
|
||||||
|
aria-expanded={rows.length > 0}
|
||||||
|
aria-controls={rows.length > 0 ? listId : undefined}
|
||||||
|
aria-activedescendant={current ? `${listId}-${at}` : undefined}
|
||||||
|
onChange={(e) => onQuery(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{rows.length > 0 ? (
|
||||||
|
<ul ref={listRef} id={listId} className="palette-list" role="listbox" aria-label={label}>
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<li
|
||||||
|
key={row.key}
|
||||||
|
id={`${listId}-${index}`}
|
||||||
|
className="palette-row"
|
||||||
|
role="option"
|
||||||
|
aria-selected={index === at}
|
||||||
|
data-selected={index === at}
|
||||||
|
// Move, not enter. The list re-renders under a still cursor every time the index
|
||||||
|
// answers, and `mouseenter` would hand the selection to whichever row happened to
|
||||||
|
// slide under a pointer nobody had touched.
|
||||||
|
onMouseMove={() => setSelected(index)}
|
||||||
|
onClick={() => choose(row)}
|
||||||
|
>
|
||||||
|
{renderRow(row)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
status && (
|
||||||
|
<p className="palette-status" data-error={status.error === true}>
|
||||||
|
{status.text}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The matched characters, marked.
|
||||||
|
*
|
||||||
|
* `ranges` are half-open offsets into `text` and they come from whatever did the matching, which is
|
||||||
|
* the only thing that knows where it landed, so neither search palette runs the match a second time
|
||||||
|
* to find out. Offsets are clamped and taken in order rather than trusted: they are computed on the
|
||||||
|
* other side of the IPC boundary against a string this side only has a copy of, and one bad pair
|
||||||
|
* would otherwise slice a row into nonsense.
|
||||||
|
*/
|
||||||
|
export function highlight(text: string, ranges: readonly MatchRange[]): ReactNode {
|
||||||
|
if (ranges.length === 0) return text;
|
||||||
|
|
||||||
|
const parts: ReactNode[] = [];
|
||||||
|
let at = 0;
|
||||||
|
const ordered = [...ranges].sort((a, b) => a.start - b.start);
|
||||||
|
|
||||||
|
ordered.forEach((range, i) => {
|
||||||
|
const start = Math.max(at, Math.min(range.start, text.length));
|
||||||
|
const end = Math.max(start, Math.min(range.end, text.length));
|
||||||
|
if (end === start) return;
|
||||||
|
if (start > at) parts.push(text.slice(at, start));
|
||||||
|
parts.push(
|
||||||
|
<mark key={i} className="palette-mark">
|
||||||
|
{text.slice(start, end)}
|
||||||
|
</mark>,
|
||||||
|
);
|
||||||
|
at = end;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (at < text.length) parts.push(text.slice(at));
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// The menu over a misspelled word: what the system thinks was meant, and the two ways of saying it
|
||||||
|
// was not a mistake.
|
||||||
|
//
|
||||||
|
// Mounted once and drawing nothing until src/editor/proofing.ts puts a word in the store, so App.tsx
|
||||||
|
// holds one line for it rather than a piece of the feature. It renders into a portal because the
|
||||||
|
// document scrolls inside its own pane and a menu clipped by the pane it belongs to is no menu at
|
||||||
|
// all, and it is positioned in viewport coordinates because that is what the editor measured the
|
||||||
|
// word in.
|
||||||
|
//
|
||||||
|
// It never takes focus. A left click on a misspelled word is somebody putting the caret in a word
|
||||||
|
// they are about to fix by hand as often as it is somebody asking what else it could have been, and
|
||||||
|
// a menu that steals the caret out of the sentence being typed has broken the more common of the
|
||||||
|
// two. So the caret stays where the click put it, typing goes on into the document and dismisses the
|
||||||
|
// menu on the way, and the buttons refuse the focus a mousedown would otherwise give them.
|
||||||
|
//
|
||||||
|
// "Learn Spelling" is the item that has to be honest about what it does. The checker is
|
||||||
|
// NSSpellChecker and the dictionary is the Mac's, so learning a word here teaches Mail, Notes and
|
||||||
|
// every other app on the machine, which is what makes it useful and also what makes it more than
|
||||||
|
// this app's business to do quietly. The note under the buttons says so in the menu, where the
|
||||||
|
// decision is being made, rather than in a tooltip nobody reads first.
|
||||||
|
|
||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { replaceSpelling } from "../editor/proofing";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { useProofing, type ProofTarget } from "../store/useProofing";
|
||||||
|
|
||||||
|
/** Clearance from the word above and from the edges of the window. */
|
||||||
|
const GAP = 6;
|
||||||
|
const MARGIN = 8;
|
||||||
|
|
||||||
|
export function ProofPopover() {
|
||||||
|
const target = useProofing((s) => s.target);
|
||||||
|
if (target === null) return null;
|
||||||
|
// Keyed so that opening the menu over a second word rebuilds it rather than sliding the first
|
||||||
|
// one's measurements across.
|
||||||
|
return <ProofMenu key={`${target.from}:${target.word}`} target={target} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProofMenu({ target }: { target: ProofTarget }) {
|
||||||
|
const closeMenu = useProofing((s) => s.closeMenu);
|
||||||
|
const ignoreWord = useProofing((s) => s.ignoreWord);
|
||||||
|
const learnWord = useProofing((s) => s.learnWord);
|
||||||
|
|
||||||
|
const popRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [at, setAt] = useState({ left: target.left, top: target.bottom + GAP });
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = popRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const box = el.getBoundingClientRect();
|
||||||
|
const left = Math.max(
|
||||||
|
MARGIN,
|
||||||
|
Math.min(target.left - box.width / 2, window.innerWidth - box.width - MARGIN),
|
||||||
|
);
|
||||||
|
// Under the word, unless the window has no room under it, in which case above it. Never over it:
|
||||||
|
// the word is what the menu is about and covering it hides the mistake being corrected.
|
||||||
|
const below = target.bottom + GAP;
|
||||||
|
const top =
|
||||||
|
below + box.height + MARGIN <= window.innerHeight
|
||||||
|
? below
|
||||||
|
: Math.max(MARGIN, target.top - GAP - box.height);
|
||||||
|
setAt({ left, top });
|
||||||
|
}, [target]);
|
||||||
|
|
||||||
|
useEscapeLayer(true, closeMenu);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (popRef.current?.contains(e.target as Node)) return;
|
||||||
|
closeMenu();
|
||||||
|
};
|
||||||
|
const close = () => closeMenu();
|
||||||
|
document.addEventListener("mousedown", onDown, true);
|
||||||
|
document.addEventListener("scroll", close, true);
|
||||||
|
window.addEventListener("resize", close);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDown, true);
|
||||||
|
document.removeEventListener("scroll", close, true);
|
||||||
|
window.removeEventListener("resize", close);
|
||||||
|
};
|
||||||
|
}, [closeMenu]);
|
||||||
|
|
||||||
|
// The caret belongs to the document, not to this menu, so a press on any of these buttons is not
|
||||||
|
// allowed to move it.
|
||||||
|
const keepFocus = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={popRef}
|
||||||
|
className="proof-pop"
|
||||||
|
role="menu"
|
||||||
|
aria-label={`Spelling suggestions for ${target.word}`}
|
||||||
|
style={{ left: at.left, top: at.top }}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{target.suggestions.length === 0 ? (
|
||||||
|
<p className="proof-none">No suggestions</p>
|
||||||
|
) : (
|
||||||
|
target.suggestions.map((suggestion) => (
|
||||||
|
<button
|
||||||
|
key={suggestion}
|
||||||
|
role="menuitem"
|
||||||
|
className="proof-suggestion"
|
||||||
|
onMouseDown={keepFocus}
|
||||||
|
onClick={() => {
|
||||||
|
replaceSpelling(target, suggestion);
|
||||||
|
closeMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="proof-sep" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
role="menuitem"
|
||||||
|
className="proof-action"
|
||||||
|
title={`Adds “${target.word}” to the dictionary every app on this Mac shares.`}
|
||||||
|
onMouseDown={keepFocus}
|
||||||
|
onClick={() => {
|
||||||
|
void learnWord(target.word);
|
||||||
|
closeMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Learn Spelling
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
role="menuitem"
|
||||||
|
className="proof-action"
|
||||||
|
title="Stops underlining this word until the app is next opened."
|
||||||
|
onMouseDown={keepFocus}
|
||||||
|
onClick={() => {
|
||||||
|
ignoreWord(target.word);
|
||||||
|
closeMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ignore
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="proof-note">Learning a word teaches this Mac, not only Margin Docs.</p>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Cmd+P: every file the index knows about, across every open root, matched against the path it
|
||||||
|
// sits at relative to that root.
|
||||||
|
//
|
||||||
|
// The query lives in the store rather than here because the store is also what asks SQLite, and the
|
||||||
|
// two have to be able to disagree for a moment: the field shows the letter that was just typed
|
||||||
|
// while the index is still answering the word before it. What sits in between is the debounce
|
||||||
|
// below, so a typist crosses the IPC boundary once for a word rather than once for a letter.
|
||||||
|
//
|
||||||
|
// A slow answer landing after a fast one is already handled on the other side of that boundary, by
|
||||||
|
// the sequence number in src/store/useSearch.ts, and this file deliberately does not grow a second
|
||||||
|
// guard for the same race: two of them would have to agree forever, and the day they stopped the
|
||||||
|
// symptom would be a row from a query nobody can see any more.
|
||||||
|
//
|
||||||
|
// An empty field is not an empty palette. Cmd+P with nothing typed offers the documents this
|
||||||
|
// session has already been in, which is the other half of what people reach for the key for.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import type { MatchRange } from "../ipc";
|
||||||
|
import { onCommand } from "../keys/commands";
|
||||||
|
import { useKeyContext } from "../keys/keymap";
|
||||||
|
import { useDocument } from "../store/useDocument";
|
||||||
|
import { useIndex } from "../store/useIndex";
|
||||||
|
import { useSearch } from "../store/useSearch";
|
||||||
|
import { notify } from "../store/useToast";
|
||||||
|
import { useWorkspace, type WorkspaceRoot } from "../store/useWorkspace";
|
||||||
|
import { Palette, highlight, type PaletteRow, type PaletteStatus } from "./Palette";
|
||||||
|
|
||||||
|
/** Long enough that a word is one query rather than five, short enough that the pause between two
|
||||||
|
* words already has an answer waiting in it. */
|
||||||
|
const DEBOUNCE_MS = 90;
|
||||||
|
|
||||||
|
/** How many already-visited documents an empty field offers before it stops being a shortlist. */
|
||||||
|
const RECENT_LIMIT = 8;
|
||||||
|
|
||||||
|
interface FileRow extends PaletteRow {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
/** The path relative to its root, whole, because that is the string the index counted its match
|
||||||
|
* offsets against and a trimmed version of it would highlight the wrong characters. */
|
||||||
|
where: string;
|
||||||
|
ranges: readonly MatchRange[];
|
||||||
|
/** The root's own name, or empty. Filled in only when more than one folder is open and the
|
||||||
|
* relative path alone would be ambiguous between them, and empty for a root that was closed
|
||||||
|
* while its answer was still in flight. */
|
||||||
|
root: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path;
|
||||||
|
|
||||||
|
function relativeTo(path: string, roots: readonly WorkspaceRoot[]): string {
|
||||||
|
for (const root of roots) {
|
||||||
|
if (path.startsWith(`${root.path}/`)) return path.slice(root.path.length + 1);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickOpen() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const query = useSearch((s) => s.quickOpenQuery);
|
||||||
|
const setQuery = useSearch((s) => s.setQuickOpenQuery);
|
||||||
|
const runQuickOpen = useSearch((s) => s.runQuickOpen);
|
||||||
|
const hits = useSearch((s) => s.quickOpenHits);
|
||||||
|
const phase = useSearch((s) => s.quickOpenPhase);
|
||||||
|
const error = useSearch((s) => s.quickOpenError);
|
||||||
|
const indexPhase = useIndex((s) => s.phase);
|
||||||
|
const roots = useWorkspace((s) => s.roots);
|
||||||
|
const select = useWorkspace((s) => s.select);
|
||||||
|
const history = useDocument((s) => s.history);
|
||||||
|
const openPath = useDocument((s) => s.path);
|
||||||
|
const openDocument = useDocument((s) => s.open);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
onCommand("quick-open", () => {
|
||||||
|
// The field starts empty every time and last time's rows go with it. `runQuickOpen("")` is
|
||||||
|
// what clears them, and going through the store rather than reaching for the array directly
|
||||||
|
// is also what bumps its sequence number, so an answer to the query this palette was closed
|
||||||
|
// on cannot arrive inside the one it was just opened for.
|
||||||
|
setQuery("");
|
||||||
|
void runQuickOpen("");
|
||||||
|
setOpen(true);
|
||||||
|
}),
|
||||||
|
[setQuery, runQuickOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEscapeLayer(open, () => setOpen(false));
|
||||||
|
useKeyContext("overlay", open);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const timer = window.setTimeout(() => void runQuickOpen(query), DEBOUNCE_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [open, query, runQuickOpen]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const choose = (path: string) => {
|
||||||
|
// Selected as well as opened, so the tree, and every command that acts on the selection, agree
|
||||||
|
// with the document that is now on screen. Opening a file from here and renaming it with the
|
||||||
|
// next key otherwise renames whatever was last clicked in the sidebar.
|
||||||
|
select(path);
|
||||||
|
openDocument(path).catch((e) => notify(`Could not open: ${String(e)}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const searching = query.trim() !== "";
|
||||||
|
|
||||||
|
const recent = () => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const rows: FileRow[] = [];
|
||||||
|
for (let i = history.length - 1; i >= 0 && rows.length < RECENT_LIMIT; i -= 1) {
|
||||||
|
const path = history[i];
|
||||||
|
// The document already on screen is not somewhere to go.
|
||||||
|
if (path === openPath || seen.has(path)) continue;
|
||||||
|
seen.add(path);
|
||||||
|
rows.push({
|
||||||
|
key: path,
|
||||||
|
path,
|
||||||
|
name: baseName(path),
|
||||||
|
where: relativeTo(path, roots),
|
||||||
|
ranges: [],
|
||||||
|
root: "",
|
||||||
|
run: () => choose(path),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows: FileRow[] = searching
|
||||||
|
? hits.map((hit) => ({
|
||||||
|
key: hit.path,
|
||||||
|
path: hit.path,
|
||||||
|
name: hit.name,
|
||||||
|
where: hit.relPath,
|
||||||
|
ranges: hit.ranges,
|
||||||
|
root: roots.length > 1 ? baseName(hit.rootPath) : "",
|
||||||
|
run: () => choose(hit.path),
|
||||||
|
}))
|
||||||
|
: recent();
|
||||||
|
|
||||||
|
const status = (): PaletteStatus => {
|
||||||
|
if (phase === "error") {
|
||||||
|
return { text: error ?? "The search index could not be read.", error: true };
|
||||||
|
}
|
||||||
|
if (!searching) {
|
||||||
|
return {
|
||||||
|
text: roots.length === 0 ? "Open a folder first." : "Type to find a file by name or path.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (phase === "loading") return { text: "Searching…" };
|
||||||
|
if (indexPhase === "indexing") return { text: "Still indexing. Try again in a moment." };
|
||||||
|
return { text: "No file matches." };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Palette
|
||||||
|
label="Quick open"
|
||||||
|
placeholder="Go to file"
|
||||||
|
query={query}
|
||||||
|
onQuery={setQuery}
|
||||||
|
rows={rows}
|
||||||
|
status={status()}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
renderRow={(row) => (
|
||||||
|
<span className="palette-main" title={row.path}>
|
||||||
|
<span className="palette-name">{row.name}</span>
|
||||||
|
<span className="palette-where">{highlight(row.where, row.ranges)}</span>
|
||||||
|
{row.root !== "" && <span className="palette-root">{row.root}</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// The start screen: what the window shows before any folder is open. A quiet list rather than a
|
||||||
|
// grid of cards, because a folder has no cover and pretending otherwise would just be a row of
|
||||||
|
// identical rectangles.
|
||||||
|
|
||||||
|
import { commandLabel, runCommand } from "../keys/commands";
|
||||||
|
import { notify } from "../store/useToast";
|
||||||
|
import { useWorkspace } from "../store/useWorkspace";
|
||||||
|
import { addRoot } from "../workspace";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
import { shortcutTitle } from "./Titlebar";
|
||||||
|
|
||||||
|
const FOLDER = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z";
|
||||||
|
const OPEN_FOLDER = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M12 10v6 M9 13h6";
|
||||||
|
|
||||||
|
const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path;
|
||||||
|
const parentOf = (path: string): string => path.slice(0, path.lastIndexOf("/")) || "/";
|
||||||
|
|
||||||
|
export function Recents() {
|
||||||
|
const recentFolders = useWorkspace((s) => s.recentFolders);
|
||||||
|
const scanPhase = useWorkspace((s) => s.scanPhase);
|
||||||
|
|
||||||
|
// `openFolder` is the picker and takes no path, so a folder that is already known is opened
|
||||||
|
// through the effects module directly rather than by asking the user to find it again.
|
||||||
|
const open = (path: string) => {
|
||||||
|
addRoot(path).catch((e) => notify(`Could not open that folder: ${String(e)}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="start">
|
||||||
|
<div className="start-drag" data-tauri-drag-region />
|
||||||
|
<div className="start-body">
|
||||||
|
<h1 className="start-title">Margin Docs</h1>
|
||||||
|
<p className="start-line">
|
||||||
|
Open a folder of markdown files. Nothing is copied, nothing is imported, and nothing is
|
||||||
|
written until you make an edit.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="start-open"
|
||||||
|
title={shortcutTitle("open-folder")}
|
||||||
|
disabled={scanPhase === "scanning"}
|
||||||
|
onClick={() => runCommand("open-folder")}
|
||||||
|
>
|
||||||
|
<Icon d={OPEN_FOLDER} size={18} />
|
||||||
|
{scanPhase === "scanning" ? "Opening…" : commandLabel("open-folder")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{recentFolders.length > 0 && (
|
||||||
|
<div className="start-recent">
|
||||||
|
<div className="nav-label">Recent</div>
|
||||||
|
<ul className="start-list">
|
||||||
|
{recentFolders.map((path) => (
|
||||||
|
<li key={path}>
|
||||||
|
<button className="start-row" onClick={() => open(path)} title={path}>
|
||||||
|
<Icon d={FOLDER} size={15} />
|
||||||
|
<span className="start-name">{baseName(path)}</span>
|
||||||
|
<span className="start-path">{parentOf(path)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// The sidebar's drag edge. The width it writes is the `--pane-sidebar` token itself, set on the
|
||||||
|
// root element, so the stylesheet keeps owning the layout and this only moves a number.
|
||||||
|
|
||||||
|
import { useLayoutEffect, type PointerEvent } from "react";
|
||||||
|
|
||||||
|
const VAR = "--pane-sidebar";
|
||||||
|
const KEY = "margindocs-pane-sidebar";
|
||||||
|
const DEFAULT = 248;
|
||||||
|
const MIN = 200;
|
||||||
|
const MAX = 460;
|
||||||
|
|
||||||
|
const clamp = (px: number): number => Math.round(Math.min(MAX, Math.max(MIN, px)));
|
||||||
|
|
||||||
|
function currentWidth(): number {
|
||||||
|
const raw = getComputedStyle(document.documentElement).getPropertyValue(VAR);
|
||||||
|
const px = parseInt(raw, 10);
|
||||||
|
return Number.isFinite(px) && px > 0 ? px : DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWidth(px: number): void {
|
||||||
|
const width = clamp(px);
|
||||||
|
document.documentElement.style.setProperty(VAR, `${width}px`);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, String(width));
|
||||||
|
} catch {
|
||||||
|
// A webview with storage denied still gets a working drag, just not a remembered one.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetWidth(): void {
|
||||||
|
document.documentElement.style.removeProperty(VAR);
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(KEY);
|
||||||
|
} catch {
|
||||||
|
// See above.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResizeHandle() {
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = parseInt(localStorage.getItem(KEY) ?? "", 10);
|
||||||
|
if (Number.isFinite(saved) && saved > 0)
|
||||||
|
document.documentElement.style.setProperty(VAR, `${clamp(saved)}px`);
|
||||||
|
} catch {
|
||||||
|
// See above.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const handle = e.currentTarget;
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startWidth = currentWidth();
|
||||||
|
handle.setPointerCapture(e.pointerId);
|
||||||
|
document.documentElement.setAttribute("data-resizing", "");
|
||||||
|
|
||||||
|
const onMove = (ev: globalThis.PointerEvent) => applyWidth(startWidth + (ev.clientX - startX));
|
||||||
|
const onUp = () => {
|
||||||
|
document.documentElement.removeAttribute("data-resizing");
|
||||||
|
handle.removeEventListener("pointermove", onMove);
|
||||||
|
handle.removeEventListener("pointerup", onUp);
|
||||||
|
};
|
||||||
|
handle.addEventListener("pointermove", onMove);
|
||||||
|
handle.addEventListener("pointerup", onUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pane-resizer"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onDoubleClick={resetWidth}
|
||||||
|
title="Drag to resize, double click to reset"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// The popup menu a row offers, in two forms over one body: a "..." button that opens it under
|
||||||
|
// itself, and a bare popup anchored to the point a right click happened. Both render into a portal
|
||||||
|
// so a menu is never clipped by the scrolling tree it belongs to.
|
||||||
|
|
||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
export interface RowMenuItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
/** A Feather-style 24x24 stroke path, the same shape `<Icon d>` takes everywhere else. */
|
||||||
|
icon: string;
|
||||||
|
danger?: boolean;
|
||||||
|
run: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A hairline between groups of items. Written inline in the array so the order stays readable. */
|
||||||
|
export type RowMenuEntry = RowMenuItem | "sep";
|
||||||
|
|
||||||
|
const MENU_ITEM = ".row-menu-item";
|
||||||
|
|
||||||
|
interface PopProps {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** A thunk, not an array: a tree of a thousand rows should not build a thousand menus it will
|
||||||
|
* never show, and the items a row offers can depend on state that moved since it was drawn. */
|
||||||
|
items: () => readonly RowMenuEntry[];
|
||||||
|
onClose: () => void;
|
||||||
|
/** Where focus goes when the menu closes, so keyboard use does not land back at the document. */
|
||||||
|
restoreFocus?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuPop({ x, y, items, onClose, restoreFocus }: PopProps) {
|
||||||
|
const popRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [pos, setPos] = useState({ left: x, top: y });
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = popRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
setPos({
|
||||||
|
left: Math.max(8, Math.min(x, window.innerWidth - rect.width - 8)),
|
||||||
|
top: Math.max(8, Math.min(y, window.innerHeight - rect.height - 8)),
|
||||||
|
});
|
||||||
|
}, [x, y]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
popRef.current?.querySelector<HTMLElement>(MENU_ITEM)?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEscapeLayer(true, () => {
|
||||||
|
onClose();
|
||||||
|
restoreFocus?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (popRef.current?.contains(e.target as Node)) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const close = () => onClose();
|
||||||
|
document.addEventListener("mousedown", onDown, true);
|
||||||
|
document.addEventListener("scroll", close, true);
|
||||||
|
window.addEventListener("resize", close);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDown, true);
|
||||||
|
document.removeEventListener("scroll", close, true);
|
||||||
|
window.removeEventListener("resize", close);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||||
|
e.preventDefault();
|
||||||
|
const all = Array.from(popRef.current?.querySelectorAll<HTMLElement>(MENU_ITEM) ?? []);
|
||||||
|
if (!all.length) return;
|
||||||
|
const at = all.indexOf(document.activeElement as HTMLElement);
|
||||||
|
const next = e.key === "ArrowDown" ? (at + 1) % all.length : (at - 1 + all.length) % all.length;
|
||||||
|
all[next]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const choose = (e: React.MouseEvent, item: RowMenuItem) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
item.run();
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={popRef}
|
||||||
|
className="row-menu-pop"
|
||||||
|
role="menu"
|
||||||
|
style={{ top: pos.top, left: pos.left }}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{items().map((item, i) =>
|
||||||
|
item === "sep" ? (
|
||||||
|
<div key={`sep-${i}`} className="menu-sep" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
role="menuitem"
|
||||||
|
className={item.danger ? "row-menu-item danger" : "row-menu-item"}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => choose(e, item)}
|
||||||
|
>
|
||||||
|
<Icon d={item.icon} size={14} />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowMenuProps {
|
||||||
|
label: string;
|
||||||
|
items: () => readonly RowMenuEntry[];
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "..." trigger, for a row that has room to show one. */
|
||||||
|
export function RowMenu({ label, items, onOpenChange, className = "" }: RowMenuProps) {
|
||||||
|
const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const btnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const wasOpen = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const open = anchor !== null;
|
||||||
|
if (open === wasOpen.current) return;
|
||||||
|
wasOpen.current = open;
|
||||||
|
onOpenChange?.(open);
|
||||||
|
}, [anchor, onOpenChange]);
|
||||||
|
|
||||||
|
const toggle = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (anchor) {
|
||||||
|
setAnchor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = btnRef.current?.getBoundingClientRect();
|
||||||
|
if (rect) setAnchor({ x: rect.left, y: rect.bottom + 4 });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={btnRef}
|
||||||
|
className={`row-menu-btn ${className}`}
|
||||||
|
data-open={anchor !== null}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
<Icon d="M12 5h.01M12 12h.01M12 19h.01" />
|
||||||
|
</button>
|
||||||
|
{anchor && (
|
||||||
|
<MenuPop
|
||||||
|
x={anchor.x}
|
||||||
|
y={anchor.y}
|
||||||
|
items={items}
|
||||||
|
onClose={() => setAnchor(null)}
|
||||||
|
restoreFocus={() => btnRef.current?.focus()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowMenuAtProps {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
items: () => readonly RowMenuEntry[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same menu opened at a point, which is what a right click on a row produces. */
|
||||||
|
export function RowMenuAt({ x, y, items, onClose }: RowMenuAtProps) {
|
||||||
|
return <MenuPop x={x} y={y} items={items} onClose={onClose} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Every key the app answers to, generated from src/keys/bindings.ts rather than written out here.
|
||||||
|
// That is the point of the table over there: a binding that exists is a binding this sheet shows,
|
||||||
|
// so the two cannot drift and there is no list to remember to update.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { BINDINGS, GROUPS, bindingLabel, keyLabel } from "../keys/bindings";
|
||||||
|
import { commandLabel, onCommand } from "../keys/commands";
|
||||||
|
import { useKeyContext } from "../keys/keymap";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
export function Shortcuts() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => onCommand("shortcuts", () => setOpen((v) => !v)), []);
|
||||||
|
useEscapeLayer(open, () => setOpen(false));
|
||||||
|
useKeyContext("overlay", open);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overlay" onClick={() => setOpen(false)}>
|
||||||
|
<div
|
||||||
|
className="panel panel-keys"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Keyboard shortcuts"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="panel-head">
|
||||||
|
<h2>Keyboard shortcuts</h2>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
title="Close (⎋)"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<Icon d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="panel-body">
|
||||||
|
{GROUPS.map((group) => {
|
||||||
|
const rows = BINDINGS.filter((binding) => binding.group === group);
|
||||||
|
if (!rows.length) return null;
|
||||||
|
return (
|
||||||
|
<section key={group} className="key-group">
|
||||||
|
<div className="nav-label">{group}</div>
|
||||||
|
<ul className="key-list">
|
||||||
|
{rows.map((binding) => (
|
||||||
|
<li key={binding.keys.join("+")} className="key-row">
|
||||||
|
<span className="key-what">{bindingLabel(binding, commandLabel)}</span>
|
||||||
|
<span className="key-combos">
|
||||||
|
{binding.keys.map((combo) => (
|
||||||
|
<kbd key={combo} className="key-cap">
|
||||||
|
{keyLabel(combo)}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
// Every open folder, one tree each. This component owns everything that spans roots: which row is
|
||||||
|
// selected, which row holds the tab stop, the arrow-key walk over the visible rows, the drag
|
||||||
|
// gesture and the menus. FileTree.tsx below it only draws.
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, type PointerEvent } from "react";
|
||||||
|
import { openExternal } from "../api/roots";
|
||||||
|
import { commandLabel, runCommand } from "../keys/commands";
|
||||||
|
import { useDocument } from "../store/useDocument";
|
||||||
|
import { notify } from "../store/useToast";
|
||||||
|
import { useWorkspace, type TreeNode } from "../store/useWorkspace";
|
||||||
|
import { movePath } from "../workspace";
|
||||||
|
import { ConfirmDialog } from "./ConfirmDialog";
|
||||||
|
import {
|
||||||
|
FileTree,
|
||||||
|
flattenTree,
|
||||||
|
splitExtension,
|
||||||
|
type DropTarget,
|
||||||
|
type TreeHandlers,
|
||||||
|
type TreeRow,
|
||||||
|
type TreeViewState,
|
||||||
|
} from "./FileTree";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
import { ResizeHandle } from "./ResizeHandle";
|
||||||
|
import { RowMenuAt, type RowMenuEntry } from "./RowMenu";
|
||||||
|
import { shortcutTitle } from "./Titlebar";
|
||||||
|
|
||||||
|
const EXPANDED_KEY = "margindocs-expanded";
|
||||||
|
const ROOTS_SEEN_KEY = "margindocs-roots-seen";
|
||||||
|
|
||||||
|
/** How far the pointer travels before a press on a row becomes a drag rather than a click. */
|
||||||
|
const DRAG_SLOP = 4;
|
||||||
|
/** How long a drag hovers a closed folder before it springs open, the way Finder does. */
|
||||||
|
const SPRING_MS = 650;
|
||||||
|
|
||||||
|
const OPEN_FOLDER = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M12 10v6 M9 13h6";
|
||||||
|
const NEW_DOC_ICON = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5 M12 12v5 M9.5 14.5h5";
|
||||||
|
const NEW_FOLDER_ICON = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M12 11v6 M9 14h6";
|
||||||
|
const RENAME_ICON = "M4 20h4L20 8l-4-4L4 16z M14 6l4 4";
|
||||||
|
const DUPLICATE_ICON = "M9 9h11v11H9z M6 15V5h9";
|
||||||
|
const REVEAL_ICON = "M9 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4 M15 3h6v6 M10 14L21 3";
|
||||||
|
const COPY_PATH_ICON = "M8 4h8a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z M9 2h6v4H9z";
|
||||||
|
const TRASH_ICON = "M5 7h14M10 7V5h4v2M7 7l1 13h8l1-13M10 11v6M14 11v6";
|
||||||
|
const CLOSE_ICON = "M18 6L6 18M6 6l12 12";
|
||||||
|
|
||||||
|
function readList(key: string): string[] {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(localStorage.getItem(key) ?? "[]");
|
||||||
|
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeList(key: string, values: readonly string[]): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(values));
|
||||||
|
} catch {
|
||||||
|
// A webview with storage denied still works, it just forgets the shape of the tree.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the pointer says the dragged row should land.
|
||||||
|
*
|
||||||
|
* A filesystem has no row order to insert into, so all three answers are one destination
|
||||||
|
* directory: the middle of a folder means that folder, and the edges of any row mean the folder
|
||||||
|
* that row already lives in. Reading the answer off the DOM rather than off the row array is what
|
||||||
|
* keeps this working while the tree scrolls under the pointer.
|
||||||
|
*/
|
||||||
|
function dropAt(x: number, y: number): DropTarget | null {
|
||||||
|
const el = document.elementFromPoint(x, y) as HTMLElement | null;
|
||||||
|
if (!el) return null;
|
||||||
|
|
||||||
|
const row = el.closest<HTMLElement>(".tree-row");
|
||||||
|
if (row?.dataset.path) {
|
||||||
|
const path = row.dataset.path;
|
||||||
|
const parent = row.dataset.parent ?? "";
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
const at = (y - rect.top) / rect.height;
|
||||||
|
if (row.dataset.dir === "true") {
|
||||||
|
if (!parent || (at > 0.25 && at < 0.75)) return { dir: path, mode: "into", row: path };
|
||||||
|
return { dir: parent, mode: at <= 0.25 ? "before" : "after", row: path };
|
||||||
|
}
|
||||||
|
if (!parent) return null;
|
||||||
|
return { dir: parent, mode: at < 0.5 ? "before" : "after", row: path };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The indent gutter of a nested list belongs to the folder that owns the list, not to the root.
|
||||||
|
const owner = el
|
||||||
|
.closest<HTMLElement>(".tree-item")
|
||||||
|
?.querySelector<HTMLElement>(":scope > .tree-row");
|
||||||
|
if (owner?.dataset.path) return { dir: owner.dataset.path, mode: "into", row: owner.dataset.path };
|
||||||
|
|
||||||
|
const section = el.closest<HTMLElement>(".tree-section");
|
||||||
|
if (section?.dataset.root) return { dir: section.dataset.root, mode: "into", row: section.dataset.root };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A drop that would not move anything, or would move a folder inside itself, is not a drop. */
|
||||||
|
function usableDrop(target: DropTarget | null, path: string, parent: string): DropTarget | null {
|
||||||
|
if (!target) return null;
|
||||||
|
if (target.dir === parent) return null;
|
||||||
|
if (target.dir === path || target.dir.startsWith(`${path}/`)) return null;
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameDrop = (a: DropTarget | null, b: DropTarget | null): boolean =>
|
||||||
|
a?.dir === b?.dir && a?.mode === b?.mode && a?.row === b?.row;
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const roots = useWorkspace((s) => s.roots);
|
||||||
|
const expanded = useWorkspace((s) => s.expanded);
|
||||||
|
const selectedPath = useWorkspace((s) => s.selectedPath);
|
||||||
|
const scanPhase = useWorkspace((s) => s.scanPhase);
|
||||||
|
const select = useWorkspace((s) => s.select);
|
||||||
|
const toggleExpanded = useWorkspace((s) => s.toggleExpanded);
|
||||||
|
const newDocument = useWorkspace((s) => s.newDocument);
|
||||||
|
const newFolder = useWorkspace((s) => s.newFolder);
|
||||||
|
const renameEntry = useWorkspace((s) => s.renameEntry);
|
||||||
|
const duplicateEntry = useWorkspace((s) => s.duplicateEntry);
|
||||||
|
const deleteEntry = useWorkspace((s) => s.deleteEntry);
|
||||||
|
const revealInFinder = useWorkspace((s) => s.revealInFinder);
|
||||||
|
const closeFolder = useWorkspace((s) => s.closeFolder);
|
||||||
|
const openDocument = useDocument((s) => s.open);
|
||||||
|
const openPath = useDocument((s) => s.path);
|
||||||
|
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
const [renamingPath, setRenamingPath] = useState<string | null>(null);
|
||||||
|
const [menuOpenPath, setMenuOpenPath] = useState<string | null>(null);
|
||||||
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; row: TreeRow } | null>(null);
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<TreeNode | null>(null);
|
||||||
|
const [dragPath, setDragPath] = useState<string | null>(null);
|
||||||
|
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
||||||
|
|
||||||
|
const gesture = useRef<{ path: string; parent: string; x: number; y: number; active: boolean } | null>(null);
|
||||||
|
const suppressClick = useRef(false);
|
||||||
|
const dropRef = useRef<DropTarget | null>(null);
|
||||||
|
const spring = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const already = useWorkspace.getState().expanded;
|
||||||
|
for (const path of readList(EXPANDED_KEY)) if (!already.has(path)) toggleExpanded(path);
|
||||||
|
setHydrated(true);
|
||||||
|
}, [toggleExpanded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydrated) return;
|
||||||
|
writeList(EXPANDED_KEY, [...expanded]);
|
||||||
|
}, [hydrated, expanded]);
|
||||||
|
|
||||||
|
// A folder the user has only just opened should show its contents. One that they opened months
|
||||||
|
// ago and then collapsed should stay collapsed, which is why "seen" is remembered separately
|
||||||
|
// rather than inferred from an empty expansion set.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydrated) return;
|
||||||
|
const seen = readList(ROOTS_SEEN_KEY);
|
||||||
|
const fresh = roots.filter((r) => !seen.includes(r.path));
|
||||||
|
if (!fresh.length) return;
|
||||||
|
const already = useWorkspace.getState().expanded;
|
||||||
|
for (const root of fresh) if (!already.has(root.path)) toggleExpanded(root.path);
|
||||||
|
writeList(ROOTS_SEEN_KEY, [...seen, ...fresh.map((r) => r.path)]);
|
||||||
|
}, [hydrated, roots, toggleExpanded]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (spring.current !== null) clearTimeout(spring.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rootNodes: TreeNode[] = useMemo(
|
||||||
|
() =>
|
||||||
|
roots.map((root) => ({
|
||||||
|
path: root.path,
|
||||||
|
name: root.name,
|
||||||
|
isDir: true,
|
||||||
|
editable: false,
|
||||||
|
children: root.tree,
|
||||||
|
})),
|
||||||
|
[roots],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = useMemo(
|
||||||
|
() => rootNodes.flatMap((node) => flattenTree([node], expanded, 0, "")),
|
||||||
|
[rootNodes, expanded],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tabStopPath =
|
||||||
|
rows.find((r) => r.node.path === selectedPath)?.node.path ?? rows[0]?.node.path ?? null;
|
||||||
|
|
||||||
|
const focusRow = (path: string) =>
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.querySelector<HTMLElement>(`.tree-row[data-path="${CSS.escape(path)}"]`)?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveFocus = (row: TreeRow | undefined) => {
|
||||||
|
if (!row) return;
|
||||||
|
select(row.node.path);
|
||||||
|
focusRow(row.node.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activate = (node: TreeNode) => {
|
||||||
|
if (suppressClick.current) {
|
||||||
|
suppressClick.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
select(node.path);
|
||||||
|
if (node.isDir) {
|
||||||
|
toggleExpanded(node.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.editable) {
|
||||||
|
openDocument(node.path).catch((e) => notify(`Could not open: ${String(e)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openExternal(node.path).catch((e) => notify(`Could not open: ${String(e)}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent, row: TreeRow) => {
|
||||||
|
const at = rows.findIndex((r) => r.node.path === row.node.path);
|
||||||
|
switch (e.key) {
|
||||||
|
case "Enter":
|
||||||
|
case " ":
|
||||||
|
e.preventDefault();
|
||||||
|
activate(row.node);
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
moveFocus(rows[at + 1]);
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
moveFocus(rows[at - 1]);
|
||||||
|
break;
|
||||||
|
case "Home":
|
||||||
|
e.preventDefault();
|
||||||
|
moveFocus(rows[0]);
|
||||||
|
break;
|
||||||
|
case "End":
|
||||||
|
e.preventDefault();
|
||||||
|
moveFocus(rows[rows.length - 1]);
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
e.preventDefault();
|
||||||
|
if (!row.node.isDir) break;
|
||||||
|
if (!expanded.has(row.node.path)) toggleExpanded(row.node.path);
|
||||||
|
else moveFocus(rows[at + 1]);
|
||||||
|
break;
|
||||||
|
case "ArrowLeft":
|
||||||
|
e.preventDefault();
|
||||||
|
if (row.node.isDir && expanded.has(row.node.path)) toggleExpanded(row.node.path);
|
||||||
|
else if (row.parentPath) moveFocus(rows.find((r) => r.node.path === row.parentPath));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setDrop = (target: DropTarget | null) => {
|
||||||
|
if (sameDrop(dropRef.current, target)) return;
|
||||||
|
dropRef.current = target;
|
||||||
|
setDropTarget(target);
|
||||||
|
if (spring.current !== null) {
|
||||||
|
clearTimeout(spring.current);
|
||||||
|
spring.current = null;
|
||||||
|
}
|
||||||
|
if (target?.mode !== "into") return;
|
||||||
|
const dir = target.dir;
|
||||||
|
if (useWorkspace.getState().expanded.has(dir)) return;
|
||||||
|
spring.current = window.setTimeout(() => {
|
||||||
|
spring.current = null;
|
||||||
|
if (dropRef.current?.dir === dir) useWorkspace.getState().toggleExpanded(dir);
|
||||||
|
}, SPRING_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: globalThis.PointerEvent) => {
|
||||||
|
const g = gesture.current;
|
||||||
|
if (!g) return;
|
||||||
|
if (!g.active) {
|
||||||
|
if (Math.abs(e.clientX - g.x) < DRAG_SLOP && Math.abs(e.clientY - g.y) < DRAG_SLOP) return;
|
||||||
|
g.active = true;
|
||||||
|
setDragPath(g.path);
|
||||||
|
}
|
||||||
|
setDrop(usableDrop(dropAt(e.clientX, e.clientY), g.path, g.parent));
|
||||||
|
};
|
||||||
|
|
||||||
|
// The disk half of a drop. Everything it does now lives in src/workspace.ts beside renamePath,
|
||||||
|
// which is where it belonged: refreshing both folders, following the selection, and rewriting the
|
||||||
|
// relative links the move broke. Calling fileMove from here would move the bytes and leave every
|
||||||
|
// link pointing at the old path.
|
||||||
|
const moveInto = async (path: string, dir: string) => {
|
||||||
|
await movePath(path, dir);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = () => {
|
||||||
|
window.removeEventListener("pointermove", onPointerMove);
|
||||||
|
window.removeEventListener("pointerup", onPointerUp);
|
||||||
|
const g = gesture.current;
|
||||||
|
gesture.current = null;
|
||||||
|
const target = dropRef.current;
|
||||||
|
if (g?.active) {
|
||||||
|
suppressClick.current = true;
|
||||||
|
if (target) moveInto(g.path, target.dir).catch((e) => notify(`Could not move: ${String(e)}`));
|
||||||
|
}
|
||||||
|
setDragPath(null);
|
||||||
|
setDrop(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e: PointerEvent, row: TreeRow) => {
|
||||||
|
suppressClick.current = false;
|
||||||
|
if (e.button !== 0 || renamingPath === row.node.path || menuOpenPath === row.node.path) return;
|
||||||
|
// A root is where its folder lives on disk, not a row inside a tree, so it does not move.
|
||||||
|
if (!row.parentPath) return;
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest(".row-menu-btn") || target.closest(".tree-twisty")) return;
|
||||||
|
gesture.current = { path: row.node.path, parent: row.parentPath, x: e.clientX, y: e.clientY, active: false };
|
||||||
|
window.addEventListener("pointermove", onPointerMove);
|
||||||
|
window.addEventListener("pointerup", onPointerUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onContextMenu = (e: MouseEvent, row: TreeRow) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
select(row.node.path);
|
||||||
|
setContextMenu({ x: e.clientX, y: e.clientY, row });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open first, then offer the rename: the editor takes focus as it mounts, and a rename field
|
||||||
|
// that opened before it would be blurred out from under the user mid-word.
|
||||||
|
const createDocument = (dir: string) => {
|
||||||
|
newDocument(dir)
|
||||||
|
.then((path) => {
|
||||||
|
select(path);
|
||||||
|
return openDocument(path).then(() => setRenamingPath(path));
|
||||||
|
})
|
||||||
|
.catch((e) => notify(`Could not create the document: ${String(e)}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFolder = (dir: string) => {
|
||||||
|
newFolder(dir)
|
||||||
|
.then((path) => {
|
||||||
|
select(path);
|
||||||
|
setRenamingPath(path);
|
||||||
|
})
|
||||||
|
.catch((e) => notify(`Could not create the folder: ${String(e)}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyPath = (path: string) => {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(path)
|
||||||
|
.then(() => notify("Path copied"))
|
||||||
|
.catch(() => notify("Could not copy the path"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuItems = (row: TreeRow): readonly RowMenuEntry[] => {
|
||||||
|
const node = row.node;
|
||||||
|
const dir = node.isDir ? node.path : row.parentPath;
|
||||||
|
const isRoot = !row.parentPath;
|
||||||
|
const items: RowMenuEntry[] = [
|
||||||
|
{ id: "new-doc", label: "New Document", icon: NEW_DOC_ICON, run: () => createDocument(dir) },
|
||||||
|
{ id: "new-folder", label: "New Folder", icon: NEW_FOLDER_ICON, run: () => createFolder(dir) },
|
||||||
|
];
|
||||||
|
if (!isRoot) {
|
||||||
|
items.push("sep");
|
||||||
|
items.push({
|
||||||
|
id: "rename",
|
||||||
|
label: "Rename",
|
||||||
|
icon: RENAME_ICON,
|
||||||
|
run: () => setRenamingPath(node.path),
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
id: "duplicate",
|
||||||
|
label: "Duplicate",
|
||||||
|
icon: DUPLICATE_ICON,
|
||||||
|
run: () =>
|
||||||
|
duplicateEntry(node.path).catch((e) => notify(`Could not duplicate: ${String(e)}`)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.push("sep");
|
||||||
|
items.push({
|
||||||
|
id: "reveal",
|
||||||
|
label: "Reveal in Finder",
|
||||||
|
icon: REVEAL_ICON,
|
||||||
|
run: () =>
|
||||||
|
revealInFinder(node.path).catch((e) => notify(`Could not reveal in Finder: ${String(e)}`)),
|
||||||
|
});
|
||||||
|
items.push({ id: "copy-path", label: "Copy Path", icon: COPY_PATH_ICON, run: () => copyPath(node.path) });
|
||||||
|
items.push("sep");
|
||||||
|
if (isRoot)
|
||||||
|
items.push({
|
||||||
|
id: "close-folder",
|
||||||
|
label: "Close Folder",
|
||||||
|
icon: CLOSE_ICON,
|
||||||
|
run: () => closeFolder(node.path),
|
||||||
|
});
|
||||||
|
else
|
||||||
|
items.push({
|
||||||
|
id: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: TRASH_ICON,
|
||||||
|
danger: true,
|
||||||
|
run: () => setPendingDelete(node),
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const view: TreeViewState = {
|
||||||
|
expanded,
|
||||||
|
selectedPath,
|
||||||
|
tabStopPath,
|
||||||
|
draggingPath: dragPath,
|
||||||
|
dropTarget,
|
||||||
|
renamingPath,
|
||||||
|
openPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlers: TreeHandlers = {
|
||||||
|
onActivate: activate,
|
||||||
|
onToggle: (node) => {
|
||||||
|
select(node.path);
|
||||||
|
toggleExpanded(node.path);
|
||||||
|
},
|
||||||
|
onKeyDown,
|
||||||
|
onPointerDown,
|
||||||
|
onContextMenu,
|
||||||
|
onMenuOpenChange: (path, open) =>
|
||||||
|
setMenuOpenPath((current) => (open ? path : current === path ? null : current)),
|
||||||
|
menuItems,
|
||||||
|
// The row hides the extension, so the rename has to put back the one it took away rather than
|
||||||
|
// quietly turning notes.md into a file with no extension at all.
|
||||||
|
onRenameCommit: (node, typed) => {
|
||||||
|
setRenamingPath(null);
|
||||||
|
const { base, hidden } = splitExtension(node.name, node.isDir);
|
||||||
|
const trimmed = typed.trim();
|
||||||
|
if (!trimmed || trimmed === base) return;
|
||||||
|
renameEntry(node.path, `${trimmed}${hidden}`).catch((e) =>
|
||||||
|
notify(`Could not rename: ${String(e)}`),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onRenameCancel: () => setRenamingPath(null),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<aside className="sidebar" aria-label="Folders">
|
||||||
|
<div className="sidebar-head">
|
||||||
|
<span className="nav-label">Folders</span>
|
||||||
|
<div className="sidebar-actions">
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
title={shortcutTitle("open-folder")}
|
||||||
|
aria-label={commandLabel("open-folder")}
|
||||||
|
onClick={() => runCommand("open-folder")}
|
||||||
|
>
|
||||||
|
<Icon d={OPEN_FOLDER} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
title={shortcutTitle("new-doc")}
|
||||||
|
aria-label={commandLabel("new-doc")}
|
||||||
|
onClick={() => runCommand("new-doc")}
|
||||||
|
>
|
||||||
|
<Icon d={NEW_DOC_ICON} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="nav-scroll">
|
||||||
|
{rootNodes.map((node) => (
|
||||||
|
<div key={node.path} className="tree-section" data-root={node.path}>
|
||||||
|
<FileTree nodes={[node]} depth={0} parentPath="" state={view} handlers={handlers} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!rootNodes.length && (
|
||||||
|
<p className="sidebar-empty">
|
||||||
|
{scanPhase === "scanning" ? "Reading the folder…" : "No folder is open."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<ResizeHandle />
|
||||||
|
|
||||||
|
{contextMenu && (
|
||||||
|
<RowMenuAt
|
||||||
|
x={contextMenu.x}
|
||||||
|
y={contextMenu.y}
|
||||||
|
items={() => menuItems(contextMenu.row)}
|
||||||
|
onClose={() => setContextMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pendingDelete && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={pendingDelete.isDir ? "Delete folder" : "Delete file"}
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
Move <strong>{pendingDelete.name}</strong> to the Trash? You can put it back from
|
||||||
|
Finder.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmLabel="Move to Trash"
|
||||||
|
onConfirm={() => {
|
||||||
|
const path = pendingDelete.path;
|
||||||
|
setPendingDelete(null);
|
||||||
|
deleteEntry(path).catch((e) => notify(`Could not delete: ${String(e)}`));
|
||||||
|
}}
|
||||||
|
onClose={() => setPendingDelete(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// The macOS overlay title bar. The traffic lights float over its left end, which is why the row
|
||||||
|
// itself carries `data-tauri-drag-region` and every control inside it does not: an interactive
|
||||||
|
// element that also drags the window swallows its own click.
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { keyLabel, keysFor } from "../keys/bindings";
|
||||||
|
import { commandLabel, onCommand, runCommand, type CommandId } from "../keys/commands";
|
||||||
|
import { useDocument } from "../store/useDocument";
|
||||||
|
import { useTheme } from "../store/useTheme";
|
||||||
|
import { notify } from "../store/useToast";
|
||||||
|
import { useWorkspace } from "../store/useWorkspace";
|
||||||
|
import { splitExtension } from "./FileTree";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
import { WidthMenu } from "./WidthMenu";
|
||||||
|
|
||||||
|
|
||||||
|
const SIDEBAR_KEY = "margindocs-sidebar";
|
||||||
|
|
||||||
|
const SIDEBAR_ICON = "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z M9 3v18";
|
||||||
|
const NEW_DOC = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5 M12 12v5 M9.5 14.5h5";
|
||||||
|
const SEARCH = "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14z M20 20l-3.6-3.6";
|
||||||
|
const SUN = "M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10z M12 1v2 M12 21v2 M4.2 4.2l1.4 1.4 M18.4 18.4l1.4 1.4 M1 12h2 M21 12h2 M4.2 19.8l1.4-1.4 M18.4 5.6l1.4-1.4";
|
||||||
|
const MOON = "M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z";
|
||||||
|
const MORE = "M5 12h.01M12 12h.01M19 12h.01";
|
||||||
|
|
||||||
|
/** A tooltip that names the action and prints its key in the glyphs the sheet uses. */
|
||||||
|
export function shortcutTitle(id: CommandId): string {
|
||||||
|
const keys = keysFor(id);
|
||||||
|
return keys.length ? `${commandLabel(id)} (${keyLabel(keys[0])})` : commandLabel(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Titlebar() {
|
||||||
|
const path = useDocument((s) => s.path);
|
||||||
|
const dirty = useDocument((s) => s.dirty);
|
||||||
|
const externalChange = useDocument((s) => s.externalChange);
|
||||||
|
const renameEntry = useWorkspace((s) => s.renameEntry);
|
||||||
|
const theme = useTheme((s) => s.theme);
|
||||||
|
const toggleTheme = useTheme((s) => s.toggle);
|
||||||
|
|
||||||
|
const [sidebar, setSidebar] = useState(
|
||||||
|
() => document.documentElement.getAttribute("data-sidebar") !== "false",
|
||||||
|
);
|
||||||
|
const [menu, setMenu] = useState(false);
|
||||||
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute("data-sidebar", String(sidebar));
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SIDEBAR_KEY, String(sidebar));
|
||||||
|
} catch {
|
||||||
|
// A webview with storage denied still toggles, it just forgets between launches.
|
||||||
|
}
|
||||||
|
}, [sidebar]);
|
||||||
|
|
||||||
|
useEffect(() => onCommand("toggle-sidebar", () => setSidebar((v) => !v)), []);
|
||||||
|
useEffect(() => setRenaming(false), [path]);
|
||||||
|
|
||||||
|
useEscapeLayer(menu, () => setMenu(false));
|
||||||
|
|
||||||
|
const fileName = path ? path.slice(path.lastIndexOf("/") + 1) : "";
|
||||||
|
const { base, hidden } = splitExtension(fileName);
|
||||||
|
|
||||||
|
// The filename and the document's H1 are unrelated, and this is the place that promise is
|
||||||
|
// easiest to break. What the title bar shows is the file on disk, and the only thing that ever
|
||||||
|
// renames it is the user typing here. Editing a heading is a content edit and nothing else: it
|
||||||
|
// does not move the file, because a path is what git, every other editor and every relative
|
||||||
|
// link from another document already agreed on.
|
||||||
|
const commitRename = (next: string) => {
|
||||||
|
setRenaming(false);
|
||||||
|
const trimmed = next.trim();
|
||||||
|
if (!path || !trimmed || trimmed === base) return;
|
||||||
|
renameEntry(path, `${trimmed}${hidden}`).catch((e) => notify(`Could not rename: ${String(e)}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuItem = (id: CommandId) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => {
|
||||||
|
setMenu(false);
|
||||||
|
runCommand(id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{commandLabel(id)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="titlebar" data-tauri-drag-region>
|
||||||
|
<div className="lead">
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
data-active={sidebar}
|
||||||
|
title={shortcutTitle("toggle-sidebar")}
|
||||||
|
aria-label={commandLabel("toggle-sidebar")}
|
||||||
|
aria-pressed={sidebar}
|
||||||
|
onClick={() => setSidebar((v) => !v)}
|
||||||
|
>
|
||||||
|
<Icon d={SIDEBAR_ICON} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{path &&
|
||||||
|
(renaming ? (
|
||||||
|
<TitleRename value={base} onCommit={commitRename} onCancel={() => setRenaming(false)} />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="doc-title"
|
||||||
|
data-external={externalChange === "changed-on-disk"}
|
||||||
|
title={
|
||||||
|
externalChange === "changed-on-disk"
|
||||||
|
? `${fileName} (changed on disk). Click to rename.`
|
||||||
|
: `${fileName}. Click to rename.`
|
||||||
|
}
|
||||||
|
onClick={() => setRenaming(true)}
|
||||||
|
>
|
||||||
|
{base}
|
||||||
|
{dirty && <span className="dirty-dot" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="actions">
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
title={shortcutTitle("new-doc")}
|
||||||
|
aria-label={commandLabel("new-doc")}
|
||||||
|
onClick={() => runCommand("new-doc")}
|
||||||
|
>
|
||||||
|
<Icon d={NEW_DOC} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
title={shortcutTitle("quick-open")}
|
||||||
|
aria-label={commandLabel("quick-open")}
|
||||||
|
onClick={() => runCommand("quick-open")}
|
||||||
|
>
|
||||||
|
<Icon d={SEARCH} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
title={theme === "dark" ? "Light theme" : "Dark theme"}
|
||||||
|
aria-label={commandLabel("toggle-theme")}
|
||||||
|
onClick={toggleTheme}
|
||||||
|
>
|
||||||
|
<Icon d={theme === "dark" ? SUN : MOON} />
|
||||||
|
</button>
|
||||||
|
<WidthMenu />
|
||||||
|
<div className="menu-wrap">
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
data-active={menu}
|
||||||
|
title="More"
|
||||||
|
aria-label="More"
|
||||||
|
aria-expanded={menu}
|
||||||
|
onClick={() => setMenu((v) => !v)}
|
||||||
|
>
|
||||||
|
<Icon d={MORE} />
|
||||||
|
</button>
|
||||||
|
{menu && (
|
||||||
|
<>
|
||||||
|
<div className="menu-backdrop" onClick={() => setMenu(false)} />
|
||||||
|
<div className="menu" role="menu">
|
||||||
|
{menuItem("open-folder")}
|
||||||
|
{menuItem("new-folder")}
|
||||||
|
<div className="menu-sep" />
|
||||||
|
{menuItem("find-in-files")}
|
||||||
|
{menuItem("shortcuts")}
|
||||||
|
{menuItem("settings")}
|
||||||
|
<div className="menu-sep" />
|
||||||
|
{menuItem("check-updates")}
|
||||||
|
{menuItem("report-issue")}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TitleRename({
|
||||||
|
value,
|
||||||
|
onCommit,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onCommit: (next: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
const settled = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const input = ref.current;
|
||||||
|
if (!input) return;
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const commit = (next: string) => {
|
||||||
|
if (settled.current) return;
|
||||||
|
settled.current = true;
|
||||||
|
onCommit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEscapeLayer(true, () => {
|
||||||
|
settled.current = true;
|
||||||
|
onCancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className="doc-title title-rename"
|
||||||
|
defaultValue={value}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-label="Rename this file"
|
||||||
|
onBlur={(e) => commit(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Enter") return;
|
||||||
|
e.preventDefault();
|
||||||
|
commit(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useToast } from "../store/useToast";
|
||||||
|
|
||||||
|
const DWELL_MS = 4200;
|
||||||
|
|
||||||
|
export function Toast() {
|
||||||
|
const message = useToast((s) => s.message);
|
||||||
|
const dismiss = useToast((s) => s.dismiss);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!message) return;
|
||||||
|
const timer = setTimeout(dismiss, DWELL_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [message, dismiss]);
|
||||||
|
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="toast" role="status" title="Dismiss" onClick={dismiss}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// The width control there was no way to click: a title bar button that shows the applied width and
|
||||||
|
// opens the three named steps with the current one marked.
|
||||||
|
//
|
||||||
|
// It belongs beside the theme toggle rather than in the editor pill. Everything in the pill edits
|
||||||
|
// the file; this edits the app's view of it and touches no byte on disk, and the title bar already
|
||||||
|
// holds the other two of exactly that kind, the sidebar and the theme, both persisted under the
|
||||||
|
// same `margindocs-` prefix and both restored by the same boot script. The pill is also the wrong
|
||||||
|
// place mechanically: its tools go dead while a save conflict is open, and being unable to widen
|
||||||
|
// the page because the file moved on disk is nonsense, and the foot of src/styles/toolbar.css
|
||||||
|
// records that the row is already four pixels over the pane at the app's minimum window.
|
||||||
|
//
|
||||||
|
// The DOM is the source of truth and this component's state is a cache of it. `applyWidth` writes
|
||||||
|
// `data-width` on the root element and index.html's boot script writes it before React exists,
|
||||||
|
// while the keyboard commands and the native menu both call `applyWidth` without telling anyone,
|
||||||
|
// so the attribute is read on mount and watched with a MutationObserver. A component that
|
||||||
|
// remembered the last width it set itself would open showing the wrong one the first time somebody
|
||||||
|
// reached for the key instead.
|
||||||
|
//
|
||||||
|
// Focus is not taken from the document. A mouse press on any button here is prevented, so the
|
||||||
|
// caret stays in the sentence somebody is in the middle of; only a keyboard open moves focus into
|
||||||
|
// the menu, and closing puts it back where it came from.
|
||||||
|
|
||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { applyWidth, WIDTHS, type EditorWidth } from "../width";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
const ITEM = ".width-menu-item";
|
||||||
|
|
||||||
|
const CHECK_D = "M20 6L9 17l-5-5";
|
||||||
|
|
||||||
|
/** The page's two edges with three lines of text between them, so the button says which width is
|
||||||
|
* applied without spending a word of the title bar on it. The edges never move and only the
|
||||||
|
* measure does, which is the whole of what the setting changes. The sibling's glyph for this is a
|
||||||
|
* double headed arrow, and it is not ported: an arrow six units long is a smudge at 16px, which is
|
||||||
|
* the only size this is ever drawn at. */
|
||||||
|
const WIDTH_ICON: Record<EditorWidth, string> = {
|
||||||
|
narrow: "M3 4v16M21 4v16M9 7h6M9 12h6M9 17h6",
|
||||||
|
normal: "M3 4v16M21 4v16M7 7h10M7 12h10M7 17h10",
|
||||||
|
wide: "M3 4v16M21 4v16M5 7h14M5 12h14M5 17h14",
|
||||||
|
};
|
||||||
|
|
||||||
|
function isWidth(value: string | null): value is EditorWidth {
|
||||||
|
return WIDTHS.includes(value as EditorWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capitalised for a menu. The names themselves belong to src/width.ts and the command ids. */
|
||||||
|
function widthLabel(width: EditorWidth): string {
|
||||||
|
return width.charAt(0).toUpperCase() + width.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No attribute at all is the default, because sheet.css only writes rules for narrow and wide and
|
||||||
|
* the boot script only sets the attribute when something was saved. */
|
||||||
|
function appliedWidth(): EditorWidth {
|
||||||
|
const value = document.documentElement.getAttribute("data-width");
|
||||||
|
return isWidth(value) ? value : "normal";
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAppliedWidth(): EditorWidth {
|
||||||
|
const [width, setWidth] = useState(appliedWidth);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const read = () => setWidth(appliedWidth());
|
||||||
|
const observer = new MutationObserver(read);
|
||||||
|
observer.observe(document.documentElement, { attributeFilter: ["data-width"] });
|
||||||
|
// The attribute can have moved between the first render and this effect running.
|
||||||
|
read();
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WidthMenu() {
|
||||||
|
const width = useAppliedWidth();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
/** Where focus was when a keyboard user opened the menu, and null when a mouse user did, since
|
||||||
|
* that press never moved it. */
|
||||||
|
const returnTo = useRef<HTMLElement | null>(null);
|
||||||
|
const focusOnOpen = useRef(false);
|
||||||
|
const labelId = useId();
|
||||||
|
|
||||||
|
const close = (restoreFocus = true) => {
|
||||||
|
setOpen(false);
|
||||||
|
const el = returnTo.current;
|
||||||
|
returnTo.current = null;
|
||||||
|
if (restoreFocus && el?.isConnected) el.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEscapeLayer(open, () => close());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !focusOnOpen.current) return;
|
||||||
|
focusOnOpen.current = false;
|
||||||
|
menuRef.current?.querySelector<HTMLElement>(ITEM)?.focus();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const toggle = (e: React.MouseEvent) => {
|
||||||
|
if (open) {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// `detail` is 0 when Enter or Space activated the button and 1 when a pointer did. A keyboard
|
||||||
|
// user cannot reach the items unless focus is moved into the menu; a mouse user is mid
|
||||||
|
// sentence and would lose their caret to a setting that has nothing to do with the text.
|
||||||
|
const byKeyboard = e.detail === 0;
|
||||||
|
returnTo.current = byKeyboard ? (document.activeElement as HTMLElement | null) : null;
|
||||||
|
focusOnOpen.current = byKeyboard;
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||||
|
e.preventDefault();
|
||||||
|
const all = Array.from(menuRef.current?.querySelectorAll<HTMLElement>(ITEM) ?? []);
|
||||||
|
const at = all.indexOf(document.activeElement as HTMLElement);
|
||||||
|
const next = e.key === "ArrowDown" ? (at + 1) % all.length : (at - 1 + all.length) % all.length;
|
||||||
|
all[next]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const choose = (next: EditorWidth) => {
|
||||||
|
applyWidth(next);
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const name = `Editor width: ${widthLabel(width)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="menu-wrap"
|
||||||
|
// Tabbing out of an open menu has to leave the menu behind, since nothing here traps focus,
|
||||||
|
// and focus that has deliberately gone somewhere else is not dragged back.
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (!open || e.currentTarget.contains(e.relatedTarget)) return;
|
||||||
|
close(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
data-active={open}
|
||||||
|
title={name}
|
||||||
|
aria-label={name}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
<Icon d={WIDTH_ICON[width]} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="menu-backdrop" onClick={() => close()} />
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="menu"
|
||||||
|
role="menu"
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
{/* Three words that mean nothing on their own, so the menu says what they are a width
|
||||||
|
of and then lends the same line to assistive tech as its own name. Presentational
|
||||||
|
because a menu's children are meant to be its items, and because being announced as
|
||||||
|
the menu's name and again as a line inside it is the same sentence twice. */}
|
||||||
|
<div className="menu-label" id={labelId} role="presentation">
|
||||||
|
Editor width
|
||||||
|
</div>
|
||||||
|
{WIDTHS.map((w) => (
|
||||||
|
<button
|
||||||
|
key={w}
|
||||||
|
className="width-menu-item"
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={w === width}
|
||||||
|
data-on={w === width}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => choose(w)}
|
||||||
|
>
|
||||||
|
<span className="width-menu-check" aria-hidden="true">
|
||||||
|
<Icon d={CHECK_D} size={14} />
|
||||||
|
</span>
|
||||||
|
{widthLabel(w)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
// A dev-only folder of documents, held in memory. It exists so the real UI can be opened in a
|
||||||
|
// plain browser with no Tauri behind it, by a person or by Playwright, without pointing the app at
|
||||||
|
// anybody's actual files.
|
||||||
|
//
|
||||||
|
// It is shaped like a folder somebody would really have rather than three files called test.md,
|
||||||
|
// because every interesting case in this app is a case the tree has to render: nesting several
|
||||||
|
// levels deep, a .txt that is editable, a .png that is not, an assets folder beside a document,
|
||||||
|
// frontmatter, a callout, a table, a toggle, a code block, and relative links between documents
|
||||||
|
// so backlinks have something to find.
|
||||||
|
//
|
||||||
|
// Anchored to the current time at load, so the tree never shows a modified date from last year.
|
||||||
|
|
||||||
|
import type { FileKind, RootInfo } from "../ipc";
|
||||||
|
|
||||||
|
const MINUTE = 60_000;
|
||||||
|
const HOUR = 3_600_000;
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
/** One file or directory. `text` is empty for a directory and for anything binary. */
|
||||||
|
export interface DevEntry {
|
||||||
|
path: string;
|
||||||
|
dir: boolean;
|
||||||
|
text: string;
|
||||||
|
/** Not a text file. Greyed in the tree, opened by the system, refused by `file_read`. */
|
||||||
|
binary: boolean;
|
||||||
|
modifiedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HANDBOOK = "/Users/you/Documents/Handbook";
|
||||||
|
export const SCRATCH = "/Users/you/Documents/Scratch";
|
||||||
|
|
||||||
|
/** Stable for a given path, the way the Rust side derives a root id from the folder it opened. */
|
||||||
|
export const rootIdFor = (path: string): string =>
|
||||||
|
path
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
|
||||||
|
export const devRoots: RootInfo[] = [
|
||||||
|
{ id: rootIdFor(HANDBOOK), path: HANDBOOK, name: "Handbook", openedMs: now - 6 * DAY },
|
||||||
|
{ id: rootIdFor(SCRATCH), path: SCRATCH, name: "Scratch", openedMs: now - 2 * HOUR },
|
||||||
|
];
|
||||||
|
|
||||||
|
const readme = `---
|
||||||
|
title: Handbook
|
||||||
|
updated: 2026-02-11
|
||||||
|
tags: [team, reference]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Handbook
|
||||||
|
|
||||||
|
Everything the team needs, in one folder, in plain markdown. Nothing here is generated and nothing
|
||||||
|
here needs an account to read.
|
||||||
|
|
||||||
|
Start with [Getting started](guides/getting-started.md). Skim [Writing](guides/writing.md) before
|
||||||
|
you open your first pull request, and keep the
|
||||||
|
[keyboard reference](reference/keyboard.md) somewhere you can see it.
|
||||||
|
|
||||||
|
## What lives where
|
||||||
|
|
||||||
|
Guides are the things you read once. The reference is the thing you come back to. Anything under
|
||||||
|
\`archive/\` is kept because deleting it would lose the argument, not because it is still true.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const gettingStarted = `---
|
||||||
|
title: Getting started
|
||||||
|
tags: [onboarding]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Getting started
|
||||||
|
|
||||||
|
Clone the repository and run the app once before you change anything. It is much easier to read a
|
||||||
|
diff when you have seen the thing the diff is about.
|
||||||
|
|
||||||
|
\`\`\`sh
|
||||||
|
git clone [email protected]:example/handbook.git
|
||||||
|
cd handbook
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The first run builds the search index. On a folder this size it takes a second or two, and quick
|
||||||
|
> open stays empty until it finishes.
|
||||||
|
|
||||||
|
## Your editor
|
||||||
|
|
||||||
|
Any editor is fine. Two settings are not optional: trim trailing whitespace, and end every file
|
||||||
|
with a newline. Without them every pull request carries noise nobody wrote.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Do not edit anything under \`archive/\`. Those documents are kept as a record and a change there
|
||||||
|
> will not be reviewed.
|
||||||
|
|
||||||
|
When something is bound to a key, [the keyboard reference](../reference/keyboard.md) is the list.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const writing = `---
|
||||||
|
title: Writing
|
||||||
|
---
|
||||||
|
|
||||||
|
# Writing
|
||||||
|
|
||||||
|
Short sentences. Say the thing, then stop. If a paragraph is doing two jobs, it is two paragraphs.
|
||||||
|
|
||||||
|
## What the editor does with what you type
|
||||||
|
|
||||||
|
| You write | On disk | In the editor |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A note | \`> [!NOTE]\` | a tinted block with a title |
|
||||||
|
| A toggle | \`<details>\` | a disclosure arrow |
|
||||||
|
| A link | \`[text](path.md)\` | underlined, click to follow |
|
||||||
|
| A table | pipes and dashes | a real table with a header row |
|
||||||
|
|
||||||
|
The file on disk stays plain markdown. Anything the editor cannot model is left exactly as it was
|
||||||
|
found and shown as a raw block you can still edit.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>House style, the short version</summary>
|
||||||
|
|
||||||
|
No em dashes. No exclamation marks. Do not start a sentence with "Basically". If you catch
|
||||||
|
yourself writing "simply", delete it and read the sentence again.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Before you open a pull request
|
||||||
|
|
||||||
|
Read it out loud once. Then read [Getting started](getting-started.md) if you have not, because
|
||||||
|
half of what gets flagged in review is covered there already, and check the
|
||||||
|
[handbook index](../README.md) still points at your new page.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const keyboard = `---
|
||||||
|
title: Keyboard reference
|
||||||
|
---
|
||||||
|
|
||||||
|
# Keyboard reference
|
||||||
|
|
||||||
|
| Key | Does |
|
||||||
|
| --- | --- |
|
||||||
|
| \`Cmd P\` | Quick open, fuzzy match on the whole path |
|
||||||
|
| \`Cmd Shift F\` | Search the text of every open folder |
|
||||||
|
| \`Cmd S\` | Save |
|
||||||
|
| \`Cmd N\` | New document, in the selected folder |
|
||||||
|
| \`Cmd O\` | Open a folder |
|
||||||
|
| \`Cmd \\\` | Show or hide the sidebar |
|
||||||
|
| \`Cmd B\` | Bold |
|
||||||
|
| \`Cmd K\` | Command palette |
|
||||||
|
|
||||||
|
Nothing here is configurable yet. If a key is wrong for you, say so and it can move.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const retro = `---
|
||||||
|
title: 2024 retro
|
||||||
|
archived: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2024 retro
|
||||||
|
|
||||||
|
Kept for the record. Most of this is out of date and none of it should be edited.
|
||||||
|
|
||||||
|
## What went well
|
||||||
|
|
||||||
|
Shipping small and often. The three week gap in July is the only stretch nobody enjoyed, and it
|
||||||
|
was the week the build broke twice.
|
||||||
|
|
||||||
|
## What did not
|
||||||
|
|
||||||
|
Documentation drifted from the code for most of the second half of the year, which is the reason
|
||||||
|
this folder exists at all.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const notes = `Scratch notes, not markdown, still editable.
|
||||||
|
|
||||||
|
Ask about the archive folder. Nobody seems to know who owns it.
|
||||||
|
Chase the design review before Thursday.
|
||||||
|
The index rebuild takes longer than it should on the big folder.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const inbox = `# Inbox
|
||||||
|
|
||||||
|
Things that have not found a home yet.
|
||||||
|
|
||||||
|
- Move the keyboard reference into the guides folder, or do not, but decide.
|
||||||
|
- A callout for "deprecated" would be useful.
|
||||||
|
- Check whether the .txt files should be indexed too.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const todo = `Buy a new keyboard
|
||||||
|
Reply to the design review thread
|
||||||
|
Rebuild the index after the folder move
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A one pixel PNG. The point of it is the tree row, not the image: it proves a file the editor
|
||||||
|
* will not open is greyed and hands itself to the system instead.
|
||||||
|
*/
|
||||||
|
export const devPng =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
||||||
|
|
||||||
|
const dir = (path: string, modifiedMs: number): DevEntry => ({
|
||||||
|
path,
|
||||||
|
dir: true,
|
||||||
|
text: "",
|
||||||
|
binary: false,
|
||||||
|
modifiedMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = (path: string, text: string, modifiedMs: number): DevEntry => ({
|
||||||
|
path,
|
||||||
|
dir: false,
|
||||||
|
text,
|
||||||
|
binary: false,
|
||||||
|
modifiedMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const devEntries: DevEntry[] = [
|
||||||
|
dir(HANDBOOK, now - 20 * MINUTE),
|
||||||
|
file(`${HANDBOOK}/README.md`, readme, now - 20 * MINUTE),
|
||||||
|
dir(`${HANDBOOK}/guides`, now - 3 * HOUR),
|
||||||
|
file(`${HANDBOOK}/guides/getting-started.md`, gettingStarted, now - 3 * HOUR),
|
||||||
|
file(`${HANDBOOK}/guides/writing.md`, writing, now - 2 * DAY),
|
||||||
|
dir(`${HANDBOOK}/reference`, now - 5 * DAY),
|
||||||
|
file(`${HANDBOOK}/reference/keyboard.md`, keyboard, now - 5 * DAY),
|
||||||
|
dir(`${HANDBOOK}/reference/assets`, now - 5 * DAY),
|
||||||
|
{
|
||||||
|
path: `${HANDBOOK}/reference/assets/diagram.png`,
|
||||||
|
dir: false,
|
||||||
|
text: "",
|
||||||
|
binary: true,
|
||||||
|
modifiedMs: now - 5 * DAY,
|
||||||
|
},
|
||||||
|
dir(`${HANDBOOK}/archive`, now - 200 * DAY),
|
||||||
|
dir(`${HANDBOOK}/archive/2024`, now - 200 * DAY),
|
||||||
|
file(`${HANDBOOK}/archive/2024/retro.md`, retro, now - 200 * DAY),
|
||||||
|
file(`${HANDBOOK}/notes.txt`, notes, now - 45 * MINUTE),
|
||||||
|
dir(SCRATCH, now - 90 * MINUTE),
|
||||||
|
file(`${SCRATCH}/inbox.md`, inbox, now - 90 * MINUTE),
|
||||||
|
file(`${SCRATCH}/todo.txt`, todo, now - 8 * HOUR),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
|
||||||
|
|
||||||
|
export const dirName = (path: string): string => path.slice(0, path.lastIndexOf("/")) || "/";
|
||||||
|
|
||||||
|
export const joinPath = (parent: string, name: string): string =>
|
||||||
|
parent.endsWith("/") ? `${parent}${name}` : `${parent}/${name}`;
|
||||||
|
|
||||||
|
export const extensionOf = (path: string): string => {
|
||||||
|
const name = baseName(path);
|
||||||
|
const dot = name.lastIndexOf(".");
|
||||||
|
return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function kindOf(entry: DevEntry): FileKind {
|
||||||
|
if (entry.dir) return "dir";
|
||||||
|
const ext = extensionOf(entry.path);
|
||||||
|
if (ext === "md" || ext === "markdown") return "markdown";
|
||||||
|
if (ext === "txt") return "text";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const editableKind = (kind: FileKind): boolean => kind === "markdown" || kind === "text";
|
||||||
|
|
||||||
|
/** Frontmatter title first, then the first heading, then the filename. What the Rust index does. */
|
||||||
|
export function titleOf(path: string, text: string): string {
|
||||||
|
const front = /^---\n([\s\S]*?)\n---/.exec(text);
|
||||||
|
const titled = front && /^title:\s*(.+)$/m.exec(front[1]);
|
||||||
|
if (titled) return titled[1].trim().replace(/^["']|["']$/g, "");
|
||||||
|
const heading = /^#\s+(.+)$/m.exec(text);
|
||||||
|
if (heading) return heading[1].trim();
|
||||||
|
return baseName(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves `](../thing.md)` against the document that wrote it. Null for anything not local. */
|
||||||
|
export function resolveRelative(fromFile: string, target: string): string | null {
|
||||||
|
if (!target || /^[a-z]+:/i.test(target) || target.startsWith("#")) return null;
|
||||||
|
const clean = target.split("#")[0].split("?")[0];
|
||||||
|
if (!clean) return null;
|
||||||
|
const parts = clean.startsWith("/") ? clean.split("/") : `${dirName(fromFile)}/${clean}`.split("/");
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part === "" || part === ".") continue;
|
||||||
|
if (part === "..") out.pop();
|
||||||
|
else out.push(part);
|
||||||
|
}
|
||||||
|
return `/${out.join("/")}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
// Serves the IPC surface from the dev fixture when the app is opened in a browser rather than in
|
||||||
|
// Tauri. This exists so the real UI can be driven and looked at, by a person or by Playwright,
|
||||||
|
// without a build of the Rust side and without pointing the app at real documents.
|
||||||
|
//
|
||||||
|
// It is reachable only when `import.meta.env.DEV` is true and `isTauri` is false, so it is absent
|
||||||
|
// from a production bundle and can never shadow the real backend inside the app.
|
||||||
|
//
|
||||||
|
// Writes mutate the fixture for the session, so creating a document and typing into it behaves
|
||||||
|
// the way it will on disk.
|
||||||
|
//
|
||||||
|
// `external` at the bottom is the other half: the world outside the app, for a test that needs a
|
||||||
|
// file to change while the app is looking at it. It mutates the fixture the way another program
|
||||||
|
// would, behind the app's back and without going through `file_write`, and hands back the exact
|
||||||
|
// `WatchEvent` payloads the Rust watcher would have emitted for what it did. Emitting them is the
|
||||||
|
// caller's job, because emitting means the Tauri event bus and this module has no opinion about
|
||||||
|
// where that comes from. src-tauri/tests/watch_payload.rs is what keeps those payloads honest.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AssetResult,
|
||||||
|
Backlink,
|
||||||
|
FileNode,
|
||||||
|
IndexStatus,
|
||||||
|
MatchRange,
|
||||||
|
QuickOpenHit,
|
||||||
|
ReadResult,
|
||||||
|
RootInfo,
|
||||||
|
SearchHit,
|
||||||
|
SpellIssue,
|
||||||
|
WatchEvent,
|
||||||
|
WriteResult,
|
||||||
|
} from "../ipc";
|
||||||
|
import {
|
||||||
|
baseName,
|
||||||
|
devEntries,
|
||||||
|
devRoots,
|
||||||
|
dirName,
|
||||||
|
editableKind,
|
||||||
|
extensionOf,
|
||||||
|
joinPath,
|
||||||
|
kindOf,
|
||||||
|
resolveRelative,
|
||||||
|
rootIdFor,
|
||||||
|
titleOf,
|
||||||
|
type DevEntry,
|
||||||
|
} from "./fixture";
|
||||||
|
|
||||||
|
const entries = new Map<string, DevEntry>(devEntries.map((e) => [e.path, { ...e }]));
|
||||||
|
const roots: RootInfo[] = devRoots.map((r) => ({ ...r }));
|
||||||
|
|
||||||
|
/** Roots with a watch running, so `external` can refuse to invent an event nobody subscribed to. */
|
||||||
|
const watching = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A modification time that is always newer than the last one handed out. `Date.now()` twice in the
|
||||||
|
* same millisecond is two writes the app cannot tell apart, and telling them apart is the entire
|
||||||
|
* mechanism behind conflict detection and the reload guard.
|
||||||
|
*/
|
||||||
|
let lastStamp = 0;
|
||||||
|
function stamp(): number {
|
||||||
|
lastStamp = Math.max(Date.now(), lastStamp + 1);
|
||||||
|
return lastStamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Held writes, for a test that needs a buffer to stay dirty while something else touches disk. */
|
||||||
|
let writeGate: Promise<void> | null = null;
|
||||||
|
let openGate: (() => void) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A first launch, which the fixture otherwise has no way to show: it is seeded with two open
|
||||||
|
* folders, so the empty state somebody new actually opens on was the one screen nobody could look
|
||||||
|
* at. With this set there are no roots and the tree comes back empty.
|
||||||
|
*/
|
||||||
|
const firstRun = (): boolean => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem("margindocs-dev-empty") === "1";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function entryAt(path: string): DevEntry {
|
||||||
|
const entry = entries.get(path);
|
||||||
|
if (!entry) throw new Error(`no such file: ${path}`);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rootFor(path: string): RootInfo | undefined {
|
||||||
|
return roots.find((r) => path === r.path || path.startsWith(`${r.path}/`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const relTo = (root: RootInfo, path: string): string => path.slice(root.path.length + 1);
|
||||||
|
|
||||||
|
function childrenOf(path: string): DevEntry[] {
|
||||||
|
const prefix = `${path}/`;
|
||||||
|
return [...entries.values()]
|
||||||
|
.filter((e) => e.path.startsWith(prefix) && !e.path.slice(prefix.length).includes("/"))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.dir !== b.dir) return a.dir ? -1 : 1;
|
||||||
|
return a.path.localeCompare(b.path, undefined, { sensitivity: "base" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeFor(entry: DevEntry): FileNode {
|
||||||
|
const kind = kindOf(entry);
|
||||||
|
return {
|
||||||
|
path: entry.path,
|
||||||
|
name: baseName(entry.path),
|
||||||
|
kind,
|
||||||
|
editable: editableKind(kind),
|
||||||
|
modifiedMs: entry.modifiedMs,
|
||||||
|
children: entry.dir ? childrenOf(entry.path).map(nodeFor) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every descendant of a directory, the directory itself included, deepest last. */
|
||||||
|
function subtree(path: string): DevEntry[] {
|
||||||
|
const prefix = `${path}/`;
|
||||||
|
return [...entries.values()].filter((e) => e.path === path || e.path.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `name` is a suggestion. A taken one gets a numbered suffix, the way the Rust side does it. */
|
||||||
|
function freePath(parent: string, name: string): string {
|
||||||
|
const candidate = joinPath(parent, name);
|
||||||
|
if (!entries.has(candidate)) return candidate;
|
||||||
|
const dot = name.lastIndexOf(".");
|
||||||
|
const stem = dot > 0 ? name.slice(0, dot) : name;
|
||||||
|
const ext = dot > 0 ? name.slice(dot) : "";
|
||||||
|
for (let n = 2; ; n += 1) {
|
||||||
|
const next = joinPath(parent, `${stem} ${n}${ext}`);
|
||||||
|
if (!entries.has(next)) return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function put(entry: DevEntry): DevEntry {
|
||||||
|
entries.set(entry.path, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves an entry and everything under it, which is the same operation for a rename and a move. */
|
||||||
|
function relocate(from: string, to: string): DevEntry {
|
||||||
|
for (const entry of subtree(from)) {
|
||||||
|
entries.delete(entry.path);
|
||||||
|
entries.set(entry.path === from ? to : to + entry.path.slice(from.length), {
|
||||||
|
...entry,
|
||||||
|
path: entry.path === from ? to : to + entry.path.slice(from.length),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return entryAt(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textFiles = (): DevEntry[] =>
|
||||||
|
[...entries.values()].filter((e) => !e.dir && !e.binary && editableKind(kindOf(e)));
|
||||||
|
|
||||||
|
/** Subsequence match, the cheap kind quick open wants: every query character in order. */
|
||||||
|
function fuzzy(haystack: string, query: string): { score: number; ranges: MatchRange[] } | null {
|
||||||
|
const lower = haystack.toLowerCase();
|
||||||
|
const needle = query.toLowerCase().replace(/\s+/g, "");
|
||||||
|
if (!needle) return { score: 0, ranges: [] };
|
||||||
|
const ranges: MatchRange[] = [];
|
||||||
|
let at = 0;
|
||||||
|
let score = 0;
|
||||||
|
let previous = -2;
|
||||||
|
for (const character of needle) {
|
||||||
|
const found = lower.indexOf(character, at);
|
||||||
|
if (found < 0) return null;
|
||||||
|
// Runs read as a word and score far better than the same letters scattered over a path.
|
||||||
|
score += found === previous + 1 ? 8 : 1;
|
||||||
|
if (found > lower.lastIndexOf("/")) score += 4;
|
||||||
|
const last = ranges[ranges.length - 1];
|
||||||
|
if (last && last.end === found) last.end = found + 1;
|
||||||
|
else ranges.push({ start: found, end: found + 1 });
|
||||||
|
previous = found;
|
||||||
|
at = found + 1;
|
||||||
|
}
|
||||||
|
return { score: score - Math.floor(haystack.length / 10), ranges };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A window of the line around the first match, so a long line does not fill the results list. */
|
||||||
|
function snippetAround(line: string, start: number, length: number) {
|
||||||
|
const from = Math.max(0, start - 32);
|
||||||
|
const head = from > 0 ? "…" : "";
|
||||||
|
const body = line.slice(from, from + 160);
|
||||||
|
const tail = from + 160 < line.length ? "…" : "";
|
||||||
|
return {
|
||||||
|
snippet: `${head}${body}${tail}`,
|
||||||
|
range: { start: head.length + (start - from), end: head.length + (start - from) + length },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole vocabulary of the dev spell checker. Real spelling comes from the system and this is
|
||||||
|
* only ever a stand-in for a browser, so the list is short on purpose: it holds the words someone
|
||||||
|
* exercising the feature is likely to type at it and nothing else.
|
||||||
|
*/
|
||||||
|
const DEV_MISSPELLINGS: Record<string, string[]> = {
|
||||||
|
teh: ["the", "then", "tea"],
|
||||||
|
recieve: ["receive", "relieve"],
|
||||||
|
seperate: ["separate", "desperate"],
|
||||||
|
occured: ["occurred"],
|
||||||
|
definately: ["definitely", "defiantly"],
|
||||||
|
accomodate: ["accommodate"],
|
||||||
|
wierd: ["weird", "wired"],
|
||||||
|
begining: ["beginning"],
|
||||||
|
neccessary: ["necessary"],
|
||||||
|
publically: ["publicly"],
|
||||||
|
writting: ["writing", "written"],
|
||||||
|
markdwon: ["markdown"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Words `spell_learn` was told about this session. The real checker teaches the whole machine. */
|
||||||
|
const devLearned = new Set<string>();
|
||||||
|
|
||||||
|
export async function mockCall<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
const a = (args ?? {}) as Record<string, never>;
|
||||||
|
switch (command) {
|
||||||
|
case "roots_list":
|
||||||
|
return (firstRun() ? [] : roots) as unknown as T;
|
||||||
|
|
||||||
|
case "root_open": {
|
||||||
|
const path = a.path as unknown as string;
|
||||||
|
const existing = roots.find((r) => r.path === path);
|
||||||
|
if (existing) return existing as unknown as T;
|
||||||
|
const opened: RootInfo = {
|
||||||
|
id: rootIdFor(path),
|
||||||
|
path,
|
||||||
|
name: baseName(path),
|
||||||
|
openedMs: Date.now(),
|
||||||
|
};
|
||||||
|
roots.push(opened);
|
||||||
|
if (!entries.has(path)) {
|
||||||
|
put({ path, dir: true, text: "", binary: false, modifiedMs: Date.now() });
|
||||||
|
}
|
||||||
|
return opened as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "root_close": {
|
||||||
|
const id = a.rootId as unknown as string;
|
||||||
|
const at = roots.findIndex((r) => r.id === id);
|
||||||
|
if (at >= 0) roots.splice(at, 1);
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "tree_read": {
|
||||||
|
const root = roots.find((r) => r.id === (a.rootId as unknown as string));
|
||||||
|
if (!root) throw new Error(`no such root: ${a.rootId as unknown as string}`);
|
||||||
|
return nodeFor(entryAt(root.path)) as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "reveal_in_finder":
|
||||||
|
case "open_external":
|
||||||
|
// Nothing to hand a file to in a browser tab, so say so rather than looking broken.
|
||||||
|
console.info(`dev mock: ${command} ${a.path as unknown as string}`);
|
||||||
|
return undefined as T;
|
||||||
|
|
||||||
|
case "file_read": {
|
||||||
|
const entry = entryAt(a.path as unknown as string);
|
||||||
|
if (entry.dir || entry.binary) throw new Error(`not a text file: ${entry.path}`);
|
||||||
|
return {
|
||||||
|
path: entry.path,
|
||||||
|
text: entry.text,
|
||||||
|
modifiedMs: entry.modifiedMs,
|
||||||
|
} satisfies ReadResult as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "file_write": {
|
||||||
|
// Held only when a test has asked for it, so a buffer can be observed dirty while something
|
||||||
|
// outside the app changes the same file.
|
||||||
|
if (writeGate) await writeGate;
|
||||||
|
const entry = entryAt(a.path as unknown as string);
|
||||||
|
const expected = a.expectedModifiedMs as unknown as number | undefined;
|
||||||
|
if (typeof expected === "number" && expected !== entry.modifiedMs) {
|
||||||
|
return {
|
||||||
|
path: entry.path,
|
||||||
|
modifiedMs: entry.modifiedMs,
|
||||||
|
conflict: true,
|
||||||
|
} satisfies WriteResult as unknown as T;
|
||||||
|
}
|
||||||
|
entry.text = a.text as unknown as string;
|
||||||
|
entry.modifiedMs = stamp();
|
||||||
|
return {
|
||||||
|
path: entry.path,
|
||||||
|
modifiedMs: entry.modifiedMs,
|
||||||
|
conflict: false,
|
||||||
|
} satisfies WriteResult as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "file_create": {
|
||||||
|
const path = freePath(a.parentPath as unknown as string, a.name as unknown as string);
|
||||||
|
return nodeFor(
|
||||||
|
put({ path, dir: false, text: "", binary: false, modifiedMs: stamp() }),
|
||||||
|
) as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "file_folder_create": {
|
||||||
|
const path = freePath(a.parentPath as unknown as string, a.name as unknown as string);
|
||||||
|
return nodeFor(
|
||||||
|
put({ path, dir: true, text: "", binary: false, modifiedMs: stamp() }),
|
||||||
|
) as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "file_rename": {
|
||||||
|
const from = a.path as unknown as string;
|
||||||
|
return nodeFor(
|
||||||
|
relocate(from, freePath(dirName(from), a.name as unknown as string)),
|
||||||
|
) as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "file_move": {
|
||||||
|
const from = a.path as unknown as string;
|
||||||
|
const to = freePath(a.destDir as unknown as string, baseName(from));
|
||||||
|
return nodeFor(relocate(from, to)) as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "file_duplicate": {
|
||||||
|
const source = entryAt(a.path as unknown as string);
|
||||||
|
const ext = extensionOf(source.path);
|
||||||
|
const stem = ext ? baseName(source.path).slice(0, -(ext.length + 1)) : baseName(source.path);
|
||||||
|
const path = freePath(dirName(source.path), ext ? `${stem} copy.${ext}` : `${stem} copy`);
|
||||||
|
return nodeFor(put({ ...source, path, modifiedMs: stamp() })) as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "file_trash": {
|
||||||
|
for (const entry of subtree(a.path as unknown as string)) entries.delete(entry.path);
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "asset_write": {
|
||||||
|
const folder = joinPath(dirName(a.docPath as unknown as string), "assets");
|
||||||
|
if (!entries.has(folder)) {
|
||||||
|
put({ path: folder, dir: true, text: "", binary: false, modifiedMs: stamp() });
|
||||||
|
}
|
||||||
|
const path = freePath(folder, (a.name as unknown as string) || "image.png");
|
||||||
|
put({ path, dir: false, text: "", binary: true, modifiedMs: stamp() });
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
relPath: `assets/${baseName(path)}`,
|
||||||
|
} satisfies AssetResult as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "watch_start":
|
||||||
|
watching.add(a.rootId as unknown as string);
|
||||||
|
return undefined as T;
|
||||||
|
|
||||||
|
case "watch_stop":
|
||||||
|
watching.delete(a.rootId as unknown as string);
|
||||||
|
return undefined as T;
|
||||||
|
|
||||||
|
case "index_rebuild":
|
||||||
|
case "index_status": {
|
||||||
|
const total = textFiles().length;
|
||||||
|
return {
|
||||||
|
phase: "idle",
|
||||||
|
indexed: total,
|
||||||
|
total,
|
||||||
|
lastIndexed: Date.now(),
|
||||||
|
error: null,
|
||||||
|
message: null,
|
||||||
|
} satisfies IndexStatus as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "search_quick_open": {
|
||||||
|
const query = a.query as unknown as string;
|
||||||
|
const limit = (a.limit as unknown as number) ?? 30;
|
||||||
|
const hits: QuickOpenHit[] = [];
|
||||||
|
for (const entry of textFiles()) {
|
||||||
|
const root = rootFor(entry.path);
|
||||||
|
if (!root) continue;
|
||||||
|
const relPath = relTo(root, entry.path);
|
||||||
|
const match = fuzzy(relPath, query);
|
||||||
|
if (!match) continue;
|
||||||
|
hits.push({
|
||||||
|
path: entry.path,
|
||||||
|
name: baseName(entry.path),
|
||||||
|
root: root.id,
|
||||||
|
relPath,
|
||||||
|
score: match.score,
|
||||||
|
ranges: match.ranges,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return hits.sort((x, y) => y.score - x.score).slice(0, limit) as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "search_text": {
|
||||||
|
const query = (a.query as unknown as string) ?? "";
|
||||||
|
const limit = (a.limit as unknown as number) ?? 100;
|
||||||
|
const needle = query.toLowerCase();
|
||||||
|
const hits: SearchHit[] = [];
|
||||||
|
if (!needle.trim()) return [] as unknown as T;
|
||||||
|
for (const entry of textFiles()) {
|
||||||
|
const root = rootFor(entry.path);
|
||||||
|
if (!root) continue;
|
||||||
|
const title = titleOf(entry.path, entry.text);
|
||||||
|
entry.text.split("\n").forEach((line, index) => {
|
||||||
|
const at = line.toLowerCase().indexOf(needle);
|
||||||
|
if (at < 0 || hits.length >= limit) return;
|
||||||
|
const { snippet, range } = snippetAround(line, at, needle.length);
|
||||||
|
hits.push({
|
||||||
|
path: entry.path,
|
||||||
|
root: root.id,
|
||||||
|
title,
|
||||||
|
line: index + 1,
|
||||||
|
snippet,
|
||||||
|
ranges: [range],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return hits.slice(0, limit) as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "backlinks_for": {
|
||||||
|
const target = a.path as unknown as string;
|
||||||
|
const found: Backlink[] = [];
|
||||||
|
for (const entry of textFiles()) {
|
||||||
|
if (entry.path === target || kindOf(entry) !== "markdown") continue;
|
||||||
|
const lines = entry.text.split("\n");
|
||||||
|
const line = lines.find((text) =>
|
||||||
|
[...text.matchAll(/\]\(([^)\s]+)\)/g)].some(
|
||||||
|
(m) => resolveRelative(entry.path, m[1]) === target,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (line === undefined) continue;
|
||||||
|
found.push({
|
||||||
|
path: entry.path,
|
||||||
|
title: titleOf(entry.path, entry.text),
|
||||||
|
snippet: line.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return found as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spelling in a browser is not the system checker and cannot be: NSSpellChecker is not
|
||||||
|
// reachable from a page. What it is instead is a fixed list of misspellings, which is enough to
|
||||||
|
// put a real underline under a real word and open a real menu of suggestions over it. A dev
|
||||||
|
// fixture that flagged every word it did not recognise would need a dictionary, and shipping
|
||||||
|
// one here to exercise a feature whose whole point is not shipping one would be absurd.
|
||||||
|
case "spell_available":
|
||||||
|
return true as unknown as T;
|
||||||
|
|
||||||
|
case "spell_check": {
|
||||||
|
const text = (a.text as unknown as string) ?? "";
|
||||||
|
const issues: SpellIssue[] = [];
|
||||||
|
for (const match of text.matchAll(/[\p{L}']+/gu)) {
|
||||||
|
const word = match[0];
|
||||||
|
const guesses = DEV_MISSPELLINGS[word.toLowerCase()];
|
||||||
|
if (guesses === undefined || devLearned.has(word.toLowerCase())) continue;
|
||||||
|
issues.push({
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + word.length,
|
||||||
|
word,
|
||||||
|
// Matching the case of what was typed, because a suggestion that comes back lower case
|
||||||
|
// for a word opening a sentence is a correction the user then has to correct.
|
||||||
|
suggestions: guesses.map((guess) =>
|
||||||
|
word[0] === word[0].toUpperCase() ? guess[0].toUpperCase() + guess.slice(1) : guess,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return issues as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "spell_learn":
|
||||||
|
devLearned.add((a.word as unknown as string).toLowerCase());
|
||||||
|
return undefined as T;
|
||||||
|
|
||||||
|
case "spell_unlearn":
|
||||||
|
devLearned.delete((a.word as unknown as string).toLowerCase());
|
||||||
|
return undefined as T;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`dev mock has no handler for ${command}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The world outside the app: what another program does to the folder while it is open.
|
||||||
|
*
|
||||||
|
* Every function here mutates the fixture directly rather than going through `mockCall`, which is
|
||||||
|
* the point. A change made this way has not been through `file_write`, so nothing has registered a
|
||||||
|
* self-write against it and nothing has told the open document its file moved on: it is a change
|
||||||
|
* the app can only find out about from a watch event, exactly like a change made by vim or by a
|
||||||
|
* git checkout.
|
||||||
|
*
|
||||||
|
* The return value is the `WatchEvent` list the Rust watcher would have emitted for that change,
|
||||||
|
* in the order it would have emitted them. A rename is two events and not one, because FSEvents
|
||||||
|
* describes the two ends as unrelated and src-tauri/src/watch.rs reports what it is told; see
|
||||||
|
* `a_rename_is_reported_at_both_ends` in src-tauri/tests/watch.rs.
|
||||||
|
*
|
||||||
|
* A change to a root with no watch running is an error rather than an event. Nothing outside a
|
||||||
|
* watched folder is reported to anybody, so a test that gets an event out of this has also proved
|
||||||
|
* that opening the folder started the watch.
|
||||||
|
*/
|
||||||
|
function watchEventFor(
|
||||||
|
path: string,
|
||||||
|
kind: WatchEvent["kind"],
|
||||||
|
oldPath: string | null = null,
|
||||||
|
): WatchEvent {
|
||||||
|
const root = rootFor(path);
|
||||||
|
if (!root) throw new Error(`no open root owns ${path}`);
|
||||||
|
if (!watching.has(root.id)) throw new Error(`no watch is running on ${root.path}`);
|
||||||
|
return { root: root.id, path, kind, oldPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const external = {
|
||||||
|
/** Another program rewrites the file. New bytes, new modification time. */
|
||||||
|
write(path: string, text: string): WatchEvent[] {
|
||||||
|
const entry = entryAt(path);
|
||||||
|
entry.text = text;
|
||||||
|
entry.modifiedMs = stamp();
|
||||||
|
return [watchEventFor(path, "modified")];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same bytes, a newer modification time: `touch`, a git checkout that restores what was
|
||||||
|
* already there, a backup tool. The watcher cannot tell this from a real edit and reports it as
|
||||||
|
* one, which is why the app compares bytes and not just timestamps.
|
||||||
|
*/
|
||||||
|
touch(path: string): WatchEvent[] {
|
||||||
|
entryAt(path).modifiedMs = stamp();
|
||||||
|
return [watchEventFor(path, "modified")];
|
||||||
|
},
|
||||||
|
|
||||||
|
/** An event with nothing behind it, for proving what the app does with a change that is not one. */
|
||||||
|
signal(path: string, kind: WatchEvent["kind"]): WatchEvent[] {
|
||||||
|
return [watchEventFor(path, kind)];
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(path: string): WatchEvent[] {
|
||||||
|
entryAt(path);
|
||||||
|
const event = watchEventFor(path, "removed");
|
||||||
|
for (const entry of subtree(path)) entries.delete(entry.path);
|
||||||
|
return [event];
|
||||||
|
},
|
||||||
|
|
||||||
|
rename(from: string, to: string): WatchEvent[] {
|
||||||
|
relocate(from, to);
|
||||||
|
return [watchEventFor(from, "removed"), watchEventFor(to, "created")];
|
||||||
|
},
|
||||||
|
|
||||||
|
/** What is actually on disk now, for asserting that the app has written nothing it should not. */
|
||||||
|
read(path: string): string | null {
|
||||||
|
const entry = entries.get(path);
|
||||||
|
return entry && !entry.dir ? entry.text : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
exists(path: string): boolean {
|
||||||
|
return entries.has(path);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds every `file_write` until `resumeWrites`. A save that cannot land is how a test keeps a
|
||||||
|
* buffer dirty for as long as it needs to, instead of racing the 500ms autosave.
|
||||||
|
*/
|
||||||
|
pauseWrites(): void {
|
||||||
|
if (writeGate) return;
|
||||||
|
writeGate = new Promise<void>((resolve) => {
|
||||||
|
openGate = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
resumeWrites(): void {
|
||||||
|
openGate?.();
|
||||||
|
writeGate = null;
|
||||||
|
openGate = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
// Everything the open document does to the disk, and the only place that decides when. The store
|
||||||
|
// next door holds what is on screen and the setters a keystroke can settle on its own; this module
|
||||||
|
// reads the file, hands it to the markdown bridge, and writes it back 500ms after the last edit.
|
||||||
|
//
|
||||||
|
// Opening writes nothing. There is exactly one call to `fileWrite` in this file, it sits inside
|
||||||
|
// `performSave`, and `performSave` returns before reaching it unless the buffer is dirty, which
|
||||||
|
// only `setContent` can make it. That is the product's first promise and
|
||||||
|
// src/store/useDocument.test.ts asserts it rather than trusting this paragraph.
|
||||||
|
//
|
||||||
|
// `setContent` marks the buffer dirty through `differsFromDisk` below, which is the second half of
|
||||||
|
// that promise. That question is asked of the document the file was read from and never of the
|
||||||
|
// keystroke before, so the answer is "is the buffer different" rather than "did something happen":
|
||||||
|
// a paragraph typed into and then undone is the document that was opened, and it does not put the
|
||||||
|
// file on the debounce. A transaction that moved something the markdown has no spelling for gets
|
||||||
|
// the same answer for the same reason, since the file would not show it either. Dragging a table
|
||||||
|
// column is the whole of that today.
|
||||||
|
//
|
||||||
|
// `performSave` also never runs twice at once for the open document: `saveNow` keeps at most one
|
||||||
|
// call to it on the wire, folding anything that arrives while one is running into a single next
|
||||||
|
// lap rather than starting a second write alongside the first.
|
||||||
|
//
|
||||||
|
// This module and src/store/useDocument.ts import each other: the store's async actions delegate
|
||||||
|
// down here, and the work down here lands back in the store. Neither touches the other while its
|
||||||
|
// own module body is still evaluating, so the cycle resolves. Nothing here runs at import time for
|
||||||
|
// the same reason: the subscription that drives the debounce is installed by `initDocument`, which
|
||||||
|
// `loadDocument` calls itself so the shell cannot forget to.
|
||||||
|
|
||||||
|
import type { Mark, Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { fileRead, fileWrite } from "./api/files";
|
||||||
|
import type { ReadResult, WriteResult } from "./ipc";
|
||||||
|
import {
|
||||||
|
parseMarkdown,
|
||||||
|
parsePlainText,
|
||||||
|
serializeMarkdown,
|
||||||
|
serializePlainText,
|
||||||
|
} from "./markdown";
|
||||||
|
import { documentKindForPath, type MarkdownDocument } from "./model/doc";
|
||||||
|
import { useDocument } from "./store/useDocument";
|
||||||
|
import { notify } from "./store/useToast";
|
||||||
|
|
||||||
|
/** Long enough that a sentence is one save, short enough that Cmd+Tab away is already on disk. */
|
||||||
|
export const SAVE_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The file as this module last saw it, either read or written: its bytes, and the tree those bytes
|
||||||
|
* are the serialization of.
|
||||||
|
*
|
||||||
|
* The bytes are here because a watcher fires on a touch, on a git checkout that restores the same
|
||||||
|
* content and on this app's own save, and without them the buffer would be thrown away and rebuilt
|
||||||
|
* for all three. The tree is here because the bytes cannot answer whether the buffer still holds
|
||||||
|
* the document that was read: a hand written file does not serialize to its own bytes, so from the
|
||||||
|
* moment it opens the two differ for house style reasons that have nothing to do with any edit.
|
||||||
|
*
|
||||||
|
* Either can be null on its own, because they answer different questions. Null bytes mean the file
|
||||||
|
* no longer holds the bytes this module last saw, so there is nothing a write could be compared
|
||||||
|
* against and `performSave` reads it as "write". A null document means the file holds a document
|
||||||
|
* this module has never had, somebody else's copy or none at all, so `differsFromDisk` reads it as
|
||||||
|
* "dirty". Both say the same thing: the one thing worse than an unnecessary write is a skipped
|
||||||
|
* necessary one.
|
||||||
|
*/
|
||||||
|
let diskText: string | null = null;
|
||||||
|
let diskDoc: ProseMirrorNode | null = null;
|
||||||
|
|
||||||
|
/** The only way either of those moves, so that they cannot drift apart into two answers about the
|
||||||
|
* same file, one of which sends a write and the other of which holds it back. */
|
||||||
|
function rememberDisk(text: string | null, doc: ProseMirrorNode | null): void {
|
||||||
|
diskText = text;
|
||||||
|
diskDoc = doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Markdown and plain text are two different round trips and picking the wrong one mangles a .txt. */
|
||||||
|
function bridgeFor(path: string) {
|
||||||
|
const kind = documentKindForPath(path);
|
||||||
|
if (kind === null) throw new Error(`${path} is not a document this editor opens`);
|
||||||
|
return kind === "markdown"
|
||||||
|
? { parse: parseMarkdown, serialize: serializeMarkdown }
|
||||||
|
: { parse: parsePlainText, serialize: serializePlainText };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes the serializer never reads, by the node that carries them.
|
||||||
|
*
|
||||||
|
* `colwidth` is the whole list, and the list was written by going through src/model/schema.ts
|
||||||
|
* attribute by attribute against src/markdown/serialize.ts. prosemirror-tables puts a width on
|
||||||
|
* every cell of a column when its edge is dragged and GFM has no column widths, so that drag is a
|
||||||
|
* real change to the document and no change at all to the file.
|
||||||
|
*
|
||||||
|
* Three others were considered and left off. `colspan` and `rowspan` are unreadable to the
|
||||||
|
* serializer too, but no op this editor offers can move them, and a table that carried one could
|
||||||
|
* not be written as a table at all, so calling a change to one insignificant would be hiding the
|
||||||
|
* one case that needs to be seen. `raw.source` is the file's own bytes and is never written to
|
||||||
|
* after the parse. And `align` is the near miss: the serializer reads it off the table's first row
|
||||||
|
* only, so a body cell's copy does not reach the file on its own, but that first row is the
|
||||||
|
* delimiter row and every align op in tables.ts writes the whole column at once. An alignment
|
||||||
|
* change is always a change to the file.
|
||||||
|
*/
|
||||||
|
const UNWRITTEN_ATTRS: Record<string, readonly string[]> = {
|
||||||
|
tableHeader: ["colwidth"],
|
||||||
|
tableCell: ["colwidth"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why nothing below compares a NodeType or a MarkType, only its name.
|
||||||
|
*
|
||||||
|
* The two documents this comparison is given are never built on the same schema. The one the file
|
||||||
|
* was read from comes off the bridge, which parses against src/model/schema.ts; the one the editor
|
||||||
|
* hands back is bound to TipTap's own schema, which src/editor/extensions.ts generates from those
|
||||||
|
* same specs and which src/editor/Editor.tsx rebinds every opened document on to before it can be
|
||||||
|
* edited. Two `Schema` instances over one set of specs, so every type object in one is a different
|
||||||
|
* object from its twin in the other, and `a.type !== b.type` was true of every pair this function
|
||||||
|
* had ever been handed. Everything behind it, the colwidth exemption included, was unreachable.
|
||||||
|
*
|
||||||
|
* The name is also the right thing to compare rather than a way around that. src/markdown/
|
||||||
|
* serialize.ts dispatches on `node.type.name` and `mark.type.name` and reads nothing else off a
|
||||||
|
* type, so two nodes agreeing on their name, their attributes, their marks, their text and their
|
||||||
|
* children are two nodes it writes the same bytes for.
|
||||||
|
*/
|
||||||
|
const sameType = (a: { name: string }, b: { name: string }): boolean => a.name === b.name;
|
||||||
|
|
||||||
|
/** Two nodes of the same type, agreeing on every attribute the serializer would go looking for. */
|
||||||
|
function sameAttrs(a: ProseMirrorNode, b: ProseMirrorNode): boolean {
|
||||||
|
const unwritten = UNWRITTEN_ATTRS[a.type.name];
|
||||||
|
const names = Object.keys(a.attrs);
|
||||||
|
// An attribute one side carries and the other does not is not provably nothing, and walking a's
|
||||||
|
// names only ever shows one of the two directions.
|
||||||
|
if (names.length !== Object.keys(b.attrs).length) return false;
|
||||||
|
for (const name of names) {
|
||||||
|
if (a.attrs[name] === b.attrs[name]) continue;
|
||||||
|
if (unwritten !== undefined && unwritten.includes(name)) continue;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The marks on one piece of text, in order.
|
||||||
|
*
|
||||||
|
* This replaces `Mark.sameSet`, which compares MarkType by object and so answered "different" for
|
||||||
|
* every span anybody had ever made bold or turned into a link. Order is compared rather than the
|
||||||
|
* set treated as unordered because ProseMirror keeps a mark set sorted by the schema's own
|
||||||
|
* declaration order and both schemas declare the same marks in the same order, so a mismatch is
|
||||||
|
* either a real difference or the two schemas having drifted apart, and both are worth a write.
|
||||||
|
*/
|
||||||
|
function sameMarks(a: readonly Mark[], b: readonly Mark[]): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i += 1) {
|
||||||
|
const one = a[i];
|
||||||
|
const other = b[i];
|
||||||
|
if (one === other) continue;
|
||||||
|
if (!sameType(one.type, other.type)) return false;
|
||||||
|
const names = Object.keys(one.attrs);
|
||||||
|
if (names.length !== Object.keys(other.attrs).length) return false;
|
||||||
|
for (const name of names) if (one.attrs[name] !== other.attrs[name]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameToTheSerializer(a: ProseMirrorNode, b: ProseMirrorNode): boolean {
|
||||||
|
// The whole reason this is cheap enough to run on every keystroke, even against a tree many
|
||||||
|
// transactions old. A transaction rebuilds only the spine down to what it touched, so every
|
||||||
|
// subtree no edit since the read has visited is still the same object it was and the walk stops
|
||||||
|
// dead at it. Only what has actually been typed into is ever compared node by node.
|
||||||
|
//
|
||||||
|
// It buys nothing between an open and the first save, because the tree that was read and the
|
||||||
|
// editor's rebind of it share no object at all, so every keystroke in that window walks the
|
||||||
|
// whole document. Measured at 0.08ms on a 57kB file of 984 nodes, against a 500ms debounce.
|
||||||
|
// Once a save has landed, `diskDoc` is the editor's own tree and the sharing is back.
|
||||||
|
if (a === b) return true;
|
||||||
|
if (!sameType(a.type, b.type) || a.text !== b.text || a.childCount !== b.childCount) return false;
|
||||||
|
if (!sameMarks(a.marks, b.marks)) return false;
|
||||||
|
if (!sameAttrs(a, b)) return false;
|
||||||
|
for (let i = 0; i < a.childCount; i += 1) {
|
||||||
|
if (!sameToTheSerializer(a.child(i), b.child(i))) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a tree differs, anywhere the file would show it, from the document on disk.
|
||||||
|
*
|
||||||
|
* This is what the dirty flag is, and the whole of it. Asking it of the document that was read
|
||||||
|
* rather than of the tree a keystroke ago is what makes it a fact about the file instead of a
|
||||||
|
* count of transactions: a paragraph typed into and then undone comes back false, because the
|
||||||
|
* buffer is the file again, and a flag that only ever counted up would have had the whole document
|
||||||
|
* rewritten in house style for an edit that no longer exists.
|
||||||
|
*
|
||||||
|
* It is deliberately lopsided: everything counts as a change to the file unless it is provably not
|
||||||
|
* one. A change wrongly called insignificant is a keystroke that never reaches the disk, which is
|
||||||
|
* the worst thing in this module; a change wrongly called significant costs one write that
|
||||||
|
* `performSave` then finds nothing to do.
|
||||||
|
*/
|
||||||
|
export function differsFromDisk(next: ProseMirrorNode): boolean {
|
||||||
|
return diskDoc === null || !sameToTheSerializer(diskDoc, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same question, asked of whatever the store is holding now. */
|
||||||
|
function bufferDiffersFromDisk(): boolean {
|
||||||
|
const now = useDocument.getState().content;
|
||||||
|
return now !== null && differsFromDisk(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(read: ReadResult, document: MarkdownDocument): void {
|
||||||
|
rememberDisk(read.text, document.doc);
|
||||||
|
useDocument.setState({
|
||||||
|
path: read.path,
|
||||||
|
document,
|
||||||
|
content: document.doc,
|
||||||
|
modifiedMs: read.modifiedMs,
|
||||||
|
frontmatter: document.frontmatter,
|
||||||
|
dirty: false,
|
||||||
|
savePhase: "idle",
|
||||||
|
saveError: null,
|
||||||
|
externalChange: "synced",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSave(): void {
|
||||||
|
cancelPendingSave();
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
saveTimer = null;
|
||||||
|
saveNow().catch((e) => notify(`Could not save: ${String(e)}`));
|
||||||
|
}, SAVE_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelPendingSave(): void {
|
||||||
|
if (saveTimer !== null) clearTimeout(saveTimer);
|
||||||
|
saveTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the debounce. Idempotent, and returning the teardown rather than keeping it private is
|
||||||
|
* what lets a test run the lifecycle without leaving a timer behind for the next one.
|
||||||
|
*/
|
||||||
|
export function initDocument(): () => void {
|
||||||
|
if (unsubscribe !== null) return unsubscribe;
|
||||||
|
const stop = useDocument.subscribe((state, previous) => {
|
||||||
|
if (state.content === previous.content) return;
|
||||||
|
// Clean is not just "nothing more to schedule". The edit that armed the timer can have been
|
||||||
|
// undone while it was still counting down, and letting it run out would put the debounce's
|
||||||
|
// whole point, one write per burst of typing, behind a document nobody changed.
|
||||||
|
if (state.dirty) scheduleSave();
|
||||||
|
else cancelPendingSave();
|
||||||
|
});
|
||||||
|
unsubscribe = () => {
|
||||||
|
stop();
|
||||||
|
unsubscribe = null;
|
||||||
|
cancelPendingSave();
|
||||||
|
};
|
||||||
|
return unsubscribe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a file and puts it in the store. Reads only: the bridge is pure, nothing here has a path
|
||||||
|
* to `fileWrite`, and a document that is opened and closed again leaves the file untouched.
|
||||||
|
*/
|
||||||
|
export async function loadDocument(path: string): Promise<void> {
|
||||||
|
initDocument();
|
||||||
|
const { parse } = bridgeFor(path);
|
||||||
|
const read = await fileRead(path);
|
||||||
|
apply(read, parse(read.text, read.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throws the buffer away and takes what is on disk. The explicit half of a conflict. */
|
||||||
|
export async function reloadDocument(): Promise<void> {
|
||||||
|
const path = useDocument.getState().path;
|
||||||
|
if (path === null) return;
|
||||||
|
cancelPendingSave();
|
||||||
|
await loadDocument(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single write for the open document that is currently on the wire, if any. */
|
||||||
|
let writeInFlight: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/** Set when a save is requested while `writeInFlight` is already running. One flag, not a queue:
|
||||||
|
* it can only ever mean "write again after this one", never "write N more times". */
|
||||||
|
let saveAgainRequested = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes and writes, now. Returns having done nothing when the buffer is clean, which is what
|
||||||
|
* makes Cmd+S on an untouched document a no-op rather than a reformat.
|
||||||
|
*
|
||||||
|
* At most one `fileWrite` for the open document is ever in flight at a time. A call that lands
|
||||||
|
* while one is already running does not start a second: it flags that another save is wanted and
|
||||||
|
* folds into a single write that goes out the moment the first one lands, picking up whatever is
|
||||||
|
* newest in the store by then. That is what keeps the backend from ever seeing two writes of the
|
||||||
|
* same path race each other, and it is also what keeps the last thing the user typed from being
|
||||||
|
* the one write that never happened: it is either the content already on the wire, or it is
|
||||||
|
* exactly what the next lap serializes.
|
||||||
|
*/
|
||||||
|
export async function saveNow(): Promise<void> {
|
||||||
|
cancelPendingSave();
|
||||||
|
if (writeInFlight !== null) {
|
||||||
|
saveAgainRequested = true;
|
||||||
|
return writeInFlight;
|
||||||
|
}
|
||||||
|
const inFlight = runSaveLoop();
|
||||||
|
writeInFlight = inFlight;
|
||||||
|
try {
|
||||||
|
await inFlight;
|
||||||
|
} finally {
|
||||||
|
if (writeInFlight === inFlight) writeInFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `performSave` once, then again for every save that arrived while it was on the wire,
|
||||||
|
* collapsed to the single latest one. Stops the moment a lap does not end in a clean write: a
|
||||||
|
* conflict or a no-op buffer is not something a stacked-up request should cause to be retried.
|
||||||
|
*/
|
||||||
|
async function runSaveLoop(): Promise<void> {
|
||||||
|
for (;;) {
|
||||||
|
saveAgainRequested = false;
|
||||||
|
const outcome = await performSave();
|
||||||
|
if (outcome !== "wrote" || !saveAgainRequested) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performSave(): Promise<"wrote" | "conflict" | "skipped"> {
|
||||||
|
const { path, document, content, dirty, modifiedMs } = useDocument.getState();
|
||||||
|
if (path === null || document === null || content === null || !dirty) return "skipped";
|
||||||
|
const { serialize } = bridgeFor(path);
|
||||||
|
|
||||||
|
useDocument.setState({ savePhase: "saving", saveError: null });
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = serialize(document, content);
|
||||||
|
} catch (e) {
|
||||||
|
useDocument.setState({ savePhase: "error", saveError: String(e) });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The second line, not the first. `differsFromDisk` is what keeps a buffer that is not different
|
||||||
|
// from the file from being dirty at all, and it has to be, because this comparison only catches
|
||||||
|
// the case where the serialized bytes already match the file: on a document the editor has
|
||||||
|
// written before they do, and on a hand written one they differ for house style reasons that have
|
||||||
|
// nothing to do with any edit, so this would let the write through and the whole file would be
|
||||||
|
// reformatted for a gesture that moved a line on screen. What is left here is everything else
|
||||||
|
// that can serialize to the bytes already on disk: two different trees that spell the same
|
||||||
|
// markdown, and a buffer this module cannot vouch for because the file moved under it.
|
||||||
|
if (text === diskText) {
|
||||||
|
// Those bytes are on disk and this is a tree that produces them, which is all `diskDoc` has
|
||||||
|
// ever claimed to be. Nothing was written, so nothing needs to be.
|
||||||
|
rememberDisk(text, content);
|
||||||
|
useDocument.setState({
|
||||||
|
dirty: bufferDiffersFromDisk(),
|
||||||
|
savePhase: "idle",
|
||||||
|
saveError: null,
|
||||||
|
});
|
||||||
|
return "skipped";
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: WriteResult;
|
||||||
|
try {
|
||||||
|
result = await fileWrite(path, text, modifiedMs ?? undefined);
|
||||||
|
} catch (e) {
|
||||||
|
if (useDocument.getState().path === path) {
|
||||||
|
useDocument.setState({ savePhase: "error", saveError: String(e) });
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The document was switched while the write was in flight, so this result belongs to a buffer
|
||||||
|
// nobody is looking at any more and applying it would stamp the new one's timestamp.
|
||||||
|
if (useDocument.getState().path !== path) return "skipped";
|
||||||
|
|
||||||
|
if (result.conflict) {
|
||||||
|
// Not an error and not something to retry. Nothing was written, the edit is still only in the
|
||||||
|
// buffer, and which copy wins is the user's call. What is on disk is somebody else's copy,
|
||||||
|
// which this module has not read, so it stops claiming to know either the bytes or the
|
||||||
|
// document: the buffer stays dirty however much of the edit the user takes back, and the
|
||||||
|
// decision the UI is now asking for is the only thing that clears it.
|
||||||
|
rememberDisk(null, null);
|
||||||
|
useDocument.setState({ savePhase: "idle", externalChange: "changed-on-disk" });
|
||||||
|
return "conflict";
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberDisk(text, content);
|
||||||
|
const stillDirty = bufferDiffersFromDisk();
|
||||||
|
useDocument.setState({
|
||||||
|
modifiedMs: result.modifiedMs,
|
||||||
|
dirty: stillDirty,
|
||||||
|
savePhase: "idle",
|
||||||
|
saveError: null,
|
||||||
|
externalChange: "synced",
|
||||||
|
});
|
||||||
|
// Typed into, or undone, while that write was on the wire. An undo is the case that needs this:
|
||||||
|
// it went past the subscription at a moment when the buffer and the file did agree, so nothing
|
||||||
|
// armed the debounce for it, and this write is what has just made it a difference again.
|
||||||
|
if (stillDirty) scheduleSave();
|
||||||
|
else cancelPendingSave();
|
||||||
|
return "wrote";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets an unsaved edit onto disk before something else happens to the document: switching away,
|
||||||
|
* closing it, quitting. Swallows its own error into a toast, because the caller is on its way
|
||||||
|
* somewhere else and failing that journey over a failed save helps nobody.
|
||||||
|
*/
|
||||||
|
export function flushPendingSave(): Promise<void> {
|
||||||
|
if (!useDocument.getState().dirty) {
|
||||||
|
cancelPendingSave();
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return saveNow().catch((e) => notify(`Could not save: ${String(e)}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a conflict the other way from `reloadDocument`: the buffer wins and the copy on disk is
|
||||||
|
* the one that goes.
|
||||||
|
*
|
||||||
|
* Dropping `modifiedMs` is what makes the next write land. The backend refuses a write whose
|
||||||
|
* expected timestamp has moved on, which is the whole conflict mechanism, and there is no way to
|
||||||
|
* say "yes, I know" other than to stop claiming to know what was there. The write itself is the
|
||||||
|
* ordinary debounced one, so nothing is put on disk here either.
|
||||||
|
*/
|
||||||
|
export function keepBuffer(): void {
|
||||||
|
if (useDocument.getState().path === null) return;
|
||||||
|
// What is on disk is whatever the other writer put there, which this module has not read, so the
|
||||||
|
// last bytes it saw are no longer the file's and neither is the document they came from. Saying
|
||||||
|
// so is what stops `performSave` from deciding this write is unnecessary and leaving the other
|
||||||
|
// copy in place, which is the opposite of what the user just asked for, and it is what keeps the
|
||||||
|
// buffer dirty through an undo taken while the banner is up.
|
||||||
|
rememberDisk(null, null);
|
||||||
|
// Dirty even if nothing has been typed: the file moved or went, so the buffer and the disk
|
||||||
|
// disagree, and that is the only thing the flag has ever meant.
|
||||||
|
useDocument.setState({ modifiedMs: null, externalChange: "synced", dirty: true });
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets go of the open document without writing it, for when the file it came from has just gone.
|
||||||
|
* `close` on its own flushes, which for a document that was this second sent to the Trash would
|
||||||
|
* put the file straight back.
|
||||||
|
*/
|
||||||
|
export function abandonDocument(): void {
|
||||||
|
cancelPendingSave();
|
||||||
|
useDocument.setState({ dirty: false });
|
||||||
|
useDocument.getState().close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something outside the app touched the open document. Clean buffers take the new bytes silently,
|
||||||
|
* dirty ones are left exactly as they are and the UI is told there is a choice to make.
|
||||||
|
*/
|
||||||
|
export async function documentChangedOnDisk(path: string): Promise<void> {
|
||||||
|
if (useDocument.getState().path !== path) return;
|
||||||
|
|
||||||
|
let read: ReadResult;
|
||||||
|
try {
|
||||||
|
read = await fileRead(path);
|
||||||
|
} catch {
|
||||||
|
// Deleted, renamed out from under us, or unreadable. The buffer is now the only copy there is,
|
||||||
|
// so it stays put. The bytes go, because there are none left to hold a write back, and the
|
||||||
|
// document stays, because it is still the last one this module knew the file to hold and it is
|
||||||
|
// what keeps a buffer nobody has typed into from turning dirty and putting the file back.
|
||||||
|
// Resurrecting a file the user deleted is `keepBuffer`, and it is the user's word, not a
|
||||||
|
// side effect of clicking into the editor afterwards.
|
||||||
|
rememberDisk(null, diskDoc);
|
||||||
|
useDocument.setState({ externalChange: "changed-on-disk" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = useDocument.getState();
|
||||||
|
if (state.path !== path) return;
|
||||||
|
if (read.modifiedMs === state.modifiedMs) return;
|
||||||
|
if (read.text === diskText) {
|
||||||
|
useDocument.setState({ modifiedMs: read.modifiedMs });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.dirty) {
|
||||||
|
// The buffer stays, but these are the file's bytes now and this module has just read them, so
|
||||||
|
// it says so rather than going on remembering the ones the other writer replaced. It does not
|
||||||
|
// parse them: the document on disk is somebody else's and no tree here is it, so the honest
|
||||||
|
// answer to "is the buffer different from the file" is that we do not know, which is the answer
|
||||||
|
// that keeps this dirty until the user picks a side.
|
||||||
|
rememberDisk(read.text, null);
|
||||||
|
useDocument.setState({ externalChange: "changed-on-disk" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { parse } = bridgeFor(path);
|
||||||
|
apply(read, parse(read.text, read.path));
|
||||||
|
}
|
||||||
@@ -0,0 +1,678 @@
|
|||||||
|
// The document surface, and the only place in the app that holds a TipTap instance.
|
||||||
|
//
|
||||||
|
// The autosave contract lives here as much as it does in the store, and it is a contract about
|
||||||
|
// what this component does NOT do. Opening a document installs a ProseMirror state and nothing
|
||||||
|
// else: no serializer runs, no write is scheduled, and `onChange` does not fire, because
|
||||||
|
// `view.updateState` is not a dispatched transaction and TipTap only emits `update` when a
|
||||||
|
// transaction actually changed the document. A file that is opened, read and closed is never
|
||||||
|
// written. Once the user types, `onChange` hands out the live ProseMirror node on every keystroke,
|
||||||
|
// which is cheap; turning that node into markdown happens once, later, on the shell's debounce.
|
||||||
|
//
|
||||||
|
// Ported from margin's editor/Editor.tsx. Margin keeps an EditorState per chapter because a book
|
||||||
|
// is many documents open at once; here there is one document at a time, so that cache collapses
|
||||||
|
// into a small path-keyed LRU whose only job is making a return to a recent file instant, with its
|
||||||
|
// undo history and its caret still where they were.
|
||||||
|
|
||||||
|
import { useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { EditorContent, useEditor } from "@tiptap/react";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import { EditorState, TextSelection } from "@tiptap/pm/state";
|
||||||
|
import type { EditorProps as ProseMirrorProps, EditorView } from "@tiptap/pm/view";
|
||||||
|
import type { CalloutKind, HeadingLevel, MarkdownDocument } from "../model/doc";
|
||||||
|
import { marks as markSpecs } from "../model/schema";
|
||||||
|
import type { MarkName } from "../model/schema";
|
||||||
|
import { notify } from "../store/useToast";
|
||||||
|
import { insertMath, insertMermaid, setCodeLanguage, tableCommand } from "./blocks";
|
||||||
|
import { createEditorExtensions } from "./extensions";
|
||||||
|
import { change, markable, place, placeable } from "./fits";
|
||||||
|
import { loadPosition, savePosition, type DocumentPosition } from "./positions";
|
||||||
|
import { searchStateOf, type SearchOptions } from "./search";
|
||||||
|
import type {
|
||||||
|
BlockCommand,
|
||||||
|
BlockKind,
|
||||||
|
DocumentFind,
|
||||||
|
EditorActiveState,
|
||||||
|
EditorHandle,
|
||||||
|
EditorProps,
|
||||||
|
TableOp,
|
||||||
|
} from "./index";
|
||||||
|
|
||||||
|
// How much of the pane the caret is kept out of when the view scrolls it into sight. The toolbar
|
||||||
|
// pill is 44px tall (32px controls, 5px of padding, a 1px border) and sticks 22px above the bottom
|
||||||
|
// of the pane, so it covers the last 66px of it; a line of body prose on top of that means the
|
||||||
|
// caret's whole line clears the glass instead of sitting against it. Nothing overlaps the top,
|
||||||
|
// where the titlebar is a sibling above the scroller rather than floating over it, so the number
|
||||||
|
// there is breathing room and nothing more.
|
||||||
|
const CARET_KEEPOUT = { top: 24, right: 0, bottom: 98, left: 0 };
|
||||||
|
|
||||||
|
/** Enough that going back to what you were just looking at is instant, and not a document store. */
|
||||||
|
const CACHE_LIMIT = 8;
|
||||||
|
|
||||||
|
const EMPTY_CONTENT = { type: "doc", content: [{ type: "paragraph" }] };
|
||||||
|
|
||||||
|
const MARK_NAMES = Object.keys(markSpecs) as MarkName[];
|
||||||
|
|
||||||
|
/** Blocks a cursor can be inside without them being what the toolbar should report. */
|
||||||
|
const PASS_THROUGH = new Set([
|
||||||
|
"paragraph",
|
||||||
|
"listItem",
|
||||||
|
"taskItem",
|
||||||
|
"tableRow",
|
||||||
|
"tableCell",
|
||||||
|
"tableHeader",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const REPORTED = new Set<string>([
|
||||||
|
"bulletList",
|
||||||
|
"orderedList",
|
||||||
|
"taskList",
|
||||||
|
"blockquote",
|
||||||
|
"codeBlock",
|
||||||
|
"toggle",
|
||||||
|
"table",
|
||||||
|
"mathBlock",
|
||||||
|
"raw",
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface Cached {
|
||||||
|
state: EditorState;
|
||||||
|
document: MarkdownDocument;
|
||||||
|
scroll: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let currentHandle: EditorHandle | null = null;
|
||||||
|
let currentFind: DocumentFind | null = null;
|
||||||
|
|
||||||
|
function subscribe(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function announce(): void {
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSnapshot = () => currentHandle;
|
||||||
|
const findSnapshot = () => currentFind;
|
||||||
|
|
||||||
|
/** The handle for the document currently on screen, or null when there is none. */
|
||||||
|
export function useEditorHandle(): EditorHandle | null {
|
||||||
|
return useSyncExternalStore(subscribe, handleSnapshot, handleSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find and replace over the open document, for whatever draws the find bar. */
|
||||||
|
export function useDocumentFind(): DocumentFind | null {
|
||||||
|
return useSyncExternalStore(subscribe, findSnapshot, findSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportContentError(error: unknown): void {
|
||||||
|
notify(`Part of this document could not be read into the editor: ${String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollerOf(editor: Editor): HTMLElement | null {
|
||||||
|
const dom = editor.view.dom as HTMLElement;
|
||||||
|
const pane = dom.closest(".editor-pane");
|
||||||
|
if (pane instanceof HTMLElement) return pane;
|
||||||
|
for (let el = dom.parentElement; el; el = el.parentElement) {
|
||||||
|
const overflow = getComputedStyle(el).overflowY;
|
||||||
|
if (overflow === "auto" || overflow === "scroll") return el;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ticking a box is an edit like any other, so it goes through the view and dirties the buffer. */
|
||||||
|
function toggleTask(view: EditorView, item: Element): boolean {
|
||||||
|
const $pos = view.state.doc.resolve(view.posAtDOM(item, 0));
|
||||||
|
for (let depth = $pos.depth; depth > 0; depth -= 1) {
|
||||||
|
const node = $pos.node(depth);
|
||||||
|
if (node.type.name !== "taskItem") continue;
|
||||||
|
view.dispatch(
|
||||||
|
view.state.tr.setNodeMarkup($pos.before(depth), undefined, {
|
||||||
|
...node.attrs,
|
||||||
|
checked: !node.attrs.checked,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ProseMirror props this component installs directly on the view, as opposed to the ones an
|
||||||
|
* extension contributes through `addProseMirrorPlugins`.
|
||||||
|
*
|
||||||
|
* Lifted out of the component and exported so that it can be enumerated. It is a third channel into
|
||||||
|
* the document, alongside the editor handle and the extensions' own plugins, and it was the one
|
||||||
|
* src/editor/fits.test.ts could not see: that file reads every extension's plugins and every
|
||||||
|
* extension's keymap, and `editorProps` is neither. A `handlePaste` or a `handleKeyDown` added here
|
||||||
|
* would be asked before any of them and answer for the whole document, unenumerated. The click
|
||||||
|
* handler that is here today only flips a checkbox, which is the harmless case; the enumeration is
|
||||||
|
* for the next one.
|
||||||
|
*/
|
||||||
|
export function createEditorProps(context: {
|
||||||
|
editable: () => boolean;
|
||||||
|
onOpenLink: (href: string) => void;
|
||||||
|
}): ProseMirrorProps {
|
||||||
|
return {
|
||||||
|
attributes: { class: "prose" },
|
||||||
|
scrollThreshold: CARET_KEEPOUT,
|
||||||
|
scrollMargin: CARET_KEEPOUT,
|
||||||
|
handleClick: (view, _pos, event) => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
// The checkbox is drawn by the list item's own ::before, so a click that lands on the item
|
||||||
|
// itself rather than on the paragraph inside it is a click on the box.
|
||||||
|
const item = target?.closest(".task-item");
|
||||||
|
if (item && item === event.target && toggleTask(view, item)) {
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const anchor = target?.closest("a[href]");
|
||||||
|
const href = anchor?.getAttribute("href");
|
||||||
|
if (!href) return false;
|
||||||
|
// A plain click puts the caret in the link text, which is the only way to edit it. Opening
|
||||||
|
// is the modified click, or any click at all while the document is not editable.
|
||||||
|
if (context.editable() && !event.metaKey && !event.ctrlKey) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
context.onOpenLink(href);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function enclosing(editor: Editor, names: readonly string[]): { name: string; pos: number } | null {
|
||||||
|
const { $from } = editor.state.selection;
|
||||||
|
for (let depth = $from.depth; depth > 0; depth -= 1) {
|
||||||
|
const name = $from.node(depth).type.name;
|
||||||
|
if (names.includes(name)) return { name, pos: $from.before(depth) };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeStateOf(editor: Editor): EditorActiveState {
|
||||||
|
const marks = MARK_NAMES.filter((mark) => editor.isActive(mark));
|
||||||
|
const { $from } = editor.state.selection;
|
||||||
|
// Asked separately from the walk below, because that walk stops at the innermost block it has a
|
||||||
|
// name for and a cell selection's own position is not inside any of them. isActive answers for a
|
||||||
|
// cursor in a cell, a selection across cells and the table selected whole, alike.
|
||||||
|
const inTable = editor.isActive("table");
|
||||||
|
const base = { marks, inTable, codeLanguage: null };
|
||||||
|
|
||||||
|
for (let depth = $from.depth; depth > 0; depth -= 1) {
|
||||||
|
const node = $from.node(depth);
|
||||||
|
const name = node.type.name;
|
||||||
|
if (PASS_THROUGH.has(name)) continue;
|
||||||
|
if (name === "heading") {
|
||||||
|
return { ...base, block: "heading", headingLevel: node.attrs.level as HeadingLevel, callout: null };
|
||||||
|
}
|
||||||
|
if (name === "callout") {
|
||||||
|
return { ...base, block: "callout", headingLevel: null, callout: node.attrs.kind as CalloutKind };
|
||||||
|
}
|
||||||
|
if (name === "codeBlock") {
|
||||||
|
const language = node.attrs.language as string | null;
|
||||||
|
return { ...base, block: "codeBlock", headingLevel: null, callout: null, codeLanguage: language };
|
||||||
|
}
|
||||||
|
if (REPORTED.has(name)) {
|
||||||
|
return { ...base, block: name as BlockKind, headingLevel: null, callout: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...base, block: "paragraph", headingLevel: null, callout: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameActive(a: EditorActiveState, b: EditorActiveState): boolean {
|
||||||
|
return (
|
||||||
|
a.block === b.block &&
|
||||||
|
a.headingLevel === b.headingLevel &&
|
||||||
|
a.callout === b.callout &&
|
||||||
|
a.inTable === b.inTable &&
|
||||||
|
a.codeLanguage === b.codeLanguage &&
|
||||||
|
a.marks.length === b.marks.length &&
|
||||||
|
a.marks.every((mark, i) => b.marks[i] === mark)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The command half of the handle, built once per editor and shared by every published snapshot.
|
||||||
|
*
|
||||||
|
* Exported for src/editor/fits.test.ts and for nothing else: the shell gets the handle through
|
||||||
|
* `useEditorHandle` and has no business building one. That test enumerates the keys of what this
|
||||||
|
* returns and refuses to pass unless every insert among them has been proved to keep its hands off
|
||||||
|
* a document it cannot insert into, which is the only way this file's own insert commands and the
|
||||||
|
* block lanes' are held to the same rule.
|
||||||
|
*/
|
||||||
|
export function createCommands(editor: Editor): Omit<EditorHandle, "active"> {
|
||||||
|
const setCallout = (kind: CalloutKind | null) => {
|
||||||
|
const found = enclosing(editor, ["callout", "blockquote"]);
|
||||||
|
if (kind === null) {
|
||||||
|
if (found?.name !== "callout") return;
|
||||||
|
change(editor, "unwrap", (chain) =>
|
||||||
|
chain.command(({ tr, state, dispatch }) => {
|
||||||
|
if (dispatch) tr.setNodeMarkup(found.pos, state.schema.nodes.blockquote, {});
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (found) {
|
||||||
|
change(editor, "wrap", (chain) =>
|
||||||
|
chain.command(({ tr, state, dispatch }) => {
|
||||||
|
if (dispatch) tr.setNodeMarkup(found.pos, state.schema.nodes.callout, { kind });
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
change(editor, "wrap", (chain) => chain.wrapIn("callout", { kind }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
focus: () => {
|
||||||
|
editor.commands.focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleMark: (mark) => {
|
||||||
|
editor.chain().focus().toggleMark(mark).run();
|
||||||
|
},
|
||||||
|
|
||||||
|
// With a collapsed caret and no link under it the url goes in as TEXT and is marked
|
||||||
|
// afterwards, and the text lands whether the mark can or not: in a fence or a raw block, where
|
||||||
|
// the schema allows no marks at all, that is the url typed into somebody's code. Asked of the
|
||||||
|
// mark rather than of the block, so it is the same question in both places and in whatever
|
||||||
|
// block comes next. The two chains below only add a mark, which ProseMirror already declines
|
||||||
|
// to do where the schema says no.
|
||||||
|
setLink: (href, title = null) => {
|
||||||
|
if (href === null) {
|
||||||
|
editor.chain().focus().extendMarkRange("link").unsetMark("link").run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editor.state.selection.empty && !editor.isActive("link")) {
|
||||||
|
if (!markable(editor.state, editor.schema.marks.link)) return;
|
||||||
|
if (!placeable(editor.state, editor.schema.nodes.text)) return;
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.extendMarkRange("link")
|
||||||
|
.insertContent({ type: "text", text: href, marks: [{ type: "link", attrs: { href, title } }] })
|
||||||
|
.run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.chain().focus().extendMarkRange("link").setMark("link", { href, title }).run();
|
||||||
|
},
|
||||||
|
|
||||||
|
// The conversions, and the other half of what fits.ts guards. `place` is for a command that
|
||||||
|
// adds a node; these change or rewrap the block the caret is already in, which is the question
|
||||||
|
// `change` answers: a raw block refuses all of them, because a conversion writes its preserved
|
||||||
|
// source back out as escaped markdown and a wrap writes it back out prefixed, and a conversion
|
||||||
|
// that would take a callout or a toggle away with it is thrown out rather than dispatched. A
|
||||||
|
// conversion added here that does not go through `change` fails src/editor/fits.test.ts.
|
||||||
|
|
||||||
|
setBlock: (block: BlockCommand) => {
|
||||||
|
switch (block) {
|
||||||
|
case "paragraph":
|
||||||
|
change(editor, "convert", (chain) => chain.clearNodes().setNode("paragraph"));
|
||||||
|
return;
|
||||||
|
case "bulletList":
|
||||||
|
change(editor, "wrap", (chain) => chain.toggleList("bulletList", "listItem"));
|
||||||
|
return;
|
||||||
|
case "orderedList":
|
||||||
|
change(editor, "wrap", (chain) => chain.toggleList("orderedList", "listItem"));
|
||||||
|
return;
|
||||||
|
case "taskList":
|
||||||
|
change(editor, "wrap", (chain) => chain.toggleList("taskList", "taskItem"));
|
||||||
|
return;
|
||||||
|
case "blockquote":
|
||||||
|
change(editor, "wrap", (chain) => chain.toggleWrap("blockquote"));
|
||||||
|
return;
|
||||||
|
case "codeBlock":
|
||||||
|
change(editor, "convert", (chain) => chain.toggleNode("codeBlock", "paragraph"));
|
||||||
|
return;
|
||||||
|
case "toggle":
|
||||||
|
// "unwrap" because this is the toggle's own button: pressed inside one it takes that
|
||||||
|
// toggle away, summary and all, which is what the user pressed it for.
|
||||||
|
change(editor, "unwrap", (chain) => chain.toggleWrap("toggle"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setHeading: (level) => {
|
||||||
|
if (level === null) change(editor, "convert", (chain) => chain.setNode("paragraph"));
|
||||||
|
else change(editor, "convert", (chain) => chain.setNode("heading", { level }));
|
||||||
|
},
|
||||||
|
|
||||||
|
setCallout,
|
||||||
|
|
||||||
|
// Every one of these goes through `place`, which is the guard and the insert in one call, for
|
||||||
|
// the reason written out in fits.ts: with the caret in a table cell an unguarded insert splits
|
||||||
|
// the table around the new node and leaves a row with no cells in it, which is a table the
|
||||||
|
// serializer writes back as three blank lines, and in a fence or a raw block it cuts the user's
|
||||||
|
// own bytes in half and writes the remainder out as prose. An insert added here that does not
|
||||||
|
// go through `place` fails src/editor/fits.test.ts, which is the point of that file.
|
||||||
|
|
||||||
|
insertRule: () => {
|
||||||
|
place(editor, editor.schema.nodes.horizontalRule, (chain) =>
|
||||||
|
chain.insertContent({ type: "horizontalRule" }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// The same guard the insert below runs, asked on its own, because the Insert image tool has to
|
||||||
|
// write the picture into the assets folder before it has a path to insert and a refusal after
|
||||||
|
// that write is an orphan file beside somebody's document. src/editor/paste.ts asks this same
|
||||||
|
// question before it sends any bytes; the toolbar had no way to.
|
||||||
|
canInsertImage: () => placeable(editor.state, editor.schema.nodes.image),
|
||||||
|
|
||||||
|
// And it still says so when it refuses, for the caller that asks afterwards anyway.
|
||||||
|
insertImage: (src, alt = null) => {
|
||||||
|
const placed = place(editor, editor.schema.nodes.image, (chain) =>
|
||||||
|
chain.insertContent({ type: "image", attrs: { src, alt, title: null } }),
|
||||||
|
);
|
||||||
|
if (!placed) notify("An image cannot go where the cursor is.");
|
||||||
|
},
|
||||||
|
|
||||||
|
insertTable: (rows, columns) => {
|
||||||
|
const table = editor.schema.nodes.table;
|
||||||
|
const cells = (type: string) =>
|
||||||
|
Array.from({ length: Math.max(1, columns) }, () => ({ type }));
|
||||||
|
const body = Array.from({ length: Math.max(0, rows - 1) }, () => ({
|
||||||
|
type: "tableRow",
|
||||||
|
content: cells("tableCell"),
|
||||||
|
}));
|
||||||
|
const searchFrom = Math.max(0, editor.state.selection.$from.pos - 1);
|
||||||
|
|
||||||
|
place(editor, table, (chain) =>
|
||||||
|
chain
|
||||||
|
.insertContent({
|
||||||
|
type: "table",
|
||||||
|
content: [{ type: "tableRow", content: cells("tableHeader") }, ...body],
|
||||||
|
})
|
||||||
|
// The insert leaves the caret past the table, so the first thing typed lands under it
|
||||||
|
// rather than in it, and the toolbar goes on reading active.inTable as false while a
|
||||||
|
// table is on screen. Same transaction as the insert, so it is one undo and not two.
|
||||||
|
.command(({ tr, dispatch }) => {
|
||||||
|
if (!dispatch) return true;
|
||||||
|
let found: number | null = null;
|
||||||
|
tr.doc.nodesBetween(searchFrom, tr.doc.content.size, (node, pos) => {
|
||||||
|
if (found !== null) return false;
|
||||||
|
if (node.type === table) found = pos;
|
||||||
|
return found === null;
|
||||||
|
});
|
||||||
|
// A table, its first row and its first cell are one position each, so three in is the
|
||||||
|
// first place text can go.
|
||||||
|
if (found !== null) tr.setSelection(TextSelection.create(tr.doc, found + 3));
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// The four below are the block lanes' own commands, and this is the whole of the wiring: each
|
||||||
|
// one lives with the extension that gives its block behaviour, in src/editor/blocks/, so that
|
||||||
|
// tables, code, math and mermaid are worked on without four hands in this file. They return
|
||||||
|
// false where they have nothing to act on, which is a button pressed in the wrong place and
|
||||||
|
// means nothing happens.
|
||||||
|
|
||||||
|
tableCommand: (op: TableOp) => {
|
||||||
|
tableCommand(editor, op);
|
||||||
|
},
|
||||||
|
|
||||||
|
insertMath: (display: boolean) => {
|
||||||
|
insertMath(editor, display);
|
||||||
|
},
|
||||||
|
|
||||||
|
insertMermaid: () => {
|
||||||
|
insertMermaid(editor);
|
||||||
|
},
|
||||||
|
|
||||||
|
setCodeLanguage: (language: string | null) => {
|
||||||
|
setCodeLanguage(editor, language);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The find bar's own handle, which is the second surface this file publishes and the other place a
|
||||||
|
* command can reach the document from outside the editor layer.
|
||||||
|
*
|
||||||
|
* Exported for src/editor/fits.test.ts for the same reason `createCommands` is: two of these seven
|
||||||
|
* write to the document, and a handle nothing enumerates is a handle a method gets added to without
|
||||||
|
* anybody saying where it may run.
|
||||||
|
*/
|
||||||
|
export function createFind(editor: Editor): Omit<DocumentFind, "state"> {
|
||||||
|
return {
|
||||||
|
setQuery: (query: string, options: SearchOptions) => {
|
||||||
|
editor.commands.setSearch(query, options);
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
editor.commands.clearSearch();
|
||||||
|
},
|
||||||
|
next: () => {
|
||||||
|
editor.commands.findNext();
|
||||||
|
},
|
||||||
|
prev: () => {
|
||||||
|
editor.commands.findPrev();
|
||||||
|
},
|
||||||
|
replaceCurrent: (text: string) => {
|
||||||
|
editor.commands.replaceCurrent(text);
|
||||||
|
},
|
||||||
|
replaceAll: (text: string) => {
|
||||||
|
editor.commands.replaceAllInDocument(text);
|
||||||
|
},
|
||||||
|
focus: () => {
|
||||||
|
editor.commands.focus();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentEditor({
|
||||||
|
document,
|
||||||
|
onChange,
|
||||||
|
onOpenLink,
|
||||||
|
editable = true,
|
||||||
|
}: EditorProps): ReactElement {
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
const onOpenLinkRef = useRef(onOpenLink);
|
||||||
|
onOpenLinkRef.current = onOpenLink;
|
||||||
|
const documentRef = useRef(document);
|
||||||
|
documentRef.current = document;
|
||||||
|
const editableRef = useRef(editable);
|
||||||
|
editableRef.current = editable;
|
||||||
|
|
||||||
|
const cache = useRef(new Map<string, Cached>());
|
||||||
|
const host = useRef<Editor | null>(null);
|
||||||
|
const installed = useRef<MarkdownDocument | null>(null);
|
||||||
|
const position = useRef<DocumentPosition | null>(null);
|
||||||
|
const scrollToken = useRef(0);
|
||||||
|
const publish = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
const extensions = useMemo(
|
||||||
|
() =>
|
||||||
|
createEditorExtensions({
|
||||||
|
documentPath: () => documentRef.current.path,
|
||||||
|
onError: notify,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions,
|
||||||
|
// Empty on purpose: the layout effect below installs the document through the one code path
|
||||||
|
// that also restores the caret and reports a document the schema cannot hold.
|
||||||
|
content: EMPTY_CONTENT,
|
||||||
|
editable,
|
||||||
|
immediatelyRender: false,
|
||||||
|
enableContentCheck: true,
|
||||||
|
editorProps: createEditorProps({
|
||||||
|
editable: () => editableRef.current,
|
||||||
|
onOpenLink: (href) => onOpenLinkRef.current(href),
|
||||||
|
}),
|
||||||
|
onContentError: ({ error }) => reportContentError(error),
|
||||||
|
onUpdate: ({ editor }) => onChangeRef.current(editor.state.doc),
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyScroll = (ed: Editor, top: number) => {
|
||||||
|
const scroller = scrollerOf(ed);
|
||||||
|
if (!scroller) return;
|
||||||
|
const token = (scrollToken.current += 1);
|
||||||
|
const apply = () => {
|
||||||
|
if (scrollToken.current === token) scroller.scrollTop = top;
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
requestAnimationFrame(apply);
|
||||||
|
// Web fonts land after the first paint and change every line's height under the caret with
|
||||||
|
// them, so the offset that was right a moment ago is wrong once Literata arrives.
|
||||||
|
window.document.fonts?.ready.then(apply).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildState = (ed: Editor, source: MarkdownDocument): EditorState => {
|
||||||
|
const base = ed.view.state;
|
||||||
|
try {
|
||||||
|
// The bridge builds its tree against src/model/schema.ts and TipTap builds an identical one
|
||||||
|
// of its own from the same specs, so the node has to be rebound before it can be edited.
|
||||||
|
const doc = ed.schema.nodeFromJSON(source.doc.toJSON());
|
||||||
|
doc.check();
|
||||||
|
return EditorState.create({ doc, plugins: base.plugins });
|
||||||
|
} catch (error) {
|
||||||
|
reportContentError(error);
|
||||||
|
return EditorState.create({ schema: base.schema, plugins: base.plugins });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remember = (path: string, entry: Cached) => {
|
||||||
|
cache.current.delete(path);
|
||||||
|
cache.current.set(path, entry);
|
||||||
|
for (const stale of Array.from(cache.current.keys()).slice(0, cache.current.size - CACHE_LIMIT)) {
|
||||||
|
cache.current.delete(stale);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stash = (ed: Editor, source: MarkdownDocument) => {
|
||||||
|
const scroll = scrollerOf(ed)?.scrollTop ?? 0;
|
||||||
|
const { from, to } = ed.state.selection;
|
||||||
|
remember(source.path, { state: ed.view.state, document: source, scroll });
|
||||||
|
savePosition(source.path, { from, to, scroll });
|
||||||
|
};
|
||||||
|
|
||||||
|
const install = (ed: Editor, source: MarkdownDocument, focus: boolean) => {
|
||||||
|
const cached = cache.current.get(source.path);
|
||||||
|
// Reusable when it is the same object, or a fresh read of a file whose bytes have not moved
|
||||||
|
// since that state was built. Anything else and the file is the newer copy, so the cached
|
||||||
|
// tree goes: an instant reopen is not worth showing somebody yesterday's document.
|
||||||
|
const entry =
|
||||||
|
cached && (cached.document === source || cached.document.source === source.source)
|
||||||
|
? cached
|
||||||
|
: null;
|
||||||
|
if (entry) {
|
||||||
|
ed.view.updateState(entry.state);
|
||||||
|
if (focus) ed.commands.focus(undefined, { scrollIntoView: false });
|
||||||
|
applyScroll(ed, entry.scroll);
|
||||||
|
} else {
|
||||||
|
ed.view.updateState(buildState(ed, source));
|
||||||
|
const saved = loadPosition(source.path);
|
||||||
|
const size = ed.state.doc.content.size;
|
||||||
|
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
|
||||||
|
const chain = ed.chain().setTextSelection(selection);
|
||||||
|
if (focus) chain.focus(undefined, { scrollIntoView: false });
|
||||||
|
chain.run();
|
||||||
|
remember(source.path, { state: ed.view.state, document: source, scroll: saved?.scroll ?? 0 });
|
||||||
|
applyScroll(ed, saved?.scroll ?? 0);
|
||||||
|
}
|
||||||
|
position.current = null;
|
||||||
|
publish.current?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
// A new editor instance, which React's strict double mount produces, cannot be handed states
|
||||||
|
// built against the old one's plugins, and it has nothing installed in it yet whatever the
|
||||||
|
// last document was.
|
||||||
|
const carried = host.current === editor;
|
||||||
|
if (!carried) cache.current.clear();
|
||||||
|
const previous = carried ? installed.current : null;
|
||||||
|
if (previous === document) return;
|
||||||
|
if (previous) stash(editor, previous);
|
||||||
|
install(editor, document, !previous || previous.path !== document.path);
|
||||||
|
installed.current = document;
|
||||||
|
host.current = editor;
|
||||||
|
// The document is installed by identity, not by field: a new object is a different file or a
|
||||||
|
// reload from disk, and a keystroke is neither.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [editor, document]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
// Not setEditable(value): its default emits an `update` with no transaction behind it, which
|
||||||
|
// would reach onChange and mark a document dirty that nobody has typed into.
|
||||||
|
editor.setEditable(editable, false);
|
||||||
|
}, [editor, editable]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const commands = createCommands(editor);
|
||||||
|
const find = createFind(editor);
|
||||||
|
let active = activeStateOf(editor);
|
||||||
|
currentHandle = { active, ...commands };
|
||||||
|
currentFind = { state: { count: 0, current: 0 }, ...find };
|
||||||
|
|
||||||
|
const push = () => {
|
||||||
|
const next = activeStateOf(editor);
|
||||||
|
if (!sameActive(active, next)) {
|
||||||
|
active = next;
|
||||||
|
currentHandle = { active, ...commands };
|
||||||
|
}
|
||||||
|
const search = searchStateOf(editor.state);
|
||||||
|
const count = search?.matches.length ?? 0;
|
||||||
|
const current = search?.current ?? 0;
|
||||||
|
if (currentFind === null || currentFind.state.count !== count || currentFind.state.current !== current) {
|
||||||
|
currentFind = { state: { count, current }, ...find };
|
||||||
|
}
|
||||||
|
announce();
|
||||||
|
};
|
||||||
|
|
||||||
|
publish.current = push;
|
||||||
|
push();
|
||||||
|
editor.on("transaction", push);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
editor.off("transaction", push);
|
||||||
|
publish.current = null;
|
||||||
|
currentHandle = null;
|
||||||
|
currentFind = null;
|
||||||
|
announce();
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const scroller = scrollerOf(editor);
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
const write = () => {
|
||||||
|
const source = installed.current;
|
||||||
|
if (source && position.current) savePosition(source.path, position.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const persist = () => {
|
||||||
|
const { from, to } = editor.state.selection;
|
||||||
|
const scroll = scroller?.scrollTop ?? 0;
|
||||||
|
position.current = { from, to, scroll };
|
||||||
|
const entry = installed.current ? cache.current.get(installed.current.path) : undefined;
|
||||||
|
if (entry) entry.scroll = scroll;
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(write, 400);
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.on("selectionUpdate", persist);
|
||||||
|
scroller?.addEventListener("scroll", persist, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
editor.off("selectionUpdate", persist);
|
||||||
|
scroller?.removeEventListener("scroll", persist);
|
||||||
|
write();
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
return <EditorContent editor={editor} className="editor-host" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
// A .txt file. Not markdown, so nothing here parses any: a line reading "# heading" is that
|
||||||
|
// literal text on screen and those literal bytes on disk, and the only thing between the two is a
|
||||||
|
// textarea.
|
||||||
|
//
|
||||||
|
// The document shape mirrors the bridge's `parsePlainText` exactly, one line to one paragraph,
|
||||||
|
// which is what makes splitting and joining on the newline exact inverses and the round trip byte
|
||||||
|
// identical down to a missing final newline.
|
||||||
|
//
|
||||||
|
// Find has no ProseMirror decorations to draw here, since a textarea has no tree to decorate. What
|
||||||
|
// it has is a native selection, which is the one highlight a textarea can show, so a match is
|
||||||
|
// found by selecting it and scrolling it into view rather than by painting a span around it. The
|
||||||
|
// state that search.ts keeps in a plugin lives in a ref instead, published through the same
|
||||||
|
// `DocumentFind` shape src/editor/index.ts declares for the markdown surface, so FindBar.tsx never
|
||||||
|
// has to know which editor it is talking to.
|
||||||
|
|
||||||
|
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { schema } from "../model/schema";
|
||||||
|
import {
|
||||||
|
EMPTY_PLAIN_SEARCH,
|
||||||
|
recomputePlainSearch,
|
||||||
|
replaceAllMatches,
|
||||||
|
replaceMatch,
|
||||||
|
sameQuery,
|
||||||
|
type PlainSearchState,
|
||||||
|
type SearchMatch,
|
||||||
|
} from "./plainFind";
|
||||||
|
import type { DocumentFind, PlainTextProps } from "./index";
|
||||||
|
|
||||||
|
function textOf(doc: ProseMirrorNode): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
doc.forEach((block) => lines.push(block.textContent));
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function docOf(text: string): ProseMirrorNode {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
const blocks = lines.map((line) =>
|
||||||
|
schema.nodes.paragraph.create(null, line ? schema.text(line) : null),
|
||||||
|
);
|
||||||
|
return schema.nodes.doc.create(null, blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The properties a mirror div needs to copy for its line wrapping, and so the vertical position
|
||||||
|
* it measures, to match the textarea's own. Only what wrapping and line height depend on: nothing
|
||||||
|
* about colour or the caret. */
|
||||||
|
function copyWrappingStyle(mirror: HTMLDivElement, field: HTMLTextAreaElement): void {
|
||||||
|
const style = getComputedStyle(field);
|
||||||
|
mirror.style.position = "absolute";
|
||||||
|
mirror.style.visibility = "hidden";
|
||||||
|
mirror.style.top = "0";
|
||||||
|
mirror.style.left = "-9999px";
|
||||||
|
mirror.style.width = `${field.clientWidth}px`;
|
||||||
|
mirror.style.fontFamily = style.fontFamily;
|
||||||
|
mirror.style.fontSize = style.fontSize;
|
||||||
|
mirror.style.fontWeight = style.fontWeight;
|
||||||
|
mirror.style.fontStyle = style.fontStyle;
|
||||||
|
mirror.style.letterSpacing = style.letterSpacing;
|
||||||
|
mirror.style.lineHeight = style.lineHeight;
|
||||||
|
mirror.style.textTransform = style.textTransform;
|
||||||
|
mirror.style.wordSpacing = style.wordSpacing;
|
||||||
|
mirror.style.tabSize = style.tabSize;
|
||||||
|
mirror.style.whiteSpace = style.whiteSpace;
|
||||||
|
mirror.style.wordBreak = style.wordBreak;
|
||||||
|
mirror.style.overflowWrap = style.overflowWrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a character offset lands inside the textarea's own box, found the only way a plain
|
||||||
|
* textarea allows: rendering the same text in an invisible twin under the same font and width and
|
||||||
|
* reading back where a marker after it fell. There is no scroll of its own to subtract, since the
|
||||||
|
* field is always exactly as tall as its text (see the layout effect below). */
|
||||||
|
function caretOffset(field: HTMLTextAreaElement, index: number): { top: number; height: number } {
|
||||||
|
const mirror = window.document.createElement("div");
|
||||||
|
copyWrappingStyle(mirror, field);
|
||||||
|
mirror.textContent = field.value.slice(0, index);
|
||||||
|
const marker = window.document.createElement("span");
|
||||||
|
marker.textContent = field.value.slice(index, index + 1) || ".";
|
||||||
|
mirror.appendChild(marker);
|
||||||
|
window.document.body.appendChild(mirror);
|
||||||
|
const top = marker.offsetTop;
|
||||||
|
const height = marker.offsetHeight;
|
||||||
|
window.document.body.removeChild(mirror);
|
||||||
|
return { top, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollerOf(field: HTMLTextAreaElement): HTMLElement | null {
|
||||||
|
for (let node = field.parentElement; node; node = node.parentElement) {
|
||||||
|
if (node.classList.contains("editor-pane")) return node;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The textarea has no scrollbar of its own, so bringing a match into view means scrolling the
|
||||||
|
* pane around it instead, the same "roughly centred" placement search.ts asks the pane for. */
|
||||||
|
function scrollMatchIntoView(field: HTMLTextAreaElement, match: SearchMatch): void {
|
||||||
|
const pane = scrollerOf(field);
|
||||||
|
if (!pane) return;
|
||||||
|
const { top, height } = caretOffset(field, match.from);
|
||||||
|
const fieldTop = field.getBoundingClientRect().top - pane.getBoundingClientRect().top + pane.scrollTop;
|
||||||
|
const target = fieldTop + top - pane.clientHeight / 2 + height / 2;
|
||||||
|
pane.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const findListeners = new Set<() => void>();
|
||||||
|
let currentPlainFind: DocumentFind | null = null;
|
||||||
|
|
||||||
|
function subscribePlainFind(listener: () => void): () => void {
|
||||||
|
findListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
findListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function announcePlainFind(): void {
|
||||||
|
for (const listener of findListeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainFindSnapshot = () => currentPlainFind;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find and replace for the .txt surface, published the same way Editor.tsx publishes the markdown
|
||||||
|
* one, so src/editor/index.ts can hand `useDocumentFind` whichever of the two is actually on
|
||||||
|
* screen without either side knowing the other exists.
|
||||||
|
*/
|
||||||
|
export function usePlainTextFind(): DocumentFind | null {
|
||||||
|
return useSyncExternalStore(subscribePlainFind, plainFindSnapshot, plainFindSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlainTextEditor({
|
||||||
|
document,
|
||||||
|
onChange,
|
||||||
|
editable = true,
|
||||||
|
}: PlainTextProps): ReactElement {
|
||||||
|
const field = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [text, setText] = useState(() => textOf(document.doc));
|
||||||
|
const source = useRef(document);
|
||||||
|
const textRef = useRef(text);
|
||||||
|
textRef.current = text;
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
const search = useRef<PlainSearchState>(EMPTY_PLAIN_SEARCH);
|
||||||
|
const publishRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
|
// Set when the open file is replaced by a newer read of itself, which is what an external edit
|
||||||
|
// to a clean buffer looks like from here. Editor.tsx stashes and restores the ProseMirror
|
||||||
|
// selection across the same event; a textarea has no selection of its own to survive a value
|
||||||
|
// change, so it has to be carried by hand or the caret lands at the end of the file.
|
||||||
|
const carry = useRef<{ start: number; end: number } | null>(null);
|
||||||
|
|
||||||
|
if (source.current !== document) {
|
||||||
|
const reload = source.current.path === document.path;
|
||||||
|
const el = field.current;
|
||||||
|
carry.current = reload && el ? { start: el.selectionStart, end: el.selectionEnd } : null;
|
||||||
|
source.current = document;
|
||||||
|
const next = textOf(document.doc);
|
||||||
|
setText(next);
|
||||||
|
textRef.current = next;
|
||||||
|
// A new file has nothing to do with whatever was being searched for in the last one, and its
|
||||||
|
// matches would point at the wrong offsets anyway. A reload of the same file is the same
|
||||||
|
// story: the offsets are against text that has just been replaced.
|
||||||
|
search.current = EMPTY_PLAIN_SEARCH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const methods = useRef<Omit<DocumentFind, "state"> | null>(null);
|
||||||
|
|
||||||
|
// Only a new object when the count or the position in it actually moved, the same guard
|
||||||
|
// Editor.tsx's own `push` keeps: FindBar re-issues `setQuery`/`clear` on every render it is open
|
||||||
|
// for, and a new object on every one of those, whether anything changed or not, is what a
|
||||||
|
// `useSyncExternalStore` subscriber reads as new state to render, which is what that repeated
|
||||||
|
// call turns into an infinite loop rather than the no-op it is meant to be.
|
||||||
|
const publish = () => {
|
||||||
|
const count = search.current.matches.length;
|
||||||
|
const current = search.current.current;
|
||||||
|
if (!currentPlainFind || currentPlainFind.state.count !== count || currentPlainFind.state.current !== current) {
|
||||||
|
currentPlainFind = { state: { count, current }, ...methods.current! };
|
||||||
|
}
|
||||||
|
announcePlainFind();
|
||||||
|
};
|
||||||
|
publishRef.current = publish;
|
||||||
|
|
||||||
|
if (!methods.current) {
|
||||||
|
// Collapses whatever is selected without moving the caret: the closest a textarea has to
|
||||||
|
// "no decoration", for the moment a query stops matching anything or find closes altogether.
|
||||||
|
const deselect = () => {
|
||||||
|
const el = field.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.setSelectionRange(el.selectionStart, el.selectionStart);
|
||||||
|
};
|
||||||
|
|
||||||
|
const land = (next: PlainSearchState) => {
|
||||||
|
search.current = next;
|
||||||
|
const match = next.matches[next.current];
|
||||||
|
if (match) {
|
||||||
|
const el = field.current;
|
||||||
|
if (el) {
|
||||||
|
el.setSelectionRange(match.from, match.to);
|
||||||
|
scrollMatchIntoView(el, match);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deselect();
|
||||||
|
}
|
||||||
|
publishRef.current();
|
||||||
|
};
|
||||||
|
|
||||||
|
methods.current = {
|
||||||
|
setQuery: (query, options) => {
|
||||||
|
if (sameQuery(search.current, query, options)) return;
|
||||||
|
land(recomputePlainSearch(textRef.current, query, options, 0));
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
search.current = EMPTY_PLAIN_SEARCH;
|
||||||
|
deselect();
|
||||||
|
publishRef.current();
|
||||||
|
},
|
||||||
|
next: () => {
|
||||||
|
const s = search.current;
|
||||||
|
if (!s.matches.length) return;
|
||||||
|
land({ ...s, current: (s.current + 1) % s.matches.length });
|
||||||
|
},
|
||||||
|
prev: () => {
|
||||||
|
const s = search.current;
|
||||||
|
if (!s.matches.length) return;
|
||||||
|
land({ ...s, current: (s.current - 1 + s.matches.length) % s.matches.length });
|
||||||
|
},
|
||||||
|
replaceCurrent: (replacement) => {
|
||||||
|
const s = search.current;
|
||||||
|
if (!s.matches.length) return;
|
||||||
|
const match = s.matches[s.current];
|
||||||
|
const nextText = replaceMatch(textRef.current, match, replacement);
|
||||||
|
textRef.current = nextText;
|
||||||
|
setText(nextText);
|
||||||
|
onChangeRef.current(docOf(nextText));
|
||||||
|
land(recomputePlainSearch(nextText, s.query, s.options, s.current));
|
||||||
|
},
|
||||||
|
replaceAll: (replacement) => {
|
||||||
|
const s = search.current;
|
||||||
|
if (!s.matches.length) return;
|
||||||
|
const nextText = replaceAllMatches(textRef.current, s.matches, replacement);
|
||||||
|
textRef.current = nextText;
|
||||||
|
setText(nextText);
|
||||||
|
onChangeRef.current(docOf(nextText));
|
||||||
|
land(recomputePlainSearch(nextText, s.query, s.options, s.current));
|
||||||
|
},
|
||||||
|
focus: () => {
|
||||||
|
field.current?.focus();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
field.current?.focus();
|
||||||
|
}, [document.path]);
|
||||||
|
|
||||||
|
// Republished after every render that changed which document is open, which is what carries the
|
||||||
|
// reset above (a new file means no active search) out to whatever is drawing the find bar. The
|
||||||
|
// functions themselves close over refs and read them fresh on every call, so nothing here needs
|
||||||
|
// rebuilding when only the text or the search changes, just the announcing of it.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
publishRef.current();
|
||||||
|
}, [document]);
|
||||||
|
|
||||||
|
// Unregistering is a real unmount only, not a document change: switching files keeps this
|
||||||
|
// component and its handle in place, and only leaving the plain text surface entirely (the
|
||||||
|
// document closes, or a markdown file replaces it) should hand `useDocumentFind` back to null.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
currentPlainFind = null;
|
||||||
|
announcePlainFind();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The pane is the scroller for every other document, and a textarea with its own scrollbar
|
||||||
|
// inside that pane would be two of them, one of which puts the last line under the chrome with
|
||||||
|
// no way to scroll it clear. So the field is always exactly as tall as its text.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = field.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
|
|
||||||
|
// Clamped, because the edit that arrived from outside may well be shorter than what was on
|
||||||
|
// screen. Landing at the end of a file that shrank under you is the same complaint as landing
|
||||||
|
// at the end of one that grew.
|
||||||
|
const want = carry.current;
|
||||||
|
carry.current = null;
|
||||||
|
if (!want) return;
|
||||||
|
const end = Math.min(want.end, el.value.length);
|
||||||
|
el.setSelectionRange(Math.min(want.start, end), end);
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
ref={field}
|
||||||
|
className="plain-text"
|
||||||
|
value={text}
|
||||||
|
readOnly={!editable}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.value;
|
||||||
|
setText(next);
|
||||||
|
textRef.current = next;
|
||||||
|
onChange(docOf(next));
|
||||||
|
// A search still running when the text under it changes stays running, against the new
|
||||||
|
// text, the same way search.ts recomputes on every transaction that changes the document.
|
||||||
|
if (search.current.query) {
|
||||||
|
search.current = recomputePlainSearch(
|
||||||
|
next,
|
||||||
|
search.current.query,
|
||||||
|
search.current.options,
|
||||||
|
search.current.current,
|
||||||
|
);
|
||||||
|
publishRef.current();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,851 @@
|
|||||||
|
// The app's only formatting surface: a sticky glass pill at the bottom of the editor pane. There
|
||||||
|
// is no slash menu and there are no drag handles, by product decision, so every control a document
|
||||||
|
// can be shaped with lives here.
|
||||||
|
//
|
||||||
|
// This component drives itself off `useEditorHandle()`, the declared surface in `./index`, and
|
||||||
|
// nothing else about the editor: no TipTap instance, no ProseMirror import, no reach into
|
||||||
|
// extensions.ts. Save state does not come from a store here on purpose: what the pill has to show
|
||||||
|
// is a conflict, and a conflict is `useDocument`'s `externalChange` rather than its `savePhase`,
|
||||||
|
// so reading one field would report the wrong thing half the time. `saveState` and `document`
|
||||||
|
// arrive as props instead, the same way `editor` arrived as a prop in the margin version this is
|
||||||
|
// ported from, and src/App.tsx is the one place the two fields are folded into one answer.
|
||||||
|
//
|
||||||
|
// Ported from ../../../margin/src/editor/FloatingToolbar.tsx: the tool() helper, the onMouseDown
|
||||||
|
// preventDefault on every button (without it, a click steals the selection before the command that
|
||||||
|
// reads it runs), the .tool-wrap plus conditional backdrop plus popover idiom, and a
|
||||||
|
// useEscapeLayer per popover so Escape unwinds them in order. The forceUpdate-on-"transaction"
|
||||||
|
// subscription from that version is not ported: `useEditorHandle()` is a hook that itself returns
|
||||||
|
// a new `active` object on every relevant change, per its own doc comment, so calling it is the
|
||||||
|
// replacement for that subscription, not an addition to it.
|
||||||
|
//
|
||||||
|
// M2 brought four block families and one hard constraint: the pill is one row and it has to fit
|
||||||
|
// the app's 880px minimum window beside a 248px sidebar, which leaves 632px of pane. Three tools
|
||||||
|
// are added, which is what M1 left room for, and everything else expands out of one of them: the
|
||||||
|
// table tool's popover is a size picker outside a table and the twelve table ops inside one, the
|
||||||
|
// callout tool's is the five kinds, the insert tool's is math and mermaid, and the language for a
|
||||||
|
// fence hangs off the code block tool that was already there, since a control the cursor has to be
|
||||||
|
// inside a fence to want is a control the pill cannot afford to carry permanently.
|
||||||
|
//
|
||||||
|
// The toggle tool is the one added since, and it is a plain button rather than a fourth popover for
|
||||||
|
// the reason the quote button is: everything a toggle needs beyond existing, its summary and
|
||||||
|
// whether it is open, is edited on the block itself. It does cost the row its last 32px at the
|
||||||
|
// minimum window, where the pill was already 4px over and living on the tightened separators in
|
||||||
|
// toolbar.css.
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type ReactElement, type ReactNode } from "react";
|
||||||
|
import { Icon } from "../components/Icon";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { assetWrite } from "../api/files";
|
||||||
|
import { notify } from "../store/useToast";
|
||||||
|
import { CALLOUT_KINDS, type MarkdownDocument } from "../model/doc";
|
||||||
|
import { HEADING_LEVELS } from "../model/schema";
|
||||||
|
import { useEditorHandle, type EditorActiveState, type TableOp } from "./index";
|
||||||
|
|
||||||
|
const DEFAULT_ACTIVE: EditorActiveState = {
|
||||||
|
marks: [],
|
||||||
|
block: "paragraph",
|
||||||
|
headingLevel: null,
|
||||||
|
callout: null,
|
||||||
|
inTable: false,
|
||||||
|
codeLanguage: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The size picker's ceiling. Anything bigger is a table nobody builds from a grid of squares. */
|
||||||
|
const PICKER_ROWS = 6;
|
||||||
|
const PICKER_COLUMNS = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The languages worth one click. Deliberately short and deliberately not the highlighter's list:
|
||||||
|
* an info string is free text, so the input beside these takes anything, and a fence the file
|
||||||
|
* already carried keeps whatever it says whether it appears here or not.
|
||||||
|
*/
|
||||||
|
const LANGUAGES = [
|
||||||
|
"javascript",
|
||||||
|
"typescript",
|
||||||
|
"python",
|
||||||
|
"rust",
|
||||||
|
"go",
|
||||||
|
"json",
|
||||||
|
"yaml",
|
||||||
|
"bash",
|
||||||
|
"sql",
|
||||||
|
"html",
|
||||||
|
"css",
|
||||||
|
"markdown",
|
||||||
|
"mermaid",
|
||||||
|
];
|
||||||
|
|
||||||
|
export type ToolbarSaveState = "idle" | "saving" | "conflict";
|
||||||
|
|
||||||
|
export interface ToolbarProps {
|
||||||
|
/** The open document, needed only for the path a pasted image is written beside. Asset writes
|
||||||
|
* are disabled while this is null. */
|
||||||
|
document: MarkdownDocument | null;
|
||||||
|
/** Idle shows nothing on the right of the pill, saving shows a quiet pulse, conflict shows a
|
||||||
|
* small clickable warning. Defaults to idle so the pill renders sensibly before whoever owns the
|
||||||
|
* document store has a real phase to report. */
|
||||||
|
saveState?: ToolbarSaveState;
|
||||||
|
/** Only read while saveState is "conflict". */
|
||||||
|
onResolveConflict?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(url: string): string {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
if (/^(https?:\/\/|mailto:|tel:|#|\/)/i.test(trimmed)) return trimmed;
|
||||||
|
return `https://${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fence's info string is a language and then `meta`, the user's own text after it, which this
|
||||||
|
* editor has no model for and never invents. Anything typed past the first space would be written
|
||||||
|
* into the fence and read back as meta, so the picker keeps the first word and leaves the rest of
|
||||||
|
* the info string to whatever the file already said.
|
||||||
|
*/
|
||||||
|
function normalizeLanguage(value: string): string | null {
|
||||||
|
const first = value.trim().split(/\s+/)[0] ?? "";
|
||||||
|
return first || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capitalised for a menu. The label on disk is upper case and belongs to the serializer. */
|
||||||
|
function calloutLabel(kind: string): string {
|
||||||
|
return kind.charAt(0).toUpperCase() + kind.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the caret is in a toggle's title, which is chrome rather than content.
|
||||||
|
*
|
||||||
|
* The title is a node view's own editable island, so ProseMirror's selection stays wherever it was
|
||||||
|
* in the document the whole time somebody is typing in there. src/editor/blocks/toggle.ts refuses
|
||||||
|
* every document-changing transaction while that is true, because otherwise a button pressed here
|
||||||
|
* edits a paragraph the user is not looking at. That refusal is the safety net; this is the half
|
||||||
|
* that has to agree with it, since a tool that draws itself live and then does nothing is the pill
|
||||||
|
* lying about what it can do.
|
||||||
|
*
|
||||||
|
* Asked of the page rather than of a subscription, because clicking into a title dispatches no
|
||||||
|
* transaction and there is nothing in the editor's own state to watch. Focus events bubble, so one
|
||||||
|
* pair on the window covers every toggle on screen and every one added later.
|
||||||
|
*/
|
||||||
|
function useCaretInToggleTitle(): boolean {
|
||||||
|
const [inTitle, setInTitle] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const read = () => {
|
||||||
|
const active = window.document.activeElement;
|
||||||
|
setInTitle(active instanceof Element && active.closest("[data-toggle-summary]") !== null);
|
||||||
|
};
|
||||||
|
read();
|
||||||
|
window.addEventListener("focusin", read);
|
||||||
|
window.addEventListener("focusout", read);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("focusin", read);
|
||||||
|
window.removeEventListener("focusout", read);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return inTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tool(
|
||||||
|
active: boolean,
|
||||||
|
onClick: () => void,
|
||||||
|
title: string,
|
||||||
|
content: ReactNode,
|
||||||
|
disabled = false,
|
||||||
|
): ReactElement {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="tool"
|
||||||
|
data-on={active}
|
||||||
|
title={title}
|
||||||
|
disabled={disabled}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOLD_D = "M7 5v14M7 5h5.5a3.5 3.5 0 0 1 0 7H7M7 12h6a3.5 3.5 0 0 1 0 7H7";
|
||||||
|
const ITALIC_D = "M10 5h6M6 19h6M13 5l-4 14";
|
||||||
|
const STRIKETHROUGH_D =
|
||||||
|
"M5 12h14M8 7.5c0-1.5 1.6-2.5 4-2.5s4 1 4 2.5M8 16.5c0 1.5 1.6 2.5 4 2.5s4-1 4-2.5";
|
||||||
|
const CODE_D = "M9 6l-5 6 5 6M15 6l5 6-5 6";
|
||||||
|
const HEADING_D = "M5 5v14M5 12h8M13 5v14";
|
||||||
|
const BULLET_LIST_D = "M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01";
|
||||||
|
const ORDERED_LIST_D =
|
||||||
|
"M10 6h11M10 12h11M10 18h11M4 4v4M3 4h2M4 10.5h1.5a1 1 0 1 1 0 2H4h1.5a1 1 0 1 1 0 2H4M4 20.5l1.4-1.7a1 1 0 1 0-1.4-1.6";
|
||||||
|
const TASK_LIST_D = "M4 5h4v4H4zM5.5 7l1 1 2-2M4 15h4v4H4zM5 17l1 1 2-2M11 7h9M11 17h9M11 12h9";
|
||||||
|
const BLOCKQUOTE_D = "M7 8h4v4a4 4 0 0 1-4 4M14 8h4v4a4 4 0 0 1-4 4";
|
||||||
|
const TOGGLE_D = "M5 7l4 5-4 5M12 9h7M12 15h7";
|
||||||
|
const CODE_BLOCK_D = "M4 6h16v12H4zM7 10l3 2-3 2";
|
||||||
|
const LINK_D =
|
||||||
|
"M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1";
|
||||||
|
const REMOVE_D = "M18 6L6 18M6 6l12 12";
|
||||||
|
const HR_D = "M5 12h5M14 12h5";
|
||||||
|
const IMAGE_D = "M4 5h16v14H4zM4 16l4.5-4.5 3 3L16 10l4 4";
|
||||||
|
const ALERT_D = "M12 3l10 18H2zM12 9v5M12 17h.01";
|
||||||
|
const CALLOUT_D = "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M12 11v5M12 8h.01";
|
||||||
|
const TABLE_D = "M4 5h16v14H4zM4 10h16M10 10v9M15 10v9";
|
||||||
|
const INSERT_D = "M12 5v14M5 12h14";
|
||||||
|
|
||||||
|
export function Toolbar({ document, saveState = "idle", onResolveConflict }: ToolbarProps): ReactElement {
|
||||||
|
const editor = useEditorHandle();
|
||||||
|
const active = editor?.active ?? DEFAULT_ACTIVE;
|
||||||
|
const inTitle = useCaretInToggleTitle();
|
||||||
|
const disabled = !editor || saveState === "conflict" || inTitle;
|
||||||
|
|
||||||
|
const [headingOpen, setHeadingOpen] = useState(false);
|
||||||
|
const [linkOpen, setLinkOpen] = useState(false);
|
||||||
|
const [calloutOpen, setCalloutOpen] = useState(false);
|
||||||
|
const [tableOpen, setTableOpen] = useState(false);
|
||||||
|
const [insertOpen, setInsertOpen] = useState(false);
|
||||||
|
const [languageOpen, setLanguageOpen] = useState(false);
|
||||||
|
const [linkValue, setLinkValue] = useState("");
|
||||||
|
const [languageValue, setLanguageValue] = useState("");
|
||||||
|
// What the size grid is hovering over, which is the picker's whole state: nothing is inserted
|
||||||
|
// until a square is clicked.
|
||||||
|
const [size, setSize] = useState<{ rows: number; columns: number } | null>(null);
|
||||||
|
const linkInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const languageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Every popover in the pill is mutually exclusive already, since each one lays a fixed backdrop
|
||||||
|
// over the other tools, so opening one closes the rest rather than letting a second live menu
|
||||||
|
// exist behind an invisible sheet.
|
||||||
|
const closePopovers = () => {
|
||||||
|
setHeadingOpen(false);
|
||||||
|
setLinkOpen(false);
|
||||||
|
setCalloutOpen(false);
|
||||||
|
setTableOpen(false);
|
||||||
|
setInsertOpen(false);
|
||||||
|
setLanguageOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (linkOpen) linkInputRef.current?.focus();
|
||||||
|
}, [linkOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (languageOpen) languageInputRef.current?.focus();
|
||||||
|
}, [languageOpen]);
|
||||||
|
|
||||||
|
// The backdrop cannot be the whole click-away story here the way it is for the titlebar's menu.
|
||||||
|
// .editor-toolbar carries a backdrop-filter, and that makes it the containing block for a fixed
|
||||||
|
// position child, so `inset: 0` on the backdrop resolves to the pill and not to the window: it
|
||||||
|
// covers the other tools, which is what makes a second press of the open one close it, and
|
||||||
|
// nothing else. A click in the document went behind it and left the menu standing, which was
|
||||||
|
// survivable with two small popovers and is not with six. Nothing is prevented, so the click
|
||||||
|
// still lands where it was aimed.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!(headingOpen || linkOpen || calloutOpen || tableOpen || insertOpen || languageOpen)) return;
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
const target = e.target instanceof Element ? e.target : null;
|
||||||
|
if (target?.closest(".editor-toolbar")) return;
|
||||||
|
closePopovers();
|
||||||
|
};
|
||||||
|
window.addEventListener("mousedown", onDown, true);
|
||||||
|
return () => window.removeEventListener("mousedown", onDown, true);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [headingOpen, linkOpen, calloutOpen, tableOpen, insertOpen, languageOpen]);
|
||||||
|
|
||||||
|
// A conflict disables every tool, and a popover left open over a disabled pill would still have
|
||||||
|
// live items in it. The file on disk has already moved by then, so nothing here gets to write to
|
||||||
|
// the buffer until the user has said which copy wins. Only the six setState functions are read,
|
||||||
|
// and those are stable, so the closure this captures is never the stale one.
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled) closePopovers();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
// Ported Mod-K handling. Note for whoever wires the pane together: cmd+k is already bound
|
||||||
|
// globally to "command-palette" in src/keys/bindings.ts with allowInInput: true, and that
|
||||||
|
// listener is installed at app boot, before this component ever mounts, so it always sees the
|
||||||
|
// keydown first. The defaultPrevented check below means this never double-fires on top of it,
|
||||||
|
// but it also means this shortcut is inert until a document-context override for cmd+k exists.
|
||||||
|
// Clicking the link tool still opens the popover either way.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.isComposing || e.defaultPrevented) return;
|
||||||
|
if (e.key.toLowerCase() !== "k" || !(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return;
|
||||||
|
if (!editor && !linkOpen) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (linkOpen) {
|
||||||
|
setLinkOpen(false);
|
||||||
|
} else {
|
||||||
|
setLinkValue("");
|
||||||
|
setLinkOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey, true);
|
||||||
|
return () => window.removeEventListener("keydown", onKey, true);
|
||||||
|
}, [editor, linkOpen]);
|
||||||
|
|
||||||
|
useEscapeLayer(linkOpen, () => {
|
||||||
|
setLinkOpen(false);
|
||||||
|
editor?.focus();
|
||||||
|
});
|
||||||
|
useEscapeLayer(headingOpen, () => setHeadingOpen(false));
|
||||||
|
useEscapeLayer(calloutOpen, () => setCalloutOpen(false));
|
||||||
|
useEscapeLayer(tableOpen, () => setTableOpen(false));
|
||||||
|
useEscapeLayer(insertOpen, () => setInsertOpen(false));
|
||||||
|
useEscapeLayer(languageOpen, () => {
|
||||||
|
setLanguageOpen(false);
|
||||||
|
editor?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyLink = () => {
|
||||||
|
if (!editor) return;
|
||||||
|
const href = normalizeUrl(linkValue);
|
||||||
|
editor.setLink(href || null);
|
||||||
|
setLinkOpen(false);
|
||||||
|
editor.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLink = () => {
|
||||||
|
if (!editor) return;
|
||||||
|
editor.setLink(null);
|
||||||
|
setLinkOpen(false);
|
||||||
|
editor.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openLink = () => {
|
||||||
|
const wasOpen = linkOpen;
|
||||||
|
closePopovers();
|
||||||
|
if (wasOpen) return;
|
||||||
|
setLinkValue("");
|
||||||
|
setLinkOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The code block tool converts into a fence from outside one and configures the fence from
|
||||||
|
// inside it. Turning one back into a paragraph moves into the foot of that popover rather than
|
||||||
|
// staying on a second press of the tool, because the pill has no room for a language control of
|
||||||
|
// its own and the tool for the block you are in is where you would look for one anyway.
|
||||||
|
const onCodeBlock = () => {
|
||||||
|
if (active.block !== "codeBlock") {
|
||||||
|
closePopovers();
|
||||||
|
editor?.setBlock("codeBlock");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wasOpen = languageOpen;
|
||||||
|
closePopovers();
|
||||||
|
if (wasOpen) return;
|
||||||
|
setLanguageValue(active.codeLanguage ?? "");
|
||||||
|
setLanguageOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLanguage = (language: string | null) => {
|
||||||
|
if (!editor) return;
|
||||||
|
editor.setCodeLanguage(language);
|
||||||
|
setLanguageOpen(false);
|
||||||
|
editor.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Row and column ops leave the popover up: adding three rows is three clicks in the same place,
|
||||||
|
// and the command has already put the cursor back in the table by the time the next one runs.
|
||||||
|
const runTable = (op: TableOp) => {
|
||||||
|
editor?.tableCommand(op);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onImageChosen = async (file: File) => {
|
||||||
|
if (!editor || !document) return;
|
||||||
|
// Asked before the bytes go anywhere. The picture has to be on disk before there is a path to
|
||||||
|
// put in the document, so a cursor in a table cell or a fenced block, where the insert is
|
||||||
|
// refused, would otherwise leave a file in the user's assets folder that nothing refers to.
|
||||||
|
if (!editor.canInsertImage()) {
|
||||||
|
notify("An image cannot go where the cursor is.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
|
||||||
|
const result = await assetWrite(document.path, bytes, file.name || "image.png");
|
||||||
|
const alt = file.name.replace(/\.[^./]+$/, "") || null;
|
||||||
|
editor.insertImage(result.relPath, alt);
|
||||||
|
editor.focus();
|
||||||
|
} catch (e) {
|
||||||
|
notify(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="editor-toolbar">
|
||||||
|
{tool(active.marks.includes("strong"), () => editor?.toggleMark("strong"), "Bold", <Icon d={BOLD_D} />, disabled)}
|
||||||
|
{tool(active.marks.includes("em"), () => editor?.toggleMark("em"), "Italic", <Icon d={ITALIC_D} />, disabled)}
|
||||||
|
{tool(
|
||||||
|
active.marks.includes("strikethrough"),
|
||||||
|
() => editor?.toggleMark("strikethrough"),
|
||||||
|
"Strikethrough",
|
||||||
|
<Icon d={STRIKETHROUGH_D} />,
|
||||||
|
disabled,
|
||||||
|
)}
|
||||||
|
{tool(active.marks.includes("code"), () => editor?.toggleMark("code"), "Inline code", <Icon d={CODE_D} />, disabled)}
|
||||||
|
<span className="tool-sep" />
|
||||||
|
<span className="tool-wrap">
|
||||||
|
{tool(
|
||||||
|
headingOpen || active.block === "heading",
|
||||||
|
() => {
|
||||||
|
const wasOpen = headingOpen;
|
||||||
|
closePopovers();
|
||||||
|
if (!wasOpen) setHeadingOpen(true);
|
||||||
|
},
|
||||||
|
"Heading",
|
||||||
|
<Icon d={HEADING_D} />,
|
||||||
|
disabled,
|
||||||
|
)}
|
||||||
|
{headingOpen && (
|
||||||
|
<>
|
||||||
|
<div className="link-pop-backdrop" onMouseDown={() => setHeadingOpen(false)} />
|
||||||
|
<div className="heading-pop" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
className="pop-item"
|
||||||
|
data-on={active.block === "paragraph"}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
editor?.setHeading(null);
|
||||||
|
setHeadingOpen(false);
|
||||||
|
editor?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Paragraph
|
||||||
|
</button>
|
||||||
|
{HEADING_LEVELS.map((level) => (
|
||||||
|
<button
|
||||||
|
key={level}
|
||||||
|
className="pop-item"
|
||||||
|
data-on={active.block === "heading" && active.headingLevel === level}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
editor?.setHeading(level);
|
||||||
|
setHeadingOpen(false);
|
||||||
|
editor?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`Heading ${level}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="tool-sep" />
|
||||||
|
{tool(active.block === "bulletList", () => editor?.setBlock("bulletList"), "Bulleted list", <Icon d={BULLET_LIST_D} />, disabled)}
|
||||||
|
{tool(active.block === "orderedList", () => editor?.setBlock("orderedList"), "Numbered list", <Icon d={ORDERED_LIST_D} />, disabled)}
|
||||||
|
{tool(active.block === "taskList", () => editor?.setBlock("taskList"), "Task list", <Icon d={TASK_LIST_D} />, disabled)}
|
||||||
|
{tool(active.block === "blockquote", () => editor?.setBlock("blockquote"), "Quote", <Icon d={BLOCKQUOTE_D} />, disabled)}
|
||||||
|
{/* The third of the three wrapping commands, beside the two it behaves like: one press puts
|
||||||
|
the block inside a <details>, a second takes it back out. The summary is typed into the
|
||||||
|
toggle itself rather than asked for here, because it is the one part of a block in this
|
||||||
|
pill that is a piece of the document and not a setting. */}
|
||||||
|
{tool(active.block === "toggle", () => editor?.setBlock("toggle"), "Toggle", <Icon d={TOGGLE_D} />, disabled)}
|
||||||
|
<span className="tool-wrap">
|
||||||
|
{tool(
|
||||||
|
calloutOpen || active.block === "callout",
|
||||||
|
() => {
|
||||||
|
const wasOpen = calloutOpen;
|
||||||
|
closePopovers();
|
||||||
|
if (!wasOpen) setCalloutOpen(true);
|
||||||
|
},
|
||||||
|
"Callout",
|
||||||
|
<Icon d={CALLOUT_D} />,
|
||||||
|
disabled,
|
||||||
|
)}
|
||||||
|
{calloutOpen && (
|
||||||
|
<>
|
||||||
|
<div className="link-pop-backdrop" onMouseDown={() => setCalloutOpen(false)} />
|
||||||
|
<div className="callout-pop" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
{CALLOUT_KINDS.map((kind) => (
|
||||||
|
<button
|
||||||
|
key={kind}
|
||||||
|
className="pop-item"
|
||||||
|
data-on={active.callout === kind}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
editor?.setCallout(kind);
|
||||||
|
setCalloutOpen(false);
|
||||||
|
editor?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{calloutLabel(kind)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{active.block === "callout" && (
|
||||||
|
<button
|
||||||
|
className="pop-item"
|
||||||
|
title="Leave the blockquote it is on disk, without the marker"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
editor?.setCallout(null);
|
||||||
|
setCalloutOpen(false);
|
||||||
|
editor?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Plain quote
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="tool-wrap">
|
||||||
|
{tool(
|
||||||
|
languageOpen || active.block === "codeBlock",
|
||||||
|
onCodeBlock,
|
||||||
|
active.block === "codeBlock"
|
||||||
|
? `Code block: ${active.codeLanguage ?? "no language"}`
|
||||||
|
: "Code block",
|
||||||
|
<Icon d={CODE_BLOCK_D} />,
|
||||||
|
disabled,
|
||||||
|
)}
|
||||||
|
{languageOpen && (
|
||||||
|
<>
|
||||||
|
<div className="link-pop-backdrop" onMouseDown={() => setLanguageOpen(false)} />
|
||||||
|
<div className="lang-pop" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<div className="lang-row">
|
||||||
|
<input
|
||||||
|
ref={languageInputRef}
|
||||||
|
className="link-input"
|
||||||
|
value={languageValue}
|
||||||
|
placeholder="Language"
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(e) => setLanguageValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
setLanguage(normalizeLanguage(languageValue));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="link-btn"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => setLanguage(normalizeLanguage(languageValue))}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
{active.codeLanguage !== null && (
|
||||||
|
<button
|
||||||
|
className="link-btn ghost"
|
||||||
|
title="Leave a bare fence"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => setLanguage(null)}
|
||||||
|
>
|
||||||
|
<Icon d={REMOVE_D} size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="lang-chips">
|
||||||
|
{LANGUAGES.map((name) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
className="lang-chip"
|
||||||
|
data-on={active.codeLanguage === name}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => setLanguage(name)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="pop-item"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
editor?.setBlock("codeBlock");
|
||||||
|
setLanguageOpen(false);
|
||||||
|
editor?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Turn into a paragraph
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="tool-sep" />
|
||||||
|
<span className="tool-wrap">
|
||||||
|
{tool(active.marks.includes("link") || linkOpen, openLink, "Link (⌘K)", <Icon d={LINK_D} />, disabled)}
|
||||||
|
{linkOpen && (
|
||||||
|
<>
|
||||||
|
<div className="link-pop-backdrop" onMouseDown={() => setLinkOpen(false)} />
|
||||||
|
<div className="link-pop" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
ref={linkInputRef}
|
||||||
|
className="link-input"
|
||||||
|
value={linkValue}
|
||||||
|
placeholder="https://..."
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(e) => setLinkValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
applyLink();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="link-btn" onMouseDown={(e) => e.preventDefault()} onClick={applyLink}>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
{active.marks.includes("link") && (
|
||||||
|
<button
|
||||||
|
className="link-btn ghost"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={removeLink}
|
||||||
|
title="Remove link"
|
||||||
|
>
|
||||||
|
<Icon d={REMOVE_D} size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="tool-sep" />
|
||||||
|
{tool(false, () => editor?.insertRule(), "Horizontal rule", <Icon d={HR_D} />, disabled)}
|
||||||
|
{tool(false, () => fileRef.current?.click(), "Insert image", <Icon d={IMAGE_D} />, disabled || !document)}
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) void onImageChosen(file);
|
||||||
|
e.currentTarget.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="tool-sep" />
|
||||||
|
<span className="tool-wrap">
|
||||||
|
{tool(
|
||||||
|
tableOpen || active.inTable,
|
||||||
|
() => {
|
||||||
|
const wasOpen = tableOpen;
|
||||||
|
closePopovers();
|
||||||
|
if (wasOpen) return;
|
||||||
|
setSize(null);
|
||||||
|
setTableOpen(true);
|
||||||
|
},
|
||||||
|
active.inTable ? "Table" : "Insert table",
|
||||||
|
<Icon d={TABLE_D} />,
|
||||||
|
disabled,
|
||||||
|
)}
|
||||||
|
{tableOpen && (
|
||||||
|
<>
|
||||||
|
<div className="link-pop-backdrop" onMouseDown={() => setTableOpen(false)} />
|
||||||
|
<div
|
||||||
|
className="table-pop"
|
||||||
|
data-mode={active.inTable ? "edit" : "insert"}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{active.inTable ? (
|
||||||
|
<>
|
||||||
|
<span className="pop-label">Row</span>
|
||||||
|
<div className="pop-row">
|
||||||
|
<button
|
||||||
|
className="pop-btn"
|
||||||
|
title="Insert a row above this one"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => runTable("addRowBefore")}
|
||||||
|
>
|
||||||
|
Above
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pop-btn"
|
||||||
|
title="Insert a row below this one"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => runTable("addRowAfter")}
|
||||||
|
>
|
||||||
|
Below
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pop-btn danger"
|
||||||
|
title="Delete this row"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => runTable("deleteRow")}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="pop-label">Column</span>
|
||||||
|
<div className="pop-row">
|
||||||
|
<button
|
||||||
|
className="pop-btn"
|
||||||
|
title="Insert a column to the left"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => runTable("addColumnBefore")}
|
||||||
|
>
|
||||||
|
Left
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pop-btn"
|
||||||
|
title="Insert a column to the right"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => runTable("addColumnAfter")}
|
||||||
|
>
|
||||||
|
Right
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pop-btn danger"
|
||||||
|
title="Delete this column"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => runTable("deleteColumn")}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Markdown has no per cell alignment, so these set the whole column the cursor
|
||||||
|
is in, which is what the delimiter row on disk can say. */}
|
||||||
|
<span className="pop-label">Align column</span>
|
||||||
|
<div className="pop-row">
|
||||||
|
<button
|
||||||
|
className="pop-btn"
|
||||||
|
title="Align this column left"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => runTable("alignLeft")}
|
||||||
|
>
|
||||||
|
Left
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pop-btn"
|
||||||
|
title="Align this column centre"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => runTable("alignCenter")}
|
||||||
|
>
|
||||||
|
Centre
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pop-btn"
|
||||||
|
title="Align this column right"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => runTable("alignRight")}
|
||||||
|
>
|
||||||
|
Right
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pop-btn"
|
||||||
|
title="Leave this column unaligned"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => runTable("alignClear")}
|
||||||
|
>
|
||||||
|
None
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* No header row control. A GFM table has one header row, it is the first one,
|
||||||
|
and there is no spelling for a table without one, so the button would offer an
|
||||||
|
edit the file cannot hold. See TableOp in src/editor/index.ts. */}
|
||||||
|
<span className="pop-label">Table</span>
|
||||||
|
<button
|
||||||
|
className="pop-item danger"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
runTable("deleteTable");
|
||||||
|
setTableOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete table
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="size-grid" onMouseLeave={() => setSize(null)}>
|
||||||
|
{Array.from({ length: PICKER_ROWS * PICKER_COLUMNS }, (_, i) => {
|
||||||
|
const rows = Math.floor(i / PICKER_COLUMNS) + 1;
|
||||||
|
const columns = (i % PICKER_COLUMNS) + 1;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className="size-cell"
|
||||||
|
data-on={size !== null && rows <= size.rows && columns <= size.columns}
|
||||||
|
title={`${rows} by ${columns} table`}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onMouseEnter={() => setSize({ rows, columns })}
|
||||||
|
onFocus={() => setSize({ rows, columns })}
|
||||||
|
onClick={() => {
|
||||||
|
editor?.insertTable(rows, columns);
|
||||||
|
setTableOpen(false);
|
||||||
|
editor?.focus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className="size-label">
|
||||||
|
{size === null ? "Pick a size" : `${size.rows} x ${size.columns}`}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="tool-wrap">
|
||||||
|
{tool(
|
||||||
|
insertOpen,
|
||||||
|
() => {
|
||||||
|
const wasOpen = insertOpen;
|
||||||
|
closePopovers();
|
||||||
|
if (!wasOpen) setInsertOpen(true);
|
||||||
|
},
|
||||||
|
"Insert",
|
||||||
|
<Icon d={INSERT_D} />,
|
||||||
|
disabled,
|
||||||
|
)}
|
||||||
|
{insertOpen && (
|
||||||
|
<>
|
||||||
|
<div className="link-pop-backdrop" onMouseDown={() => setInsertOpen(false)} />
|
||||||
|
<div className="insert-pop" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
className="pop-item"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
editor?.insertMath(false);
|
||||||
|
setInsertOpen(false);
|
||||||
|
editor?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Inline formula
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pop-item"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
editor?.insertMath(true);
|
||||||
|
setInsertOpen(false);
|
||||||
|
editor?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Display formula
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pop-item"
|
||||||
|
title="A fenced block with mermaid as its language"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
editor?.insertMermaid();
|
||||||
|
setInsertOpen(false);
|
||||||
|
editor?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mermaid diagram
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="toolbar-status" data-phase={saveState}>
|
||||||
|
{saveState === "saving" && <span className="toolbar-status-dot" aria-hidden="true" />}
|
||||||
|
{saveState === "conflict" && (
|
||||||
|
<button
|
||||||
|
className="toolbar-status-conflict"
|
||||||
|
title="This file changed on disk. Click to resolve."
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => onResolveConflict?.()}
|
||||||
|
>
|
||||||
|
<Icon d={ALERT_D} size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
// Highlighting is paint, and the tests that matter are the ones that prove it stayed paint: the
|
||||||
|
// text of a fence, the attributes on it and the bytes it serializes to are the same whether or not
|
||||||
|
// a grammar was ever run over it. The rest is about the two ways a highlighter goes wrong in a real
|
||||||
|
// editor. It can throw or misalign on input it did not expect, which is answered here by fences
|
||||||
|
// nobody has a grammar for, and it can be slow, which is answered by counting how much of the
|
||||||
|
// document it walks when one character is typed.
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { EditorState } from "@tiptap/pm/state";
|
||||||
|
import type { Plugin } from "@tiptap/pm/state";
|
||||||
|
import type { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||||
|
|
||||||
|
// The highlighter is private to code.ts, deliberately, so the only place left to watch how often it
|
||||||
|
// runs is underneath it. Everything the real lowlight does still happens; the wrapper only records
|
||||||
|
// the text it was handed.
|
||||||
|
const { highlighted } = vi.hoisted(() => ({ highlighted: [] as string[] }));
|
||||||
|
|
||||||
|
vi.mock("lowlight", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("lowlight")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createLowlight(...created: Parameters<typeof actual.createLowlight>) {
|
||||||
|
const instance = actual.createLowlight(...created);
|
||||||
|
return {
|
||||||
|
...instance,
|
||||||
|
highlight(...call: Parameters<typeof instance.highlight>) {
|
||||||
|
highlighted.push(call[1]);
|
||||||
|
return instance.highlight(...call);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createEditorExtensions } = await import("../extensions");
|
||||||
|
const { setCodeLanguage } = await import("./code");
|
||||||
|
const { parseMarkdown, serializeMarkdown } = await import("../../markdown");
|
||||||
|
|
||||||
|
const extensions = () =>
|
||||||
|
createEditorExtensions({ documentPath: () => "/notes/a.md", onError: () => {} });
|
||||||
|
|
||||||
|
function editorFor(source: string): Editor {
|
||||||
|
return new Editor({
|
||||||
|
element: null,
|
||||||
|
injectCSS: false,
|
||||||
|
extensions: extensions(),
|
||||||
|
content: parseMarkdown(source, "/notes/a.md").doc.toJSON(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The document with the highlighting plugin over it, and nothing else.
|
||||||
|
*
|
||||||
|
* An editor cannot be mounted without a DOM and an unmounted one's state carries no plugins at all,
|
||||||
|
* so the plugin is lifted out of the extension manager and given a state of its own. What it sees
|
||||||
|
* there is what it sees in the app: a real state over a real parsed document, and transactions
|
||||||
|
* applied to it one at a time. Only the view is missing, and a decoration is computed without one.
|
||||||
|
*/
|
||||||
|
function stateFor(source: string): EditorState {
|
||||||
|
const editor = editorFor(source);
|
||||||
|
const plugin = editor.extensionManager.plugins.find((candidate: Plugin) =>
|
||||||
|
String((candidate as unknown as { key: string }).key).startsWith("codeHighlighting"),
|
||||||
|
);
|
||||||
|
if (!plugin) throw new Error("the code highlighting plugin is not in the extension list");
|
||||||
|
const state = EditorState.create({ doc: editor.state.doc, plugins: [plugin] });
|
||||||
|
editor.destroy();
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Block {
|
||||||
|
pos: number;
|
||||||
|
node: ProseMirrorNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeBlocks(doc: ProseMirrorNode): Block[] {
|
||||||
|
const found: Block[] = [];
|
||||||
|
doc.descendants((node, pos) => {
|
||||||
|
if (node.type.name !== "codeBlock") return true;
|
||||||
|
found.push({ pos, node });
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decorations(state: EditorState): Decoration[] {
|
||||||
|
for (const plugin of state.plugins) {
|
||||||
|
const set = plugin.getState(state) as DecorationSet | undefined;
|
||||||
|
if (set) return set.find();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function classOf(decoration: Decoration): string {
|
||||||
|
return (decoration as unknown as { type: { attrs: { class: string } } }).type.attrs.class;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The text a span was cut from, which is the only thing that says it landed in the right place. */
|
||||||
|
function textOf(state: EditorState, decoration: Decoration): string {
|
||||||
|
return state.doc.textBetween(decoration.from, decoration.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spanWith(state: EditorState, className: string): string | undefined {
|
||||||
|
const found = decorations(state).find((decoration) => classOf(decoration).includes(className));
|
||||||
|
return found && textOf(state, found);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fence(language: string, ...lines: string[]): string {
|
||||||
|
return ["```" + language, ...lines, "```", ""].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
highlighted.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the highlighter", () => {
|
||||||
|
it("colours the four languages this repo's own docs are written in", () => {
|
||||||
|
const sources: Record<string, string> = {
|
||||||
|
rust: fence("rust", "fn main() {}"),
|
||||||
|
toml: fence("toml", "[package]", 'name = "margin-docs"'),
|
||||||
|
swift: fence("swift", "let x = 1"),
|
||||||
|
kotlin: fence("kotlin", "val x = 1"),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [language, source] of Object.entries(sources)) {
|
||||||
|
const state = stateFor(source);
|
||||||
|
expect([language, decorations(state).length > 0]).toEqual([language, true]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts every span over the characters it was cut from", () => {
|
||||||
|
const state = stateFor(fence("rust", 'fn main() { let x = "hi"; }', "// a comment"));
|
||||||
|
const [block] = codeBlocks(state.doc);
|
||||||
|
const from = block.pos + 1;
|
||||||
|
const to = from + block.node.content.size;
|
||||||
|
|
||||||
|
const found = decorations(state);
|
||||||
|
expect(found.length).toBeGreaterThan(3);
|
||||||
|
for (const decoration of found) {
|
||||||
|
expect(decoration.from).toBeGreaterThanOrEqual(from);
|
||||||
|
expect(decoration.to).toBeLessThanOrEqual(to);
|
||||||
|
expect(decoration.from).toBeLessThan(decoration.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(spanWith(state, "hljs-keyword")).toBe("fn");
|
||||||
|
expect(spanWith(state, "hljs-string")).toBe('"hi"');
|
||||||
|
expect(spanWith(state, "hljs-comment")).toBe("// a comment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps its offsets over text that is not one code unit per character", () => {
|
||||||
|
const state = stateFor(fence("ts", 'const flag = "🇬🇧 ok";', "\tconst tabbed = 1;"));
|
||||||
|
|
||||||
|
expect(codeBlocks(state.doc)[0].node.textContent).toBe(
|
||||||
|
'const flag = "🇬🇧 ok";\n\tconst tabbed = 1;',
|
||||||
|
);
|
||||||
|
expect(spanWith(state, "hljs-string")).toBe('"🇬🇧 ok"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a fence tagged with a language nobody has plain, and loses nothing", () => {
|
||||||
|
const source = fence("nosuchlanguage", "this is not code in any language", " indented ");
|
||||||
|
const parsed = parseMarkdown(source, "/notes/a.md");
|
||||||
|
const state = stateFor(source);
|
||||||
|
|
||||||
|
expect(decorations(state)).toEqual([]);
|
||||||
|
expect(codeBlocks(state.doc)[0].node.textContent).toBe(
|
||||||
|
"this is not code in any language\n indented ",
|
||||||
|
);
|
||||||
|
expect(serializeMarkdown(parsed, state.doc)).toBe(source);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a bare fence plain", () => {
|
||||||
|
const state = stateFor(fence("", "just some text"));
|
||||||
|
|
||||||
|
expect(codeBlocks(state.doc)[0].node.attrs.language).toBe(null);
|
||||||
|
expect(decorations(state)).toEqual([]);
|
||||||
|
expect(highlighted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a mermaid fence to the lane that draws it", () => {
|
||||||
|
const state = stateFor(fence("mermaid", "graph TD;", " a-->b;"));
|
||||||
|
|
||||||
|
expect(decorations(state)).toEqual([]);
|
||||||
|
expect(highlighted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw on code that is broken in its own language", () => {
|
||||||
|
const source = fence("json", "{ this is not, json: ]]", '"neither" is "this"');
|
||||||
|
const parsed = parseMarkdown(source, "/notes/a.md");
|
||||||
|
const state = stateFor(source);
|
||||||
|
|
||||||
|
expect(codeBlocks(state.doc)[0].node.textContent).toBe(
|
||||||
|
'{ this is not, json: ]]\n"neither" is "this"',
|
||||||
|
);
|
||||||
|
expect(serializeMarkdown(parsed, state.doc)).toBe(source);
|
||||||
|
for (const decoration of decorations(state)) {
|
||||||
|
expect(textOf(state, decoration).length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a fence too long to be read as code plain, and keeps every character", () => {
|
||||||
|
const line = "const x = 1; // a line of code that is being repeated a great many times\n";
|
||||||
|
const long = line.repeat(1000);
|
||||||
|
expect(long.length).toBeGreaterThan(50_000);
|
||||||
|
|
||||||
|
const state = stateFor(fence("ts", long.trimEnd()));
|
||||||
|
|
||||||
|
expect(decorations(state)).toEqual([]);
|
||||||
|
expect(highlighted).toEqual([]);
|
||||||
|
expect(codeBlocks(state.doc)[0].node.textContent).toBe(long.trimEnd());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not write to the document it is painting over", () => {
|
||||||
|
const source = ["# Notes", "", fence("rust twoslash", "fn main() {}"), "Prose.", ""].join("\n");
|
||||||
|
const parsed = parseMarkdown(source, "/notes/a.md");
|
||||||
|
const state = stateFor(source);
|
||||||
|
|
||||||
|
expect(state.doc.toJSON()).toEqual(parsed.doc.toJSON());
|
||||||
|
expect(codeBlocks(state.doc)[0].node.attrs).toEqual({ language: "rust", meta: "twoslash" });
|
||||||
|
expect(serializeMarkdown(parsed, state.doc)).toBe(source);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what a keystroke costs", () => {
|
||||||
|
const twoBlocks = () =>
|
||||||
|
stateFor(
|
||||||
|
[fence("ts", "const first = 1;"), fence("ts", "const second = 2;"), "Prose.", ""].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const endOf = (block: Block) => block.pos + 1 + block.node.content.size;
|
||||||
|
|
||||||
|
it("re-highlights only the block the edit landed in", () => {
|
||||||
|
const state = twoBlocks();
|
||||||
|
const blocks = codeBlocks(state.doc);
|
||||||
|
expect(blocks).toHaveLength(2);
|
||||||
|
expect(highlighted).toEqual(["const first = 1;", "const second = 2;"]);
|
||||||
|
|
||||||
|
highlighted.length = 0;
|
||||||
|
state.apply(state.tr.insertText("2", endOf(blocks[1])));
|
||||||
|
|
||||||
|
expect(highlighted).toEqual(["const second = 2;2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the untouched block's spans forward, still over their own characters", () => {
|
||||||
|
const before = twoBlocks();
|
||||||
|
const state = before.apply(before.tr.insertText("// ", codeBlocks(before.doc)[0].pos + 1));
|
||||||
|
|
||||||
|
const second = codeBlocks(state.doc)[1];
|
||||||
|
const inSecond = decorations(state).filter((decoration) => decoration.from > second.pos);
|
||||||
|
expect(inSecond.length).toBeGreaterThan(0);
|
||||||
|
for (const decoration of inSecond) {
|
||||||
|
expect("const second = 2;").toContain(textOf(state, decoration));
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = decorations(state).find(
|
||||||
|
(decoration) =>
|
||||||
|
decoration.from > second.pos && classOf(decoration).includes("hljs-keyword"),
|
||||||
|
);
|
||||||
|
expect(keyword && textOf(state, keyword)).toBe("const");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-highlights a block whose language changed, and no other", () => {
|
||||||
|
const before = twoBlocks();
|
||||||
|
const block = codeBlocks(before.doc)[0];
|
||||||
|
|
||||||
|
highlighted.length = 0;
|
||||||
|
const state = before.apply(
|
||||||
|
before.tr.setNodeMarkup(block.pos, null, { ...block.node.attrs, language: "rust" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(highlighted).toEqual(["const first = 1;"]);
|
||||||
|
expect(spanWith(state, "hljs-keyword")).toBe("const");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the spans of a block that was deleted, and highlights nothing again", () => {
|
||||||
|
const before = twoBlocks();
|
||||||
|
const block = codeBlocks(before.doc)[1];
|
||||||
|
|
||||||
|
highlighted.length = 0;
|
||||||
|
const state = before.apply(
|
||||||
|
before.tr.delete(block.pos, block.pos + block.node.nodeSize),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(highlighted).toEqual([]);
|
||||||
|
expect(codeBlocks(state.doc)).toHaveLength(1);
|
||||||
|
for (const decoration of decorations(state)) {
|
||||||
|
expect("const first = 1;").toContain(textOf(state, decoration));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights a block that was inserted after the document loaded, and no other", () => {
|
||||||
|
const before = twoBlocks();
|
||||||
|
const at = codeBlocks(before.doc)[1].pos;
|
||||||
|
|
||||||
|
highlighted.length = 0;
|
||||||
|
const state = before.apply(
|
||||||
|
before.tr.insert(
|
||||||
|
at,
|
||||||
|
before.doc.type.schema.nodes.codeBlock.create({ language: "rust", meta: null }, [
|
||||||
|
before.doc.type.schema.text("fn main() {}"),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(highlighted).toEqual(["fn main() {}"]);
|
||||||
|
expect(codeBlocks(state.doc)).toHaveLength(3);
|
||||||
|
expect(spanWith(state, "hljs-keyword")).toBe("const");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not run at all for a transaction that changed no text", () => {
|
||||||
|
const before = twoBlocks();
|
||||||
|
|
||||||
|
highlighted.length = 0;
|
||||||
|
const state = before.apply(before.tr.setMeta("nothing", true));
|
||||||
|
|
||||||
|
expect(highlighted).toEqual([]);
|
||||||
|
expect(decorations(state).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setCodeLanguage", () => {
|
||||||
|
function editorAtFence(source: string): Editor {
|
||||||
|
const editor = editorFor(source);
|
||||||
|
editor.commands.setTextSelection(codeBlocks(editor.state.doc)[0].pos + 1);
|
||||||
|
return editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("writes the language and leaves the meta the user wrote alone", () => {
|
||||||
|
const source = fence("ts twoslash", "const x = 1;");
|
||||||
|
const parsed = parseMarkdown(source, "/notes/a.md");
|
||||||
|
const editor = editorAtFence(source);
|
||||||
|
|
||||||
|
expect(setCodeLanguage(editor, "rust")).toBe(true);
|
||||||
|
expect(codeBlocks(editor.state.doc)[0].node.attrs).toEqual({
|
||||||
|
language: "rust",
|
||||||
|
meta: "twoslash",
|
||||||
|
});
|
||||||
|
const written = serializeMarkdown(parsed, editor.state.doc);
|
||||||
|
expect(written).toBe(fence("rust twoslash", "const x = 1;"));
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the fence back to a bare one", () => {
|
||||||
|
const source = fence("ts", "const x = 1;");
|
||||||
|
const parsed = parseMarkdown(source, "/notes/a.md");
|
||||||
|
const editor = editorAtFence(source);
|
||||||
|
|
||||||
|
expect(setCodeLanguage(editor, null)).toBe(true);
|
||||||
|
expect(codeBlocks(editor.state.doc)[0].node.attrs.language).toBe(null);
|
||||||
|
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(fence("", "const x = 1;"));
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a blank language as a bare fence", () => {
|
||||||
|
const editor = editorAtFence(fence("ts", "const x = 1;"));
|
||||||
|
|
||||||
|
expect(setCodeLanguage(editor, " ")).toBe(true);
|
||||||
|
expect(codeBlocks(editor.state.doc)[0].node.attrs.language).toBe(null);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a language with a space in it, which the fence would read back as two things", () => {
|
||||||
|
const source = fence("ts", "const x = 1;");
|
||||||
|
const parsed = parseMarkdown(source, "/notes/a.md");
|
||||||
|
const editor = editorAtFence(source);
|
||||||
|
|
||||||
|
expect(setCodeLanguage(editor, "ts twoslash")).toBe(false);
|
||||||
|
expect(codeBlocks(editor.state.doc)[0].node.attrs).toEqual({ language: "ts", meta: null });
|
||||||
|
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(source);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when the block already says that, so nothing is dirtied", () => {
|
||||||
|
const editor = editorAtFence(fence("ts", "const x = 1;"));
|
||||||
|
const before = editor.state.doc.toJSON();
|
||||||
|
|
||||||
|
expect(setCodeLanguage(editor, "ts")).toBe(false);
|
||||||
|
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing outside a code block", () => {
|
||||||
|
const editor = editorFor(["Just a paragraph.", ""].join("\n"));
|
||||||
|
const before = editor.state.doc.toJSON();
|
||||||
|
|
||||||
|
expect(setCodeLanguage(editor, "rust")).toBe(false);
|
||||||
|
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
// Code block behaviour: syntax highlighting, and the language on the fence.
|
||||||
|
//
|
||||||
|
// Highlighting is decorations over the block's own text and never an edit to it. The spans belong
|
||||||
|
// to the view, nothing they do reaches the tree, and a highlighted block therefore serializes back
|
||||||
|
// to exactly the fence it was read from. lowlight rather than shiki, because a decoration set has
|
||||||
|
// to be rebuilt synchronously inside the plugin and shiki highlights asynchronously.
|
||||||
|
//
|
||||||
|
// `language` and `meta` are already attributes on the schema's codeBlock, so setting a language is
|
||||||
|
// an ordinary attribute edit and does change the document, which is the point: the fence on disk
|
||||||
|
// changes with it. `meta` is never touched. It is whatever the user wrote after the language on
|
||||||
|
// their own opening fence, this editor has no model for it, and it rides along untouched.
|
||||||
|
//
|
||||||
|
// The decoration set is rebuilt per code block rather than per document. A document is autosaved
|
||||||
|
// half a second after the last keystroke, so the typing path is the hot one, and re-running a
|
||||||
|
// grammar over every fence in a long file on every character typed is the obvious way to make this
|
||||||
|
// editor feel slow. A transaction says which ranges it touched; only the code blocks those ranges
|
||||||
|
// land in are highlighted again, and the rest are carried over by mapping the old set forward.
|
||||||
|
//
|
||||||
|
// A codeBlock whose language is mermaid belongs to the mermaid lane, which draws it as a diagram
|
||||||
|
// through a node view. Nothing here decorates one.
|
||||||
|
|
||||||
|
import { Extension } from "@tiptap/core";
|
||||||
|
import type { Editor } from "@tiptap/core";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||||
|
import type { Transaction } from "@tiptap/pm/state";
|
||||||
|
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||||
|
import type { LanguageFn } from "highlight.js";
|
||||||
|
import ini from "highlight.js/lib/languages/ini";
|
||||||
|
import kotlin from "highlight.js/lib/languages/kotlin";
|
||||||
|
import rust from "highlight.js/lib/languages/rust";
|
||||||
|
import swift from "highlight.js/lib/languages/swift";
|
||||||
|
import { common, createLowlight } from "lowlight";
|
||||||
|
|
||||||
|
const lowlight = createLowlight(common);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four languages this app's own docs folder is written in, registered by hand.
|
||||||
|
*
|
||||||
|
* lowlight's common set carries all four today, toml as an alias of ini, so the loop below does
|
||||||
|
* nothing on this version. It is here because "common" is somebody else's list and it has been
|
||||||
|
* trimmed before: if one of these ever falls out of it, the app's own documentation is the first
|
||||||
|
* thing that stops highlighting, and that is a silly way to find out.
|
||||||
|
*/
|
||||||
|
const REQUIRED: ReadonlyArray<readonly [string, LanguageFn]> = [
|
||||||
|
["rust", rust],
|
||||||
|
["toml", ini],
|
||||||
|
["swift", swift],
|
||||||
|
["kotlin", kotlin],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [name, grammar] of REQUIRED) {
|
||||||
|
if (!lowlight.registered(name)) lowlight.register(name, grammar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Past this many characters a fence is left plain.
|
||||||
|
*
|
||||||
|
* A block this long is a pasted file rather than code anyone is reading, and a grammar walking it
|
||||||
|
* again on every keystroke is a stutter the user cannot explain. Nothing is lost by not colouring
|
||||||
|
* it: the text is the document's, the decorations were only ever paint.
|
||||||
|
*/
|
||||||
|
const MAX_HIGHLIGHT_CHARS = 50_000;
|
||||||
|
|
||||||
|
type HighlightRoot = ReturnType<ReturnType<typeof createLowlight>["highlight"]>;
|
||||||
|
type HighlightChild = HighlightRoot["children"][number];
|
||||||
|
|
||||||
|
const codeHighlightKey = new PluginKey<DecorationSet>("codeHighlighting");
|
||||||
|
|
||||||
|
/** The class names lowlight put on one span, as ProseMirror wants them: one string. */
|
||||||
|
function classNameOf(properties: Record<string, unknown> | undefined): string {
|
||||||
|
const value = properties?.className;
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((name): name is string => typeof name === "string").join(" ");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The language to highlight this block with, or null to leave it plain.
|
||||||
|
*
|
||||||
|
* A fence's info string is the user's text, not a menu selection: it can be blank, it can name a
|
||||||
|
* language nobody has a grammar for, and it can be a typo. All three are plain text and none of
|
||||||
|
* them is an error, so an unregistered name is answered here rather than by letting the highlighter
|
||||||
|
* throw. Guessing is not on the list either: highlightAuto would colour a paragraph of prose as
|
||||||
|
* whichever language it happened to resemble.
|
||||||
|
*/
|
||||||
|
function highlightableLanguage(node: ProseMirrorNode): string | null {
|
||||||
|
const language = typeof node.attrs.language === "string" ? node.attrs.language.trim() : "";
|
||||||
|
if (!language) return null;
|
||||||
|
// Matched loosely, unlike the mermaid lane's own exact test, because the two must not both draw
|
||||||
|
// the same block and the safe direction to be wrong in is leaving a block plain.
|
||||||
|
if (language.toLowerCase() === "mermaid") return null;
|
||||||
|
return lowlight.registered(language) ? language : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `base` is the position of the block's first character, so `pos + 1` for the node at `pos`. */
|
||||||
|
function decorationsFor(node: ProseMirrorNode, base: number): Decoration[] {
|
||||||
|
const language = highlightableLanguage(node);
|
||||||
|
if (!language) return [];
|
||||||
|
|
||||||
|
const text = node.textContent;
|
||||||
|
if (!text || text.length > MAX_HIGHLIGHT_CHARS) return [];
|
||||||
|
|
||||||
|
let tree: HighlightRoot;
|
||||||
|
try {
|
||||||
|
tree = lowlight.highlight(language, text);
|
||||||
|
} catch {
|
||||||
|
// A grammar that throws on somebody's file is a highlighter's problem and never the document's.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const decorations: Decoration[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
const walk = (children: readonly HighlightChild[]): void => {
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.type === "text") {
|
||||||
|
offset += child.value.length;
|
||||||
|
} else if (child.type === "element") {
|
||||||
|
const from = offset;
|
||||||
|
walk(child.children);
|
||||||
|
const className = classNameOf(child.properties);
|
||||||
|
if (className && offset > from) {
|
||||||
|
decorations.push(Decoration.inline(base + from, base + offset, { class: className }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(tree.children);
|
||||||
|
|
||||||
|
// The highlighter is a third party walking the user's text, and a decoration that runs past the
|
||||||
|
// end of the block throws inside the view rather than merely looking wrong. If what came back
|
||||||
|
// does not measure the same as what went in, the offsets cannot be trusted and the block stays
|
||||||
|
// plain.
|
||||||
|
return offset === text.length ? decorations : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightWholeDoc(doc: ProseMirrorNode): DecorationSet {
|
||||||
|
const decorations: Decoration[] = [];
|
||||||
|
doc.descendants((node, pos) => {
|
||||||
|
if (node.type.name !== "codeBlock") return true;
|
||||||
|
decorations.push(...decorationsFor(node, pos + 1));
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return DecorationSet.create(doc, decorations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The code blocks a transaction landed in, by position in the new document.
|
||||||
|
*
|
||||||
|
* Every step carries a map of the ranges it replaced. Mapping a step's range through the steps that
|
||||||
|
* came after it puts it in the final document's coordinates, where the blocks it overlaps are the
|
||||||
|
* ones whose text or attributes could have changed. An attribute-only edit counts: setting the
|
||||||
|
* language rewrites the node, which shows up here as a touched range around it, which is what makes
|
||||||
|
* the fence recolour the moment its language changes.
|
||||||
|
*/
|
||||||
|
function touchedCodeBlocks(tr: Transaction, doc: ProseMirrorNode): Map<number, ProseMirrorNode> {
|
||||||
|
const blocks = new Map<number, ProseMirrorNode>();
|
||||||
|
const end = doc.content.size;
|
||||||
|
|
||||||
|
tr.mapping.maps.forEach((stepMap, index) => {
|
||||||
|
const rest = tr.mapping.slice(index + 1);
|
||||||
|
stepMap.forEach((_oldFrom, _oldTo, newFrom, newTo) => {
|
||||||
|
const from = Math.max(0, Math.min(end, rest.map(newFrom, -1)));
|
||||||
|
const to = Math.max(from, Math.min(end, rest.map(newTo, 1)));
|
||||||
|
doc.nodesBetween(from, to, (node, pos) => {
|
||||||
|
if (node.type.name !== "codeBlock") return true;
|
||||||
|
blocks.set(pos, node);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CodeHighlighting = Extension.create({
|
||||||
|
name: "codeHighlighting",
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin<DecorationSet>({
|
||||||
|
key: codeHighlightKey,
|
||||||
|
state: {
|
||||||
|
init: (_config, state) => highlightWholeDoc(state.doc),
|
||||||
|
apply(tr, value) {
|
||||||
|
if (!tr.docChanged) return value;
|
||||||
|
|
||||||
|
const doc = tr.doc;
|
||||||
|
const touched = touchedCodeBlocks(tr, doc);
|
||||||
|
let next = value.map(tr.mapping, doc);
|
||||||
|
if (!touched.size) return next;
|
||||||
|
|
||||||
|
const added: Decoration[] = [];
|
||||||
|
for (const [pos, node] of touched) {
|
||||||
|
const from = pos + 1;
|
||||||
|
const to = from + node.content.size;
|
||||||
|
// The spans mapped forward through the edit are the ones this block had before it,
|
||||||
|
// stretched over text the grammar has not seen. They go before the new ones do.
|
||||||
|
const stale = next.find(from, to);
|
||||||
|
if (stale.length) next = next.remove(stale);
|
||||||
|
added.push(...decorationsFor(node, from));
|
||||||
|
}
|
||||||
|
return added.length ? next.add(doc, added) : next;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
decorations: (state) => codeHighlightKey.getState(state) ?? DecorationSet.empty,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** null clears the fence back to a bare ```. False when the cursor is not in a code block. */
|
||||||
|
export function setCodeLanguage(editor: Editor, language: string | null): boolean {
|
||||||
|
if (!editor.isActive("codeBlock")) return false;
|
||||||
|
|
||||||
|
const next = language === null ? null : language.trim() || null;
|
||||||
|
|
||||||
|
// A fence's info string is one word of language and everything after it is `meta`, so a language
|
||||||
|
// with a space in it would be read back off disk as a different language plus a meta the user
|
||||||
|
// never wrote. Refusing leaves the file saying what it already says.
|
||||||
|
if (next !== null && /\s/.test(next)) return false;
|
||||||
|
|
||||||
|
// Setting the language a block already has would dirty the document and spend an autosave
|
||||||
|
// rewriting the file the user is looking at, for no change at all.
|
||||||
|
const current = (editor.getAttributes("codeBlock").language as string | null | undefined) ?? null;
|
||||||
|
if (current === next) return false;
|
||||||
|
|
||||||
|
// Read out of the chain rather than off run(), because focus answers a different question, and
|
||||||
|
// answers it with false whenever there is no view to focus.
|
||||||
|
let changed = false;
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.command(({ commands }) => {
|
||||||
|
changed = commands.updateAttributes("codeBlock", { language: next });
|
||||||
|
return changed;
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// The seam the block lanes plug into, and the only file that knows all five of them exist.
|
||||||
|
//
|
||||||
|
// extensions.ts generates one TipTap extension per entry in the frozen schema, which covers what a
|
||||||
|
// node IS. What a node DOES, its ProseMirror plugins, its node views, its keymap and its input
|
||||||
|
// rules, has no place on a mechanically generated extension, and five unrelated blocks sharing one
|
||||||
|
// file would mean five reasons to edit it and five chances to break somebody else's block while
|
||||||
|
// doing so. So each lane is one Extension in one file, listed here once. extensions.ts spreads this
|
||||||
|
// array without knowing what is in it, and nothing else imports the lane files.
|
||||||
|
//
|
||||||
|
// A lane may add plugins, node views, keyboard shortcuts and input rules. It may not add, remove or
|
||||||
|
// alter a node or a mark. The schema is src/model/schema.ts, the markdown bridge is written against
|
||||||
|
// it, and an editor whose schema has drifted from the contract is an editor that cannot hold a
|
||||||
|
// document the bridge just parsed, which is somebody's file lost on the next save.
|
||||||
|
|
||||||
|
import type { Extensions } from "@tiptap/core";
|
||||||
|
import { CodeHighlighting, setCodeLanguage } from "./code";
|
||||||
|
import { MathRendering, insertMath } from "./math";
|
||||||
|
import { MermaidRendering, insertMermaid } from "./mermaid";
|
||||||
|
import { Tables, tableCommand } from "./tables";
|
||||||
|
import { Toggles } from "./toggle";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In precedence order, lowest first. TipTap reverses the extension list before it collects
|
||||||
|
* ProseMirror plugins, so the last entry here contributes the first plugin the view asks, and for a
|
||||||
|
* node view the first plugin asked is the one that gets the node. Mermaid is last because a mermaid
|
||||||
|
* diagram is a codeBlock: it and the highlighter are looking at the same node type, and the diagram
|
||||||
|
* is the more specific of the two.
|
||||||
|
*
|
||||||
|
* Position is the weaker of the two levers, and it is worth knowing which because the stronger one
|
||||||
|
* is now in use. TipTap sorts the reversed list by each extension's `priority` before it collects
|
||||||
|
* anything, so a higher priority beats any position in this array; every lane here leaves it at the
|
||||||
|
* default and is ordered by position alone. src/editor/paste.ts is the one that does not, and it
|
||||||
|
* says why: its handlers guard the document against every other plugin's, so being ahead of the
|
||||||
|
* table plugins below cannot be left to where two arrays happen to put it.
|
||||||
|
*
|
||||||
|
* Toggles goes first rather than beside the lane it reads most like. The toggle node is claimed by
|
||||||
|
* nobody else, so where it sits changes nothing about which plugin gets that node view, and the
|
||||||
|
* four below it are in an order that was argued over: put anywhere else it would move one of them
|
||||||
|
* and leave the sentence above no longer true of the array under it.
|
||||||
|
*/
|
||||||
|
export const BLOCK_EXTENSIONS: Extensions = [
|
||||||
|
Toggles,
|
||||||
|
Tables,
|
||||||
|
MathRendering,
|
||||||
|
CodeHighlighting,
|
||||||
|
MermaidRendering,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the editor handle's block commands delegate to. Each returns false when it has nothing to
|
||||||
|
* act on where the cursor is, which is what a toolbar button pressed in the wrong place should do,
|
||||||
|
* and each is responsible for its own focus the way every other command in the handle is.
|
||||||
|
*/
|
||||||
|
export { insertMath, insertMermaid, setCodeLanguage, tableCommand };
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
// The math lane's tests, which stop at the edge of the DOM.
|
||||||
|
//
|
||||||
|
// vite.config.ts runs vitest in the node environment, so there is no document for a view to mount
|
||||||
|
// in and the node view in math.ts cannot be built from here. What a formula looks like on screen,
|
||||||
|
// and the field that opens on it when it is selected, belong to the Playwright suite. What belongs
|
||||||
|
// here is the half that touches the document, because that is the half that can cost somebody a
|
||||||
|
// file: the two ways a formula gets made, and the LaTeX already in the document surviving both of
|
||||||
|
// them character for character.
|
||||||
|
//
|
||||||
|
// The last group tests KaTeX rather than this app. It is here because the node view rests on two
|
||||||
|
// promises the library makes and could quietly stop keeping on an upgrade: that it does not throw
|
||||||
|
// under these options, and that what it hands back when it cannot parse something still contains
|
||||||
|
// the source it was given.
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import type { JSONContent } from "@tiptap/core";
|
||||||
|
import { EditorState, NodeSelection } from "@tiptap/pm/state";
|
||||||
|
import { CellSelection, TableMap } from "@tiptap/pm/tables";
|
||||||
|
import katex from "katex";
|
||||||
|
import { parseMarkdown, serializeMarkdown } from "../../markdown";
|
||||||
|
import { createEditorExtensions } from "../extensions";
|
||||||
|
import { insertMath } from "./math";
|
||||||
|
|
||||||
|
const extensions = () =>
|
||||||
|
createEditorExtensions({ documentPath: () => "/notes/a.md", onError: () => {} });
|
||||||
|
|
||||||
|
function editorWith(content: JSONContent): Editor {
|
||||||
|
return new Editor({ element: null, injectCSS: false, extensions: extensions(), content });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same editor with the extensions' ProseMirror plugins actually installed, which TipTap only
|
||||||
|
* does when it mounts a view and there is no DOM here to mount into. src/editor/Editor.tsx swaps a
|
||||||
|
* state built this way in for every document it opens, so this is what the app runs minus the
|
||||||
|
* screen. Only the table tests need it, because a cell selection is prosemirror-tables' own.
|
||||||
|
*/
|
||||||
|
function editorWithPlugins(content: JSONContent): Editor {
|
||||||
|
const editor = editorWith(content);
|
||||||
|
editor.view.updateState(
|
||||||
|
EditorState.create({ doc: editor.state.doc, plugins: editor.extensionManager.plugins }),
|
||||||
|
);
|
||||||
|
return editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file, opened, with the bytes it would be written back as. */
|
||||||
|
function open(source: string) {
|
||||||
|
const parsed = parseMarkdown(source, "/notes/a.md");
|
||||||
|
const editor = editorWithPlugins(parsed.doc.toJSON());
|
||||||
|
return { editor, written: () => serializeMarkdown(parsed, editor.state.doc) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The document position just inside a cell, for a table that is the document's first block. */
|
||||||
|
function inCell(editor: Editor, row: number, column: number): number {
|
||||||
|
const table = editor.state.doc.firstChild!;
|
||||||
|
return 1 + TableMap.get(table).positionAt(row, column, table) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter, through the keymap the extension really installs rather than through a function this file
|
||||||
|
* reached into. A headless editor has no view to take a key event, so the plugins' own handlers are
|
||||||
|
* called in the order ProseMirror would call them, with the proxy view TipTap answers with and the
|
||||||
|
* two things prosemirror-keymap reads off an event: the key name, and the four modifier flags.
|
||||||
|
*/
|
||||||
|
function pressEnter(editor: Editor): void {
|
||||||
|
const event = {
|
||||||
|
key: "Enter",
|
||||||
|
keyCode: 13,
|
||||||
|
altKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
} as unknown as KeyboardEvent;
|
||||||
|
|
||||||
|
for (const plugin of editor.extensionManager.plugins) {
|
||||||
|
// Called through the plugin, which is the "this" ProseMirror types the prop as wanting.
|
||||||
|
if (plugin.props.handleKeyDown?.call(plugin, editor.view, event)) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CODE = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "codeBlock",
|
||||||
|
attrs: { language: "ts", meta: null },
|
||||||
|
content: [{ type: "text", text: "const x = 1;" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const RAW = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "raw",
|
||||||
|
attrs: { source: "<figure><img src='x.png'></figure>" },
|
||||||
|
content: [{ type: "text", text: "<figure><img src='x.png'></figure>" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("insertMath", () => {
|
||||||
|
it("puts an empty display equation in and selects it", () => {
|
||||||
|
const editor = editorWith({ type: "doc", content: [{ type: "paragraph" }] });
|
||||||
|
|
||||||
|
expect(insertMath(editor, true)).toBe(true);
|
||||||
|
const block = editor.state.doc.firstChild;
|
||||||
|
expect(block?.type.name).toBe("mathBlock");
|
||||||
|
// Not empty. This assertion used to read "" and that was the bug: an empty formula is a box on
|
||||||
|
// screen the file has no way to spell, and inline it went out as $$$$ and came back as text, so
|
||||||
|
// the first autosave took it away without telling anybody. A new formula is made with the
|
||||||
|
// placeholder in it, which is a formula that survives being written.
|
||||||
|
expect(block?.attrs.latex).toBe("\\square");
|
||||||
|
// Selected is what opens the field on it, so it is the half of the command that matters.
|
||||||
|
expect(editor.state.selection instanceof NodeSelection).toBe(true);
|
||||||
|
expect((editor.state.selection as NodeSelection).node.type.name).toBe("mathBlock");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The caret mid paragraph is the case the first version of this got wrong. A display formula
|
||||||
|
// dropped there splits the paragraph and lands between the halves, so the position the insert was
|
||||||
|
// asked for is not the position the formula ends up at, and selecting the wrong one means a
|
||||||
|
// formula on screen with no way into its field. The end of a paragraph is the one place the two
|
||||||
|
// answers agree, which is why the test above did not catch it.
|
||||||
|
it("selects the display equation even when the caret was in the middle of a paragraph", () => {
|
||||||
|
const editor = editorWith({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: "before after" }] }],
|
||||||
|
});
|
||||||
|
editor.commands.setTextSelection(8);
|
||||||
|
|
||||||
|
expect(insertMath(editor, true)).toBe(true);
|
||||||
|
expect(editor.state.doc.child(0).textContent).toBe("before ");
|
||||||
|
expect(editor.state.doc.child(1).type.name).toBe("mathBlock");
|
||||||
|
expect(editor.state.doc.child(2).textContent).toBe("after");
|
||||||
|
expect(editor.state.selection instanceof NodeSelection).toBe(true);
|
||||||
|
expect((editor.state.selection as NodeSelection).node.type.name).toBe("mathBlock");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts an inline formula in without disturbing the text around it", () => {
|
||||||
|
const editor = editorWith({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: "ab" }] }],
|
||||||
|
});
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
|
||||||
|
expect(insertMath(editor, false)).toBe(true);
|
||||||
|
const paragraph = editor.state.doc.firstChild;
|
||||||
|
expect(paragraph?.type.name).toBe("paragraph");
|
||||||
|
expect([...Array(paragraph?.childCount ?? 0)].map((_, i) => paragraph?.child(i).type.name)).toEqual([
|
||||||
|
"text",
|
||||||
|
"mathInline",
|
||||||
|
"text",
|
||||||
|
]);
|
||||||
|
expect(paragraph?.textContent).toBe("ab");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses inside a code block, and leaves the fence exactly as it was", () => {
|
||||||
|
const editor = editorWith(CODE);
|
||||||
|
const before = editor.state.doc.toJSON();
|
||||||
|
|
||||||
|
expect(insertMath(editor, false)).toBe(false);
|
||||||
|
expect(insertMath(editor, true)).toBe(false);
|
||||||
|
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses inside a raw block, which is somebody's bytes and not a place for a formula", () => {
|
||||||
|
const editor = editorWith(RAW);
|
||||||
|
const before = editor.state.doc.toJSON();
|
||||||
|
|
||||||
|
expect(insertMath(editor, false)).toBe(false);
|
||||||
|
expect(insertMath(editor, true)).toBe(false);
|
||||||
|
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The three below are one bug each, reproduced from the bytes they cost. All three were live in a
|
||||||
|
// build whose table, rule and diagram inserts were already guarded: this command had a private
|
||||||
|
// copy of the guard that had never been given the isolating rule, and a private guard is a guard
|
||||||
|
// that is only as good as the last person who remembered it existed. It is gone, and this command
|
||||||
|
// now asks the one in src/editor/fits.ts that every other insert asks.
|
||||||
|
|
||||||
|
it("refuses with the caret in a table cell, and leaves the file byte identical", () => {
|
||||||
|
const source = "| h1 | h2 |\n| - | - |\n| a | b |\n";
|
||||||
|
const { editor, written } = open(source);
|
||||||
|
editor.commands.setTextSelection(inCell(editor, 1, 0));
|
||||||
|
|
||||||
|
// What this used to do: split the table around the formula, leave the body row empty and the
|
||||||
|
// moved cells in a second table with no header, and hand the autosave
|
||||||
|
// "| h1 | h2 |\n| - | - |\n| | |\n\n$$\n$$\n\n| a | b |\n| - | - |\n" half a second later.
|
||||||
|
expect(insertMath(editor, true)).toBe(false);
|
||||||
|
expect(written()).toBe(source);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses over a dragged cell selection, and every cell keeps its text", () => {
|
||||||
|
const source = "| h1 | h2 |\n| - | - |\n| a | b |\n| c | d |\n";
|
||||||
|
const { editor, written } = open(source);
|
||||||
|
const map = TableMap.get(editor.state.doc.firstChild!);
|
||||||
|
const table = editor.state.doc.firstChild!;
|
||||||
|
editor.view.dispatch(
|
||||||
|
editor.state.tr.setSelection(
|
||||||
|
CellSelection.create(editor.state.doc, 1 + map.positionAt(0, 0, table), 1 + map.positionAt(2, 1, table)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// An inline formula fits in a cell perfectly well, which is why the position rule alone let
|
||||||
|
// this through: over a rectangle of cells the insert does not go in a cell, it replaces the
|
||||||
|
// content of all six of them at once. Six cells of somebody's text for one empty formula.
|
||||||
|
expect(insertMath(editor, false)).toBe(false);
|
||||||
|
expect(insertMath(editor, true)).toBe(false);
|
||||||
|
expect(written()).toBe(source);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes a formula the save can keep, which an empty one is not", () => {
|
||||||
|
const { editor, written } = open("hello\n");
|
||||||
|
editor.commands.setTextSelection(6);
|
||||||
|
|
||||||
|
expect(insertMath(editor, false)).toBe(true);
|
||||||
|
const file = written();
|
||||||
|
expect(file).toBe("hello$$\\square$$\n");
|
||||||
|
|
||||||
|
// The whole point of the placeholder, asserted the only way that means anything: the file goes
|
||||||
|
// back through the parser and there is still a formula in it. An empty one wrote "hello$$$$",
|
||||||
|
// which comes back as four dollar signs of literal text, and the box the user was looking at
|
||||||
|
// was gone with nobody told.
|
||||||
|
const reopened = parseMarkdown(file, "/notes/a.md");
|
||||||
|
const found: string[] = [];
|
||||||
|
reopened.doc.descendants((node) => {
|
||||||
|
if (node.type.name === "mathInline") found.push(node.attrs.latex as string);
|
||||||
|
});
|
||||||
|
expect(found).toEqual(["\\square"]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the fence rule", () => {
|
||||||
|
it("turns a paragraph holding just $$ into a math block, selected", () => {
|
||||||
|
const editor = editorWith({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: "$$" }] }],
|
||||||
|
});
|
||||||
|
editor.commands.setTextSelection(3);
|
||||||
|
|
||||||
|
pressEnter(editor);
|
||||||
|
|
||||||
|
expect(editor.state.doc.childCount).toBe(1);
|
||||||
|
expect(editor.state.doc.firstChild?.type.name).toBe("mathBlock");
|
||||||
|
// The placeholder here too, for the reason on the insertMath test above: a formula made by
|
||||||
|
// typing a fence has the same claim to still being there after a save as one made by a button.
|
||||||
|
expect(editor.state.doc.firstChild?.attrs.latex).toBe("\\square");
|
||||||
|
expect(editor.state.selection instanceof NodeSelection).toBe(true);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a paragraph that says anything else alone", () => {
|
||||||
|
for (const text of ["$$x", "a $$", "$", "$5 and $10"]) {
|
||||||
|
const editor = editorWith({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
||||||
|
});
|
||||||
|
editor.commands.setTextSelection(text.length + 1);
|
||||||
|
|
||||||
|
pressEnter(editor);
|
||||||
|
|
||||||
|
// Enter still splits the paragraph, which is the base keymap's business and not this lane's.
|
||||||
|
// What is asserted is only that no formula was made out of somebody's prose.
|
||||||
|
let found = false;
|
||||||
|
editor.state.doc.descendants((node) => {
|
||||||
|
if (node.type.name === "mathBlock" || node.type.name === "mathInline") found = true;
|
||||||
|
});
|
||||||
|
expect([text, found]).toEqual([text, false]);
|
||||||
|
editor.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the LaTeX already in the document", () => {
|
||||||
|
const source = [
|
||||||
|
"Before.",
|
||||||
|
"",
|
||||||
|
"$$",
|
||||||
|
"\\frac{a}{b} = \\sum_{i=0}^{n} x_i",
|
||||||
|
"$$",
|
||||||
|
"",
|
||||||
|
"After $$x^2$$ here.",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
it("comes back byte for byte after a formula is inserted somewhere else", () => {
|
||||||
|
const parsed = parseMarkdown(source, "/notes/a.md");
|
||||||
|
const editor = editorWith(parsed.doc.toJSON());
|
||||||
|
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(source);
|
||||||
|
|
||||||
|
// Into the first paragraph, which is the one place in the file this is allowed to change.
|
||||||
|
editor.commands.setTextSelection(4);
|
||||||
|
expect(insertMath(editor, false)).toBe(true);
|
||||||
|
|
||||||
|
const written = serializeMarkdown(parsed, editor.state.doc);
|
||||||
|
expect(written).toContain("$$\n\\frac{a}{b} = \\sum_{i=0}^{n} x_i\n$$");
|
||||||
|
expect(written).toContain("After $$x^2$$ here.");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("KaTeX under the options this lane renders with", () => {
|
||||||
|
// The same object math.ts builds its render call from. Repeated rather than exported, because
|
||||||
|
// what is being pinned here is the library's behaviour under them and not their spelling.
|
||||||
|
const options = {
|
||||||
|
throwOnError: false,
|
||||||
|
strict: false,
|
||||||
|
trust: false,
|
||||||
|
errorColor: "var(--danger)",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
it("draws a formula, with the source it was given still in the markup", () => {
|
||||||
|
const markup = katex.renderToString("\\frac{a}{b}", options);
|
||||||
|
expect(markup).toContain("katex");
|
||||||
|
expect(markup).toContain("\\frac{a}{b}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw on LaTeX it cannot parse, and shows the source in the error colour", () => {
|
||||||
|
// Two shapes, both of them the source and the colour. LaTeX KaTeX cannot get through the
|
||||||
|
// parser at all comes back as one .katex-error span holding the whole expression; a command it
|
||||||
|
// parses and has never heard of is drawn as the text of the command, in the same colour.
|
||||||
|
for (const broken of ["\\frac{", "\\notacommand", "^", "\\begin{matrix}", "\\sqrt{}}{"]) {
|
||||||
|
const markup = katex.renderToString(broken, options);
|
||||||
|
expect([broken, markup.includes(broken)]).toEqual([broken, true]);
|
||||||
|
expect([broken, markup.includes("var(--danger)")]).toEqual([broken, true]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the error colour through as the custom property it was handed", () => {
|
||||||
|
// KaTeX puts the colour in an attribute on the element it draws, where a stylesheet cannot
|
||||||
|
// reach it, so the token has to survive the trip out through the markup exactly as written.
|
||||||
|
expect(katex.renderToString("\\frac{", options)).toContain("var(--danger)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
// Math: KaTeX over the mathInline and mathBlock nodes the bridge produces.
|
||||||
|
//
|
||||||
|
// Both are atoms carrying their LaTeX as an attribute, so rendering one is a node view drawing an
|
||||||
|
// attribute and editing one is that same node view handing the source back. Nothing here parses,
|
||||||
|
// normalises or rewrites the LaTeX: what round trips to disk is the attribute exactly as it was
|
||||||
|
// read, and KaTeX only ever gets a copy of it.
|
||||||
|
//
|
||||||
|
// LaTeX KaTeX cannot render is shown as the source with the error beside it, never as an empty
|
||||||
|
// box and never dropped. A formula this editor fails to draw is still the user's formula, and it
|
||||||
|
// has to survive being opened and saved by an editor that could not display it.
|
||||||
|
//
|
||||||
|
// An atom has no editable text of its own, so the field the source is typed into is this file's to
|
||||||
|
// draw and this file's to write back. It appears while the node is selected, which is what both a
|
||||||
|
// click on a formula and an arrow key into one produce, and every keystroke in it is a transaction
|
||||||
|
// like any other. The document is therefore never holding a formula the field has already moved
|
||||||
|
// past: an autosave that lands mid edit writes what is on screen, and closing the file does not
|
||||||
|
// take the last few characters with it.
|
||||||
|
|
||||||
|
import { Extension } from "@tiptap/core";
|
||||||
|
import type { Editor } from "@tiptap/core";
|
||||||
|
import type { Node as ProseMirrorNode, NodeType } from "@tiptap/pm/model";
|
||||||
|
import { NodeSelection, Plugin, PluginKey, Selection } from "@tiptap/pm/state";
|
||||||
|
import type { Command, Transaction } from "@tiptap/pm/state";
|
||||||
|
import type { EditorView, NodeView } from "@tiptap/pm/view";
|
||||||
|
import katex from "katex";
|
||||||
|
import { place } from "../fits";
|
||||||
|
|
||||||
|
// KaTeX's stylesheet, and the twenty faces it names, are pulled into the bundle from here rather
|
||||||
|
// than from main.tsx alongside the app's own sheets, because this is the file that cannot work
|
||||||
|
// without them. The app runs under a CSP of font-src 'self', so a font fetched from KaTeX's CDN
|
||||||
|
// never arrives and every formula is drawn in a fallback face at metrics the layout was not
|
||||||
|
// measured for. Importing the sheet is what makes Vite emit the woff2 files as local assets and
|
||||||
|
// rewrite the URLs on to them, so this import is load bearing and is not a stray dependency.
|
||||||
|
import "katex/dist/katex.min.css";
|
||||||
|
|
||||||
|
/** A paragraph holding exactly this becomes a math block when Enter is pressed in it. */
|
||||||
|
const FENCE = "$$";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a new formula is made with, since a new formula is never made empty.
|
||||||
|
*
|
||||||
|
* An empty formula is a box on screen that the file has no way to spell. Inline, it goes out as
|
||||||
|
* `$$$$`, which is not math to anything that reads it back, so the box the user is looking at is
|
||||||
|
* gone the next time the document is opened and they were never told. That is the failure this
|
||||||
|
* editor exists not to have: something on screen that the save quietly does not keep.
|
||||||
|
*
|
||||||
|
* Three ways out of it were on the table. Refusing to insert until there is content cannot work,
|
||||||
|
* because the insert is how the content gets typed. Keeping the node out of the saved document
|
||||||
|
* until it has LaTeX is the same disappearance one layer down, since an autosave then writes a file
|
||||||
|
* without a formula the user can see. So the node is created with content: `\square` is the glyph
|
||||||
|
* mathematics already uses for the term that has not been written yet, KaTeX draws it, and it round
|
||||||
|
* trips as `$$\square$$` like any other formula. Nothing vanishes, because there is nothing empty.
|
||||||
|
*
|
||||||
|
* The field opens with it selected, so typing over it is the same keystroke it would have been in
|
||||||
|
* an empty box, and the user who walks away is left with a formula they can see rather than one
|
||||||
|
* they cannot.
|
||||||
|
*/
|
||||||
|
const PLACEHOLDER = "\\square";
|
||||||
|
|
||||||
|
/** Past these the field scrolls rather than growing. A formula this long is not being read. */
|
||||||
|
const MAX_ROWS = 16;
|
||||||
|
const MAX_COLS = 64;
|
||||||
|
const MIN_COLS = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KaTeX is never allowed to throw, and never allowed to be the reason a formula is not on screen.
|
||||||
|
*
|
||||||
|
* `throwOnError` false is what turns a parse failure into markup: KaTeX draws the source it could
|
||||||
|
* not read in the error colour with the reason on the element's title, which is the whole of the
|
||||||
|
* error state for LaTeX it understands well enough to refuse. `strict` false is the same bargain
|
||||||
|
* one level down, for the LaTeX it can read and would rather complain about, a unicode letter in
|
||||||
|
* math mode being the usual one; the alternative is a console full of warnings about somebody's
|
||||||
|
* own file. `trust` stays off because the markup goes into the page with innerHTML, and it is what
|
||||||
|
* decides whether \href in a document that arrived from somewhere else becomes a link.
|
||||||
|
*
|
||||||
|
* The error colour is a custom property rather than a hex value because KaTeX writes it into a
|
||||||
|
* style attribute on the element it draws, and an inline style is not something a stylesheet can
|
||||||
|
* take back.
|
||||||
|
*/
|
||||||
|
const KATEX_OPTIONS = {
|
||||||
|
throwOnError: false,
|
||||||
|
strict: false,
|
||||||
|
trust: false,
|
||||||
|
errorColor: "var(--danger)",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a node of this type ended up, looked for in the ranges the steps from `since` on wrote.
|
||||||
|
*
|
||||||
|
* Asked this way rather than by mapping the insertion point forward, which is the obvious move and
|
||||||
|
* is wrong. A block formula dropped into the middle of a paragraph splits it, and the position it
|
||||||
|
* was asked for stays with the first half, several places short of the formula. Mapping it forward
|
||||||
|
* then finds no formula there and nothing gets selected, which is a formula on screen with no way
|
||||||
|
* into its field. The end of a paragraph is the one place the two answers agree, which is why
|
||||||
|
* every test that put the caret there passed.
|
||||||
|
*/
|
||||||
|
function placedAt(tr: Transaction, since: number, type: NodeType): number | null {
|
||||||
|
let found: number | null = null;
|
||||||
|
|
||||||
|
for (let step = since; step < tr.steps.length && found === null; step += 1) {
|
||||||
|
const forward = tr.mapping.slice(step + 1);
|
||||||
|
tr.mapping.maps[step].forEach((_from, _to, newFrom, newTo) => {
|
||||||
|
if (found !== null) return;
|
||||||
|
const size = tr.doc.content.size;
|
||||||
|
const from = Math.min(size, Math.max(0, forward.map(newFrom, -1)));
|
||||||
|
const to = Math.min(size, Math.max(from, forward.map(newTo, 1)));
|
||||||
|
tr.doc.nodesBetween(from, to, (node, pos) => {
|
||||||
|
if (found === null && node.type === type) found = pos;
|
||||||
|
return found === null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A placeholder formula where the cursor is, selected so that its field opens on it.
|
||||||
|
*
|
||||||
|
* Whether it can go there at all is `place`'s question and is asked before this runs, which is why
|
||||||
|
* there is no check of its own here. There used to be one, a private copy of the walk in fits.ts
|
||||||
|
* that had never been given the isolating rule, and it answered yes with the caret in a table cell:
|
||||||
|
* the insert then split the table around the formula and emptied the row it had been in, and the
|
||||||
|
* autosave wrote that to the user's file half a second later with no keystroke behind it.
|
||||||
|
*/
|
||||||
|
function placeMath(type: NodeType): Command {
|
||||||
|
return (state, dispatch) => {
|
||||||
|
if (dispatch) {
|
||||||
|
const tr = state.tr;
|
||||||
|
const before = tr.steps.length;
|
||||||
|
// Marks carry on to an inline formula, since **$x$** is a thing the file can say and the
|
||||||
|
// bridge already reads and writes. A block one is in a part of the document where no mark
|
||||||
|
// can go, and handing it the marks under the cursor would make it unplaceable.
|
||||||
|
tr.replaceSelectionWith(type.create({ latex: PLACEHOLDER }), type.isInline);
|
||||||
|
// Selecting it is what opens its field, on the placeholder, which the field selects whole so
|
||||||
|
// the first thing typed replaces it.
|
||||||
|
const placed = placedAt(tr, before, type);
|
||||||
|
if (placed !== null) tr.setSelection(NodeSelection.create(tr.doc, placed));
|
||||||
|
dispatch(tr.scrollIntoView());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `$$` alone in a paragraph, then Enter.
|
||||||
|
*
|
||||||
|
* Not an input rule, though it reads like one: an input rule fires on text input and Enter is not
|
||||||
|
* text, so there would be nothing to run it. There is deliberately no rule for `$…$` either. A
|
||||||
|
* dollar sign is money or a shell prompt far more often than it is mathematics, and turning
|
||||||
|
* "$5 and $10" into an equation as somebody types is exactly the unasked for rewrite this editor
|
||||||
|
* does not do. The bridge takes the same line one layer down, where single dollar math is off in
|
||||||
|
* the parser.
|
||||||
|
*/
|
||||||
|
const openMathBlock: Command = (state, dispatch) => {
|
||||||
|
const { $from, empty } = state.selection;
|
||||||
|
if (!empty) return false;
|
||||||
|
if ($from.parent.type.name !== "paragraph" || $from.parent.textContent !== FENCE) return false;
|
||||||
|
|
||||||
|
const type = state.schema.nodes.mathBlock;
|
||||||
|
const depth = $from.depth;
|
||||||
|
const index = $from.index(depth - 1);
|
||||||
|
if (!type || !$from.node(depth - 1).canReplaceWith(index, index + 1, type)) return false;
|
||||||
|
|
||||||
|
if (dispatch) {
|
||||||
|
const from = $from.before(depth);
|
||||||
|
// The placeholder, for the reason written on it: this is the other way a formula is made, and
|
||||||
|
// a formula made by typing a fence has the same claim to still being there after a save as one
|
||||||
|
// made from the toolbar.
|
||||||
|
const tr = state.tr.replaceWith(from, $from.after(depth), type.create({ latex: PLACEHOLDER }));
|
||||||
|
tr.setSelection(NodeSelection.create(tr.doc, from));
|
||||||
|
dispatch(tr.scrollIntoView());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One formula: what KaTeX drew, and the field the LaTeX behind it is typed into.
|
||||||
|
*
|
||||||
|
* The two are siblings inside the element the node's own toDOM describes, and which of them is on
|
||||||
|
* screen is a data attribute the stylesheet reads. Neither is content in ProseMirror's sense: the
|
||||||
|
* node is an atom, there is no contentDOM, and every mutation inside here is declared to be this
|
||||||
|
* file's own so that nothing KaTeX draws can be read back into the document.
|
||||||
|
*/
|
||||||
|
class MathView implements NodeView {
|
||||||
|
readonly dom: HTMLElement;
|
||||||
|
|
||||||
|
private readonly view: EditorView;
|
||||||
|
private readonly getPos: () => number | undefined;
|
||||||
|
private readonly display: boolean;
|
||||||
|
private readonly render: HTMLElement;
|
||||||
|
private readonly field: HTMLTextAreaElement;
|
||||||
|
private node: ProseMirrorNode;
|
||||||
|
private editing = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
node: ProseMirrorNode,
|
||||||
|
view: EditorView,
|
||||||
|
getPos: () => number | undefined,
|
||||||
|
display: boolean,
|
||||||
|
) {
|
||||||
|
this.node = node;
|
||||||
|
this.view = view;
|
||||||
|
this.getPos = getPos;
|
||||||
|
this.display = display;
|
||||||
|
|
||||||
|
const owner = view.dom.ownerDocument;
|
||||||
|
this.dom = owner.createElement(display ? "div" : "span");
|
||||||
|
this.dom.className = display ? "math-block" : "math-inline";
|
||||||
|
if (display) this.dom.setAttribute("data-math-block", "");
|
||||||
|
|
||||||
|
this.render = owner.createElement(display ? "div" : "span");
|
||||||
|
this.render.className = "math-render";
|
||||||
|
this.dom.appendChild(this.render);
|
||||||
|
|
||||||
|
this.field = owner.createElement("textarea");
|
||||||
|
this.field.className = "math-source";
|
||||||
|
this.field.spellcheck = false;
|
||||||
|
this.field.setAttribute("aria-label", display ? "Display equation source" : "Inline math source");
|
||||||
|
this.field.addEventListener("input", this.onInput);
|
||||||
|
this.field.addEventListener("keydown", this.onKeyDown);
|
||||||
|
this.dom.appendChild(this.field);
|
||||||
|
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
private get latex(): string {
|
||||||
|
const value = this.node.attrs.latex;
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
update(node: ProseMirrorNode): boolean {
|
||||||
|
// A node of another type is another node view; ProseMirror builds a fresh one rather than
|
||||||
|
// asking this one to become something it was not written to be.
|
||||||
|
if (node.type !== this.node.type) return false;
|
||||||
|
this.node = node;
|
||||||
|
this.draw();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectNode(): void {
|
||||||
|
// A document being looked at rather than edited gets no field, so it gets the outline
|
||||||
|
// ProseMirror would have drawn on its own: it says the formula is selected without offering to
|
||||||
|
// change it. Node views that define this one are asked instead of that outline, not as well.
|
||||||
|
if (!this.view.editable) {
|
||||||
|
this.dom.classList.add("ProseMirror-selectednode");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.editing) return;
|
||||||
|
this.editing = true;
|
||||||
|
this.draw();
|
||||||
|
this.take();
|
||||||
|
}
|
||||||
|
|
||||||
|
deselectNode(): void {
|
||||||
|
this.dom.classList.remove("ProseMirror-selectednode");
|
||||||
|
if (!this.editing) return;
|
||||||
|
this.editing = false;
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything that lands inside the field is the field's own. ProseMirror handling the mousedown
|
||||||
|
* that opens it would put a node selection where the caret was going, and the field would never
|
||||||
|
* take focus at all.
|
||||||
|
*/
|
||||||
|
stopEvent(event: Event): boolean {
|
||||||
|
const target = event.target;
|
||||||
|
return target instanceof HTMLElement && this.field.contains(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The element is this file's from end to end, so nothing read off it is news to the document. */
|
||||||
|
ignoreMutation(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
// Also what cancels the frame `take` queued: the element is on its way out, and focusing it
|
||||||
|
// then would put the caret at a position the document no longer has.
|
||||||
|
this.editing = false;
|
||||||
|
this.field.removeEventListener("input", this.onInput);
|
||||||
|
this.field.removeEventListener("keydown", this.onKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything on the element that depends on the node or on whether it is being edited. */
|
||||||
|
private draw(): void {
|
||||||
|
const latex = this.latex;
|
||||||
|
// Mirrored on to the element the way the node's own toDOM writes it, so that anything reading
|
||||||
|
// the page back, a copy, a drag, a mutation ProseMirror decides to re-parse after all, takes
|
||||||
|
// the source out of the attribute the parse rule names rather than out of what KaTeX drew.
|
||||||
|
this.dom.setAttribute("data-latex", latex);
|
||||||
|
// The empty string and nothing else, because this flag is what tells the user the formula has
|
||||||
|
// no spelling and will not be saved, and a formula of one space is saved: the writer drops an
|
||||||
|
// equation only when its latex is empty. `paint` below asks a different question, which is
|
||||||
|
// whether KaTeX has anything to draw, and whitespace is a fair no to that one.
|
||||||
|
this.flag("data-math-empty", latex === "");
|
||||||
|
this.flag("data-editing", this.editing);
|
||||||
|
// The field is the source of truth while it is being typed in. Writing to it here would take
|
||||||
|
// the caret to the end of a formula the user is in the middle of.
|
||||||
|
if (!this.editing) {
|
||||||
|
this.field.value = latex;
|
||||||
|
this.size();
|
||||||
|
}
|
||||||
|
this.paint(latex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private flag(name: string, on: boolean): void {
|
||||||
|
if (on) this.dom.setAttribute(name, "");
|
||||||
|
else this.dom.removeAttribute(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private paint(latex: string): void {
|
||||||
|
if (latex.trim() === "") {
|
||||||
|
this.render.textContent = "";
|
||||||
|
this.render.removeAttribute("data-math-error");
|
||||||
|
this.render.removeAttribute("title");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.render.innerHTML = katex.renderToString(latex, {
|
||||||
|
...KATEX_OPTIONS,
|
||||||
|
displayMode: this.display,
|
||||||
|
});
|
||||||
|
this.render.removeAttribute("data-math-error");
|
||||||
|
this.render.removeAttribute("title");
|
||||||
|
} catch (error) {
|
||||||
|
// throwOnError covers the LaTeX KaTeX parses and then refuses to typeset. This is the rest of
|
||||||
|
// it: input it never expected, on which it throws something that is not a parse error. What
|
||||||
|
// goes on the page is the source as it stands, because that is what the file holds and what
|
||||||
|
// there is to fix.
|
||||||
|
this.render.textContent = latex;
|
||||||
|
this.render.setAttribute("data-math-error", "");
|
||||||
|
this.render.title = String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sized by the textarea's own rows and cols, so nothing here measures anything or sets a style. */
|
||||||
|
private size(): void {
|
||||||
|
const lines = this.field.value.split("\n");
|
||||||
|
const widest = lines.reduce((most, line) => Math.max(most, line.length), 0);
|
||||||
|
this.field.rows = Math.min(MAX_ROWS, lines.length);
|
||||||
|
this.field.cols = Math.min(MAX_COLS, Math.max(MIN_COLS, widest + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus, on the next frame rather than now.
|
||||||
|
*
|
||||||
|
* ProseMirror is part way through drawing the selection this call came from and finishes it by
|
||||||
|
* putting the document's own selection around the node, and TipTap's focus command may have a
|
||||||
|
* frame of its own already queued in front of that. Either would take the caret straight back
|
||||||
|
* out of the field.
|
||||||
|
*/
|
||||||
|
private take(): void {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!this.editing) return;
|
||||||
|
// Already in it, which is what a keystroke that rewrote the node looks like from here. Moving
|
||||||
|
// the caret then would jump it to the end of a formula being edited in the middle.
|
||||||
|
if (this.field.ownerDocument.activeElement === this.field) return;
|
||||||
|
this.field.focus({ preventScroll: true });
|
||||||
|
const end = this.field.value.length;
|
||||||
|
// A formula that is still nothing but the placeholder is one nobody has typed into yet, so
|
||||||
|
// the placeholder is selected and the first keystroke replaces it. Anything else gets the
|
||||||
|
// caret at the end, because it is somebody's formula and a keystroke must not wipe it.
|
||||||
|
const start = this.field.value === PLACEHOLDER ? 0 : end;
|
||||||
|
this.field.setSelectionRange(start, end);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly onInput = (): void => {
|
||||||
|
this.size();
|
||||||
|
this.commit(this.field.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onKeyDown = (event: KeyboardEvent): void => {
|
||||||
|
// A display equation is written over several lines often enough that Enter has to be a newline
|
||||||
|
// inside one, so it is inline math that Enter leaves and a block that needs the modifier.
|
||||||
|
const leaving =
|
||||||
|
event.key === "Escape" ||
|
||||||
|
(event.key === "Enter" && (!this.display || event.metaKey || event.ctrlKey));
|
||||||
|
if (leaving) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.leave();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((event.key === "Backspace" || event.key === "Delete") && this.field.value === "") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.discard();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The field's text on to the node, as a transaction like any other keystroke in the document.
|
||||||
|
*
|
||||||
|
* Not held back until the field is left. A formula the field is holding and the document is not
|
||||||
|
* is one an autosave writes the previous version of and a switch to another file loses outright,
|
||||||
|
* and neither is worth the tidier undo history that batching it would buy.
|
||||||
|
*/
|
||||||
|
private commit(latex: string): void {
|
||||||
|
const pos = this.getPos();
|
||||||
|
if (pos === undefined) return;
|
||||||
|
const { state } = this.view;
|
||||||
|
const node = state.doc.nodeAt(pos);
|
||||||
|
if (!node || node.type !== this.node.type || node.attrs.latex === latex) return;
|
||||||
|
// Null for the type and nothing for the marks, so an inline formula inside a bold run comes
|
||||||
|
// back out of this still bold. setNodeMarkup keeps the marks it was not given new ones for.
|
||||||
|
this.view.dispatch(state.tr.setNodeMarkup(pos, null, { ...node.attrs, latex }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puts the caret back in the document just past the node, which is what re-renders it. */
|
||||||
|
private leave(): void {
|
||||||
|
const pos = this.getPos();
|
||||||
|
const { state } = this.view;
|
||||||
|
if (pos !== undefined) {
|
||||||
|
const after = Math.min(pos + this.node.nodeSize, state.doc.content.size);
|
||||||
|
this.view.dispatch(state.tr.setSelection(Selection.near(state.doc.resolve(after), 1)));
|
||||||
|
}
|
||||||
|
this.view.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backspace in an empty field takes the formula with it, the field being all there is of it. */
|
||||||
|
private discard(): void {
|
||||||
|
const pos = this.getPos();
|
||||||
|
const { state } = this.view;
|
||||||
|
if (pos === undefined) return;
|
||||||
|
if (!(state.selection instanceof NodeSelection) || state.selection.from !== pos) return;
|
||||||
|
this.view.dispatch(state.tr.deleteSelection().scrollIntoView());
|
||||||
|
this.view.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MathRendering = Extension.create({
|
||||||
|
name: "mathRendering",
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: new PluginKey("mathViews"),
|
||||||
|
props: {
|
||||||
|
nodeViews: {
|
||||||
|
mathInline: (node, view, getPos) => new MathView(node, view, getPos, false),
|
||||||
|
mathBlock: (node, view, getPos) => new MathView(node, view, getPos, true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
const editor = this.editor;
|
||||||
|
|
||||||
|
// ProseMirror's own calling convention rather than editor.commands.command, which dispatches
|
||||||
|
// its transaction whatever the command answered. Enter is pressed everywhere in the document
|
||||||
|
// and a key that did nothing here has to leave nothing at all behind it.
|
||||||
|
return {
|
||||||
|
Enter: () => openMathBlock(editor.state, editor.view.dispatch),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `display` picks mathBlock over mathInline. False where neither can be placed, which is a toolbar
|
||||||
|
* button pressed somewhere a formula cannot go and means nothing happens.
|
||||||
|
*
|
||||||
|
* The guard is `place`'s and is the same one every other insert in the editor asks, deliberately:
|
||||||
|
* this command had a private one and it was the private one that was missing a rule.
|
||||||
|
*/
|
||||||
|
export function insertMath(editor: Editor, display: boolean): boolean {
|
||||||
|
const type = editor.schema.nodes[display ? "mathBlock" : "mathInline"];
|
||||||
|
if (!type) return false;
|
||||||
|
return place(editor, type, (chain) =>
|
||||||
|
chain.command(({ state, dispatch }) => placeMath(type)(state, dispatch)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
// What can be asserted about a mermaid block without a browser, which is most of what matters.
|
||||||
|
//
|
||||||
|
// The node view itself needs a DOM and a real ProseMirror view, and this suite runs in node, so the
|
||||||
|
// drawing is not what is tested here. What is tested is everything the drawing is not allowed to
|
||||||
|
// disturb: that the extension adds nothing to the schema, that a ```mermaid fence is still an
|
||||||
|
// ordinary code block that round trips byte for byte through the editor, that exactly one plugin in
|
||||||
|
// the whole build claims the code block node view, and that the decoration telling a block the caret
|
||||||
|
// is inside it lands on the right blocks and only those.
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
|
||||||
|
import type { Plugin } from "@tiptap/pm/state";
|
||||||
|
import type { DecorationSet } from "@tiptap/pm/view";
|
||||||
|
import { createEditorExtensions } from "../extensions";
|
||||||
|
import { parseMarkdown, serializeMarkdown } from "../../markdown";
|
||||||
|
import { MermaidRendering, insertMermaid } from "./mermaid";
|
||||||
|
|
||||||
|
const PATH = "/notes/diagrams.md";
|
||||||
|
const FENCE = "```";
|
||||||
|
|
||||||
|
const extensions = () => createEditorExtensions({ documentPath: () => PATH, onError: () => {} });
|
||||||
|
|
||||||
|
function makeEditor(content?: object): Editor {
|
||||||
|
return new Editor({
|
||||||
|
element: null,
|
||||||
|
injectCSS: false,
|
||||||
|
extensions: extensions(),
|
||||||
|
content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An editor holding what the bridge made of `source`, which is how a document really arrives. */
|
||||||
|
function editorFor(source: string): Editor {
|
||||||
|
return makeEditor(parseMarkdown(source, PATH).doc.toJSON());
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockAt(editor: Editor, index: number): { node: ProseMirrorNode; pos: number } {
|
||||||
|
const doc = editor.state.doc;
|
||||||
|
let pos = 0;
|
||||||
|
for (let i = 0; i < index; i += 1) pos += doc.child(i).nodeSize;
|
||||||
|
return { node: doc.child(index), pos };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one plugin that draws diagrams, found the way the view finds it: by what it offers.
|
||||||
|
*
|
||||||
|
* The plugins are asked of the extensions rather than of the state, because TipTap only installs
|
||||||
|
* them when it mounts a view and there is no DOM here to mount one in.
|
||||||
|
*/
|
||||||
|
function nodeViewPlugins(editor: Editor): Plugin[] {
|
||||||
|
return editor.extensionManager.plugins.filter(
|
||||||
|
(plugin) => plugin.props.nodeViews?.codeBlock !== undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cursorDecorations(editor: Editor): DecorationSet | null {
|
||||||
|
const [plugin] = nodeViewPlugins(editor);
|
||||||
|
// `this` matters: ProseMirror calls a props function with the plugin as its receiver.
|
||||||
|
const found = plugin.props.decorations?.call(plugin, editor.state);
|
||||||
|
return (found as DecorationSet | null | undefined) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decoratedRanges(editor: Editor): Array<[number, number]> {
|
||||||
|
const set = cursorDecorations(editor);
|
||||||
|
if (!set) return [];
|
||||||
|
return set.find().map((decoration) => [decoration.from, decoration.to]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIAGRAM = [
|
||||||
|
"# Diagrams",
|
||||||
|
"",
|
||||||
|
`${FENCE}mermaid`,
|
||||||
|
"graph TD;",
|
||||||
|
" A-->B;",
|
||||||
|
FENCE,
|
||||||
|
"",
|
||||||
|
`${FENCE}ts`,
|
||||||
|
"const x = 1;",
|
||||||
|
FENCE,
|
||||||
|
"",
|
||||||
|
"After.",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
describe("the mermaid extension", () => {
|
||||||
|
it("is the one the registry names", () => {
|
||||||
|
expect(MermaidRendering.name).toBe("mermaidRendering");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds no node and no mark, so the bridge and the editor still agree", () => {
|
||||||
|
const plain = new Editor({
|
||||||
|
element: null,
|
||||||
|
injectCSS: false,
|
||||||
|
extensions: extensions().filter((extension) => extension.name !== "mermaidRendering"),
|
||||||
|
content: { type: "doc", content: [{ type: "paragraph" }] },
|
||||||
|
});
|
||||||
|
const withMermaid = makeEditor();
|
||||||
|
|
||||||
|
expect(Object.keys(withMermaid.schema.nodes)).toEqual(Object.keys(plain.schema.nodes));
|
||||||
|
expect(Object.keys(withMermaid.schema.marks)).toEqual(Object.keys(plain.schema.marks));
|
||||||
|
|
||||||
|
plain.destroy();
|
||||||
|
withMermaid.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is the only plugin in the build that claims the code block node view", () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
// Two plugins offering a node view for one node is a silent bug: ProseMirror takes the first
|
||||||
|
// one asked and the other never runs. The code lane leaves this to mermaid on purpose.
|
||||||
|
expect(nodeViewPlugins(editor)).toHaveLength(1);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a ```mermaid fence in a document", () => {
|
||||||
|
it("is an ordinary code block carrying its own language", () => {
|
||||||
|
const editor = editorFor(DIAGRAM);
|
||||||
|
const { node } = blockAt(editor, 1);
|
||||||
|
|
||||||
|
expect(node.type.name).toBe("codeBlock");
|
||||||
|
expect(node.attrs.language).toBe("mermaid");
|
||||||
|
expect(node.textContent).toBe("graph TD;\n A-->B;");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round trips byte for byte through the editor", () => {
|
||||||
|
const parsed = parseMarkdown(DIAGRAM, PATH);
|
||||||
|
const editor = makeEditor(parsed.doc.toJSON());
|
||||||
|
|
||||||
|
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(DIAGRAM);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps its indentation, its blank lines and its meta on the way back", () => {
|
||||||
|
const source = [
|
||||||
|
`${FENCE}mermaid theme=forest`,
|
||||||
|
"sequenceDiagram",
|
||||||
|
" Alice->>John: Hello",
|
||||||
|
"",
|
||||||
|
" John-->>Alice: Hi",
|
||||||
|
FENCE,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const parsed = parseMarkdown(source, PATH);
|
||||||
|
const editor = makeEditor(parsed.doc.toJSON());
|
||||||
|
const { node } = blockAt(editor, 0);
|
||||||
|
|
||||||
|
expect(node.attrs.meta).toBe("theme=forest");
|
||||||
|
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(source);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the decoration that says the caret is inside", () => {
|
||||||
|
it("is absent while the cursor is somewhere else", () => {
|
||||||
|
const editor = editorFor(DIAGRAM);
|
||||||
|
editor.commands.setTextSelection(1);
|
||||||
|
|
||||||
|
expect(decoratedRanges(editor)).toEqual([]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers the block the cursor is in, and nothing else", () => {
|
||||||
|
const editor = editorFor(DIAGRAM);
|
||||||
|
const { node, pos } = blockAt(editor, 1);
|
||||||
|
editor.commands.setTextSelection(pos + 1);
|
||||||
|
|
||||||
|
expect(decoratedRanges(editor)).toEqual([[pos, pos + node.nodeSize]]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a code block that is not a diagram", () => {
|
||||||
|
const editor = editorFor(DIAGRAM);
|
||||||
|
const { pos } = blockAt(editor, 2);
|
||||||
|
editor.commands.setTextSelection(pos + 1);
|
||||||
|
|
||||||
|
expect(decoratedRanges(editor)).toEqual([]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire on the paragraph that follows the fence", () => {
|
||||||
|
const editor = editorFor(DIAGRAM);
|
||||||
|
const { pos } = blockAt(editor, 3);
|
||||||
|
editor.commands.setTextSelection(pos + 1);
|
||||||
|
|
||||||
|
expect(decoratedRanges(editor)).toEqual([]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers the block when it is selected whole rather than typed in", () => {
|
||||||
|
const editor = editorFor(DIAGRAM);
|
||||||
|
const { node, pos } = blockAt(editor, 1);
|
||||||
|
editor.view.dispatch(
|
||||||
|
editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, pos)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(decoratedRanges(editor)).toEqual([[pos, pos + node.nodeSize]]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers every diagram a whole document selection touches", () => {
|
||||||
|
const source = [
|
||||||
|
`${FENCE}mermaid`,
|
||||||
|
"graph TD;",
|
||||||
|
FENCE,
|
||||||
|
"",
|
||||||
|
"Between.",
|
||||||
|
"",
|
||||||
|
`${FENCE}mermaid`,
|
||||||
|
"graph LR;",
|
||||||
|
FENCE,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
const editor = editorFor(source);
|
||||||
|
editor.commands.selectAll();
|
||||||
|
|
||||||
|
expect(decoratedRanges(editor)).toHaveLength(2);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a fence whose language only looks like mermaid alone", () => {
|
||||||
|
// The code lane leaves this block plain too, so a capitalised info string is neither drawn nor
|
||||||
|
// coloured. It stays the text the user wrote, which is the safe way for the two to disagree.
|
||||||
|
const source = [`${FENCE}Mermaid`, "graph TD;", FENCE, ""].join("\n");
|
||||||
|
const editor = editorFor(source);
|
||||||
|
editor.commands.setTextSelection(1);
|
||||||
|
|
||||||
|
expect(blockAt(editor, 0).node.attrs.language).toBe("Mermaid");
|
||||||
|
expect(decoratedRanges(editor)).toEqual([]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("insertMermaid", () => {
|
||||||
|
it("turns the empty paragraph the cursor is on into an empty fence", () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
|
||||||
|
expect(insertMermaid(editor)).toBe(true);
|
||||||
|
expect(editor.state.doc.childCount).toBe(1);
|
||||||
|
|
||||||
|
const { node } = blockAt(editor, 0);
|
||||||
|
expect(node.type.name).toBe("codeBlock");
|
||||||
|
expect(node.attrs.language).toBe("mermaid");
|
||||||
|
expect(node.attrs.meta).toBe(null);
|
||||||
|
expect(node.textContent).toBe("");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a ```mermaid fence and nothing else", () => {
|
||||||
|
const parsed = parseMarkdown("", PATH);
|
||||||
|
const editor = makeEditor(parsed.doc.toJSON());
|
||||||
|
insertMermaid(editor);
|
||||||
|
|
||||||
|
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(`${FENCE}mermaid\n${FENCE}\n`);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits the paragraph it was called from without losing a character of it", () => {
|
||||||
|
// The same thing the toolbar's rule and table buttons have always done with a caret in the
|
||||||
|
// middle of a line. What matters is that the words are all still there, on both sides of it.
|
||||||
|
const editor = editorFor("Some prose.\n");
|
||||||
|
editor.commands.setTextSelection(3);
|
||||||
|
|
||||||
|
expect(insertMermaid(editor)).toBe(true);
|
||||||
|
expect(editor.state.doc.childCount).toBe(3);
|
||||||
|
expect(editor.state.doc.child(0).textContent).toBe("So");
|
||||||
|
expect(editor.state.doc.child(1).attrs.language).toBe("mermaid");
|
||||||
|
expect(editor.state.doc.child(2).textContent).toBe("me prose.");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes something the bridge can read back, even inside a list", () => {
|
||||||
|
const parsed = parseMarkdown("- one\n- two\n", PATH);
|
||||||
|
const editor = makeEditor(parsed.doc.toJSON());
|
||||||
|
editor.commands.setTextSelection(4);
|
||||||
|
insertMermaid(editor);
|
||||||
|
|
||||||
|
const written = serializeMarkdown(parsed, editor.state.doc);
|
||||||
|
const reread = parseMarkdown(written, PATH);
|
||||||
|
expect(written).toContain(`${FENCE}mermaid`);
|
||||||
|
expect(serializeMarkdown(reread, reread.doc)).toBe(written);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing, and says so, in a table cell", () => {
|
||||||
|
// Left to itself ProseMirror would split the table in two around the block and leave a row with
|
||||||
|
// no cells in it, which is a table the serializer has nothing to write.
|
||||||
|
const editor = editorFor("| a | b |\n| - | - |\n| 1 | 2 |\n");
|
||||||
|
expect(editor.state.doc.child(0).type.name).toBe("table");
|
||||||
|
|
||||||
|
editor.view.dispatch(
|
||||||
|
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3)),
|
||||||
|
);
|
||||||
|
const before = editor.state.doc;
|
||||||
|
|
||||||
|
expect(insertMermaid(editor)).toBe(false);
|
||||||
|
expect(editor.state.doc).toBe(before);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing, and says so, inside another fence", () => {
|
||||||
|
const editor = editorFor(`${FENCE}ts\nconst x = 1;\n${FENCE}\n`);
|
||||||
|
editor.commands.setTextSelection(3);
|
||||||
|
const before = editor.state.doc;
|
||||||
|
|
||||||
|
expect(insertMermaid(editor)).toBe(false);
|
||||||
|
expect(editor.state.doc).toBe(before);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing, and says so, inside a raw block", () => {
|
||||||
|
const editor = editorFor("<figure><img src='x.png'></figure>\n");
|
||||||
|
expect(editor.state.doc.child(0).type.name).toBe("raw");
|
||||||
|
editor.commands.setTextSelection(3);
|
||||||
|
const before = editor.state.doc;
|
||||||
|
|
||||||
|
expect(insertMermaid(editor)).toBe(false);
|
||||||
|
expect(editor.state.doc).toBe(before);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
// Mermaid diagrams, which are a fenced code block whose language is `mermaid` and nothing else.
|
||||||
|
//
|
||||||
|
// There is no mermaid node in the schema and there will not be one. On disk a diagram is ```mermaid
|
||||||
|
// and the bridge reads it as a codeBlock like any other fence, so every byte of it round trips as
|
||||||
|
// that block's text whether or not it draws. This lane only changes how such a block is shown.
|
||||||
|
//
|
||||||
|
// Mermaid renders asynchronously, which a ProseMirror view update is not, so the node view draws
|
||||||
|
// the fence first and swaps the SVG in when it arrives. A diagram that fails to parse stays as the
|
||||||
|
// code the user wrote, with the error beside it: a broken diagram is a typo to fix, not a block to
|
||||||
|
// hide.
|
||||||
|
//
|
||||||
|
// Nothing below ever writes to the document. The one transaction this file dispatches sets a text
|
||||||
|
// selection, which is a caret move and not an edit, and it is what makes clicking a drawn diagram
|
||||||
|
// put the cursor in the source that drew it. A render result is painted into DOM that sits outside
|
||||||
|
// contentDOM and is declared to ProseMirror as not the document's, so a picture mermaid hands back
|
||||||
|
// can never be read into the tree and saved over somebody's fence.
|
||||||
|
//
|
||||||
|
// ProseMirror resolves node views by node name and the first plugin asked wins, so this file is
|
||||||
|
// handed every code block in the document, not only the mermaid ones. The other kind gets a node
|
||||||
|
// view built from the schema's own toDOM, which is the same `pre > code` a code block had before
|
||||||
|
// this lane existed: the same element prose.css styles and the same one the code lane's decorations
|
||||||
|
// land on.
|
||||||
|
|
||||||
|
import { Extension } from "@tiptap/core";
|
||||||
|
import type { Editor } from "@tiptap/core";
|
||||||
|
import { DOMSerializer } from "@tiptap/pm/model";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||||
|
import type { EditorState } from "@tiptap/pm/state";
|
||||||
|
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||||
|
import type { EditorView, NodeView, ViewMutationRecord } from "@tiptap/pm/view";
|
||||||
|
import type { Mermaid, MermaidConfig } from "mermaid";
|
||||||
|
import { place } from "../fits";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one info string that draws. Matched exactly, case included.
|
||||||
|
*
|
||||||
|
* The code lane matches the same word case insensitively when it decides what to leave plain, so
|
||||||
|
* ```Mermaid is highlighted by nobody and drawn by nobody: it stays the fence the user typed. That
|
||||||
|
* is the safe direction for the two lanes to disagree in. Both drawing it and colouring it would
|
||||||
|
* mean two plugins fighting over one block.
|
||||||
|
*/
|
||||||
|
const LANGUAGE = "mermaid";
|
||||||
|
|
||||||
|
const DRAWING = "Drawing diagram…";
|
||||||
|
|
||||||
|
const FAILED = "Mermaid could not draw this diagram.";
|
||||||
|
|
||||||
|
const mermaidKey = new PluginKey("mermaidRendering");
|
||||||
|
|
||||||
|
/** On a node decoration, this marks the code block the selection is currently inside. */
|
||||||
|
const CURSOR_INSIDE = { mermaidCursor: true };
|
||||||
|
|
||||||
|
function isDiagram(node: ProseMirrorNode): boolean {
|
||||||
|
return node.type.name === "codeBlock" && node.attrs.language === LANGUAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
// The library, loaded once and only if a diagram is ever drawn
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let loading: Promise<Mermaid> | null = null;
|
||||||
|
let configured: string | null = null;
|
||||||
|
let drawings = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mermaid is several megabytes and a dependency graph to match, so this is the only place it is
|
||||||
|
* mentioned outside a type position and the import is dynamic. The bundler gives it a chunk of its
|
||||||
|
* own, and a user who never writes a diagram never fetches it.
|
||||||
|
*
|
||||||
|
* A failed load clears the promise rather than keeping it, so a chunk that did not arrive once is
|
||||||
|
* asked for again by the next block instead of poisoning every diagram in the app.
|
||||||
|
*/
|
||||||
|
function load(): Promise<Mermaid> {
|
||||||
|
if (!loading) {
|
||||||
|
loading = import("mermaid")
|
||||||
|
.then((module) => module.default)
|
||||||
|
.catch((error) => {
|
||||||
|
loading = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeName(): string {
|
||||||
|
return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's palette, handed to mermaid as its own theme variables.
|
||||||
|
*
|
||||||
|
* `base` is the one mermaid theme meant to be recoloured; the others are fixed palettes that would
|
||||||
|
* put somebody else's lavender and yellow in the middle of this page. The values are read off the
|
||||||
|
* token layer at render time rather than named here, so a diagram is drawn in the same ink as the
|
||||||
|
* document around it and follows tokens.css when that changes.
|
||||||
|
*/
|
||||||
|
function themeVariables(): Record<string, string | boolean> {
|
||||||
|
const style = getComputedStyle(document.documentElement);
|
||||||
|
const variables: Record<string, string | boolean> = {
|
||||||
|
darkMode: themeName() === "dark",
|
||||||
|
fontFamily: "var(--font-ui)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const palette: ReadonlyArray<readonly [string, string]> = [
|
||||||
|
["background", "--paper"],
|
||||||
|
["primaryColor", "--code-surface"],
|
||||||
|
["primaryTextColor", "--ink"],
|
||||||
|
["primaryBorderColor", "--doc-rule-strong"],
|
||||||
|
["secondaryColor", "--shell"],
|
||||||
|
["tertiaryColor", "--raised"],
|
||||||
|
["lineColor", "--ink-soft"],
|
||||||
|
["textColor", "--ink"],
|
||||||
|
// The card a label on an arrow sits on. Left to itself the base theme picks near black for it
|
||||||
|
// in dark mode, which puts a hole in the middle of the diagram.
|
||||||
|
["edgeLabelBackground", "--code-surface"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [variable, token] of palette) {
|
||||||
|
const value = style.getPropertyValue(token).trim();
|
||||||
|
// An empty custom property means the stylesheet is not loaded yet. Mermaid derives its shades
|
||||||
|
// from these by colour arithmetic, and "" is not a colour, so a missing token is left to the
|
||||||
|
// theme's own default rather than passed on.
|
||||||
|
if (value) variables[variable] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return variables;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configFor(): MermaidConfig {
|
||||||
|
return {
|
||||||
|
// The whole point of this file: nothing scans the page for diagrams, every render is asked for
|
||||||
|
// by a node view that knows which block it belongs to.
|
||||||
|
startOnLoad: false,
|
||||||
|
// Strict is mermaid's own default and the right one here. The text being drawn came out of a
|
||||||
|
// file on disk, so it is sanitised and its click handlers are dropped.
|
||||||
|
securityLevel: "strict",
|
||||||
|
// Without this a parse failure leaves mermaid's own error diagram behind in the page and a
|
||||||
|
// stray temporary div in the body. The error belongs in this block, drawn by the code below.
|
||||||
|
suppressErrorRendering: true,
|
||||||
|
theme: "base",
|
||||||
|
fontFamily: "var(--font-ui)",
|
||||||
|
themeVariables: themeVariables(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One diagram, as an SVG string. Throws whatever mermaid threw.
|
||||||
|
*
|
||||||
|
* The id has to be unique per diagram: mermaid scopes the stylesheet it puts inside each SVG with
|
||||||
|
* `#id`, so two diagrams sharing one would style each other.
|
||||||
|
*/
|
||||||
|
async function toSvg(text: string): Promise<string> {
|
||||||
|
const mermaid = await load();
|
||||||
|
|
||||||
|
const theme = themeName();
|
||||||
|
if (theme !== configured) {
|
||||||
|
mermaid.initialize(configFor());
|
||||||
|
configured = theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsing first keeps a syntax error away from the renderer entirely, which is the difference
|
||||||
|
// between an error this file can show and a half drawn diagram.
|
||||||
|
await mermaid.parse(text);
|
||||||
|
const { svg } = await mermaid.render(`mermaid-diagram-${(drawings += 1)}`, text);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageOf(error: unknown): string {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
if (error && typeof error === "object") {
|
||||||
|
// Mermaid's parse errors are plain objects carrying the offending line under `str`.
|
||||||
|
const detail = error as { str?: unknown; message?: unknown };
|
||||||
|
if (typeof detail.str === "string") return detail.str;
|
||||||
|
if (typeof detail.message === "string") return detail.message;
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
// The theme watch
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const live = new Set<DiagramView>();
|
||||||
|
|
||||||
|
let watcher: MutationObserver | null = null;
|
||||||
|
let watched: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A drawn diagram is a picture with the palette baked into it, so the theme changing under it is
|
||||||
|
* the one event that invalidates a render nothing else touched. One observer serves every block,
|
||||||
|
* and it exists only while there is a diagram on screen to redraw.
|
||||||
|
*/
|
||||||
|
function watchTheme(): void {
|
||||||
|
if (watcher) return;
|
||||||
|
watched = themeName();
|
||||||
|
watcher = new MutationObserver(() => {
|
||||||
|
const theme = themeName();
|
||||||
|
if (theme === watched) return;
|
||||||
|
watched = theme;
|
||||||
|
configured = null;
|
||||||
|
for (const view of live) view.redraw();
|
||||||
|
});
|
||||||
|
watcher.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwatchTheme(): void {
|
||||||
|
if (!watcher || live.size > 0) return;
|
||||||
|
watcher.disconnect();
|
||||||
|
watcher = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
// The node views
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every code block that is not a diagram, rendered by the schema rather than by hand.
|
||||||
|
*
|
||||||
|
* Going through the node's own serializer is what makes this a no-op: the DOM here is the DOM
|
||||||
|
* ProseMirror would have built for a code block if this file did not exist, down to whether
|
||||||
|
* `data-language` is written at all, so nothing about an ordinary fence changes because the mermaid
|
||||||
|
* lane happens to be installed.
|
||||||
|
*/
|
||||||
|
class SourceView implements NodeView {
|
||||||
|
readonly dom: HTMLElement;
|
||||||
|
readonly contentDOM: HTMLElement | null;
|
||||||
|
private node: ProseMirrorNode;
|
||||||
|
|
||||||
|
constructor(node: ProseMirrorNode) {
|
||||||
|
const serializer = DOMSerializer.fromSchema(node.type.schema);
|
||||||
|
const rendered = DOMSerializer.renderSpec(document, serializer.nodes[node.type.name](node));
|
||||||
|
this.dom = rendered.dom;
|
||||||
|
this.contentDOM = rendered.contentDOM ?? null;
|
||||||
|
this.node = node;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(next: ProseMirrorNode): boolean {
|
||||||
|
// Same markup means the same element, so ProseMirror updates the text inside contentDOM and
|
||||||
|
// this view stands. Anything else is a rebuild, the language crossing into mermaid included,
|
||||||
|
// and a rebuild is what the standard node view would have done with the same change.
|
||||||
|
if (!next.sameMarkup(this.node)) return false;
|
||||||
|
this.node = next;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiagramState = "empty" | "source" | "pending" | "diagram" | "error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One mermaid block: the picture, and the source that made it.
|
||||||
|
*
|
||||||
|
* Both are always in the DOM and which one is shown is a CSS state, because the source is the
|
||||||
|
* document's own content and hiding it by removing it would be an edit. The cursor being inside
|
||||||
|
* the block arrives as a node decoration from the plugin below rather than being asked for here,
|
||||||
|
* since a node view is only told about the selection when something else redraws it.
|
||||||
|
*/
|
||||||
|
class DiagramView implements NodeView {
|
||||||
|
readonly dom: HTMLElement;
|
||||||
|
readonly contentDOM: HTMLElement;
|
||||||
|
|
||||||
|
private readonly figure: HTMLElement;
|
||||||
|
private readonly drawing: HTMLElement;
|
||||||
|
private readonly note: HTMLElement;
|
||||||
|
private readonly view: EditorView;
|
||||||
|
private readonly getPos: () => number | undefined;
|
||||||
|
|
||||||
|
private node: ProseMirrorNode;
|
||||||
|
private inside: boolean;
|
||||||
|
|
||||||
|
/** Bumped by anything that makes a render in flight the answer to a question nobody asked. */
|
||||||
|
private token = 0;
|
||||||
|
/** The text the picture on screen was drawn from, or null when there is no picture. */
|
||||||
|
private drawn: string | null = null;
|
||||||
|
private failure: string | null = null;
|
||||||
|
private gone = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
node: ProseMirrorNode,
|
||||||
|
view: EditorView,
|
||||||
|
getPos: () => number | undefined,
|
||||||
|
decorations: readonly Decoration[],
|
||||||
|
) {
|
||||||
|
this.node = node;
|
||||||
|
this.view = view;
|
||||||
|
this.getPos = getPos;
|
||||||
|
this.inside = hasCursor(decorations);
|
||||||
|
|
||||||
|
this.dom = document.createElement("div");
|
||||||
|
this.dom.className = "mermaid-block";
|
||||||
|
|
||||||
|
this.figure = document.createElement("div");
|
||||||
|
this.figure.className = "mermaid-figure";
|
||||||
|
// Not part of the document, so the caret has no business in it and ProseMirror is told as much
|
||||||
|
// here as well as through ignoreMutation below.
|
||||||
|
this.figure.contentEditable = "false";
|
||||||
|
|
||||||
|
this.drawing = document.createElement("div");
|
||||||
|
this.drawing.className = "mermaid-drawing";
|
||||||
|
|
||||||
|
this.note = document.createElement("div");
|
||||||
|
this.note.className = "mermaid-note";
|
||||||
|
|
||||||
|
this.figure.append(this.drawing, this.note);
|
||||||
|
|
||||||
|
const source = document.createElement("pre");
|
||||||
|
source.className = "mermaid-source";
|
||||||
|
source.setAttribute("data-language", LANGUAGE);
|
||||||
|
this.contentDOM = document.createElement("code");
|
||||||
|
source.appendChild(this.contentDOM);
|
||||||
|
|
||||||
|
this.dom.append(this.figure, source);
|
||||||
|
|
||||||
|
this.figure.addEventListener("mousedown", this.enter);
|
||||||
|
|
||||||
|
live.add(this);
|
||||||
|
watchTheme();
|
||||||
|
this.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
update(next: ProseMirrorNode, decorations: readonly Decoration[]): boolean {
|
||||||
|
// The language leaving mermaid is a different kind of block with different DOM, so this view is
|
||||||
|
// finished and ProseMirror builds the plain one in its place.
|
||||||
|
if (!isDiagram(next)) return false;
|
||||||
|
|
||||||
|
const edited = next.textContent !== this.node.textContent;
|
||||||
|
this.node = next;
|
||||||
|
this.inside = hasCursor(decorations);
|
||||||
|
|
||||||
|
if (edited) {
|
||||||
|
// Whatever is being drawn was drawn from text that is no longer in this block, and the error
|
||||||
|
// on screen, if there is one, is about a line the user may have just fixed.
|
||||||
|
this.token += 1;
|
||||||
|
this.failure = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.apply();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The theme changed, so the picture is right about the diagram and wrong about the ink. */
|
||||||
|
redraw(): void {
|
||||||
|
this.failure = null;
|
||||||
|
this.drawn = null;
|
||||||
|
this.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.gone = true;
|
||||||
|
this.figure.removeEventListener("mousedown", this.enter);
|
||||||
|
live.delete(this);
|
||||||
|
unwatchTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The figure is the view's own drawing, not the document. Reading an SVG mermaid just handed over
|
||||||
|
* back into the tree would replace the user's fence with a transcription of its own picture, so
|
||||||
|
* every mutation outside contentDOM is none of ProseMirror's business.
|
||||||
|
*/
|
||||||
|
ignoreMutation(mutation: ViewMutationRecord): boolean {
|
||||||
|
return !this.contentDOM.contains(mutation.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopEvent(event: Event): boolean {
|
||||||
|
const target = event.target;
|
||||||
|
return target instanceof Node ? !this.contentDOM.contains(target) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clicking the picture puts the caret in the source that drew it, which is how a diagram is edited. */
|
||||||
|
private enter = (event: MouseEvent): void => {
|
||||||
|
const pos = this.getPos();
|
||||||
|
if (pos === undefined) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const { state } = this.view;
|
||||||
|
const inside = Math.min(pos + 1, state.doc.content.size);
|
||||||
|
this.view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, inside)));
|
||||||
|
this.view.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** What should be on screen for the block as it is now, and a render if that is not known yet. */
|
||||||
|
private apply(): void {
|
||||||
|
const text = this.node.textContent;
|
||||||
|
|
||||||
|
if (!text.trim()) {
|
||||||
|
this.show("empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Shown whether or not the caret is in the block, because a diagram that will not draw is a
|
||||||
|
// line to go and fix and the message is how anybody knows which line.
|
||||||
|
if (this.failure !== null) {
|
||||||
|
this.note.textContent = `${FAILED}\n\n${this.failure}`;
|
||||||
|
this.show("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.inside) {
|
||||||
|
this.show("source");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.drawn === text) {
|
||||||
|
this.show("diagram");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.draw(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private draw(text: string): void {
|
||||||
|
const token = (this.token += 1);
|
||||||
|
|
||||||
|
// A diagram already on screen stays there while the next one is drawn, so a theme change or a
|
||||||
|
// finished edit does not blink the block out of the page and back into it.
|
||||||
|
if (this.drawing.firstChild) {
|
||||||
|
this.show("diagram");
|
||||||
|
this.dom.setAttribute("data-busy", "");
|
||||||
|
} else {
|
||||||
|
this.note.textContent = DRAWING;
|
||||||
|
this.show("pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
toSvg(text).then(
|
||||||
|
(svg) => {
|
||||||
|
if (this.stale(token)) return;
|
||||||
|
this.dom.removeAttribute("data-busy");
|
||||||
|
// Mermaid sanitises what it returns, and a script arriving through innerHTML does not run
|
||||||
|
// in any case, so the SVG goes in as markup and the error below never does.
|
||||||
|
this.drawing.innerHTML = svg;
|
||||||
|
this.drawn = text;
|
||||||
|
this.failure = null;
|
||||||
|
this.apply();
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
if (this.stale(token)) return;
|
||||||
|
this.dom.removeAttribute("data-busy");
|
||||||
|
this.drawing.textContent = "";
|
||||||
|
this.drawn = null;
|
||||||
|
this.failure = messageOf(error);
|
||||||
|
this.apply();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mermaid answers whenever it answers, and by then the block may have been edited, the document
|
||||||
|
* may have been closed and this view may have been thrown away. The token covers every one of
|
||||||
|
* those: it is bumped by an edit, by a theme change and by destroy, so an answer to a question
|
||||||
|
* nobody is asking any more is dropped rather than painted somewhere it no longer belongs.
|
||||||
|
*/
|
||||||
|
private stale(token: number): boolean {
|
||||||
|
return this.gone || token !== this.token || this.view.isDestroyed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private show(state: DiagramState): void {
|
||||||
|
this.dom.setAttribute("data-state", state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCursor(decorations: readonly Decoration[]): boolean {
|
||||||
|
return decorations.some((decoration) => decoration.spec?.mermaidCursor === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
// The plugin
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A node decoration on every mermaid block the selection touches.
|
||||||
|
*
|
||||||
|
* This is how a node view is told the caret is inside it. A decoration changing is one of the two
|
||||||
|
* things that make ProseMirror ask a node view to update, and the selection moving on its own is
|
||||||
|
* not the other, so without this a block would keep drawing the diagram with the cursor in it.
|
||||||
|
*/
|
||||||
|
function cursorDecorations(state: EditorState): DecorationSet | null {
|
||||||
|
const { from, to } = state.selection;
|
||||||
|
const found: Decoration[] = [];
|
||||||
|
|
||||||
|
state.doc.nodesBetween(from, to, (node, pos) => {
|
||||||
|
if (node.type.name !== "codeBlock") return true;
|
||||||
|
if (isDiagram(node)) found.push(Decoration.node(pos, pos + node.nodeSize, {}, CURSOR_INSIDE));
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return found.length > 0 ? DecorationSet.create(state.doc, found) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MermaidRendering = Extension.create({
|
||||||
|
name: "mermaidRendering",
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: mermaidKey,
|
||||||
|
props: {
|
||||||
|
nodeViews: {
|
||||||
|
codeBlock: (node, view, getPos, decorations) =>
|
||||||
|
isDiagram(node)
|
||||||
|
? new DiagramView(node, view, getPos, decorations)
|
||||||
|
: new SourceView(node),
|
||||||
|
},
|
||||||
|
decorations: cursorDecorations,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts an empty ```mermaid fence. False where a code block cannot go.
|
||||||
|
*
|
||||||
|
* Through `place` rather than asking `fits` about each end itself, which is what this did while it
|
||||||
|
* was the only insert in its own file. Both spellings refuse the same things today, but only one of
|
||||||
|
* them refuses the next thing the guard learns: `fits` gained a cell selection rule after a drag
|
||||||
|
* across a table lost six cells to an insert that had asked it the older way, and a caller holding
|
||||||
|
* its own copy of the question is a caller that does not get told. There is one gate and every
|
||||||
|
* insert goes through it.
|
||||||
|
*/
|
||||||
|
export function insertMermaid(editor: Editor): boolean {
|
||||||
|
return place(editor, editor.schema.nodes.codeBlock, (chain) =>
|
||||||
|
chain.insertContent({ type: "codeBlock", attrs: { language: LANGUAGE, meta: null } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,755 @@
|
|||||||
|
// A table is the one block in this editor whose behaviour is a library's rather than this app's,
|
||||||
|
// which makes it the one block where "it works" is easy to assume and easy to be wrong about. Two
|
||||||
|
// things are asserted here that a passing prosemirror-tables would not give for free.
|
||||||
|
//
|
||||||
|
// The first is alignment, which is this file's own and not the library's. GFM keeps alignment in
|
||||||
|
// the delimiter row, one entry per column, so the test that matters is not what the attribute says
|
||||||
|
// but what the serializer writes: a column whose cells disagree is a table the file cannot hold.
|
||||||
|
//
|
||||||
|
// The second is the header row, for the same reason from the other end. GFM has exactly one and it
|
||||||
|
// is the first row, so an edit that leaves body cells in row zero is an edit whose result the file
|
||||||
|
// cannot spell, and the screen would go on showing it until the file was next opened.
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import type { JSONContent } from "@tiptap/core";
|
||||||
|
import { EditorState } from "@tiptap/pm/state";
|
||||||
|
import type { Transaction } from "@tiptap/pm/state";
|
||||||
|
import { Fragment, Slice } from "@tiptap/pm/model";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { EditorView } from "@tiptap/pm/view";
|
||||||
|
import { CellSelection, TableMap } from "@tiptap/pm/tables";
|
||||||
|
import { createEditorExtensions } from "../extensions";
|
||||||
|
import { serializeMarkdown } from "../../markdown";
|
||||||
|
import { tableCommand, typingKey } from "./tables";
|
||||||
|
|
||||||
|
const EMPTY: JSONContent = { type: "doc", content: [{ type: "paragraph" }] };
|
||||||
|
|
||||||
|
function makeEditor(content: JSONContent = EMPTY): Editor {
|
||||||
|
const editor = new Editor({
|
||||||
|
element: null,
|
||||||
|
injectCSS: false,
|
||||||
|
extensions: createEditorExtensions({ documentPath: () => "/notes/a.md", onError: () => {} }),
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TipTap only installs the extensions' ProseMirror plugins when it mounts a view, and there is no
|
||||||
|
// DOM here to mount into. Swapping in a state built with them is what src/editor/Editor.tsx does
|
||||||
|
// on every document it installs, so this is the same editor the app runs, minus the screen.
|
||||||
|
editor.view.updateState(
|
||||||
|
EditorState.create({ doc: editor.state.doc, plugins: editor.extensionManager.plugins }),
|
||||||
|
);
|
||||||
|
return editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One key, offered to the plugins in the order the view would offer it and stopping at the first
|
||||||
|
* that claims it. Which plugin answered is the whole question in half these tests, so the walk is
|
||||||
|
* the real one rather than a call into the binding this file happens to be about.
|
||||||
|
*/
|
||||||
|
function press(editor: Editor, key: string, shift = false): boolean {
|
||||||
|
const event = {
|
||||||
|
key,
|
||||||
|
keyCode: key === "Tab" ? 9 : 8,
|
||||||
|
shiftKey: shift,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
preventDefault: () => {},
|
||||||
|
} as unknown as KeyboardEvent;
|
||||||
|
const view = editor.view as unknown as EditorView;
|
||||||
|
|
||||||
|
for (const plugin of editor.state.plugins) {
|
||||||
|
const handler = plugin.props?.handleKeyDown;
|
||||||
|
if (handler && handler.call(plugin, view, event)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One printable character, offered to the plugins the way the view offers one and, when nobody
|
||||||
|
* claims it, put in the way the view would put it. True when a plugin claimed it.
|
||||||
|
*
|
||||||
|
* `tr.insertText` with no range is `Selection.replace`, and over a cell selection that replaces
|
||||||
|
* every range in the selection: the character goes into the last cell of the rectangle and the
|
||||||
|
* rest are emptied. That fallback is the bug, so it is run here rather than described.
|
||||||
|
*/
|
||||||
|
function type(editor: Editor, character: string): boolean {
|
||||||
|
const view = editor.view as unknown as EditorView;
|
||||||
|
const { $from, $to } = editor.state.selection;
|
||||||
|
const deflt = () => editor.state.tr.insertText(character).scrollIntoView();
|
||||||
|
|
||||||
|
for (const plugin of editor.state.plugins) {
|
||||||
|
const handler = plugin.props?.handleTextInput;
|
||||||
|
if (handler && handler.call(plugin, view, $from.pos, $to.pos, character, deflt)) return true;
|
||||||
|
}
|
||||||
|
editor.view.dispatch(deflt());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A table alone in a document. The first row is header cells, as every GFM table's is. */
|
||||||
|
function tableDoc(rows: string[][]): JSONContent {
|
||||||
|
return {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
content: rows.map((cells, row) => ({
|
||||||
|
type: "tableRow",
|
||||||
|
content: cells.map((text) => ({
|
||||||
|
type: row === 0 ? "tableHeader" : "tableCell",
|
||||||
|
...(text ? { content: [{ type: "text", text }] } : {}),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The document's first table and where it starts, wherever in the tree it happens to sit. */
|
||||||
|
function tableAt(editor: Editor): { node: ProseMirrorNode; pos: number } {
|
||||||
|
let found: { node: ProseMirrorNode; pos: number } | null = null;
|
||||||
|
editor.state.doc.descendants((node, pos) => {
|
||||||
|
if (found !== null) return false;
|
||||||
|
if (node.type.name === "table") found = { node, pos };
|
||||||
|
return found === null;
|
||||||
|
});
|
||||||
|
if (found === null) throw new Error("this document has no table in it");
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = (editor: Editor) => tableAt(editor).node;
|
||||||
|
|
||||||
|
/** The document position just inside a cell. */
|
||||||
|
function inCell(editor: Editor, row: number, column: number): number {
|
||||||
|
const { node, pos } = tableAt(editor);
|
||||||
|
return pos + 1 + TableMap.get(node).positionAt(row, column, node) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cursorIn(editor: Editor, row: number, column: number): void {
|
||||||
|
editor.commands.setTextSelection(inCell(editor, row, column));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCells(editor: Editor, from: [number, number], to: [number, number]): void {
|
||||||
|
const anchor = inCell(editor, from[0], from[1]) - 1;
|
||||||
|
const head = inCell(editor, to[0], to[1]) - 1;
|
||||||
|
editor.view.dispatch(
|
||||||
|
editor.state.tr.setSelection(CellSelection.create(editor.state.doc, anchor, head)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The table as a grid of whatever `of` reads off a cell. */
|
||||||
|
function grid<T>(editor: Editor, of: (cell: ProseMirrorNode) => T): T[][] {
|
||||||
|
const out: T[][] = [];
|
||||||
|
table(editor).forEach((row) => {
|
||||||
|
const cells: T[] = [];
|
||||||
|
row.forEach((cell) => cells.push(of(cell)));
|
||||||
|
out.push(cells);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shape = (editor: Editor) => grid(editor, (cell) => cell.textContent);
|
||||||
|
const kinds = (editor: Editor) => grid(editor, (cell) => cell.type.name);
|
||||||
|
const aligns = (editor: Editor) => grid(editor, (cell) => cell.attrs.align as string | null);
|
||||||
|
|
||||||
|
/** A clipboard carrying one word of plain text, which is what a slice with something in it is. */
|
||||||
|
function textSlice(editor: Editor, text: string): Slice {
|
||||||
|
return new Slice(Fragment.from(editor.schema.text(text)), 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the bridge would write for this document, in a file that holds nothing else. */
|
||||||
|
function written(editor: Editor): string {
|
||||||
|
const doc = editor.state.doc;
|
||||||
|
return serializeMarkdown({ frontmatter: null, doc, source: "", path: "/notes/a.md" }, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
const GRID = [
|
||||||
|
["a", "b", "c"],
|
||||||
|
["1", "2", "3"],
|
||||||
|
["4", "5", "6"],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("Tab in a table", () => {
|
||||||
|
it("moves to the next cell and wraps on to the next row", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
cursorIn(editor, 0, 0);
|
||||||
|
|
||||||
|
expect(press(editor, "Tab")).toBe(true);
|
||||||
|
expect(editor.state.selection.from).toBe(inCell(editor, 0, 1));
|
||||||
|
expect(press(editor, "Tab")).toBe(true);
|
||||||
|
expect(press(editor, "Tab")).toBe(true);
|
||||||
|
expect(editor.state.selection.from).toBe(inCell(editor, 1, 0));
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves back on Shift-Tab, and stops at the first cell", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
cursorIn(editor, 1, 0);
|
||||||
|
|
||||||
|
expect(press(editor, "Tab", true)).toBe(true);
|
||||||
|
expect(editor.state.selection.from).toBe(inCell(editor, 0, 2));
|
||||||
|
|
||||||
|
// Nowhere to go, so the binding declines and the key falls through untouched by it.
|
||||||
|
cursorIn(editor, 0, 0);
|
||||||
|
press(editor, "Tab", true);
|
||||||
|
expect(shape(editor)).toEqual(GRID);
|
||||||
|
expect(kinds(editor)[0]).toEqual(["tableHeader", "tableHeader", "tableHeader"]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The precedence assertion. shortcuts.ts also binds Tab, for sinking a list item, and it is the
|
||||||
|
// only other binding on the key: a fourth row here is proof that this lane's is asked first and
|
||||||
|
// that the list one does not answer inside a table.
|
||||||
|
it("grows the table out of the last cell, into a body row", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
cursorIn(editor, 2, 2);
|
||||||
|
|
||||||
|
expect(press(editor, "Tab")).toBe(true);
|
||||||
|
expect(shape(editor)).toEqual([...GRID, ["", "", ""]]);
|
||||||
|
expect(kinds(editor)[3]).toEqual(["tableCell", "tableCell", "tableCell"]);
|
||||||
|
expect(editor.state.selection.from).toBe(inCell(editor, 3, 0));
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grows a table that is nothing but its header row", () => {
|
||||||
|
const editor = makeEditor(tableDoc([["a", "b"]]));
|
||||||
|
cursorIn(editor, 0, 1);
|
||||||
|
|
||||||
|
expect(press(editor, "Tab")).toBe(true);
|
||||||
|
expect(kinds(editor)).toEqual([
|
||||||
|
["tableHeader", "tableHeader"],
|
||||||
|
["tableCell", "tableCell"],
|
||||||
|
]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves Tab alone outside a table", () => {
|
||||||
|
const editor = makeEditor({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: "x" }] }],
|
||||||
|
});
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
|
||||||
|
expect(press(editor, "Tab")).toBe(false);
|
||||||
|
expect(editor.state.doc.textContent).toBe("x");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Backspace over a cell selection", () => {
|
||||||
|
it("empties the cells and keeps the table the shape it was", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
selectCells(editor, [1, 0], [2, 1]);
|
||||||
|
|
||||||
|
expect(press(editor, "Backspace")).toBe(true);
|
||||||
|
expect(shape(editor)).toEqual([
|
||||||
|
["a", "b", "c"],
|
||||||
|
["", "", "3"],
|
||||||
|
["", "", "6"],
|
||||||
|
]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A printable character over the same rectangle, which until this lane claimed it did what
|
||||||
|
// Backspace does and then wrote the character into the corner of the wreckage.
|
||||||
|
//
|
||||||
|
// Reported and reproduced in Chromium: a real mouse drag from (0,0) to (1,1) of a 2x2 body and the
|
||||||
|
// three keystrokes "zqx" took "| 1 | 2 |\n| 3 | 4 |" to four empty cells with "zqx" in the last
|
||||||
|
// one. Four cells of somebody's table for three letters, none of them the cell the drag started
|
||||||
|
// in. It is the destruction src/editor/paste.ts refuses for a Cmd+V that lands on the same
|
||||||
|
// selection, arriving by the one route with no guard on it.
|
||||||
|
describe("typing over a cell selection", () => {
|
||||||
|
it("leaves every cell in the rectangle exactly as it was", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
const before = written(editor);
|
||||||
|
selectCells(editor, [1, 0], [2, 1]);
|
||||||
|
|
||||||
|
expect(type(editor, "z")).toBe(true);
|
||||||
|
|
||||||
|
expect(shape(editor)).toEqual(GRID);
|
||||||
|
expect(written(editor)).toBe(before);
|
||||||
|
// And the rectangle is still selected, so Backspace, the toolbar and a click into one cell are
|
||||||
|
// all still where the user left them.
|
||||||
|
expect(editor.state.selection instanceof CellSelection).toBe(true);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The other half, twice, because a guard that claimed every character everywhere would pass the
|
||||||
|
// test above and would be an editor nobody can type in.
|
||||||
|
it("still types into a single cell, and still types outside a table", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
cursorIn(editor, 1, 0);
|
||||||
|
|
||||||
|
expect(type(editor, "z")).toBe(false);
|
||||||
|
expect(shape(editor)[1][0]).toBe("z1");
|
||||||
|
editor.destroy();
|
||||||
|
|
||||||
|
const prose = makeEditor();
|
||||||
|
expect(type(prose, "z")).toBe(false);
|
||||||
|
expect(prose.state.doc.textContent).toBe("z");
|
||||||
|
prose.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// And the half this project keeps getting wrong: an answer nothing asks for is not an answer.
|
||||||
|
// ProseMirror stops at the first plugin that claims a character, so a guard behind the plugin
|
||||||
|
// that would have destroyed the cells is a guard the running editor never reaches. TipTap's own
|
||||||
|
// input rules claim handleTextInput too, which is what this is measured against.
|
||||||
|
it("is the first plugin in the list that claims a typed character", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
const claimants = editor.state.plugins.flatMap((plugin, index) =>
|
||||||
|
plugin.props?.handleTextInput ? [index] : [],
|
||||||
|
);
|
||||||
|
const guard = editor.state.plugins.findIndex((plugin) => plugin.spec.key === typingKey);
|
||||||
|
|
||||||
|
expect(guard).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(claimants[0]).toBe(guard);
|
||||||
|
expect(claimants.length).toBeGreaterThan(1);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the row and column ops", () => {
|
||||||
|
it("adds and removes rows where the cursor is", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
cursorIn(editor, 1, 0);
|
||||||
|
|
||||||
|
expect(tableCommand(editor, "addRowAfter")).toBe(true);
|
||||||
|
expect(shape(editor)).toEqual([["a", "b", "c"], ["1", "2", "3"], ["", "", ""], ["4", "5", "6"]]);
|
||||||
|
|
||||||
|
cursorIn(editor, 2, 0);
|
||||||
|
expect(tableCommand(editor, "deleteRow")).toBe(true);
|
||||||
|
expect(shape(editor)).toEqual(GRID);
|
||||||
|
|
||||||
|
cursorIn(editor, 1, 0);
|
||||||
|
expect(tableCommand(editor, "addRowBefore")).toBe(true);
|
||||||
|
expect(shape(editor)).toEqual([["a", "b", "c"], ["", "", ""], ["1", "2", "3"], ["4", "5", "6"]]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds and removes columns where the cursor is", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
cursorIn(editor, 0, 1);
|
||||||
|
|
||||||
|
expect(tableCommand(editor, "addColumnAfter")).toBe(true);
|
||||||
|
expect(shape(editor)[0]).toEqual(["a", "b", "", "c"]);
|
||||||
|
expect(kinds(editor)[0]).toEqual(["tableHeader", "tableHeader", "tableHeader", "tableHeader"]);
|
||||||
|
|
||||||
|
cursorIn(editor, 0, 2);
|
||||||
|
expect(tableCommand(editor, "deleteColumn")).toBe(true);
|
||||||
|
expect(shape(editor)).toEqual(GRID);
|
||||||
|
|
||||||
|
cursorIn(editor, 0, 0);
|
||||||
|
expect(tableCommand(editor, "addColumnBefore")).toBe(true);
|
||||||
|
expect(shape(editor)[1]).toEqual(["", "1", "2", "3"]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the whole table out, leaving a document behind", () => {
|
||||||
|
const editor = makeEditor({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: "before" }] }, ...tableDoc(GRID).content!],
|
||||||
|
});
|
||||||
|
editor.commands.setTextSelection(editor.state.doc.content.size - 4);
|
||||||
|
|
||||||
|
expect(tableCommand(editor, "deleteTable")).toBe(true);
|
||||||
|
expect(editor.state.doc.childCount).toBe(1);
|
||||||
|
expect(editor.state.doc.textContent).toBe("before");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes a table that is the whole document out, leaving something behind", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
cursorIn(editor, 0, 0);
|
||||||
|
|
||||||
|
expect(tableCommand(editor, "deleteTable")).toBe(true);
|
||||||
|
expect(editor.state.doc.childCount).toBe(1);
|
||||||
|
expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
|
||||||
|
expect(editor.state.doc.textContent).toBe("");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// prosemirror-tables builds a new cell from the one it is standing beside, so all three of these
|
||||||
|
// leave a table whose first row is not the row the serializer will write as the header. What is
|
||||||
|
// on screen after the edit has to be what the file says, or the edit is taken back the next time
|
||||||
|
// the document is opened.
|
||||||
|
it("keeps the header first when a row goes in above it", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
cursorIn(editor, 0, 0);
|
||||||
|
|
||||||
|
expect(tableCommand(editor, "addRowBefore")).toBe(true);
|
||||||
|
expect(kinds(editor)).toEqual([
|
||||||
|
["tableHeader", "tableHeader", "tableHeader"],
|
||||||
|
["tableCell", "tableCell", "tableCell"],
|
||||||
|
["tableCell", "tableCell", "tableCell"],
|
||||||
|
["tableCell", "tableCell", "tableCell"],
|
||||||
|
]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the header first when a column goes in in front of it", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
cursorIn(editor, 0, 0);
|
||||||
|
|
||||||
|
expect(tableCommand(editor, "addColumnBefore")).toBe(true);
|
||||||
|
expect(kinds(editor)[0]).toEqual(["tableHeader", "tableHeader", "tableHeader", "tableHeader"]);
|
||||||
|
expect(kinds(editor)[1]).toEqual(["tableCell", "tableCell", "tableCell", "tableCell"]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The last row and the last column decline rather than emptying the table out, which is
|
||||||
|
// prosemirror-tables' own answer and the right one: a table with no rows is not something GFM can
|
||||||
|
// write, and Delete table is the op that means what this would have meant.
|
||||||
|
it("will not take the last row or the last column", () => {
|
||||||
|
for (const op of ["deleteRow", "deleteColumn"] as const) {
|
||||||
|
const editor = makeEditor(tableDoc([["a"]]));
|
||||||
|
cursorIn(editor, 0, 0);
|
||||||
|
const before = editor.state.doc.toJSON();
|
||||||
|
|
||||||
|
expect([op, tableCommand(editor, op)]).toEqual([op, false]);
|
||||||
|
expect([op, editor.state.doc.toJSON()]).toEqual([op, before]);
|
||||||
|
editor.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a header row when the header row is the one deleted", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
cursorIn(editor, 0, 0);
|
||||||
|
|
||||||
|
expect(tableCommand(editor, "deleteRow")).toBe(true);
|
||||||
|
expect(shape(editor)).toEqual([GRID[1], GRID[2]]);
|
||||||
|
expect(kinds(editor)[0]).toEqual(["tableHeader", "tableHeader", "tableHeader"]);
|
||||||
|
expect(kinds(editor)[1]).toEqual(["tableCell", "tableCell", "tableCell"]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing at all with the cursor outside a table", () => {
|
||||||
|
const editor = makeEditor({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: "x" }] }],
|
||||||
|
});
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
const before = editor.state.doc.toJSON();
|
||||||
|
|
||||||
|
for (const op of ["addRowAfter", "deleteRow", "addColumnAfter", "deleteColumn", "deleteTable", "alignCenter"] as const) {
|
||||||
|
expect([op, tableCommand(editor, op)]).toEqual([op, false]);
|
||||||
|
}
|
||||||
|
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("alignment", () => {
|
||||||
|
it("writes the whole column, header included, and nothing beside it", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
cursorIn(editor, 2, 1);
|
||||||
|
|
||||||
|
expect(tableCommand(editor, "alignCenter")).toBe(true);
|
||||||
|
expect(aligns(editor)).toEqual([
|
||||||
|
[null, "center", null],
|
||||||
|
[null, "center", null],
|
||||||
|
[null, "center", null],
|
||||||
|
]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reaches the serializer's delimiter row, and comes back off it", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
cursorIn(editor, 1, 0);
|
||||||
|
tableCommand(editor, "alignRight");
|
||||||
|
cursorIn(editor, 1, 2);
|
||||||
|
tableCommand(editor, "alignCenter");
|
||||||
|
|
||||||
|
expect(written(editor)).toBe(
|
||||||
|
["| a | b | c |", "| -: | - | :-: |", "| 1 | 2 | 3 |", "| 4 | 5 | 6 |", ""].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
cursorIn(editor, 1, 0);
|
||||||
|
expect(tableCommand(editor, "alignClear")).toBe(true);
|
||||||
|
cursorIn(editor, 1, 2);
|
||||||
|
expect(tableCommand(editor, "alignClear")).toBe(true);
|
||||||
|
expect(written(editor)).toBe(
|
||||||
|
["| a | b | c |", "| - | - | - |", "| 1 | 2 | 3 |", "| 4 | 5 | 6 |", ""].join("\n"),
|
||||||
|
);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers every column a cell selection touches", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
selectCells(editor, [0, 0], [1, 1]);
|
||||||
|
|
||||||
|
expect(tableCommand(editor, "alignLeft")).toBe(true);
|
||||||
|
expect(aligns(editor)).toEqual([
|
||||||
|
["left", "left", null],
|
||||||
|
["left", "left", null],
|
||||||
|
["left", "left", null],
|
||||||
|
]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declines a column that already reads that way", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
cursorIn(editor, 0, 0);
|
||||||
|
|
||||||
|
expect(tableCommand(editor, "alignLeft")).toBe(true);
|
||||||
|
expect(tableCommand(editor, "alignLeft")).toBe(false);
|
||||||
|
expect(tableCommand(editor, "alignClear")).toBe(true);
|
||||||
|
expect(tableCommand(editor, "alignClear")).toBe(false);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a row added above the header row", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
cursorIn(editor, 1, 2);
|
||||||
|
tableCommand(editor, "alignRight");
|
||||||
|
cursorIn(editor, 0, 0);
|
||||||
|
|
||||||
|
expect(tableCommand(editor, "addRowBefore")).toBe(true);
|
||||||
|
expect(aligns(editor)).toEqual([
|
||||||
|
[null, null, "right"],
|
||||||
|
[null, null, "right"],
|
||||||
|
[null, null, "right"],
|
||||||
|
[null, null, "right"],
|
||||||
|
]);
|
||||||
|
expect(written(editor).split("\n")[1]).toBe("| - | - | -: |");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays with its own column when a column beside it goes", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
cursorIn(editor, 1, 2);
|
||||||
|
tableCommand(editor, "alignCenter");
|
||||||
|
cursorIn(editor, 1, 0);
|
||||||
|
|
||||||
|
expect(tableCommand(editor, "deleteColumn")).toBe(true);
|
||||||
|
expect(aligns(editor)[0]).toEqual([null, "center"]);
|
||||||
|
expect(written(editor).split("\n")[1]).toBe("| - | :-: |");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not follow a new column in beside it", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
cursorIn(editor, 1, 0);
|
||||||
|
tableCommand(editor, "alignCenter");
|
||||||
|
|
||||||
|
expect(tableCommand(editor, "addColumnAfter")).toBe(true);
|
||||||
|
expect(aligns(editor)[0]).toEqual(["center", null, null, null]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// prosemirror-tables builds a new cell from the attribute's default, so every one of these would
|
||||||
|
// leave a column disagreeing with itself. The delimiter row is written off the first row, which
|
||||||
|
// makes the row added above it the one that would take the whole table's alignment off.
|
||||||
|
it("survives a row added under it", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
cursorIn(editor, 1, 1);
|
||||||
|
tableCommand(editor, "alignCenter");
|
||||||
|
cursorIn(editor, 2, 2);
|
||||||
|
press(editor, "Tab");
|
||||||
|
|
||||||
|
expect(aligns(editor)[3]).toEqual([null, "center", null]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dragging a column edge is the one edit the file has nowhere to put. It is asserted rather than
|
||||||
|
// assumed because the failure is invisible: the markdown is identical, so a resize looks saved and
|
||||||
|
// is gone the next time the document is opened. See the note at the top of tables.ts.
|
||||||
|
describe("a resized column", () => {
|
||||||
|
it("changes the document without changing a byte of the markdown", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
const before = written(editor);
|
||||||
|
|
||||||
|
const pos = inCell(editor, 0, 0) - 1;
|
||||||
|
const cell = editor.state.doc.nodeAt(pos)!;
|
||||||
|
editor.view.dispatch(
|
||||||
|
editor.state.tr.setNodeMarkup(pos, null, { ...cell.attrs, colwidth: [180] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(table(editor).firstChild!.firstChild!.attrs.colwidth).toEqual([180]);
|
||||||
|
expect(written(editor)).toBe(before);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A table indented under a bullet, which is the one place a table has structure around it that
|
||||||
|
// another extension's keys will act on. Every key this lane binds is asked here, because what is
|
||||||
|
// behind each of them is a list command that reshapes the list rather than the table: a key that
|
||||||
|
// falls through from inside a cell can take the bullet out from under the table the cursor is in.
|
||||||
|
const NESTED: JSONContent = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "T" }] },
|
||||||
|
{
|
||||||
|
type: "bulletList",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "listItem",
|
||||||
|
content: [
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "item" }] },
|
||||||
|
...tableDoc([["a", "b"], ["c", "d"]]).content!,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "listItem",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: "next" }] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The node names from the document down to the cursor, which is what a lifted list item loses. */
|
||||||
|
function path(editor: Editor): string[] {
|
||||||
|
const { $from } = editor.state.selection;
|
||||||
|
const names: string[] = [];
|
||||||
|
for (let depth = 1; depth <= $from.depth; depth += 1) names.push($from.node(depth).type.name);
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("a table nested in a list item", () => {
|
||||||
|
it("stays where it is when Shift-Tab is pressed in the first cell", () => {
|
||||||
|
const editor = makeEditor(NESTED);
|
||||||
|
cursorIn(editor, 0, 0);
|
||||||
|
const before = editor.state.doc.toJSON();
|
||||||
|
const at = editor.state.selection.from;
|
||||||
|
|
||||||
|
expect(path(editor)).toEqual(["bulletList", "listItem", "table", "tableRow", "tableHeader"]);
|
||||||
|
press(editor, "Tab", true);
|
||||||
|
|
||||||
|
// Nowhere to go, so nothing moves. What must not happen is the key reaching the list command
|
||||||
|
// behind this lane's binding, which lifts the item and dissolves the list around the table.
|
||||||
|
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||||
|
expect(editor.state.selection.from).toBe(at);
|
||||||
|
expect(path(editor)).toEqual(["bulletList", "listItem", "table", "tableRow", "tableHeader"]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps its list when Backspace is pressed at the start of the first cell", () => {
|
||||||
|
const editor = makeEditor(NESTED);
|
||||||
|
cursorIn(editor, 0, 0);
|
||||||
|
const before = editor.state.doc.toJSON();
|
||||||
|
|
||||||
|
press(editor, "Backspace");
|
||||||
|
|
||||||
|
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps its list when Delete is pressed at the end of the last cell", () => {
|
||||||
|
const editor = makeEditor(NESTED);
|
||||||
|
const map = TableMap.get(table(editor));
|
||||||
|
const row = map.height - 1;
|
||||||
|
const column = map.width - 1;
|
||||||
|
const cell = table(editor).nodeAt(map.map[row * map.width + column])!;
|
||||||
|
editor.commands.setTextSelection(inCell(editor, row, column) + cell.content.size);
|
||||||
|
const before = editor.state.doc.toJSON();
|
||||||
|
|
||||||
|
press(editor, "Delete");
|
||||||
|
|
||||||
|
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A paste is not this lane's feature and prosemirror-tables claims one, which is exactly why the
|
||||||
|
// assertion is here: this file installs that plugin, so what it does with a paste is this file's
|
||||||
|
// answer to give. Over a rectangle of dragged cells the library replaces the content of every cell
|
||||||
|
// in the rectangle with the slice, whatever the slice is. One word replaced six cells. An image on
|
||||||
|
// the clipboard carries no HTML and no text, so the slice ProseMirror hands along is the empty one
|
||||||
|
// and six cells were emptied by a paste that put nothing anywhere.
|
||||||
|
//
|
||||||
|
// Both were reproduced against the plugin order this file used to run: pasting the word `z` over
|
||||||
|
// a two by three rectangle gave [["z","z","z"],["z","z","z"],["4","5","6"]], and Slice.empty over
|
||||||
|
// the same rectangle gave [["","",""],["","",""],["4","5","6"]]. The app's clipboard plugin now
|
||||||
|
// sits in front of the library's and refuses both, and stands aside for the one paste the library
|
||||||
|
// does better, which is cells over cells.
|
||||||
|
//
|
||||||
|
// The walk below is EditorView.prototype.someProp rather than a loop over `editor.state.plugins`
|
||||||
|
// written here. They agree today, and the point is that this asks the question the running editor
|
||||||
|
// asks instead of a modelled one: the props the component puts on the view come first, then the
|
||||||
|
// direct plugins, then the state's, and a guard written in the wrong one of those three answers
|
||||||
|
// nothing. Four guards in this project have been shipped and never reached, and every test that
|
||||||
|
// missed one was a test that called the handler itself.
|
||||||
|
describe("a paste over a dragged rectangle of cells", () => {
|
||||||
|
const paste = (editor: Editor, slice: Slice): boolean => {
|
||||||
|
const event = { preventDefault: () => {} } as unknown as ClipboardEvent;
|
||||||
|
const view = {
|
||||||
|
get state() {
|
||||||
|
return editor.state;
|
||||||
|
},
|
||||||
|
dispatch: (tr: Transaction) => editor.view.dispatch(tr),
|
||||||
|
directPlugins: [],
|
||||||
|
_props: {},
|
||||||
|
someProp: EditorView.prototype.someProp,
|
||||||
|
focus: () => {},
|
||||||
|
dom: null,
|
||||||
|
composing: false,
|
||||||
|
dragging: null,
|
||||||
|
editable: true,
|
||||||
|
} as unknown as EditorView;
|
||||||
|
return view.someProp("handlePaste", (f) => f(view, event, slice)) === true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The clipboard a real copy out of a table puts there, which is the one shape to stand aside for. */
|
||||||
|
const cellSlice = (editor: Editor, from: [number, number], to: [number, number]): Slice => {
|
||||||
|
selectCells(editor, from, to);
|
||||||
|
return editor.state.selection.content();
|
||||||
|
};
|
||||||
|
|
||||||
|
it("leaves every cell alone when the clipboard carried nothing", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
const before = written(editor);
|
||||||
|
selectCells(editor, [0, 0], [1, 2]);
|
||||||
|
|
||||||
|
expect(paste(editor, Slice.empty)).toBe(true);
|
||||||
|
|
||||||
|
expect(shape(editor)).toEqual(GRID);
|
||||||
|
expect(written(editor)).toBe(before);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves every cell alone when the clipboard carried a word", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
const before = written(editor);
|
||||||
|
selectCells(editor, [0, 0], [1, 2]);
|
||||||
|
|
||||||
|
expect(paste(editor, textSlice(editor, "z"))).toBe(true);
|
||||||
|
|
||||||
|
// Six cells for one word is not a paste anybody meant, and it is not undoable in the file: the
|
||||||
|
// save lands half a second later whether or not the user has noticed yet.
|
||||||
|
expect(shape(editor)).toEqual(GRID);
|
||||||
|
expect(written(editor)).toBe(before);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still lays out a rectangle of cells copied out of a table", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
const copied = cellSlice(editor, [1, 0], [1, 1]);
|
||||||
|
selectCells(editor, [2, 0], [2, 1]);
|
||||||
|
|
||||||
|
expect(paste(editor, copied)).toBe(true);
|
||||||
|
|
||||||
|
// The library's own edit, kept because it is better than anything this app would do with it: it
|
||||||
|
// lays the cells out over the rectangle and keeps every boundary the user copied. Refusing this
|
||||||
|
// would be the guard destroying a paste in order to guard it.
|
||||||
|
expect(shape(editor)).toEqual([
|
||||||
|
["a", "b", "c"],
|
||||||
|
["1", "2", "3"],
|
||||||
|
["1", "2", "6"],
|
||||||
|
]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a word into the one cell the caret is in", () => {
|
||||||
|
const editor = makeEditor(tableDoc(GRID));
|
||||||
|
cursorIn(editor, 1, 1);
|
||||||
|
editor.commands.setTextSelection(inCell(editor, 1, 1) + 1);
|
||||||
|
|
||||||
|
// Nobody claims it, so ProseMirror's own handler runs and does the ordinary thing. Refusing a
|
||||||
|
// paste at a caret in a cell would have been this guard overreaching in the other direction.
|
||||||
|
expect(paste(editor, textSlice(editor, "z"))).toBe(false);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
// Table behaviour: everything about editing a GFM table that is not its shape.
|
||||||
|
//
|
||||||
|
// The shape is already in src/model/schema.ts and generated into an extension by extensions.ts, so
|
||||||
|
// nothing here declares a node. What is missing is the behaviour prosemirror-tables carries: the
|
||||||
|
// cell selection, Tab between cells, and the row and column edits a toolbar asks for by name. That
|
||||||
|
// library ships inside @tiptap/pm/tables and reads the `tableRole` extensions.ts already puts on
|
||||||
|
// each spec, so it plugs in whole rather than being reimplemented.
|
||||||
|
//
|
||||||
|
// Alignment is the one thing the library has no idea about, and it runs through everything below.
|
||||||
|
// `align` is a cell attribute the bridge reads back out of the GFM delimiter row, and that row is
|
||||||
|
// per column: markdown cannot say that one cell is centred and the rest of its column is not. So an
|
||||||
|
// align op writes the whole column, and a row added into a column has to be told what that column
|
||||||
|
// says, because prosemirror-tables builds its new cells from the attribute's default. The
|
||||||
|
// serializer reads the delimiter row off the table's first row, which makes a row added above the
|
||||||
|
// first one the worst case: left alone it would take the whole table's alignment off the next time
|
||||||
|
// the file was written.
|
||||||
|
//
|
||||||
|
// The other thing markdown cannot follow is the shape of the header. A GFM table has exactly one
|
||||||
|
// header row, it is the first one, and there is no spelling for a table without one, so the ops
|
||||||
|
// here keep the document to that shape rather than offering edits the file cannot hold. That is
|
||||||
|
// also why there is no header row toggle: both directions of it are a change the next open of the
|
||||||
|
// file silently takes back.
|
||||||
|
//
|
||||||
|
// One thing the library does that the file cannot follow either: dragging a column edge writes
|
||||||
|
// `colwidth` on to every cell in that column, and GFM has no column widths for the serializer to
|
||||||
|
// put them in. The drag is a real document change all the same, and src/document.ts is where it
|
||||||
|
// stops being one: a transaction that only moved something the markdown cannot spell does not mark
|
||||||
|
// the buffer dirty, so the drag never reaches the debounce and no save is scheduled behind it.
|
||||||
|
|
||||||
|
import { Extension } from "@tiptap/core";
|
||||||
|
import type { Editor } from "@tiptap/core";
|
||||||
|
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||||
|
import type { Command, Transaction } from "@tiptap/pm/state";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import {
|
||||||
|
TableMap,
|
||||||
|
addColumnAfter,
|
||||||
|
addColumnBefore,
|
||||||
|
addRow,
|
||||||
|
columnResizing,
|
||||||
|
deleteCellSelection,
|
||||||
|
deleteColumn,
|
||||||
|
deleteRow,
|
||||||
|
deleteTable,
|
||||||
|
goToNextCell,
|
||||||
|
isInTable,
|
||||||
|
selectedRect,
|
||||||
|
tableEditing,
|
||||||
|
} from "@tiptap/pm/tables";
|
||||||
|
import type { TableRect } from "@tiptap/pm/tables";
|
||||||
|
import type { ColumnAlign } from "../../model/doc";
|
||||||
|
import { overCells } from "../fits";
|
||||||
|
import type { TableOp } from "../index";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The typing guard, named so that a test can find it in the plugin list and say where in that list
|
||||||
|
* it sits. Being right about a rectangle of cells is worth nothing if something else is asked
|
||||||
|
* first, which is the mistake this lane has already made once with a paste.
|
||||||
|
*/
|
||||||
|
export const typingKey = new PluginKey("tableTyping");
|
||||||
|
|
||||||
|
/** What each column says, read where the serializer reads it: the table's first row. */
|
||||||
|
function columnAlignments(table: ProseMirrorNode): ColumnAlign[] {
|
||||||
|
const map = TableMap.get(table);
|
||||||
|
return Array.from(
|
||||||
|
{ length: map.width },
|
||||||
|
(_unused, column) => (table.nodeAt(map.map[column])?.attrs.align ?? null) as ColumnAlign,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every cell of every column made to agree with `alignment`, whatever the edit left behind. */
|
||||||
|
function restoreAlignments(tr: Transaction, tablePos: number, alignment: ColumnAlign[]): void {
|
||||||
|
const table = tr.doc.nodeAt(tablePos);
|
||||||
|
if (!table) return;
|
||||||
|
const map = TableMap.get(table);
|
||||||
|
const columns = Math.min(map.width, alignment.length);
|
||||||
|
const done = new Set<number>();
|
||||||
|
|
||||||
|
for (let row = 0; row < map.height; row += 1) {
|
||||||
|
for (let column = 0; column < columns; column += 1) {
|
||||||
|
const pos = map.map[row * map.width + column];
|
||||||
|
if (done.has(pos)) continue;
|
||||||
|
done.add(pos);
|
||||||
|
const cell = table.nodeAt(pos);
|
||||||
|
// Null for the type: a header cell that is centred is still a header cell, and setNodeMarkup
|
||||||
|
// is the only way to change an attribute and keep both the type and the content.
|
||||||
|
if (cell && cell.attrs.align !== alignment[column]) {
|
||||||
|
tr.setNodeMarkup(tablePos + 1 + pos, null, { ...cell.attrs, align: alignment[column] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row zero holding header cells and every other row holding body cells, whatever the edit left.
|
||||||
|
*
|
||||||
|
* GFM has one shape for a table: the first row is the header and the delimiter row under it is what
|
||||||
|
* makes the block a table at all. serialize.ts writes the first row as the header whichever kind of
|
||||||
|
* cell it is holding, so a table that says otherwise on screen is a table that comes back different
|
||||||
|
* the next time the file is opened. prosemirror-tables copies the type of the cell it is building
|
||||||
|
* beside, which is how a row added above the header, or a column added in front of it, leaves body
|
||||||
|
* cells in row zero.
|
||||||
|
*/
|
||||||
|
function normaliseHeaderRow(tr: Transaction, tablePos: number): void {
|
||||||
|
const table = tr.doc.nodeAt(tablePos);
|
||||||
|
if (!table || table.type.name !== "table") return;
|
||||||
|
const map = TableMap.get(table);
|
||||||
|
const types = table.type.schema.nodes;
|
||||||
|
const done = new Set<number>();
|
||||||
|
|
||||||
|
for (let row = 0; row < map.height; row += 1) {
|
||||||
|
const want = row === 0 ? types.tableHeader : types.tableCell;
|
||||||
|
for (let column = 0; column < map.width; column += 1) {
|
||||||
|
const pos = map.map[row * map.width + column];
|
||||||
|
if (done.has(pos)) continue;
|
||||||
|
done.add(pos);
|
||||||
|
const cell = table.nodeAt(pos);
|
||||||
|
// Both cell types hold inline content, so this changes the type and keeps the text and the
|
||||||
|
// alignment that were in it.
|
||||||
|
if (cell && cell.type !== want) tr.setNodeMarkup(tablePos + 1 + pos, want, cell.attrs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab out of the last cell should land in the row it has just made, not stay where it was. */
|
||||||
|
function cursorIntoLastRow(tr: Transaction, tablePos: number): void {
|
||||||
|
const table = tr.doc.nodeAt(tablePos);
|
||||||
|
if (!table) return;
|
||||||
|
const map = TableMap.get(table);
|
||||||
|
const cell = tablePos + 1 + map.positionAt(map.height - 1, 0, table);
|
||||||
|
tr.setSelection(TextSelection.near(tr.doc.resolve(cell + 1))).scrollIntoView();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A row added where `at` says, with the alignments the table already had put back over it.
|
||||||
|
*
|
||||||
|
* One transaction rather than a command each, so that Tab out of the last cell is one Cmd+Z rather
|
||||||
|
* than two, and so that the document is never momentarily a table whose column disagrees with its
|
||||||
|
* own delimiter row.
|
||||||
|
*/
|
||||||
|
function insertRow(
|
||||||
|
at: (rect: TableRect) => number,
|
||||||
|
then?: (tr: Transaction, tablePos: number) => void,
|
||||||
|
): Command {
|
||||||
|
return (state, dispatch) => {
|
||||||
|
if (!isInTable(state)) return false;
|
||||||
|
if (dispatch) {
|
||||||
|
const rect = selectedRect(state);
|
||||||
|
const alignment = columnAlignments(rect.table);
|
||||||
|
const tr = addRow(state.tr, rect, at(rect));
|
||||||
|
// tableStart is the position just inside the table, so one before it is the table itself,
|
||||||
|
// and every row went in after that point rather than before it.
|
||||||
|
const tablePos = rect.tableStart - 1;
|
||||||
|
restoreAlignments(tr, tablePos, alignment);
|
||||||
|
normaliseHeaderRow(tr, tablePos);
|
||||||
|
if (then) then(tr, tablePos);
|
||||||
|
dispatch(tr);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const addRowAbove = insertRow((rect) => rect.top);
|
||||||
|
const addRowBelow = insertRow((rect) => rect.bottom);
|
||||||
|
const addRowAtEnd = insertRow((rect) => rect.map.height, cursorIntoLastRow);
|
||||||
|
|
||||||
|
/** Tab: the next cell along, or the row that has to be made first when there is no next cell. */
|
||||||
|
const nextCellOrNewRow: Command = (state, dispatch) =>
|
||||||
|
goToNextCell(1)(state, dispatch) || addRowAtEnd(state, dispatch);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of prosemirror-tables' own structural commands, with the header row put back over whatever it
|
||||||
|
* produced, in the transaction the command built rather than a second one behind it.
|
||||||
|
*
|
||||||
|
* The library is asked with a dispatch that only catches the transaction, so a command that answers
|
||||||
|
* false still leaves nothing behind, and a table this edit removed outright is a table
|
||||||
|
* `normaliseHeaderRow` declines to find.
|
||||||
|
*/
|
||||||
|
function normalising(command: Command): Command {
|
||||||
|
return (state, dispatch, view) => {
|
||||||
|
if (!isInTable(state)) return false;
|
||||||
|
if (!dispatch) return command(state, undefined, view);
|
||||||
|
|
||||||
|
const tablePos = selectedRect(state).tableStart - 1;
|
||||||
|
let caught: Transaction | null = null;
|
||||||
|
const acted = command(
|
||||||
|
state,
|
||||||
|
(tr) => {
|
||||||
|
caught = tr;
|
||||||
|
},
|
||||||
|
view,
|
||||||
|
);
|
||||||
|
if (!acted || caught === null) return acted;
|
||||||
|
|
||||||
|
normaliseHeaderRow(caught, tablePos);
|
||||||
|
dispatch(caught);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole column the selection covers, header cell included.
|
||||||
|
*
|
||||||
|
* Setting only the cell under the cursor would show an alignment on screen that the next save
|
||||||
|
* silently takes back off, and leaving the header out would lose the alignment outright, since the
|
||||||
|
* first row is the one the delimiter row is written from.
|
||||||
|
*/
|
||||||
|
function alignColumn(align: ColumnAlign): Command {
|
||||||
|
return (state, dispatch) => {
|
||||||
|
if (!isInTable(state)) return false;
|
||||||
|
const { left, right, map, table, tableStart } = selectedRect(state);
|
||||||
|
|
||||||
|
// Positions relative to the table, and a set because a cell that spans columns appears in the
|
||||||
|
// map once per column it covers.
|
||||||
|
const cells = new Set<number>();
|
||||||
|
for (let row = 0; row < map.height; row += 1) {
|
||||||
|
for (let column = left; column < right; column += 1) {
|
||||||
|
const pos = map.map[row * map.width + column];
|
||||||
|
if (table.nodeAt(pos)?.attrs.align !== align) cells.add(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cells.size === 0) return false;
|
||||||
|
|
||||||
|
if (dispatch) {
|
||||||
|
const tr = state.tr;
|
||||||
|
for (const pos of cells) {
|
||||||
|
const cell = table.nodeAt(pos);
|
||||||
|
if (cell) tr.setNodeMarkup(tableStart + pos, null, { ...cell.attrs, align });
|
||||||
|
}
|
||||||
|
dispatch(tr);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The command, with the key claimed for as long as the cursor is in a table, whether or not the
|
||||||
|
* command found anything to do with it.
|
||||||
|
*
|
||||||
|
* A binding that answers false hands the key on to whatever is bound behind it, and behind this
|
||||||
|
* lane's Tab and Shift-Tab is shortcuts.ts's list pair, which reshapes the list around the table
|
||||||
|
* rather than anything inside it. A table indented under a bullet is an ordinary thing to write,
|
||||||
|
* and Shift-Tab in its first cell has nowhere to go: the answer to that is the cursor staying where
|
||||||
|
* it is, not the item being lifted out and the list dissolved by a key pressed to move back a cell.
|
||||||
|
*/
|
||||||
|
function claimedInTable(command: Command): Command {
|
||||||
|
return (state, dispatch, view) => {
|
||||||
|
if (!isInTable(state)) return false;
|
||||||
|
command(state, dispatch, view);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every op the handle can name, as the ProseMirror command that performs it.
|
||||||
|
*
|
||||||
|
* There is no header row op. GFM writes the first row of a table as its header and has no spelling
|
||||||
|
* for a table without one or for a second one, so both directions of a toggle are an edit the
|
||||||
|
* serializer cannot carry and the next open of the file does not show. An op the file cannot hold
|
||||||
|
* is an op that is not offered.
|
||||||
|
*/
|
||||||
|
const TABLE_OPS: { [op in TableOp]: Command } = {
|
||||||
|
addRowBefore: addRowAbove,
|
||||||
|
addRowAfter: addRowBelow,
|
||||||
|
deleteRow: normalising(deleteRow),
|
||||||
|
addColumnBefore: normalising(addColumnBefore),
|
||||||
|
addColumnAfter: normalising(addColumnAfter),
|
||||||
|
deleteColumn: normalising(deleteColumn),
|
||||||
|
deleteTable,
|
||||||
|
alignLeft: alignColumn("left"),
|
||||||
|
alignCenter: alignColumn("center"),
|
||||||
|
alignRight: alignColumn("right"),
|
||||||
|
alignClear: alignColumn(null),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A printable character typed over a rectangle of dragged cells, which does nothing.
|
||||||
|
*
|
||||||
|
* ProseMirror offers a character to `handleTextInput` whenever the selection is not an ordinary one
|
||||||
|
* inside a single textblock, and when nobody claims it the character goes in through
|
||||||
|
* `tr.insertText`, which is `Selection.replace`. A cell selection replaces every range it holds:
|
||||||
|
* the character lands in the LAST cell of the rectangle and the other cells are emptied. Measured,
|
||||||
|
* in a browser, on the table this lane was reported against: a drag across a 2x2 body and the three
|
||||||
|
* keystrokes "zqx" took "| 1 | 2 |\n| 3 | 4 |" to four empty cells with "zqx" sitting in the last
|
||||||
|
* of them. Four cells of somebody's table for three characters, and none of them the cell the drag
|
||||||
|
* started in.
|
||||||
|
*
|
||||||
|
* That is the destruction src/editor/paste.ts refuses for a Cmd+V arriving at the same selection,
|
||||||
|
* and it arrived here by the one route with no guard on it at all.
|
||||||
|
*
|
||||||
|
* Emptying the cells is what Backspace over a rectangle does, in the keymap at the end of this
|
||||||
|
* file, and that is right: delete is the verb that was pressed and the rows and the columns survive
|
||||||
|
* it. A letter is not that verb. A rectangle is a selection of whole cells rather than of text, so
|
||||||
|
* there is no text for a character to replace and no one cell it belongs in: putting it in the
|
||||||
|
* first cell or the last one both throw away cells the user never aimed at, and neither is what
|
||||||
|
* they asked for. So nothing happens, the key is claimed so that nothing else does it either, and
|
||||||
|
* the rectangle stays selected, which leaves Backspace, the toolbar and a click into one cell all
|
||||||
|
* exactly where they were.
|
||||||
|
*/
|
||||||
|
const typing = new Plugin({
|
||||||
|
key: typingKey,
|
||||||
|
props: {
|
||||||
|
handleTextInput: (view) => overCells(view.state),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Tables = Extension.create({
|
||||||
|
name: "tables",
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
// There was a third plugin in front of these two once, and it answered one paste: an empty
|
||||||
|
// slice over a rectangle of cells, which is what a clipboard holding only an image looks like
|
||||||
|
// by the time it reaches a handler. `tableEditing` takes that empty slice and empties every
|
||||||
|
// cell in the rectangle, so a PNG pasted over a dragged table deleted the table's text.
|
||||||
|
//
|
||||||
|
// It was here because src/editor/paste.ts already refused exactly that and was never asked:
|
||||||
|
// these plugins came ninth in the list and that one came fifteenth. The guard sitting in front
|
||||||
|
// of the plugin it guards was the right instinct and the wrong fix, because it only covered
|
||||||
|
// the empty slice, and the same ordering handed the library every non-empty paste over a cell
|
||||||
|
// selection too. One word pasted over four dragged cells replaced all four.
|
||||||
|
//
|
||||||
|
// So the paste ordering is fixed instead: paste.ts asks for the highest priority in the editor,
|
||||||
|
// is asked first for every paste, and stands aside only for cells pasted into a table, which is
|
||||||
|
// the one paste those two are better at. `typing` below is not a second copy of that question,
|
||||||
|
// it is a different event: nothing in this editor claimed a typed character, and a typed
|
||||||
|
// character over a rectangle is the same destruction arriving by the one route nobody guarded.
|
||||||
|
return [
|
||||||
|
typing,
|
||||||
|
// columnResizing before tableEditing, which takes mousedown for the cell selection drag: a
|
||||||
|
// press on a column edge is a resize, and the plugin that decides that has to be asked first.
|
||||||
|
columnResizing(),
|
||||||
|
tableEditing(),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
const editor = this.editor;
|
||||||
|
|
||||||
|
// ProseMirror's own calling convention rather than editor.commands.command, which dispatches
|
||||||
|
// its transaction whatever the command answered. These four are asked on every Tab and every
|
||||||
|
// Backspace in the document, and a key pressed outside a table has to leave nothing behind.
|
||||||
|
const run = (command: Command) => () =>
|
||||||
|
command(editor.state, editor.view.dispatch, editor.view);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Tab out of the last cell grows the table, which is the only way to add a row without
|
||||||
|
// reaching for the toolbar. Shift-Tab has no matching gesture: there is no row before the
|
||||||
|
// first one to make, so in the first cell it moves nothing and answers for the key anyway.
|
||||||
|
// Both are claimed the same way so that neither can be handed on to a list command; see
|
||||||
|
// claimedInTable above for what that costs the document when it is.
|
||||||
|
Tab: run(claimedInTable(nextCellOrNewRow)),
|
||||||
|
"Shift-Tab": run(claimedInTable(goToNextCell(-1))),
|
||||||
|
|
||||||
|
// Said here rather than left to tableEditing's own binding further down the plugin list.
|
||||||
|
// A cell selection has to be emptied and not removed: deleting it as a selection would take
|
||||||
|
// the rows and columns the cells were in along with the text that was in them.
|
||||||
|
//
|
||||||
|
// Not claimed the way the two above are: with a plain cursor in a cell there is nothing to
|
||||||
|
// empty, and a Backspace that stopped here would be a Backspace that never deletes a
|
||||||
|
// character. What is behind these is StarterKit's list keymap, which reads the cursor's
|
||||||
|
// parent as its list item and finds a table row instead, so it declines from inside a cell
|
||||||
|
// and the key reaches the editing it was pressed for. src/editor/blocks/tables.test.ts holds
|
||||||
|
// that assertion, since it is the library's behaviour rather than this file's.
|
||||||
|
Backspace: run(deleteCellSelection),
|
||||||
|
Delete: run(deleteCellSelection),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** False when the cursor is not in a table, or the op has nothing to act on where it is. */
|
||||||
|
export function tableCommand(editor: Editor, op: TableOp): boolean {
|
||||||
|
const command = TABLE_OPS[op];
|
||||||
|
// Read out of the chain rather than off the chain's own result, because focus is in the chain too
|
||||||
|
// and answers a different question, with a false of its own whenever there is no view to focus.
|
||||||
|
let acted = false;
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.command(({ state, dispatch }) => {
|
||||||
|
acted = command(state, dispatch);
|
||||||
|
return acted;
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return acted;
|
||||||
|
}
|
||||||
@@ -0,0 +1,773 @@
|
|||||||
|
// What can be asserted about a toggle without a browser, which is the half that costs somebody a
|
||||||
|
// file.
|
||||||
|
//
|
||||||
|
// vite.config.ts runs vitest in the node environment, so there is no page here and nothing a
|
||||||
|
// browser does with one is on trial: the arrow's drawing, the caret's travel through a title and
|
||||||
|
// the placeholder on an untitled one belong to the Playwright suite. What belongs here is
|
||||||
|
// everything that reaches disk. The two attributes a toggle carries are the two things this lane
|
||||||
|
// writes, and both of them go out through an html block that the bridge will only read back if it
|
||||||
|
// is spelled one exact way, so a title the editor mangles on the way in is a `<details>` that opens
|
||||||
|
// as a raw block the next time the file is looked at.
|
||||||
|
//
|
||||||
|
// The node view is built here all the same, against the page written out at the bottom of this
|
||||||
|
// file, because two of the things it decides reach disk as surely as the attributes do: which
|
||||||
|
// clicks on the summary write `open`, and which commands are allowed to run while the caret is
|
||||||
|
// somewhere the document's selection is not. Both were bugs a green suite did not see, and neither
|
||||||
|
// is a question about a browser. They are questions about what these handlers do with what a
|
||||||
|
// browser sends them.
|
||||||
|
//
|
||||||
|
// The entity group is the one to keep. A summary holding `&`, `<` or `>` is escaped by the
|
||||||
|
// serializer and unescaped by the parser, and the parser refuses to model any toggle those two do
|
||||||
|
// not agree about character for character. An extra round of escaping would not throw, would not
|
||||||
|
// fail a type check and would not lose the document: it would grow another `amp;` in somebody's
|
||||||
|
// heading on every save.
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import type { JSONContent } from "@tiptap/core";
|
||||||
|
import { EditorState, TextSelection } from "@tiptap/pm/state";
|
||||||
|
import type { Plugin, Transaction } from "@tiptap/pm/state";
|
||||||
|
import { DecorationSet } from "@tiptap/pm/view";
|
||||||
|
import type { EditorView } from "@tiptap/pm/view";
|
||||||
|
import { createEditorExtensions } from "../extensions";
|
||||||
|
import { parseMarkdown, serializeMarkdown } from "../../markdown";
|
||||||
|
import { Toggles, setToggleOpen, setToggleSummary } from "./toggle";
|
||||||
|
|
||||||
|
const PATH = "/notes/writing.md";
|
||||||
|
|
||||||
|
const extensions = () => createEditorExtensions({ documentPath: () => PATH, onError: () => {} });
|
||||||
|
|
||||||
|
const EMPTY: JSONContent = { type: "doc", content: [{ type: "paragraph" }] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An editor with the lanes' plugins in its state.
|
||||||
|
*
|
||||||
|
* TipTap only installs them when it mounts a view and there is no DOM here to mount into, so the
|
||||||
|
* state is rebuilt with them the way src/editor/Editor.tsx installs every document it opens. It
|
||||||
|
* matters more here than it does for a keymap: this lane's plugin also appends a transaction, and a
|
||||||
|
* plugin that is not in the state is never asked to.
|
||||||
|
*/
|
||||||
|
function makeEditor(content: JSONContent = EMPTY): Editor {
|
||||||
|
const editor = new Editor({ element: null, injectCSS: false, extensions: extensions(), content });
|
||||||
|
editor.view.updateState(
|
||||||
|
EditorState.create({ doc: editor.state.doc, plugins: editor.extensionManager.plugins }),
|
||||||
|
);
|
||||||
|
return editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorFor(source: string): Editor {
|
||||||
|
return makeEditor(parseMarkdown(source, PATH).doc.toJSON());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the bridge would write for the document as it stands now. */
|
||||||
|
function written(source: string, editor: Editor): string {
|
||||||
|
return serializeMarkdown(parseMarkdown(source, PATH), editor.state.doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the first toggle in the document is, and what it holds. */
|
||||||
|
function toggleIn(editor: Editor): { pos: number; open: boolean; summary: string; body: string } {
|
||||||
|
let found: { pos: number; open: boolean; summary: string; body: string } | null = null;
|
||||||
|
editor.state.doc.descendants((node, pos) => {
|
||||||
|
if (found || node.type.name !== "toggle") return !found;
|
||||||
|
found = {
|
||||||
|
pos,
|
||||||
|
open: node.attrs.open as boolean,
|
||||||
|
summary: node.attrs.summary as string,
|
||||||
|
body: node.textContent,
|
||||||
|
};
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (!found) throw new Error("no toggle in the document");
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The plugins offering a node view for the toggle node, found the way the view finds them. */
|
||||||
|
function nodeViewPlugins(editor: Editor): Plugin[] {
|
||||||
|
return editor.extensionManager.plugins.filter(
|
||||||
|
(plugin) => plugin.props.nodeViews?.toggle !== undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUMMARY = "Tom & Jerry <3>";
|
||||||
|
const PLAIN = "Tom & Jerry <3>";
|
||||||
|
|
||||||
|
const DOC = [
|
||||||
|
"# Writing",
|
||||||
|
"",
|
||||||
|
"<details>",
|
||||||
|
`<summary>${SUMMARY}</summary>`,
|
||||||
|
"",
|
||||||
|
"No em dashes.",
|
||||||
|
"",
|
||||||
|
"</details>",
|
||||||
|
"",
|
||||||
|
"After.",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
describe("the toggle extension", () => {
|
||||||
|
it("is the one the registry names", () => {
|
||||||
|
expect(Toggles.name).toBe("toggles");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds no node and no mark, so the bridge and the editor still agree", () => {
|
||||||
|
const plain = new Editor({
|
||||||
|
element: null,
|
||||||
|
injectCSS: false,
|
||||||
|
extensions: extensions().filter((extension) => extension.name !== "toggles"),
|
||||||
|
content: EMPTY,
|
||||||
|
});
|
||||||
|
const withToggles = makeEditor();
|
||||||
|
|
||||||
|
expect(Object.keys(withToggles.schema.nodes)).toEqual(Object.keys(plain.schema.nodes));
|
||||||
|
expect(Object.keys(withToggles.schema.marks)).toEqual(Object.keys(plain.schema.marks));
|
||||||
|
|
||||||
|
plain.destroy();
|
||||||
|
withToggles.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The node view is the whole feature: without one the browser's own disclosure takes the click,
|
||||||
|
// the open attribute never moves and a keystroke aimed at the title lands in the body. Two
|
||||||
|
// plugins claiming the node would be the same bug from the other end, since ProseMirror takes the
|
||||||
|
// first one asked and the other never runs.
|
||||||
|
it("is the only plugin in the build that claims the toggle node view", () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
|
||||||
|
expect(nodeViewPlugins(editor)).toHaveLength(1);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a <details> read off disk", () => {
|
||||||
|
it("arrives as a toggle carrying its summary as text, not as entities", () => {
|
||||||
|
const editor = editorFor(DOC);
|
||||||
|
const toggle = toggleIn(editor);
|
||||||
|
|
||||||
|
expect(toggle.summary).toBe(PLAIN);
|
||||||
|
expect(toggle.open).toBe(false);
|
||||||
|
expect(toggle.body).toBe("No em dashes.");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is written back byte for byte when nothing was edited", () => {
|
||||||
|
const editor = editorFor(DOC);
|
||||||
|
|
||||||
|
expect(written(DOC, editor)).toBe(DOC);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the whole file byte identical when only the summary is edited", () => {
|
||||||
|
const editor = editorFor(DOC);
|
||||||
|
const { pos } = toggleIn(editor);
|
||||||
|
|
||||||
|
expect(setToggleSummary(pos, "Tom & Jerry <4>")(editor.state, editor.view.dispatch)).toBe(true);
|
||||||
|
expect(written(DOC, editor)).toBe(DOC.replace("<3>", "<4>"));
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The failure this is shaped to catch is invisible: an editor that put the escaped form on the
|
||||||
|
// node would write `&amp;` here, the file would still parse, and the title would grow a word
|
||||||
|
// every time the document was saved.
|
||||||
|
it("does not escape the ampersand it already escaped once", () => {
|
||||||
|
const editor = editorFor(DOC);
|
||||||
|
const { pos } = toggleIn(editor);
|
||||||
|
|
||||||
|
// The one keystroke: a character on the end of a title that already holds all three of the
|
||||||
|
// characters the serializer has to spell as entities.
|
||||||
|
setToggleSummary(pos, `${PLAIN}!`)(editor.state, editor.view.dispatch);
|
||||||
|
const once = written(DOC, editor);
|
||||||
|
expect(once).toBe(DOC.replace(SUMMARY, `${SUMMARY}!`));
|
||||||
|
|
||||||
|
// And back through the bridge, which is where a second round of escaping would show up.
|
||||||
|
const again = makeEditor(parseMarkdown(once, PATH).doc.toJSON());
|
||||||
|
expect(toggleIn(again).summary).toBe(`${PLAIN}!`);
|
||||||
|
expect(written(once, again)).toBe(once);
|
||||||
|
again.destroy();
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the open marker on to the tag, and takes it off again", () => {
|
||||||
|
const editor = editorFor(DOC);
|
||||||
|
const { pos } = toggleIn(editor);
|
||||||
|
|
||||||
|
expect(setToggleOpen(pos, true)(editor.state, editor.view.dispatch)).toBe(true);
|
||||||
|
expect(written(DOC, editor)).toBe(DOC.replace("<details>", "<details open>"));
|
||||||
|
|
||||||
|
expect(setToggleOpen(pos, false)(editor.state, editor.view.dispatch)).toBe(true);
|
||||||
|
expect(written(DOC, editor)).toBe(DOC);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the body alone whatever happens to the two attributes", () => {
|
||||||
|
const editor = editorFor(DOC);
|
||||||
|
const { pos, body } = toggleIn(editor);
|
||||||
|
|
||||||
|
setToggleSummary(pos, "Something else")(editor.state, editor.view.dispatch);
|
||||||
|
setToggleOpen(pos, true)(editor.state, editor.view.dispatch);
|
||||||
|
|
||||||
|
expect(toggleIn(editor).body).toBe(body);
|
||||||
|
expect(editor.state.doc.textContent).toBe(editorFor(DOC).state.doc.textContent);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the two commands", () => {
|
||||||
|
it("decline a position that is not a toggle, and one that already reads that way", () => {
|
||||||
|
const editor = editorFor(DOC);
|
||||||
|
const { pos, summary } = toggleIn(editor);
|
||||||
|
const before = editor.state.doc.toJSON();
|
||||||
|
|
||||||
|
expect(setToggleOpen(0, true)(editor.state, editor.view.dispatch)).toBe(false);
|
||||||
|
expect(setToggleSummary(0, "x")(editor.state, editor.view.dispatch)).toBe(false);
|
||||||
|
expect(setToggleOpen(pos, false)(editor.state, editor.view.dispatch)).toBe(false);
|
||||||
|
expect(setToggleSummary(pos, summary)(editor.state, editor.view.dispatch)).toBe(false);
|
||||||
|
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A closed <details> does not draw its children, so a caret left in one is a caret nobody can see
|
||||||
|
// and the next keystroke goes somewhere invisible.
|
||||||
|
it("bring the caret out of a body that is being closed", () => {
|
||||||
|
const editor = editorFor(DOC);
|
||||||
|
const { pos } = toggleIn(editor);
|
||||||
|
setToggleOpen(pos, true)(editor.state, editor.view.dispatch);
|
||||||
|
|
||||||
|
const inside = pos + 2;
|
||||||
|
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, inside)));
|
||||||
|
expect(editor.state.selection.from).toBe(inside);
|
||||||
|
|
||||||
|
setToggleOpen(pos, false)(editor.state, editor.view.dispatch);
|
||||||
|
const node = editor.state.doc.nodeAt(pos)!;
|
||||||
|
expect(editor.state.selection.from >= pos + node.nodeSize).toBe(true);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leave the caret where it is when it was never inside", () => {
|
||||||
|
const editor = editorFor(DOC);
|
||||||
|
const { pos } = toggleIn(editor);
|
||||||
|
setToggleOpen(pos, true)(editor.state, editor.view.dispatch);
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
|
||||||
|
setToggleOpen(pos, false)(editor.state, editor.view.dispatch);
|
||||||
|
expect(editor.state.selection.from).toBe(2);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// What the pill's Toggle button runs, which is `toggleWrap("toggle")` in setBlock in
|
||||||
|
// src/editor/Editor.tsx. A toggle takes the attribute's own default, which is closed, so without
|
||||||
|
// the plugin's appended transaction the paragraph the user just wrapped is behind an arrow and
|
||||||
|
// reads as a deletion.
|
||||||
|
describe("wrapping a block in a toggle", () => {
|
||||||
|
const PROSE: JSONContent = {
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: "Keep this visible." }] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("leaves it open, with the block still on screen inside it", () => {
|
||||||
|
const editor = makeEditor(PROSE);
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
|
||||||
|
expect(editor.chain().toggleWrap("toggle").run()).toBe(true);
|
||||||
|
const toggle = toggleIn(editor);
|
||||||
|
expect(toggle.open).toBe(true);
|
||||||
|
expect(toggle.summary).toBe("");
|
||||||
|
expect(toggle.body).toBe("Keep this visible.");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes something the bridge writes and reads back as the same toggle", () => {
|
||||||
|
const editor = makeEditor(PROSE);
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
editor.chain().toggleWrap("toggle").run();
|
||||||
|
setToggleSummary(toggleIn(editor).pos, "House style")(editor.state, editor.view.dispatch);
|
||||||
|
|
||||||
|
const source = written("Keep this visible.\n", editor);
|
||||||
|
expect(source).toBe(
|
||||||
|
["<details open>", "<summary>House style</summary>", "", "Keep this visible.", "", "</details>", ""].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const reopened = makeEditor(parseMarkdown(source, PATH).doc.toJSON());
|
||||||
|
const toggle = toggleIn(reopened);
|
||||||
|
expect([toggle.open, toggle.summary, toggle.body]).toEqual([true, "House style", "Keep this visible."]);
|
||||||
|
reopened.destroy();
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes it back out on a second press, which is what the same button means", () => {
|
||||||
|
const editor = makeEditor(PROSE);
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
editor.chain().toggleWrap("toggle").run();
|
||||||
|
|
||||||
|
expect(editor.chain().toggleWrap("toggle").run()).toBe(true);
|
||||||
|
expect(editor.state.doc.toJSON()).toEqual(PROSE);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The guard on the appended transaction, and the reason it is written the way it is. Opening a
|
||||||
|
// document restores the caret it was last left at, and that is a selection with no edit behind it:
|
||||||
|
// a toggle opened by one would be a byte written into a file nobody has touched.
|
||||||
|
describe("a caret that moves without an edit", () => {
|
||||||
|
it("never opens the toggle it lands in, and never dirties the document", () => {
|
||||||
|
const editor = editorFor(DOC);
|
||||||
|
const { pos } = toggleIn(editor);
|
||||||
|
const before = editor.state.doc.toJSON();
|
||||||
|
|
||||||
|
editor.view.dispatch(
|
||||||
|
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos + 2)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(toggleIn(editor).open).toBe(false);
|
||||||
|
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||||
|
expect(written(DOC, editor)).toBe(DOC);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The few parts of a page the node view reaches for, written out rather than depended on.
|
||||||
|
*
|
||||||
|
* A DOM implementation is not a dependency this project has, and the handful of calls the node view
|
||||||
|
* makes into one does not earn it: it creates elements, hangs listeners on them, asks which one the
|
||||||
|
* page thinks has the caret, and asks for a frame. What a browser does with an element is
|
||||||
|
* Playwright's question. What the handlers in toggle.ts do with what a browser sends them is this
|
||||||
|
* file's, and that is the whole of what these model.
|
||||||
|
*/
|
||||||
|
interface PageEvent {
|
||||||
|
type: string;
|
||||||
|
target: PageElement;
|
||||||
|
key?: string;
|
||||||
|
isComposing?: boolean;
|
||||||
|
defaultPrevented: boolean;
|
||||||
|
preventDefault: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PageElement {
|
||||||
|
className = "";
|
||||||
|
contentEditable = "inherit";
|
||||||
|
textContent = "";
|
||||||
|
parent: PageElement | null = null;
|
||||||
|
|
||||||
|
private readonly attributes = new Set<string>();
|
||||||
|
private readonly listeners = new Map<string, ((event: PageEvent) => void)[]>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly tagName: string,
|
||||||
|
readonly ownerDocument: Page,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Only ever asked whether there is one, which is how a leftover <br> in a title is found. */
|
||||||
|
get firstChild(): object | null {
|
||||||
|
return this.textContent === "" ? null : { nodeName: "#text" };
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttribute(name: string, _value: string): void {
|
||||||
|
this.attributes.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAttribute(name: string): boolean {
|
||||||
|
return this.attributes.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAttribute(name: string, on: boolean): void {
|
||||||
|
if (on) this.attributes.add(name);
|
||||||
|
else this.attributes.delete(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendChild(child: PageElement): void {
|
||||||
|
child.parent = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
append(...children: PageElement[]): void {
|
||||||
|
for (const child of children) this.appendChild(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
contains(other: PageElement | null): boolean {
|
||||||
|
for (let element = other; element; element = element.parent) if (element === this) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(): void {
|
||||||
|
this.ownerDocument.activeElement = this;
|
||||||
|
this.fire("focus");
|
||||||
|
}
|
||||||
|
|
||||||
|
blur(): void {
|
||||||
|
if (this.ownerDocument.activeElement === this) this.ownerDocument.activeElement = null;
|
||||||
|
this.fire("blur");
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, listener: (event: PageEvent) => void): void {
|
||||||
|
const list = this.listeners.get(type) ?? [];
|
||||||
|
list.push(listener);
|
||||||
|
this.listeners.set(type, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(type: string, listener: (event: PageEvent) => void): void {
|
||||||
|
this.listeners.set(type, (this.listeners.get(type) ?? []).filter((one) => one !== listener));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bubbling, because the row's listeners are what an event on the title inside it reaches. */
|
||||||
|
fire(type: string, extra: Partial<PageEvent> = {}): PageEvent {
|
||||||
|
const event: PageEvent = {
|
||||||
|
type,
|
||||||
|
target: this,
|
||||||
|
defaultPrevented: false,
|
||||||
|
preventDefault: () => {
|
||||||
|
event.defaultPrevented = true;
|
||||||
|
},
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
for (let element: PageElement | null = this; element; element = element.parent) {
|
||||||
|
for (const listener of element.listeners.get(type) ?? []) listener(event);
|
||||||
|
}
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Page {
|
||||||
|
activeElement: PageElement | null = null;
|
||||||
|
readonly created: PageElement[] = [];
|
||||||
|
|
||||||
|
private readonly frames: (() => void)[] = [];
|
||||||
|
|
||||||
|
readonly defaultView = {
|
||||||
|
requestAnimationFrame: (run: () => void): number => this.frames.push(run),
|
||||||
|
};
|
||||||
|
|
||||||
|
createElement(tagName: string): PageElement {
|
||||||
|
const element = new PageElement(tagName, this);
|
||||||
|
this.created.push(element);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nothing here models a caret inside a title, only which element holds one. */
|
||||||
|
getSelection(): null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The frame a browser would run next, which is also where TipTap's own focus call lands. */
|
||||||
|
runFrames(): number {
|
||||||
|
const queued = this.frames.splice(0);
|
||||||
|
for (const run of queued) run();
|
||||||
|
return queued.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Mounted {
|
||||||
|
page: Page;
|
||||||
|
details: PageElement;
|
||||||
|
summary: PageElement;
|
||||||
|
title: PageElement;
|
||||||
|
destroy: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The node view for the toggle at `pos`, built the way the editor's view builds one. */
|
||||||
|
function mount(editor: Editor, pos: number): Mounted {
|
||||||
|
const page = new Page();
|
||||||
|
const view = {
|
||||||
|
dom: page.createElement("div"),
|
||||||
|
get state(): EditorState {
|
||||||
|
return editor.state;
|
||||||
|
},
|
||||||
|
dispatch: (tr: Transaction) => editor.view.dispatch(tr),
|
||||||
|
editable: true,
|
||||||
|
focus: () => {},
|
||||||
|
} as unknown as EditorView;
|
||||||
|
|
||||||
|
const build = nodeViewPlugins(editor)[0]?.props.nodeViews?.toggle;
|
||||||
|
const node = editor.state.doc.nodeAt(pos);
|
||||||
|
if (!build || !node) throw new Error("nothing to build a node view from");
|
||||||
|
const nodeView = build(node, view, () => pos, [], DecorationSet.empty);
|
||||||
|
|
||||||
|
const find = (match: (element: PageElement) => boolean): PageElement => {
|
||||||
|
const element = page.created.find(match);
|
||||||
|
if (!element) throw new Error("the node view did not build that element");
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
details: find((element) => element.tagName === "details"),
|
||||||
|
summary: find((element) => element.tagName === "summary"),
|
||||||
|
title: find((element) => element.hasAttribute("data-toggle-summary")),
|
||||||
|
destroy: () => nodeView.destroy?.(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the text of this block ends, which is where a click in it leaves the caret. */
|
||||||
|
function endOf(editor: Editor, text: string): number {
|
||||||
|
let found: number | null = null;
|
||||||
|
editor.state.doc.descendants((node, pos) => {
|
||||||
|
if (found !== null) return false;
|
||||||
|
if (node.isTextblock && node.textContent === text) found = pos + 1 + node.content.size;
|
||||||
|
return found === null;
|
||||||
|
});
|
||||||
|
if (found === null) throw new Error(`no block reading ${text}`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_DOC = [
|
||||||
|
"# Doc",
|
||||||
|
"",
|
||||||
|
"First paragraph.",
|
||||||
|
"",
|
||||||
|
"Second paragraph.",
|
||||||
|
"",
|
||||||
|
"<details open>",
|
||||||
|
"<summary>My title</summary>",
|
||||||
|
"",
|
||||||
|
"Toggle body.",
|
||||||
|
"",
|
||||||
|
"</details>",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// A `<details>` is a disclosure control and the browser works one from the keyboard whether this
|
||||||
|
// file wants it to or not: a space typed anywhere inside the summary sends the row a click of its
|
||||||
|
// own. Taken as a press, that closed the toggle under the caret on every other space of a title and
|
||||||
|
// wrote the flip to the file each time.
|
||||||
|
describe("a space typed in a title", () => {
|
||||||
|
it("leaves the toggle as it was, and the space in the title", () => {
|
||||||
|
const editor = editorFor(PAGE_DOC);
|
||||||
|
const ui = mount(editor, toggleIn(editor).pos);
|
||||||
|
|
||||||
|
ui.title.focus();
|
||||||
|
ui.title.fire("keydown", { key: " " });
|
||||||
|
// The character the browser puts in the element, and then the click it sends the row after it.
|
||||||
|
ui.title.textContent = "My title ";
|
||||||
|
ui.title.fire("input");
|
||||||
|
ui.summary.fire("click");
|
||||||
|
|
||||||
|
const toggle = toggleIn(editor);
|
||||||
|
expect(toggle.open).toBe(true);
|
||||||
|
expect(toggle.summary).toBe("My title ");
|
||||||
|
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC.replace("My title", "My title "));
|
||||||
|
ui.destroy();
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not find a press that never became a click waiting for it", () => {
|
||||||
|
const editor = editorFor(PAGE_DOC);
|
||||||
|
const ui = mount(editor, toggleIn(editor).pos);
|
||||||
|
|
||||||
|
// Pressed on the row, and then the pointer left and no click ever came of it.
|
||||||
|
ui.summary.fire("mousedown");
|
||||||
|
ui.title.focus();
|
||||||
|
ui.title.fire("keydown", { key: " " });
|
||||||
|
ui.summary.fire("click");
|
||||||
|
|
||||||
|
expect(toggleIn(editor).open).toBe(true);
|
||||||
|
ui.destroy();
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a press on the summary row", () => {
|
||||||
|
it("still flips the toggle and writes it, which is what the arrow is for", () => {
|
||||||
|
const editor = editorFor(PAGE_DOC);
|
||||||
|
const ui = mount(editor, toggleIn(editor).pos);
|
||||||
|
|
||||||
|
ui.summary.fire("mousedown");
|
||||||
|
ui.summary.fire("click");
|
||||||
|
|
||||||
|
expect(toggleIn(editor).open).toBe(false);
|
||||||
|
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC.replace("<details open>", "<details>"));
|
||||||
|
ui.destroy();
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips it from the keyboard when the row itself is what has focus", () => {
|
||||||
|
const editor = editorFor(PAGE_DOC);
|
||||||
|
const ui = mount(editor, toggleIn(editor).pos);
|
||||||
|
|
||||||
|
ui.page.activeElement = ui.summary;
|
||||||
|
ui.summary.fire("click");
|
||||||
|
|
||||||
|
expect(toggleIn(editor).open).toBe(false);
|
||||||
|
ui.destroy();
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The caret in a title is not in the document: ProseMirror's selection is still wherever it was
|
||||||
|
// when the caret went in there, so a toolbar button pressed while a title is being typed runs its
|
||||||
|
// command against a paragraph the user is not looking at.
|
||||||
|
describe("a command aimed at the document while the caret is in a title", () => {
|
||||||
|
it("changes nothing, and the file with it", () => {
|
||||||
|
const editor = editorFor(PAGE_DOC);
|
||||||
|
const ui = mount(editor, toggleIn(editor).pos);
|
||||||
|
editor.commands.setTextSelection(endOf(editor, "Second paragraph."));
|
||||||
|
ui.title.focus();
|
||||||
|
const before = editor.state.doc.toJSON();
|
||||||
|
|
||||||
|
// What the pill's Horizontal rule runs, less the focus() in front of it, which wants a browser.
|
||||||
|
editor.commands.insertContent({ type: "horizontalRule" });
|
||||||
|
|
||||||
|
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||||
|
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC);
|
||||||
|
ui.destroy();
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the caret in the title, so a second press is refused like the first", () => {
|
||||||
|
const editor = editorFor(PAGE_DOC);
|
||||||
|
const ui = mount(editor, toggleIn(editor).pos);
|
||||||
|
editor.commands.setTextSelection(endOf(editor, "Second paragraph."));
|
||||||
|
ui.title.focus();
|
||||||
|
const before = editor.state.doc.toJSON();
|
||||||
|
|
||||||
|
editor.commands.insertContent({ type: "horizontalRule" });
|
||||||
|
// TipTap's focus command queues a view.focus() for the next frame, and it lands behind the
|
||||||
|
// refusal: without the caret being put back, the second press of the same button has a caret in
|
||||||
|
// the document again and edits the place the first press was refused for.
|
||||||
|
ui.title.blur();
|
||||||
|
expect(ui.page.runFrames()).toBe(1);
|
||||||
|
expect(ui.page.activeElement).toBe(ui.title);
|
||||||
|
|
||||||
|
editor.commands.insertContent({ type: "horizontalRule" });
|
||||||
|
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||||
|
ui.destroy();
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is taken back the moment the caret leaves the title", () => {
|
||||||
|
const editor = editorFor(PAGE_DOC);
|
||||||
|
const ui = mount(editor, toggleIn(editor).pos);
|
||||||
|
editor.commands.setTextSelection(endOf(editor, "Second paragraph."));
|
||||||
|
ui.title.focus();
|
||||||
|
ui.title.blur();
|
||||||
|
const before = editor.state.doc.toJSON();
|
||||||
|
|
||||||
|
editor.commands.insertContent({ type: "horizontalRule" });
|
||||||
|
|
||||||
|
expect(editor.state.doc.toJSON()).not.toEqual(before);
|
||||||
|
ui.destroy();
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is never the title's own writing, which is the one edit that is where the caret is", () => {
|
||||||
|
const editor = editorFor(PAGE_DOC);
|
||||||
|
const ui = mount(editor, toggleIn(editor).pos);
|
||||||
|
ui.title.focus();
|
||||||
|
|
||||||
|
ui.title.textContent = "Renamed";
|
||||||
|
ui.title.fire("input");
|
||||||
|
|
||||||
|
expect(toggleIn(editor).summary).toBe("Renamed");
|
||||||
|
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC.replace("My title", "Renamed"));
|
||||||
|
ui.destroy();
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const TWO_TOGGLES = [
|
||||||
|
"<details>",
|
||||||
|
"<summary>Closed</summary>",
|
||||||
|
"",
|
||||||
|
"Hidden body.",
|
||||||
|
"",
|
||||||
|
"</details>",
|
||||||
|
"",
|
||||||
|
"<details open>",
|
||||||
|
"<summary>My title</summary>",
|
||||||
|
"",
|
||||||
|
"Toggle body.",
|
||||||
|
"",
|
||||||
|
"</details>",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
/** Every toggle in the document, in the order they are written. */
|
||||||
|
function togglesIn(editor: Editor): { pos: number; open: boolean; summary: string }[] {
|
||||||
|
const found: { pos: number; open: boolean; summary: string }[] = [];
|
||||||
|
editor.state.doc.descendants((node, pos) => {
|
||||||
|
if (node.type.name !== "toggle") return true;
|
||||||
|
found.push({ pos, open: node.attrs.open as boolean, summary: node.attrs.summary as string });
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The plugin opens the collapsed toggle the caret ends an edit inside, because a caret behind a
|
||||||
|
// closed arrow is one nobody can see. The caret in a title is not in the document at all, so the
|
||||||
|
// selection that rule reads is stale and the toggle it names is one nobody is in.
|
||||||
|
describe("typing in a title while the selection is left inside another toggle", () => {
|
||||||
|
it("does not open the toggle it is left in, and writes no byte into that one", () => {
|
||||||
|
const editor = editorFor(TWO_TOGGLES);
|
||||||
|
const [closed, titled] = togglesIn(editor);
|
||||||
|
const ui = mount(editor, titled.pos);
|
||||||
|
editor.view.dispatch(
|
||||||
|
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, closed.pos + 3)),
|
||||||
|
);
|
||||||
|
ui.title.focus();
|
||||||
|
|
||||||
|
ui.title.textContent = "Renamed";
|
||||||
|
ui.title.fire("input");
|
||||||
|
|
||||||
|
expect(togglesIn(editor)[0].open).toBe(false);
|
||||||
|
expect(written(TWO_TOGGLES, editor)).toBe(TWO_TOGGLES.replace("My title", "Renamed"));
|
||||||
|
ui.destroy();
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// src/markdown/parse.ts pairs `<details>` among the root's own children and nowhere else, so a
|
||||||
|
// toggle anywhere but the top level of the document goes to disk as something the next open of the
|
||||||
|
// file reads back as one raw block: the bytes are kept and both constructs stop being editable.
|
||||||
|
describe("a toggle the bridge could not read back", () => {
|
||||||
|
const CALLOUT = "> [!NOTE]\n> Callout body.\n";
|
||||||
|
|
||||||
|
it("is not made inside a callout", () => {
|
||||||
|
const editor = editorFor(CALLOUT);
|
||||||
|
editor.commands.setTextSelection(endOf(editor, "Callout body."));
|
||||||
|
const before = editor.state.doc.toJSON();
|
||||||
|
|
||||||
|
editor.chain().toggleWrap("toggle").run();
|
||||||
|
|
||||||
|
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||||
|
expect(written(CALLOUT, editor)).toBe(CALLOUT);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not made inside another toggle", () => {
|
||||||
|
const editor = editorFor(PAGE_DOC);
|
||||||
|
editor.commands.setTextSelection(endOf(editor, "Toggle body."));
|
||||||
|
const before = editor.state.doc.toJSON();
|
||||||
|
|
||||||
|
// Attributes the toggle already there does not carry, because that is the call that nests:
|
||||||
|
// TipTap's isNodeActive wants them to match before it takes a second press as taking one out,
|
||||||
|
// so anything else wraps a second toggle around the inside of the first.
|
||||||
|
editor.chain().toggleWrap("toggle", { open: false }).run();
|
||||||
|
|
||||||
|
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||||
|
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The rule is about a toggle being put somewhere, and an edit inside one that is already there
|
||||||
|
// reaches into the same node without moving it. Reading that as a toggle being nested would
|
||||||
|
// refuse every keystroke in every toggle body in the document.
|
||||||
|
it("does not stand in the way of an edit inside a toggle that is already there", () => {
|
||||||
|
const editor = editorFor(PAGE_DOC);
|
||||||
|
editor.commands.setTextSelection(endOf(editor, "Toggle body."));
|
||||||
|
|
||||||
|
editor.commands.insertContent({ type: "text", text: " More." });
|
||||||
|
|
||||||
|
expect(toggleIn(editor).body).toBe("Toggle body. More.");
|
||||||
|
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC.replace("Toggle body.", "Toggle body. More."));
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is still made at the top level, where it is read back as itself", () => {
|
||||||
|
const editor = editorFor(PAGE_DOC);
|
||||||
|
editor.commands.setTextSelection(endOf(editor, "First paragraph."));
|
||||||
|
|
||||||
|
expect(editor.chain().toggleWrap("toggle").run()).toBe(true);
|
||||||
|
expect(togglesIn(editor)).toHaveLength(2);
|
||||||
|
const source = written(PAGE_DOC, editor);
|
||||||
|
expect(parseMarkdown(source, PATH).doc.childCount).toBe(editor.state.doc.childCount);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||