mirror of
https://github.com/priyanshujain/margin-docs.git
synced 2026-10-02 11:07:05 +00:00
feature fixes
This commit is contained in:
1 parent
586ee946d0
commit
c1c47bc513
63 files changed
+12055
-201
No files matched your search
@@ -55,7 +55,7 @@ jobs:
|
|||||||
# Every target except `no_write_on_open`, which gets the step below to itself.
|
# Every target except `no_write_on_open`, which gets the step below to itself.
|
||||||
- name: Test
|
- name: Test
|
||||||
working-directory: src-tauri
|
working-directory: src-tauri
|
||||||
run: cargo test --lib --bins --test fs --test fts5 --test watch --test watch_payload
|
run: cargo test --lib --bins --test fs --test fts5 --test watch --test watch_payload --test pdf --test grammar --test export_writes_only_the_pdf
|
||||||
|
|
||||||
# The suite that checks the promise the whole project rests on, that the editor writes
|
# The suite that checks the promise the whole project rests on, that the editor writes
|
||||||
# nothing the user did not edit. It runs against a real git repository rather than against a
|
# nothing the user did not edit. It runs against a real git repository rather than against a
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ jobs:
|
|||||||
IFS=. read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
IFS=. read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
||||||
VERSION="$MAJOR.$MINOR.$((PATCH + 1))"
|
VERSION="$MAJOR.$MINOR.$((PATCH + 1))"
|
||||||
fi
|
fi
|
||||||
|
# This string becomes a git tag, a TOML value and a JSON value, and it is typed into a
|
||||||
|
# box by hand. Anything that is not three numbers is a release that goes wrong somewhere
|
||||||
|
# further down, where it is much harder to read.
|
||||||
|
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
echo "::error::'$VERSION' is not a version. Give three numbers separated by dots, such as 0.2.0."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
|
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
echo "Releasing v$VERSION"
|
echo "Releasing v$VERSION"
|
||||||
@@ -43,7 +50,29 @@ jobs:
|
|||||||
tmp=$(mktemp)
|
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' 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
|
jq --arg v "$VERSION" '.version = $v' package.json > "$tmp" && mv "$tmp" package.json
|
||||||
sed -i "0,/^version = \".*\"/s//version = \"$VERSION\"/" src-tauri/Cargo.toml
|
# The crate's own version, and only that one. Anchored to the [package] section rather
|
||||||
|
# than to the first `version =` line in the file, because a dependency written in the
|
||||||
|
# long form puts `version = "0.4"` on a line of its own and the first such line is not
|
||||||
|
# necessarily the crate's. Bumping the wrong one is a release that builds and ships the
|
||||||
|
# version before it.
|
||||||
|
awk -v v="$VERSION" '
|
||||||
|
/^\[/ { section = $0 }
|
||||||
|
section == "[package]" && !done && /^version[[:space:]]*=/ {
|
||||||
|
print "version = \"" v "\""
|
||||||
|
done = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
END { if (!done) exit 1 }
|
||||||
|
' src-tauri/Cargo.toml > "$tmp" || {
|
||||||
|
echo "::error::No version key under [package] in src-tauri/Cargo.toml. Nothing was bumped."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
mv "$tmp" src-tauri/Cargo.toml
|
||||||
|
if ! grep -q "^version = \"$VERSION\"\$" src-tauri/Cargo.toml; then
|
||||||
|
echo "::error::src-tauri/Cargo.toml does not carry version $VERSION after the bump."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Commit and tag
|
- name: Commit and tag
|
||||||
env:
|
env:
|
||||||
@@ -111,6 +140,62 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Apple codesigning and notarization. Put into the environment here rather than passed
|
||||||
|
# straight to the build step, because the bundler reads all of these with `var_os` and an
|
||||||
|
# empty string counts as set: a step that always passed `${{ secrets.APPLE_CERTIFICATE }}`
|
||||||
|
# would make a repository without a certificate fail on an empty .p12 rather than fall back
|
||||||
|
# to an ad hoc signature. What is not written below is simply not in the build's environment.
|
||||||
|
#
|
||||||
|
# So a repository with none of these secrets still builds and still publishes. What it gets
|
||||||
|
# is an ad hoc signed bundle, which is fine to install by hand and is not fine as an update:
|
||||||
|
# macOS will not let one replace a Developer ID signed copy. docs/release.md has the whole of
|
||||||
|
# that, including why self-update cannot work until the certificate exists.
|
||||||
|
#
|
||||||
|
# Half-configured is the one case that fails rather than warning. A repository that has a
|
||||||
|
# certificate but no notarization credentials produces a signed bundle Gatekeeper still
|
||||||
|
# refuses on any machine that has not seen it before, and doing that quietly is worse than
|
||||||
|
# not building.
|
||||||
|
- name: Prepare Apple signing
|
||||||
|
env:
|
||||||
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
run: |
|
||||||
|
keep() {
|
||||||
|
delim="EOF_$(openssl rand -hex 12)"
|
||||||
|
{
|
||||||
|
printf '%s<<%s\n' "$1" "$delim"
|
||||||
|
printf '%s\n' "$2"
|
||||||
|
printf '%s\n' "$delim"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -z "$APPLE_CERTIFICATE" ] && [ -z "$APPLE_SIGNING_IDENTITY" ]; then
|
||||||
|
echo "::warning::No Developer ID certificate is configured. This build will be ad hoc signed, and installed copies will not be able to update themselves. See docs/release.md."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$APPLE_CERTIFICATE" ] || [ -z "$APPLE_CERTIFICATE_PASSWORD" ] || [ -z "$APPLE_SIGNING_IDENTITY" ]; then
|
||||||
|
echo "::error::Apple signing is half configured. APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD and APPLE_SIGNING_IDENTITY are set together or not at all."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$APPLE_ID" ] || [ -z "$APPLE_PASSWORD" ] || [ -z "$APPLE_TEAM_ID" ]; then
|
||||||
|
echo "::error::A Developer ID signed build has to be notarized or Gatekeeper refuses it on any machine that has not seen it before. Set APPLE_ID, APPLE_PASSWORD and APPLE_TEAM_ID."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
keep APPLE_CERTIFICATE "$APPLE_CERTIFICATE"
|
||||||
|
keep APPLE_CERTIFICATE_PASSWORD "$APPLE_CERTIFICATE_PASSWORD"
|
||||||
|
keep APPLE_SIGNING_IDENTITY "$APPLE_SIGNING_IDENTITY"
|
||||||
|
keep APPLE_ID "$APPLE_ID"
|
||||||
|
keep APPLE_PASSWORD "$APPLE_PASSWORD"
|
||||||
|
keep APPLE_TEAM_ID "$APPLE_TEAM_ID"
|
||||||
|
echo "Signing as a Developer ID application and notarizing."
|
||||||
|
|
||||||
- name: Build and upload
|
- name: Build and upload
|
||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
@@ -134,6 +219,17 @@ jobs:
|
|||||||
gh release download "$TAG" --repo "$REPO" --pattern latest.json --output latest.json --clobber
|
gh release download "$TAG" --repo "$REPO" --pattern latest.json --output latest.json --clobber
|
||||||
echo "Platforms in latest.json:"
|
echo "Platforms in latest.json:"
|
||||||
jq '.platforms | keys' latest.json
|
jq '.platforms | keys' latest.json
|
||||||
|
|
||||||
|
# The manifest has to describe this release and not the one before it. tauri-action writes
|
||||||
|
# the version out of the manifests the build job checked out, so a mismatch here means the
|
||||||
|
# tag and the bump have come apart somewhere, and publishing it would tell every installed
|
||||||
|
# copy that the version it is already running is the newest one.
|
||||||
|
MANIFEST_VERSION=$(jq -r '.version // ""' latest.json)
|
||||||
|
if [ "${MANIFEST_VERSION#v}" != "${TAG#v}" ]; then
|
||||||
|
echo "::error::latest.json says version '$MANIFEST_VERSION' but the tag is '$TAG'. Refusing to publish a manifest that does not describe this release."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# A universal build emits one .app.tar.gz, but tauri-action writes it into latest.json
|
# A universal build emits one .app.tar.gz, but tauri-action writes it into latest.json
|
||||||
# under both darwin-aarch64 and darwin-x86_64, pointing them at the same file and the same
|
# under both darwin-aarch64 and darwin-x86_64, pointing them at the same file and the same
|
||||||
# signature. It has to: an installed copy asks the manifest for the architecture it is
|
# signature. It has to: an installed copy asks the manifest for the architecture it is
|
||||||
@@ -141,10 +237,22 @@ jobs:
|
|||||||
# darwin-universal would offer nobody an update. Those two keys are the whole manifest for
|
# darwin-universal would offer nobody an update. Those two keys are the whole manifest for
|
||||||
# this app, and a release that is missing either one is a release half the users cannot
|
# this app, and a release that is missing either one is a release half the users cannot
|
||||||
# take.
|
# take.
|
||||||
|
#
|
||||||
|
# The signature is checked alongside the url because an unsigned entry is not a smaller
|
||||||
|
# problem than a missing one. The updater refuses a download whose signature does not
|
||||||
|
# verify against the public key baked into the app, so an entry with an empty signature is
|
||||||
|
# an update every installed copy will offer, download and then reject.
|
||||||
for key in darwin-aarch64 darwin-x86_64; do
|
for key in darwin-aarch64 darwin-x86_64; do
|
||||||
if ! jq -e ".platforms[\"$key\"].url" latest.json > /dev/null; then
|
url=$(jq -r ".platforms[\"$key\"].url // \"\"" latest.json)
|
||||||
|
signature=$(jq -r ".platforms[\"$key\"].signature // \"\"" latest.json)
|
||||||
|
if [ -z "$url" ]; then
|
||||||
echo "::error::latest.json is missing platform '$key': refusing to publish a partial update manifest. Re-run the release."
|
echo "::error::latest.json is missing platform '$key': refusing to publish a partial update manifest. Re-run the release."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if [ -z "$signature" ]; then
|
||||||
|
echo "::error::latest.json has no signature for platform '$key'. An installed copy would download the update and refuse it. Check that TAURI_SIGNING_PRIVATE_KEY is set."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
gh release edit "$TAG" --repo "$REPO" --draft=false --latest
|
gh release edit "$TAG" --repo "$REPO" --draft=false --latest
|
||||||
+75
-17
@@ -23,12 +23,16 @@ and `src-tauri/Cargo.toml` together, commits that to main, tags it, and builds t
|
|||||||
whatever main happens to be by then.
|
whatever main happens to be by then.
|
||||||
|
|
||||||
It builds one universal macOS bundle and publishes nothing until it has landed. The last job
|
It builds one universal macOS bundle and publishes nothing until it has landed. The last job
|
||||||
downloads `latest.json` and refuses to take the release out of draft unless `darwin-aarch64` and
|
downloads `latest.json` and refuses to take the release out of draft unless three things hold: the
|
||||||
`darwin-x86_64` are both present in it. One universal build writes both of those keys, pointing
|
version in the manifest is the version in the tag, `darwin-aarch64` and `darwin-x86_64` are both
|
||||||
them at the same archive and the same signature, because an installed copy asks the manifest for
|
present, and each of them has a signature as well as a url. One universal build writes both of
|
||||||
the architecture it is running on and never for a universal one. A half-populated manifest is worse
|
those keys, pointing them at the same archive and the same signature, because an installed copy
|
||||||
than no release at all: the updater would offer an update to the platforms that made it and error
|
asks the manifest for the architecture it is running on and never for a universal one. A
|
||||||
on the ones that did not.
|
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. A manifest carrying the version before
|
||||||
|
this one is worse again, because it tells everybody the copy they are already running is the newest
|
||||||
|
there is, and an entry with an empty signature is an update every installed copy downloads and then
|
||||||
|
refuses.
|
||||||
|
|
||||||
Linux is not built. It was until recently, because this pipeline was copied from margin-calendar,
|
Linux is not built. It was until recently, because this pipeline was copied from margin-calendar,
|
||||||
which ships on Linux. This app does not, so the row and the manifest key it required are gone and
|
which ships on Linux. This app does not, so the row and the manifest key it required are gone and
|
||||||
@@ -38,17 +42,54 @@ Windows is not built. Nothing in `tauri.conf.json` targets it and the app has ne
|
|||||||
|
|
||||||
## What the build needs
|
## What the build needs
|
||||||
|
|
||||||
Two repository secrets: `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,
|
Two repository secrets sign the updater artifacts, so that an installed copy can tell a real update
|
||||||
which sign the updater artifacts so an installed copy can tell a real update from anything else
|
from anything else offered at the same URL: `TAURI_SIGNING_PRIVATE_KEY` and
|
||||||
offered at the same URL. The public half lives in `src-tauri/tauri.release.conf.json`, baked into
|
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. The public half lives in
|
||||||
every build, so the private half can never be rotated without stranding everyone who has not
|
`src-tauri/tauri.release.conf.json`, baked into every build, so the private half can never be
|
||||||
updated yet; back up the key and its password somewhere that is not the machine that generated
|
rotated without stranding everyone who has not updated yet; back up the key and its password
|
||||||
them. That file currently carries the placeholder `REPLACE_WITH_TAURI_SIGNER_PUBKEY`; generating
|
somewhere that is not the machine that generated them.
|
||||||
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
|
That file currently carries the placeholder `REPLACE_WITH_TAURI_SIGNER_PUBKEY`, and the keypair
|
||||||
holds no OAuth client, unlike margin-calendar, so there are no other repository secrets and nothing
|
behind it does not exist yet. Making it is a one-time step, run once by whoever owns the repository
|
||||||
|
and never again:
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm tauri signer generate -w ~/.tauri/margin-docs.key
|
||||||
|
```
|
||||||
|
|
||||||
|
It asks for a password, writes the private key to that path and the public key beside it as
|
||||||
|
`margin-docs.key.pub`, and prints both. The public one replaces the placeholder in
|
||||||
|
`tauri.release.conf.json` and is committed. The private one is the contents of
|
||||||
|
`~/.tauri/margin-docs.key`, pasted into `TAURI_SIGNING_PRIVATE_KEY`, with its password in
|
||||||
|
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. Neither the key file nor the password goes into the
|
||||||
|
repository.
|
||||||
|
|
||||||
|
Six more secrets codesign and notarize the bundle, and unlike the two above they are optional as
|
||||||
|
far as the workflow is concerned: with none of them set the build still runs, still publishes, and
|
||||||
|
produces an ad hoc signed app. `APPLE_CERTIFICATE` is the Developer ID Application certificate
|
||||||
|
exported from Keychain Access as a `.p12` and base64 encoded (`base64 -i certificate.p12`), with
|
||||||
|
the password it was exported under in `APPLE_CERTIFICATE_PASSWORD`. `APPLE_SIGNING_IDENTITY` is the
|
||||||
|
certificate's full name, which looks like `Developer ID Application: Your Name (TEAMID)`. The other
|
||||||
|
three are for notarization: `APPLE_ID` is the Apple account's email address, `APPLE_PASSWORD` is an
|
||||||
|
app-specific password made at appleid.apple.com rather than the account password itself, and
|
||||||
|
`APPLE_TEAM_ID` is the ten character team identifier.
|
||||||
|
|
||||||
|
Those six are all or nothing. A repository with a certificate but no notarization credentials fails
|
||||||
|
the build rather than warning, because a signed bundle that has not been notarized is one Gatekeeper
|
||||||
|
refuses on any machine that has not seen it before, and finding that out from a user is worse than
|
||||||
|
finding it out from a red run.
|
||||||
|
|
||||||
|
The bundle asks for the hardened runtime, which notarization requires, and for no entitlements at
|
||||||
|
all. That is worth a sentence because it nearly went the other way. Deleting a document goes to the
|
||||||
|
Trash through the `trash` crate, whose macOS default is to ask Finder over an Apple event, and the
|
||||||
|
hardened runtime blocks an Apple event unless the bundle carries
|
||||||
|
`com.apple.security.automation.apple-events` and the user agrees to a permission prompt about
|
||||||
|
controlling Finder. `src-tauri/src/fs.rs` asks for `trashItemAtURL:` instead, which needs none of
|
||||||
|
that. The cost is Put Back, which the Finder method leaves on the file and this one does not; the
|
||||||
|
file is still in the Trash and can still be dragged out of it.
|
||||||
|
|
||||||
|
Beyond signing 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.
|
equivalent to a `google-credentials.json` to embed at build time.
|
||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
@@ -56,4 +97,21 @@ equivalent to a `google-credentials.json` to embed at build time.
|
|||||||
Installed copies check
|
Installed copies check
|
||||||
`https://github.com/priyanshujain/margin-docs/releases/latest/download/latest.json` and update
|
`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
|
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.
|
fails the manifest check stays a draft and no one is offered a broken update. The app checks that
|
||||||
|
URL once a day in the background and on the Check for Updates menu item, and either way what
|
||||||
|
happens next is a dialog rather than an install: the version, the release notes, the download with
|
||||||
|
a progress bar, and Later as a real answer.
|
||||||
|
|
||||||
|
Self-update does not work yet, and it will not until there is an Apple Developer ID certificate.
|
||||||
|
This is not a gap in the workflow, which is wired for one, or in the app, which is wired for the
|
||||||
|
whole flow. It is macOS: an update replaces the installed bundle with the downloaded one, and the
|
||||||
|
system will not let an ad hoc signed bundle take the place of a signed one, nor run a replacement
|
||||||
|
whose signature does not match what was there before. Every build this repository can currently
|
||||||
|
produce is ad hoc signed, so every self-update attempt ends in a bundle the system refuses to
|
||||||
|
launch. Until the certificate exists, updates are something to install by hand, and the honest
|
||||||
|
version of the feature is that the app will find the update, show it, download it, and fail on the
|
||||||
|
last step.
|
||||||
|
|
||||||
|
There is a second thing gated behind the same certificate. The build is not notarized either, so a
|
||||||
|
copy downloaded from the releases page is quarantined by the browser and refused on first launch
|
||||||
|
with the message about an unidentified developer. Both problems have the one fix.
|
||||||
Generated
+4701
-43
File diff suppressed because it is too large.
Load diff
+51
-1
@@ -33,13 +33,45 @@ trash = "5"
|
|||||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
tokio = { version = "1", features = ["sync", "time"] }
|
tokio = { version = "1", features = ["sync", "time"] }
|
||||||
|
|
||||||
|
# PDF export. Typst is the typesetter: a document becomes Typst source, the source is compiled in
|
||||||
|
# process and the bytes go to whatever the native save panel pointed at. No headless browser, no
|
||||||
|
# LaTeX install, nothing for the user to have on their machine first.
|
||||||
|
#
|
||||||
|
# typst and typst-pdf move together and are pinned to the same minor, because typst-as-lib links
|
||||||
|
# against a particular pair and a mismatched trio does not compile. fontdb finds a system face for
|
||||||
|
# the one thing this app does not bundle, a monospace family for code.
|
||||||
|
typst = "0.14.2"
|
||||||
|
typst-pdf = "0.14.2"
|
||||||
|
typst-as-lib = "0.15.5"
|
||||||
|
fontdb = "0.23"
|
||||||
|
# Inline image bytes arrive over the IPC boundary as base64: a mermaid diagram is rendered to SVG
|
||||||
|
# in the webview and has no file behind it to read.
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
|
# Grammar, which is Harper's and is the one checker this app does ship. Spelling stays the system's
|
||||||
|
# below; Harper's own spell rule is turned off in grammar.rs for that reason.
|
||||||
|
#
|
||||||
|
# Pinned exactly: the [patch] stubs at the foot of this file are tied to this version's burn/cubecl
|
||||||
|
# graph. A minor bump could silently invalidate a patch ("unused"), and the whole CUDA and LLVM
|
||||||
|
# subtree those stubs remove would come back. Bump deliberately and re-audit the stubs.
|
||||||
|
harper-core = { version = "=2.5.0", features = ["concurrent"] }
|
||||||
|
|
||||||
# Spelling is the system's, not ours. NSSpellChecker is the same checker every other Mac app
|
# 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
|
# 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
|
# 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.
|
# through Tauri, so asking for them adds nothing to the build but the features named.
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc2 = "0.6"
|
objc2 = "0.6"
|
||||||
objc2-app-kit = { version = "0.3", features = ["NSSpellChecker"] }
|
# NSMenu and its neighbours are for Writing Tools, which has no API this app can call: the system
|
||||||
|
# puts a submenu on Edit and the only way in is to find that item and perform it. Deliberately not
|
||||||
|
# NSWritingToolsCoordinator, which the sibling asks for and never uses.
|
||||||
|
objc2-app-kit = { version = "0.3", features = [
|
||||||
|
"NSSpellChecker",
|
||||||
|
"NSApplication",
|
||||||
|
"NSMenu",
|
||||||
|
"NSMenuItem",
|
||||||
|
"NSResponder",
|
||||||
|
] }
|
||||||
objc2-foundation = { version = "0.3", features = ["NSString", "NSArray", "NSRange", "NSTextCheckingResult"] }
|
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.
|
# There is no auto-updater and no process to restart on a phone: the store is the update channel.
|
||||||
@@ -50,3 +82,21 @@ tauri-plugin-updater = "2"
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
|
# harper-core transitively declares optional, disabled GPU backends through burn: a `burn-cuda`
|
||||||
|
# CUDA backend and, under cubecl, a `cubecl-cpu` LLVM/MLIR JIT runtime. Both are off, and neither
|
||||||
|
# is ever compiled, but Cargo still version-resolves and downloads the whole dead subtree behind
|
||||||
|
# them, which is the cuda toolchain crates on one side and tracel-llvm plus its LLVM bundler on the
|
||||||
|
# other. Replacing the two roots with empty stubs takes several hundred crates out of the graph and
|
||||||
|
# a large part of the build with them.
|
||||||
|
[patch.crates-io]
|
||||||
|
burn-cuda = { path = "stubs/burn-cuda" }
|
||||||
|
cubecl-cpu = { path = "stubs/cubecl-cpu" }
|
||||||
|
|
||||||
|
# Optimize dependencies even in dev builds. Harper's grammar engine, and the burn-ndarray POS
|
||||||
|
# tagger under it, is roughly ten times slower unoptimized, which is the difference between a check
|
||||||
|
# that lands while the user is still typing the next word and one that takes seconds. This compiles
|
||||||
|
# dependencies at opt-level 3 and leaves this crate itself unoptimized, so incremental rebuilds of
|
||||||
|
# our own code stay fast. The first build after adding it is slower, once.
|
||||||
|
[profile.dev.package."*"]
|
||||||
|
opt-level = 3
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,31 @@
|
|||||||
|
# Where these faces came from
|
||||||
|
|
||||||
|
Nine static instances, cut from the four variable fonts in `public/fonts/` that the editor renders
|
||||||
|
with. Same designs, same licence, same bytes underneath: the only difference is that a weight axis
|
||||||
|
has been pinned rather than left open.
|
||||||
|
|
||||||
|
They exist because Typst does not support a variable axis. It warns that it does not, then lays the
|
||||||
|
text out at the default instance whatever weight was asked for, so a PDF set from the variable files
|
||||||
|
has its headings, its bold runs and its callout labels all at 400 and no visible hierarchy at all.
|
||||||
|
|
||||||
|
They live here rather than in `public/fonts/` because nothing in the webview loads them. Everything
|
||||||
|
under `public/` is copied into the frontend bundle, and these are compiled into the binary by
|
||||||
|
`src-tauri/src/pdf.rs`, so keeping them here ships them once instead of twice.
|
||||||
|
|
||||||
|
To regenerate after updating a variable font, with fonttools installed:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fontTools.ttLib import TTFont
|
||||||
|
from fontTools.varLib import instancer
|
||||||
|
|
||||||
|
font = TTFont("public/fonts/Literata-VF.ttf")
|
||||||
|
instancer.instantiateVariableFont(font, {"wght": 600, "opsz": 12}, inplace=True)
|
||||||
|
# then set name IDs 1, 2, 4, 6, 16 and 17 to the family and style, and save.
|
||||||
|
font.save("src-tauri/fonts/Literata-SemiBold.ttf")
|
||||||
|
```
|
||||||
|
|
||||||
|
Literata pins `opsz` to 12, which is its own default and the size the body text is set at. The
|
||||||
|
weights are 400, 600 and 700 upright and 400 and 700 italic for Literata, and 400 and 700 in both
|
||||||
|
slopes for Hanken Grotesk, which are the weights `src/export/typst.ts` asks for. Typst falls back to
|
||||||
|
the nearest weight it has, so a face that is missing is a heading that comes out too heavy rather
|
||||||
|
than an export that fails.
|
||||||
@@ -183,3 +183,61 @@ pub struct SpellIssue {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub suggestions: Vec<String>,
|
pub suggestions: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An image the PDF exporter has to put on a page.
|
||||||
|
///
|
||||||
|
/// `data` present means the bytes came with the request, base64 encoded, which is the only way a
|
||||||
|
/// mermaid diagram can arrive: the frontend renders it to SVG and there is no file behind it and
|
||||||
|
/// never will be. `data` absent means read the file at `path`, which is what an ordinary
|
||||||
|
/// `` is, and it is absent far more often than not: base64 encoding every photograph
|
||||||
|
/// in a document through the IPC boundary costs a third again in bytes for a file the backend can
|
||||||
|
/// already open.
|
||||||
|
///
|
||||||
|
/// A read goes through the same root guard every other read in fs.rs does. A document is untrusted
|
||||||
|
/// input. `` is a link anybody can type into a markdown file, and an
|
||||||
|
/// exporter is not the place where this app starts reading outside an open folder.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ImageInput {
|
||||||
|
/// How the Typst source refers to it, which is also the key the file resolver answers on.
|
||||||
|
pub path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub data: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Something the exporter worked around rather than something it refused to do. Payload of the
|
||||||
|
/// `pdf-warnings` event, which is how these travel: a compile answers with raw bytes and has
|
||||||
|
/// nowhere to put a second value.
|
||||||
|
///
|
||||||
|
/// `count` is here because the alternative is forty toasts. A document with forty formulas the
|
||||||
|
/// converter could not typeset has one problem, not forty, and the user wants to be told once with
|
||||||
|
/// a number on it.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PdfWarning {
|
||||||
|
/// math | image | typst
|
||||||
|
pub kind: String,
|
||||||
|
pub message: String,
|
||||||
|
pub count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One grammar problem 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, for
|
||||||
|
/// exactly the reason `SpellIssue` gives: the other end is JavaScript addressing a ProseMirror
|
||||||
|
/// document, and ProseMirror counts in code points. Harper already counts that way, so unlike the
|
||||||
|
/// spelling path there is no conversion to do and nowhere for one to go wrong.
|
||||||
|
///
|
||||||
|
/// `kind` is Harper's own name for the rule that fired, which is what the popover shows above the
|
||||||
|
/// message so a correction can be judged before it is taken. `suggestions` can be empty: a rule
|
||||||
|
/// that can see a sentence is wrong without knowing how to fix it is still worth an underline.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GrammarIssue {
|
||||||
|
pub start: usize,
|
||||||
|
pub end: usize,
|
||||||
|
pub kind: String,
|
||||||
|
pub message: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub suggestions: Vec<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// The faces a PDF is set in.
|
||||||
|
//
|
||||||
|
// Nine of them are bundled and compiled into the binary by pdf.rs, one per weight and slope,
|
||||||
|
// because a document that typesets differently on two machines is not an export. The two things this app does not bundle
|
||||||
|
// are a monospace family for code and a math family for formulas, both of which are large and
|
||||||
|
// neither of which the editor shows on screen, so they come off the system instead and this module
|
||||||
|
// is how they are found.
|
||||||
|
//
|
||||||
|
// A family that is not installed is not an error. Typst is handed whatever was found and falls
|
||||||
|
// back through the rest of its book for anything missing, and the export still happens.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use fontdb::{Database, Family, Query};
|
||||||
|
|
||||||
|
/// Monospace families in descending order of preference, starting with the name Typst's `raw`
|
||||||
|
/// element asks for by default so that a machine which happens to have it needs no help. The rest
|
||||||
|
/// are what macOS, Windows and the common Linux desktops actually ship.
|
||||||
|
const MONOSPACE_FAMILIES: [&str; 8] = [
|
||||||
|
"DejaVu Sans Mono",
|
||||||
|
"SF Mono",
|
||||||
|
"Menlo",
|
||||||
|
"Monaco",
|
||||||
|
"Andale Mono",
|
||||||
|
"Consolas",
|
||||||
|
"Liberation Mono",
|
||||||
|
"Courier New",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Math families, again starting with Typst's own default. A math face is not interchangeable with
|
||||||
|
/// a text one: laying out an equation needs the OpenType MATH table, and Typst will not fall back
|
||||||
|
/// off this list on its own, so it is long on purpose.
|
||||||
|
const MATH_FAMILIES: [&str; 8] = [
|
||||||
|
"New Computer Modern Math",
|
||||||
|
"Latin Modern Math",
|
||||||
|
"STIX Two Math",
|
||||||
|
"Cambria Math",
|
||||||
|
"XITS Math",
|
||||||
|
"TeX Gyre Pagella Math",
|
||||||
|
"DejaVu Math TeX Gyre",
|
||||||
|
"Asana Math",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Loading the system font list walks several directories, so it happens once per compile rather
|
||||||
|
/// than once per family looked up.
|
||||||
|
fn system_db() -> Database {
|
||||||
|
let mut db = Database::new();
|
||||||
|
db.load_system_fonts();
|
||||||
|
db
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A key that is the same for every face of one font collection, so a `.ttc` is read once instead
|
||||||
|
/// of once per style. `with_face_data` hands back the whole collection either way, and Typst
|
||||||
|
/// expands it into every face it holds.
|
||||||
|
fn source_key(face: &fontdb::FaceInfo) -> String {
|
||||||
|
match &face.source {
|
||||||
|
fontdb::Source::File(path) => path.to_string_lossy().into_owned(),
|
||||||
|
fontdb::Source::SharedFile(path, _) => path.to_string_lossy().into_owned(),
|
||||||
|
fontdb::Source::Binary(_) => format!("{:?}:{}", face.id, face.index),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn installed(face: &fontdb::FaceInfo, family: &str) -> bool {
|
||||||
|
face.families
|
||||||
|
.iter()
|
||||||
|
.any(|(name, _)| name.eq_ignore_ascii_case(family))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn faces_for(db: &Database, family: &str, into: &mut Vec<Vec<u8>>, seen: &mut HashSet<String>) {
|
||||||
|
let styles = [
|
||||||
|
(fontdb::Weight::NORMAL, fontdb::Style::Normal),
|
||||||
|
(fontdb::Weight::NORMAL, fontdb::Style::Italic),
|
||||||
|
(fontdb::Weight::BOLD, fontdb::Style::Normal),
|
||||||
|
(fontdb::Weight::BOLD, fontdb::Style::Italic),
|
||||||
|
];
|
||||||
|
for (weight, style) in styles {
|
||||||
|
let query = Query {
|
||||||
|
families: &[Family::Name(family)],
|
||||||
|
weight,
|
||||||
|
style,
|
||||||
|
..Query::default()
|
||||||
|
};
|
||||||
|
let Some(id) = db.query(&query) else { continue };
|
||||||
|
// fontdb answers a query with its closest match rather than with nothing, so a family that
|
||||||
|
// is not installed comes back as some unrelated face. Checking the name of what came back
|
||||||
|
// is the only way to tell a hit from a substitution.
|
||||||
|
let Some(face) = db.face(id) else { continue };
|
||||||
|
if !installed(face, family) || !seen.insert(source_key(face)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(bytes) = db.with_face_data(id, |data, _index| data.to_vec()) {
|
||||||
|
into.push(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What this machine turned out to have.
|
||||||
|
///
|
||||||
|
/// The names matter as much as the bytes. Typst warns once per family it was asked for and could
|
||||||
|
/// not find, so a preamble naming a hopeful list of eight monospaces produces seven warnings on a
|
||||||
|
/// machine with one of them, and the export ends with a toast about fonts nobody chose. Naming only
|
||||||
|
/// what is here means naming nothing that is not.
|
||||||
|
pub struct Fallbacks {
|
||||||
|
pub fonts: Vec<Vec<u8>>,
|
||||||
|
pub monospace: Vec<String>,
|
||||||
|
pub math: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The faces the bundle is missing: a monospace for code blocks and a math family for formulas.
|
||||||
|
///
|
||||||
|
/// Every candidate that is installed is loaded, not just the first, so that the preamble can name
|
||||||
|
/// them in preference order and let Typst pick.
|
||||||
|
pub fn fallbacks() -> Fallbacks {
|
||||||
|
let db = system_db();
|
||||||
|
let mut fonts = Vec::new();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let monospace = collect_installed(&db, &MONOSPACE_FAMILIES, &mut fonts, &mut seen);
|
||||||
|
let math = collect_installed(&db, &MATH_FAMILIES, &mut fonts, &mut seen);
|
||||||
|
Fallbacks {
|
||||||
|
fonts,
|
||||||
|
monospace,
|
||||||
|
math,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_installed(
|
||||||
|
db: &Database,
|
||||||
|
families: &[&str],
|
||||||
|
fonts: &mut Vec<Vec<u8>>,
|
||||||
|
seen: &mut HashSet<String>,
|
||||||
|
) -> Vec<String> {
|
||||||
|
families
|
||||||
|
.iter()
|
||||||
|
.filter(|family| {
|
||||||
|
let before = fonts.len();
|
||||||
|
faces_for(db, family, fonts, seen);
|
||||||
|
// A family whose file was already loaded under another name is still installed, so the
|
||||||
|
// count is not the test on its own.
|
||||||
|
fonts.len() > before || db.faces().any(|face| installed(face, family))
|
||||||
|
})
|
||||||
|
.map(|family| (*family).to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -670,6 +670,22 @@ pub fn duplicate_entry(path: &Path) -> Result<FileNode, String> {
|
|||||||
/// Never `fs::remove_file`. These are the user's own documents and this app does not get to be the
|
/// 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.
|
/// reason one of them is gone for good.
|
||||||
pub fn trash_entry(path: &Path) -> Result<(), String> {
|
pub fn trash_entry(path: &Path) -> Result<(), String> {
|
||||||
|
// Not `trash::delete`, whose macOS default asks Finder to do it over an Apple event. That is
|
||||||
|
// the method that leaves Put Back on the file, and it is the wrong trade here: an Apple event
|
||||||
|
// from a hardened runtime needs an entitlement and a one time permission prompt, and a delete
|
||||||
|
// that fails because the user said no to a dialog about controlling Finder is a worse answer
|
||||||
|
// than a delete with no Put Back. `trashItemAtURL:` asks nobody, makes no sound and is faster.
|
||||||
|
// A file trashed this way is still in the Trash and can still be dragged back out.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
use trash::macos::{DeleteMethod, TrashContextExtMacos};
|
||||||
|
let mut context = trash::TrashContext::default();
|
||||||
|
context.set_delete_method(DeleteMethod::NsFileManager);
|
||||||
|
context
|
||||||
|
.delete(path)
|
||||||
|
.map_err(|e| format!("{}: {e}", path.display()))
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
trash::delete(path).map_err(|e| format!("{}: {e}", path.display()))
|
trash::delete(path).map_err(|e| format!("{}: {e}", path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Grammar, which is Harper's.
|
||||||
|
//
|
||||||
|
// Deliberately not a port of the sibling's proofing.rs. That file checks spelling and grammar
|
||||||
|
// together because it had to ship its own speller; here NSSpellChecker already does the spelling
|
||||||
|
// in spell.rs and macspell.rs, and Harper's own spell rule stays off for the same reason: the
|
||||||
|
// system checker knows the user's names, their languages and every word they have ever taught
|
||||||
|
// their Mac, and a second opinion from a bundled dictionary is a worse one.
|
||||||
|
//
|
||||||
|
// The engine is built once and kept. Building it reads a curated dictionary and a part of speech
|
||||||
|
// model out of the binary, which is far too much work to repeat per paragraph, and this module
|
||||||
|
// keeps it in a static rather than in `tauri::Manager` state because a `LintGroup` is not the kind
|
||||||
|
// of thing the rest of the crate has any business reaching. Built on the first check rather than
|
||||||
|
// at launch, so a window opens without waiting for a model nobody has asked a question of yet.
|
||||||
|
//
|
||||||
|
// Nothing here is on the main thread. `grammar_check` is a `#[tauri::command(async)]`, so a long
|
||||||
|
// paragraph does not hold the window still while it is linted, which matters here for the same
|
||||||
|
// reason it matters in spell.rs: this runs while the user is typing.
|
||||||
|
|
||||||
|
use std::sync::{Arc, LazyLock, Mutex};
|
||||||
|
|
||||||
|
use harper_core::linting::{LintGroup, Linter, Suggestion};
|
||||||
|
use harper_core::spell::FstDictionary;
|
||||||
|
use harper_core::{Dialect, Document};
|
||||||
|
|
||||||
|
use crate::dto::GrammarIssue;
|
||||||
|
|
||||||
|
/// What the popover offers, which is what the spelling menu beside it offers.
|
||||||
|
const MAX_SUGGESTIONS: usize = 5;
|
||||||
|
|
||||||
|
/// The linter and the dictionary it was built against, which `Document` needs as well.
|
||||||
|
struct Harper {
|
||||||
|
linter: LintGroup,
|
||||||
|
dict: Arc<FstDictionary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Built on first use and kept for the life of the process. The `Mutex` is not about sharing: it
|
||||||
|
/// is that linting takes `&mut self`, and two paragraphs arriving at once would otherwise have
|
||||||
|
/// nowhere to queue.
|
||||||
|
static ENGINE: LazyLock<Mutex<Harper>> = LazyLock::new(|| Mutex::new(build_harper()));
|
||||||
|
|
||||||
|
fn build_harper() -> Harper {
|
||||||
|
let dict = FstDictionary::curated();
|
||||||
|
let mut linter = LintGroup::new_curated(dict.clone(), Dialect::American);
|
||||||
|
// The system checker does the spelling, and it does it better: see the note at the top.
|
||||||
|
linter.config.set_rule_enabled("SpellCheck", false);
|
||||||
|
Harper { linter, dict }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this build has a grammar engine behind it.
|
||||||
|
///
|
||||||
|
/// A compile-time fact, answered the way `spell_available` answers its own: harper-core is a
|
||||||
|
/// dependency of this crate or it is not, and on a build where it is there is no runtime state in
|
||||||
|
/// which the engine has gone missing. Deliberately does not touch `ENGINE`, because the frontend
|
||||||
|
/// asks this once at launch and forcing the model load here would put the whole of it in front of
|
||||||
|
/// the first window for an answer that is already known.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn grammar_available() -> Result<bool, String> {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every grammar problem in one run of text, with offsets in characters counted from the start of
|
||||||
|
/// that run.
|
||||||
|
///
|
||||||
|
/// Like `spell_check`, it is told about a paragraph and answers about that paragraph: it has no
|
||||||
|
/// idea a document exists, and the caller adds its own base offset afterwards. Harper counts in
|
||||||
|
/// characters already, so unlike the spelling path there is no conversion here and nowhere for one
|
||||||
|
/// to go wrong.
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn grammar_check(text: String) -> Result<Vec<GrammarIssue>, String> {
|
||||||
|
let mut engine = ENGINE.lock().map_err(|e| e.to_string())?;
|
||||||
|
Ok(collect_grammar(&mut engine, &text))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_grammar(harper: &mut Harper, text: &str) -> Vec<GrammarIssue> {
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let doc = Document::new_plain_english(text, harper.dict.as_ref());
|
||||||
|
|
||||||
|
let mut issues = Vec::new();
|
||||||
|
for lint in harper.linter.lint(&doc) {
|
||||||
|
// Clamped end first and then start against it, so a span this side never indexes past the
|
||||||
|
// text and never comes out inverted. Harper should not hand back either, but this is a
|
||||||
|
// foreign engine walking the user's prose and a slice with a bad pair of bounds is a panic
|
||||||
|
// rather than a wrong underline.
|
||||||
|
let end = lint.span.end.min(chars.len());
|
||||||
|
let start = lint.span.start.min(end);
|
||||||
|
let existing: String = chars[start..end].iter().collect();
|
||||||
|
|
||||||
|
// A lint about nothing but whitespace is dropped, and Harper does produce them: two spaces
|
||||||
|
// between sentences are "French spaces" to it, and the suggestion is one space. There is
|
||||||
|
// nothing worth drawing there. An underline over characters that are not visible is not
|
||||||
|
// visible either, it cannot be clicked to reach the offer behind it, and on the caller's
|
||||||
|
// side those spaces are frequently not the user's at all: src/editor/proofing.ts blanks
|
||||||
|
// inline code spans and leaf nodes into runs of spaces of the same width so that the words
|
||||||
|
// either side keep their positions, and a checker underlining those would be underlining
|
||||||
|
// exactly the thing that file went to the trouble of refusing to send.
|
||||||
|
if existing.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut suggestions = Vec::new();
|
||||||
|
for suggestion in &lint.suggestions {
|
||||||
|
match suggestion {
|
||||||
|
Suggestion::ReplaceWith(replacement) => {
|
||||||
|
suggestions.push(replacement.iter().collect())
|
||||||
|
}
|
||||||
|
// An insertion is offered as the whole of what the span would become, because the
|
||||||
|
// popover replaces the underlined text with whatever is chosen and knows nothing
|
||||||
|
// about the shape of the edit behind it.
|
||||||
|
Suggestion::InsertAfter(insertion) => {
|
||||||
|
suggestions.push(format!("{existing}{}", insertion.iter().collect::<String>()))
|
||||||
|
}
|
||||||
|
Suggestion::Remove => suggestions.push(String::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suggestions.truncate(MAX_SUGGESTIONS);
|
||||||
|
|
||||||
|
issues.push(GrammarIssue {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
// Harper's own name for the category the rule falls into, which is what the popover
|
||||||
|
// shows above the message so a correction can be judged before it is taken.
|
||||||
|
kind: format!("{:?}", lint.lint_kind),
|
||||||
|
message: lint.message,
|
||||||
|
suggestions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
issues
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
pub mod dto;
|
pub mod dto;
|
||||||
|
pub mod fonts;
|
||||||
pub mod fs;
|
pub mod fs;
|
||||||
|
pub mod grammar;
|
||||||
pub mod index;
|
pub mod index;
|
||||||
mod library;
|
mod library;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
mod macspell;
|
mod macspell;
|
||||||
|
pub mod pdf;
|
||||||
pub mod spell;
|
pub mod spell;
|
||||||
pub mod watch;
|
pub mod watch;
|
||||||
|
pub mod writingtools;
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
@@ -57,6 +61,9 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
|
|||||||
let save = MenuItemBuilder::with_id("save", "Save")
|
let save = MenuItemBuilder::with_id("save", "Save")
|
||||||
.accelerator("CmdOrCtrl+S")
|
.accelerator("CmdOrCtrl+S")
|
||||||
.build(handle)?;
|
.build(handle)?;
|
||||||
|
let export_pdf = MenuItemBuilder::with_id("export-pdf", "Export as PDF…")
|
||||||
|
.accelerator("CmdOrCtrl+Shift+E")
|
||||||
|
.build(handle)?;
|
||||||
let close_folder = MenuItemBuilder::with_id("close-folder", "Close Folder").build(handle)?;
|
let close_folder = MenuItemBuilder::with_id("close-folder", "Close Folder").build(handle)?;
|
||||||
let check_updates =
|
let check_updates =
|
||||||
MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?;
|
MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?;
|
||||||
@@ -99,6 +106,7 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
|
|||||||
&command_palette,
|
&command_palette,
|
||||||
&PredefinedMenuItem::separator(handle)?,
|
&PredefinedMenuItem::separator(handle)?,
|
||||||
&save,
|
&save,
|
||||||
|
&export_pdf,
|
||||||
&PredefinedMenuItem::separator(handle)?,
|
&PredefinedMenuItem::separator(handle)?,
|
||||||
&close_folder,
|
&close_folder,
|
||||||
&PredefinedMenuItem::separator(handle)?,
|
&PredefinedMenuItem::separator(handle)?,
|
||||||
@@ -114,6 +122,7 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
|
|||||||
.item(&command_palette)
|
.item(&command_palette)
|
||||||
.item(&PredefinedMenuItem::separator(handle)?)
|
.item(&PredefinedMenuItem::separator(handle)?)
|
||||||
.item(&save)
|
.item(&save)
|
||||||
|
.item(&export_pdf)
|
||||||
.item(&PredefinedMenuItem::separator(handle)?)
|
.item(&PredefinedMenuItem::separator(handle)?)
|
||||||
.item(&close_folder)
|
.item(&close_folder)
|
||||||
.build()?;
|
.build()?;
|
||||||
@@ -140,6 +149,28 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
|
|||||||
app_submenu.insert(&settings, 3)?;
|
app_submenu.insert(&settings, 3)?;
|
||||||
app_submenu.insert(&PredefinedMenuItem::separator(handle)?, 4)?;
|
app_submenu.insert(&PredefinedMenuItem::separator(handle)?, 4)?;
|
||||||
}
|
}
|
||||||
|
// Writing Tools, which is the system's and needs macOS 15.1 with Apple Intelligence on.
|
||||||
|
//
|
||||||
|
// These two rows carry the chords and Apple's own Writing Tools rows deliberately do not,
|
||||||
|
// which is the opposite of what the sibling app does. AppKit performs a key equivalent by
|
||||||
|
// firing its menu item directly, so a chord on the system's row would reach Writing Tools
|
||||||
|
// without passing the selection guard in src/editor/writing.ts, and that guard exists
|
||||||
|
// because a rewrite spanning a link loses its address and one spanning two table cells
|
||||||
|
// widens the table. These ids go through the command table, so they meet the guard.
|
||||||
|
// Exactly one menu item owns each chord either way; this is which one.
|
||||||
|
if let Some(edit) = find_submenu("Edit") {
|
||||||
|
let proofread = MenuItemBuilder::with_id("writing-proofread", "Proofread")
|
||||||
|
.accelerator("Shift+Alt+F")
|
||||||
|
.build(handle)?;
|
||||||
|
let rewrite = MenuItemBuilder::with_id("writing-rewrite", "Rewrite")
|
||||||
|
.accelerator("Shift+Alt+R")
|
||||||
|
.build(handle)?;
|
||||||
|
edit.append_items(&[
|
||||||
|
&PredefinedMenuItem::separator(handle)?,
|
||||||
|
&proofread,
|
||||||
|
&rewrite,
|
||||||
|
])?;
|
||||||
|
}
|
||||||
if let Some(view) = find_submenu("View") {
|
if let Some(view) = find_submenu("View") {
|
||||||
let toggle_sidebar = MenuItemBuilder::with_id("toggle-sidebar", "Toggle Sidebar")
|
let toggle_sidebar = MenuItemBuilder::with_id("toggle-sidebar", "Toggle Sidebar")
|
||||||
.accelerator("CmdOrCtrl+\\")
|
.accelerator("CmdOrCtrl+\\")
|
||||||
@@ -193,6 +224,10 @@ pub fn run() {
|
|||||||
if let Err(e) = index::open(app.handle()) {
|
if let Err(e) = index::open(app.handle()) {
|
||||||
eprintln!("failed to open the search index: {e}");
|
eprintln!("failed to open the search index: {e}");
|
||||||
}
|
}
|
||||||
|
// The Writing Tools submenu is AppKit's, not build_menu's: the system inserts it into Edit
|
||||||
|
// on its own terms, so when it is there to label is writingtools.rs's problem and not this
|
||||||
|
// file's. One call, whatever it decides to wait for.
|
||||||
|
writingtools::install(app.handle());
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,6 +251,9 @@ pub fn run() {
|
|||||||
| "toggle-sidebar"
|
| "toggle-sidebar"
|
||||||
| "check-updates"
|
| "check-updates"
|
||||||
| "report-issue"
|
| "report-issue"
|
||||||
|
| "export-pdf"
|
||||||
|
| "writing-proofread"
|
||||||
|
| "writing-rewrite"
|
||||||
) {
|
) {
|
||||||
app.emit("menu-action", event.id().0.as_str()).ok();
|
app.emit("menu-action", event.id().0.as_str()).ok();
|
||||||
}
|
}
|
||||||
@@ -253,6 +291,12 @@ pub fn run() {
|
|||||||
spell::spell_learn,
|
spell::spell_learn,
|
||||||
spell::spell_unlearn,
|
spell::spell_unlearn,
|
||||||
spell::spell_available,
|
spell::spell_available,
|
||||||
|
writingtools::writing_available,
|
||||||
|
writingtools::writing_run,
|
||||||
|
pdf::pdf_compile,
|
||||||
|
pdf::pdf_write,
|
||||||
|
grammar::grammar_available,
|
||||||
|
grammar::grammar_check,
|
||||||
])
|
])
|
||||||
.run(context)
|
.run(context)
|
||||||
.expect("error while running Margin Docs");
|
.expect("error while running Margin Docs");
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
// PDF export: Typst source in, PDF bytes out, compiled in this process.
|
||||||
|
//
|
||||||
|
// Nothing about this touches the user's markdown. The converter that produces the source lives in
|
||||||
|
// src/export/typst.ts and reads the ProseMirror document; this module compiles what it is given
|
||||||
|
// and writes the result where a native save panel pointed. A document that has never been saved
|
||||||
|
// exports exactly as well as one that has.
|
||||||
|
//
|
||||||
|
// The compiler sees no filesystem and no network. Everything it can open is put in front of it by
|
||||||
|
// hand: the nine bundled faces, the images this call was handed, and a vendored copy of mitex
|
||||||
|
// served at `/mitex/`. That is the whole world, so a document cannot make the exporter fetch a
|
||||||
|
// package, and the export works on a machine that has never been online.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use base64::Engine;
|
||||||
|
use tauri::{Emitter, Manager};
|
||||||
|
use typst::diag::{Severity, SourceDiagnostic, Warned};
|
||||||
|
use typst::layout::PagedDocument;
|
||||||
|
use typst_as_lib::TypstEngine;
|
||||||
|
|
||||||
|
use crate::dto::{ImageInput, PdfWarning};
|
||||||
|
|
||||||
|
// The faces a document is set in, one file per weight and slope.
|
||||||
|
//
|
||||||
|
// Not the variable fonts in public/fonts/ that the editor itself renders with, and that is not
|
||||||
|
// duplication for its own sake: Typst does not support a variable axis, warns that it does not, and
|
||||||
|
// lays the text out at the default instance regardless of the weight asked for. Every heading, every
|
||||||
|
// bold run and every callout label would come out at 400, which is a PDF where the hierarchy the
|
||||||
|
// author can see on screen is gone. These nine are static instances cut from those same four files;
|
||||||
|
// src-tauri/fonts/PROVENANCE.md is how, and is what to repeat when a face is updated.
|
||||||
|
static FACES: [&[u8]; 9] = [
|
||||||
|
include_bytes!("../fonts/Literata-Regular.ttf"),
|
||||||
|
include_bytes!("../fonts/Literata-SemiBold.ttf"),
|
||||||
|
include_bytes!("../fonts/Literata-Bold.ttf"),
|
||||||
|
include_bytes!("../fonts/Literata-Italic.ttf"),
|
||||||
|
include_bytes!("../fonts/Literata-BoldItalic.ttf"),
|
||||||
|
include_bytes!("../fonts/HankenGrotesk-Regular.ttf"),
|
||||||
|
include_bytes!("../fonts/HankenGrotesk-Bold.ttf"),
|
||||||
|
include_bytes!("../fonts/HankenGrotesk-Italic.ttf"),
|
||||||
|
include_bytes!("../fonts/HankenGrotesk-BoldItalic.ttf"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// mitex 0.2.5, vendored under src-tauri/vendor/mitex with its LICENSE, and served as ordinary
|
||||||
|
// paths under `/mitex/` rather than through Typst's package system. A package spec would mean a
|
||||||
|
// download on first export and a cache directory to keep, for a dependency that is 380K and never
|
||||||
|
// changes. Every file in the package has to be here: lib.typ imports mitex.typ relatively, that
|
||||||
|
// imports specs/mod.typ, and mitex.typ loads the wasm module beside it.
|
||||||
|
static MITEX_LIB: &str = include_str!("../vendor/mitex/lib.typ");
|
||||||
|
static MITEX_MAIN: &str = include_str!("../vendor/mitex/mitex.typ");
|
||||||
|
static MITEX_SPECS: &str = include_str!("../vendor/mitex/specs/mod.typ");
|
||||||
|
static MITEX_PRELUDE: &str = include_str!("../vendor/mitex/specs/prelude.typ");
|
||||||
|
static MITEX_LATEX: &str = include_str!("../vendor/mitex/specs/latex/standard.typ");
|
||||||
|
static MITEX_WASM: &[u8] = include_bytes!("../vendor/mitex/mitex.wasm");
|
||||||
|
|
||||||
|
/// The import path the generated source uses, and the contract with src/export/typst.ts:
|
||||||
|
/// `#import "/mitex/lib.typ": mitex, mi`.
|
||||||
|
const MITEX_ROOT: &str = "/mitex/";
|
||||||
|
|
||||||
|
/// What `/mitex/lib.typ` answers with on a second attempt, after a formula stopped the first one.
|
||||||
|
///
|
||||||
|
/// It keeps the names the generated source imports and sets every formula as the LaTeX the user
|
||||||
|
/// wrote, which is the readable thing to do with a formula nothing can typeset. Nothing here loads
|
||||||
|
/// the wasm module, because that is what failed.
|
||||||
|
static MITEX_PLAIN_LIB: &str = r#"#let mitex-source(it) = {
|
||||||
|
if type(it) == str { it } else if type(it) == content and it.has("text") { it.text } else { repr(it) }
|
||||||
|
}
|
||||||
|
#let mi(it, ..args) = raw(mitex-source(it))
|
||||||
|
#let mimath(it, ..args) = raw(mitex-source(it))
|
||||||
|
#let mitext(it) = raw(mitex-source(it))
|
||||||
|
#let mitex(it, mode: "math", ..args) = block(raw(mitex-source(it)))
|
||||||
|
#let mitex-convert(it, mode: "math", spec: none) = mitex-source(it)
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// A 1x1 transparent image in each format Typst picks from a file extension, so that an image the
|
||||||
|
/// exporter could not read leaves a gap on the page rather than killing the export.
|
||||||
|
///
|
||||||
|
/// One per format because Typst trusts the extension over the bytes: handing PNG bytes to
|
||||||
|
/// `#image("photo.jpg")` is a decode error, which is the hard failure this exists to avoid. The
|
||||||
|
/// formats Typst does not recognise from an extension fall through to sniffing the data, so PNG is
|
||||||
|
/// the right default for those.
|
||||||
|
const PLACEHOLDER_PNG: &str =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4//8/AwAI/AL+p5qgoAAAAABJRU5ErkJggg==";
|
||||||
|
const PLACEHOLDER_JPEG: &str = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAAA//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AR//Z";
|
||||||
|
const PLACEHOLDER_GIF: &str = "R0lGODdhAQABAIEAAP///wAAAAAAAAAAACwAAAAAAQABAAAIBAABBAQAOw==";
|
||||||
|
const PLACEHOLDER_WEBP: &str =
|
||||||
|
"UklGRkAAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAAAAFZQOCAYAAAAMAEAnQEqAQABAAFAJiWkAANwAP789AAA";
|
||||||
|
const PLACEHOLDER_SVG: &str = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1\" height=\"1\"/>";
|
||||||
|
|
||||||
|
fn placeholder_for(path: &str) -> Vec<u8> {
|
||||||
|
let extension = Path::new(path)
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_lowercase();
|
||||||
|
let encoded = match extension.as_str() {
|
||||||
|
"jpg" | "jpeg" => PLACEHOLDER_JPEG,
|
||||||
|
"gif" => PLACEHOLDER_GIF,
|
||||||
|
"webp" => PLACEHOLDER_WEBP,
|
||||||
|
"svg" | "svgz" => return PLACEHOLDER_SVG.as_bytes().to_vec(),
|
||||||
|
_ => PLACEHOLDER_PNG,
|
||||||
|
};
|
||||||
|
STANDARD.decode(encoded).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bytes of one image, either the ones that came with the request or the ones on disk.
|
||||||
|
///
|
||||||
|
/// A read is guarded exactly as every read in fs.rs is: the path is resolved and has to land
|
||||||
|
/// inside a folder the user actually opened. A document is untrusted input, ``
|
||||||
|
/// is a link anybody can type, and an exporter is not where this app starts reading outside an
|
||||||
|
/// open folder. The path has to be absolute for that check to mean anything, which is why the
|
||||||
|
/// converter sends an absolute one for any image it has no bytes for.
|
||||||
|
fn image_bytes(image: &ImageInput, root_paths: &[String]) -> Result<Vec<u8>, String> {
|
||||||
|
match &image.data {
|
||||||
|
Some(data) => STANDARD
|
||||||
|
.decode(data.as_bytes())
|
||||||
|
.map_err(|e| format!("could not decode \"{}\": {e}", image.path)),
|
||||||
|
None => {
|
||||||
|
let path = crate::fs::resolve_in_roots(root_paths, &image.path)?;
|
||||||
|
std::fs::read(&path).map_err(|e| format!("could not read \"{}\": {e}", image.path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn font_list(families: &[String], last_resort: &str) -> String {
|
||||||
|
let mut names: Vec<String> = families.iter().map(|f| format!("\"{f}\"")).collect();
|
||||||
|
names.push(format!("\"{last_resort}\""));
|
||||||
|
format!("({})", names.join(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The only Typst this module writes, and it names font families and nothing else.
|
||||||
|
///
|
||||||
|
/// It belongs here rather than in the converter because which faces the compiler can see is this
|
||||||
|
/// module's business: nine bundled, plus whichever monospace and math families the machine turned
|
||||||
|
/// out to have. src/export/typst.ts names none of them for exactly that reason.
|
||||||
|
///
|
||||||
|
/// Math is the one that cannot be left alone. Typst sets every equation to the single family
|
||||||
|
/// "New Computer Modern Math" and switches font fallback off while it does it, so on a machine
|
||||||
|
/// without that family one formula is a hard compile error rather than a warning. Naming a list is
|
||||||
|
/// what makes a formula typeset at all, and Literata closes both lists so that a machine with none
|
||||||
|
/// of the candidates gets a page that reads badly and a warning saying so, instead of no PDF.
|
||||||
|
///
|
||||||
|
/// Only families that are actually installed are named, because Typst warns once for every family
|
||||||
|
/// it was asked for and could not find, and a hopeful list would end every export with a toast
|
||||||
|
/// about fonts nobody chose.
|
||||||
|
fn font_preamble(fallbacks: &crate::fonts::Fallbacks) -> String {
|
||||||
|
format!(
|
||||||
|
"#set text(font: \"Literata\")\n\
|
||||||
|
#show raw: set text(font: {})\n\
|
||||||
|
#show math.equation: set text(font: {})\n",
|
||||||
|
font_list(&fallbacks.monospace, "Literata"),
|
||||||
|
font_list(&fallbacks.math, "Literata"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a warning, or bumps the count of one already there.
|
||||||
|
///
|
||||||
|
/// Forty formulas that would not typeset are one problem and not forty toasts, and the same
|
||||||
|
/// message arriving twice is the overwhelmingly common case: one broken construct repeated down a
|
||||||
|
/// document.
|
||||||
|
fn note(warnings: &mut Vec<PdfWarning>, kind: &str, message: String) {
|
||||||
|
if let Some(existing) = warnings
|
||||||
|
.iter_mut()
|
||||||
|
.find(|w| w.kind == kind && w.message == message)
|
||||||
|
{
|
||||||
|
existing.count += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
warnings.push(PdfWarning {
|
||||||
|
kind: kind.to_string(),
|
||||||
|
message,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn in_mitex(span: typst::syntax::Span) -> bool {
|
||||||
|
span.id().is_some_and(|id| {
|
||||||
|
id.vpath()
|
||||||
|
.as_rooted_path()
|
||||||
|
.to_string_lossy()
|
||||||
|
.starts_with(MITEX_ROOT)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn touches_mitex(diagnostic: &SourceDiagnostic) -> bool {
|
||||||
|
in_mitex(diagnostic.span) || diagnostic.trace.iter().any(|point| in_mitex(point.span))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which kind a diagnostic gets, or `None` for one the user should never see.
|
||||||
|
///
|
||||||
|
/// A warning raised inside the vendored mitex sources with nothing in its trace leading back out
|
||||||
|
/// of them is mitex talking about itself: a deprecation in a pinned copy of a dependency, the same
|
||||||
|
/// on every machine and in every document, and nothing anybody reading a toast can act on. One
|
||||||
|
/// with the document in its trace is a formula that did not come out right, which is a "math"
|
||||||
|
/// warning and is worth saying.
|
||||||
|
fn kind_for(diagnostic: &SourceDiagnostic) -> Option<&'static str> {
|
||||||
|
if !in_mitex(diagnostic.span) {
|
||||||
|
return Some("typst");
|
||||||
|
}
|
||||||
|
if diagnostic.trace.iter().any(|point| !in_mitex(point.span)) {
|
||||||
|
return Some("math");
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pass over the compiler. `lib` is what `/mitex/lib.typ` answers with, which is the whole
|
||||||
|
/// difference between a formula that typesets and one that is shown as the source the user wrote.
|
||||||
|
///
|
||||||
|
/// The one error in this crate that is not a `String`, and deliberately. The caller has to ask
|
||||||
|
/// whether the failure came from inside mitex before it decides whether a second attempt is worth
|
||||||
|
/// making, and that question is asked of the diagnostics' spans. Formatting them first would throw
|
||||||
|
/// away the only thing the answer depends on.
|
||||||
|
fn compile_once(
|
||||||
|
source: &str,
|
||||||
|
lib: &str,
|
||||||
|
binaries: &[(&str, Vec<u8>)],
|
||||||
|
fonts: &[Vec<u8>],
|
||||||
|
) -> Result<(Vec<u8>, Vec<SourceDiagnostic>), Vec<SourceDiagnostic>> {
|
||||||
|
let engine = TypstEngine::builder()
|
||||||
|
.main_file(source)
|
||||||
|
.fonts(fonts.iter().map(|font| font.as_slice()))
|
||||||
|
.with_static_source_file_resolver([
|
||||||
|
("/mitex/lib.typ", lib),
|
||||||
|
("/mitex/mitex.typ", MITEX_MAIN),
|
||||||
|
("/mitex/specs/mod.typ", MITEX_SPECS),
|
||||||
|
("/mitex/specs/prelude.typ", MITEX_PRELUDE),
|
||||||
|
("/mitex/specs/latex/standard.typ", MITEX_LATEX),
|
||||||
|
])
|
||||||
|
.with_static_file_resolver(binaries.iter().map(|(path, bytes)| (*path, bytes.as_slice())))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let Warned { output, warnings } = engine.compile();
|
||||||
|
let document: PagedDocument = output.map_err(|e| match e {
|
||||||
|
typst_as_lib::TypstAsLibError::TypstSource(diagnostics) => diagnostics.into_iter().collect(),
|
||||||
|
other => vec![SourceDiagnostic::error(
|
||||||
|
typst::syntax::Span::detached(),
|
||||||
|
other.to_string(),
|
||||||
|
)],
|
||||||
|
})?;
|
||||||
|
let bytes = typst_pdf::pdf(&document, &Default::default())
|
||||||
|
.map_err(|d| d.into_iter().collect::<Vec<_>>())?;
|
||||||
|
Ok((bytes, warnings.into_iter().collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compiles Typst source to PDF bytes, with whatever the compiler had to work around.
|
||||||
|
///
|
||||||
|
/// Separate from the command so a test can reach it: a `#[tauri::command]` taking an `AppHandle`
|
||||||
|
/// needs a running app, and none of the work below wants one.
|
||||||
|
pub fn compile(
|
||||||
|
source: String,
|
||||||
|
images: &[ImageInput],
|
||||||
|
root_paths: &[String],
|
||||||
|
) -> Result<(Vec<u8>, Vec<PdfWarning>), String> {
|
||||||
|
let mut warnings: Vec<PdfWarning> = Vec::new();
|
||||||
|
|
||||||
|
let mut binaries: Vec<(&str, Vec<u8>)> = Vec::with_capacity(images.len() + 1);
|
||||||
|
for image in images {
|
||||||
|
match image_bytes(image, root_paths) {
|
||||||
|
Ok(bytes) => binaries.push((image.path.as_str(), bytes)),
|
||||||
|
Err(e) => {
|
||||||
|
note(&mut warnings, "image", e);
|
||||||
|
binaries.push((image.path.as_str(), placeholder_for(&image.path)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binaries.push(("/mitex/mitex.wasm", MITEX_WASM.to_vec()));
|
||||||
|
|
||||||
|
// The two families this app does not bundle, a monospace for code blocks and a math face for
|
||||||
|
// formulas, come off the system. Neither is shown in the editor, both are large, and shipping a
|
||||||
|
// mono nobody ever sees on screen so that a code block matches across machines is a trade this
|
||||||
|
// project has decided against.
|
||||||
|
let mut fallbacks = crate::fonts::fallbacks();
|
||||||
|
let mut fonts: Vec<Vec<u8>> = FACES.iter().map(|face| face.to_vec()).collect();
|
||||||
|
fonts.append(&mut fallbacks.fonts);
|
||||||
|
|
||||||
|
let source = format!("{}{source}", font_preamble(&fallbacks));
|
||||||
|
|
||||||
|
let failure = match compile_once(&source, MITEX_LIB, &binaries, &fonts) {
|
||||||
|
Ok((bytes, diagnostics)) => {
|
||||||
|
for diagnostic in &diagnostics {
|
||||||
|
if let Some(kind) = kind_for(diagnostic) {
|
||||||
|
note(&mut warnings, kind, diagnostic.message.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok((bytes, warnings));
|
||||||
|
}
|
||||||
|
Err(failure) => failure,
|
||||||
|
};
|
||||||
|
|
||||||
|
// mitex turns LaTeX into Typst by running a wasm module over it, and a formula it cannot parse
|
||||||
|
// is a hard error rather than a bad-looking equation. One `$\frac{$` a user typed halfway down
|
||||||
|
// a page of notes would otherwise cost them the entire export, so the second attempt serves a
|
||||||
|
// stand-in `/mitex/lib.typ` that sets every formula as the source the user actually wrote. The
|
||||||
|
// page reads worse and the warning says so, which is a trade the user can act on.
|
||||||
|
if !failure.iter().any(touches_mitex) {
|
||||||
|
return Err(format_diagnostics(&failure));
|
||||||
|
}
|
||||||
|
let (bytes, diagnostics) = compile_once(&source, MITEX_PLAIN_LIB, &binaries, &fonts)
|
||||||
|
// The first failure is the one worth reading: the second is whatever the stand-in tripped
|
||||||
|
// over on the way, and the formula that started it is named in the first.
|
||||||
|
.map_err(|_| format_diagnostics(&failure))?;
|
||||||
|
|
||||||
|
note(
|
||||||
|
&mut warnings,
|
||||||
|
"math",
|
||||||
|
"a formula could not be typeset, so every formula is shown as the source it was written in"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
for diagnostic in &diagnostics {
|
||||||
|
if let Some(kind) = kind_for(diagnostic) {
|
||||||
|
note(&mut warnings, kind, diagnostic.message.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((bytes, warnings))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compiles Typst source to PDF bytes. Warnings, when there are any, arrive separately on the
|
||||||
|
/// `pdf-warnings` event, because a compile answers with raw bytes and has nowhere to put a second
|
||||||
|
/// value.
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn pdf_compile(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
source: String,
|
||||||
|
images: Vec<ImageInput>,
|
||||||
|
) -> Result<tauri::ipc::Response, String> {
|
||||||
|
// The same gate `fs::checked` puts in front of every other read, reached the same way it is:
|
||||||
|
// the open roots out of shared state, then `resolve_in_roots`. The lock is dropped before the
|
||||||
|
// compile, which is the slowest thing this app does and has no business holding it.
|
||||||
|
let roots = app.state::<crate::Roots>();
|
||||||
|
let root_paths: Vec<String> = {
|
||||||
|
let open = roots.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
open.iter().map(|root| root.path.clone()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (bytes, warnings) = compile(source, &images, &root_paths)?;
|
||||||
|
if !warnings.is_empty() {
|
||||||
|
app.emit("pdf-warnings", warnings).ok();
|
||||||
|
}
|
||||||
|
Ok(tauri::ipc::Response::new(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the finished file to wherever the save panel pointed.
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn pdf_write(path: String, bytes: Vec<u8>) -> Result<(), String> {
|
||||||
|
// No root guard here, deliberately, and the next person to read this will assume that is a
|
||||||
|
// bug. It is not: this path came from the user through a native save panel, so it is the
|
||||||
|
// user's own choice of destination and not something a document said. The guard exists to stop
|
||||||
|
// an untrusted document naming a path, and saving somewhere outside every open folder is the
|
||||||
|
// ordinary case rather than the attack.
|
||||||
|
//
|
||||||
|
// What that argument does not cover is a destination that is already one of the user's
|
||||||
|
// documents. `atomic_write` replaces whatever is there, so a `.md` name typed into the save
|
||||||
|
// panel is the one way an export can destroy markdown, and this app does not get to be the
|
||||||
|
// reason a document is gone. The save panel carries a PDF filter and AppKit usually appends
|
||||||
|
// `.pdf` to a name typed with another extension, but that is the panel being careful rather
|
||||||
|
// than this command, and the promise is not the panel's to keep.
|
||||||
|
//
|
||||||
|
// Only a file that is already there is refused. Writing a document-shaped name that nothing
|
||||||
|
// holds yet is odd rather than destructive, and the user can see what they typed.
|
||||||
|
let target = Path::new(&path);
|
||||||
|
if target.is_file() && matches!(crate::fs::kind_for(target, false), "markdown" | "text") {
|
||||||
|
let name = target
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| path.clone());
|
||||||
|
return Err(format!("{name} is a document. A PDF cannot be written over it."));
|
||||||
|
}
|
||||||
|
crate::fs::atomic_write(target, &bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_diagnostics(diagnostics: &[SourceDiagnostic]) -> String {
|
||||||
|
diagnostics
|
||||||
|
.iter()
|
||||||
|
.map(|diagnostic| {
|
||||||
|
let kind = match diagnostic.severity {
|
||||||
|
Severity::Error => "error",
|
||||||
|
Severity::Warning => "warning",
|
||||||
|
};
|
||||||
|
let mut message = format!("{kind}: {}", diagnostic.message);
|
||||||
|
for hint in &diagnostic.hints {
|
||||||
|
message.push_str(&format!("\n hint: {hint}"));
|
||||||
|
}
|
||||||
|
message
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Writing Tools, which is Apple's and not this app's.
|
||||||
|
//
|
||||||
|
// There is no API to call. The system puts a "Writing Tools" submenu on the Edit menu of any app
|
||||||
|
// with an editable text view, and the only way in from here is to find that item on the live
|
||||||
|
// NSMenu and perform it. What happens next happens in the webview, to the DOM, without asking:
|
||||||
|
// that is the whole risk in this feature, docs/architecture.md is where the seam is described, and
|
||||||
|
// src/editor/writing.ts is where the selection that may be handed to one is decided.
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
/// What the main thread last saw. Read below by a command that has no handle to hop with.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
static SUBMENU_SEEN: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod mac {
|
||||||
|
use objc2::rc::Retained;
|
||||||
|
use objc2::MainThreadMarker;
|
||||||
|
use objc2_app_kit::{NSApplication, NSMenu, NSMenuItem};
|
||||||
|
use objc2_foundation::NSArray;
|
||||||
|
|
||||||
|
fn submenu_named(items: &NSArray<NSMenuItem>, title: &str) -> Option<Retained<NSMenu>> {
|
||||||
|
for i in 0..items.count() {
|
||||||
|
let item = items.objectAtIndex(i);
|
||||||
|
if item.title().to_string() == title {
|
||||||
|
return item.submenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_menu(mtm: MainThreadMarker) -> Option<Retained<NSMenu>> {
|
||||||
|
let main = NSApplication::sharedApplication(mtm).mainMenu()?;
|
||||||
|
for i in 0..main.numberOfItems() {
|
||||||
|
let Some(item) = main.itemAtIndex(i) else { continue };
|
||||||
|
let Some(submenu) = item.submenu() else { continue };
|
||||||
|
if submenu.title().to_string() == "Edit" || item.title().to_string() == "Edit" {
|
||||||
|
return Some(submenu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn writing_tools_menu(mtm: MainThreadMarker) -> Option<Retained<NSMenu>> {
|
||||||
|
submenu_named(&edit_menu(mtm)?.itemArray(), "Writing Tools")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the submenu is on the Edit menu right now, and false off the main thread, where
|
||||||
|
/// AppKit may not be asked.
|
||||||
|
pub fn available() -> bool {
|
||||||
|
MainThreadMarker::new().is_some_and(|mtm| writing_tools_menu(mtm).is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires one row of the submenu by its title, which is English because the frontend has no way
|
||||||
|
/// to know what this Mac calls it. A row that is not found is an error rather than a no-op: a
|
||||||
|
/// gesture that does nothing and says nothing is the worst answer available.
|
||||||
|
pub fn perform(tool: &str) -> Result<(), String> {
|
||||||
|
let Some(mtm) = MainThreadMarker::new() else {
|
||||||
|
return Err("Writing Tools has to be performed on the main thread.".into());
|
||||||
|
};
|
||||||
|
let Some(menu) = writing_tools_menu(mtm) else {
|
||||||
|
return Err("This Mac has no Writing Tools menu.".into());
|
||||||
|
};
|
||||||
|
let items = menu.itemArray();
|
||||||
|
for i in 0..items.count() {
|
||||||
|
if items.objectAtIndex(i).title().to_string() == tool {
|
||||||
|
menu.performActionForItemAtIndex(i as isize);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(format!("The Writing Tools menu has no {tool} item."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called once from lib.rs's `setup`, which is early enough.
|
||||||
|
///
|
||||||
|
/// The sibling app looks for the submenu here and it was worth checking rather than copying,
|
||||||
|
/// because the submenu is AppKit's and nothing in this process puts it there. Measured on macOS
|
||||||
|
/// 26.5: at `setup`, before the webview has loaded a page, the Edit menu already carries "Writing
|
||||||
|
/// Tools" with Proofread and Rewrite on it, so there is nothing to wait for and no window event to
|
||||||
|
/// hang this off. A Mac without Apple Intelligence has no submenu at any moment, which is the same
|
||||||
|
/// code path.
|
||||||
|
///
|
||||||
|
/// This deliberately does not put Shift+Option+F and Shift+Option+R on Apple's own rows, which is
|
||||||
|
/// what the sibling does. AppKit performs a key equivalent by firing the menu item directly, so a
|
||||||
|
/// chord on the system's row reaches Writing Tools without passing the selection guard in
|
||||||
|
/// src/editor/writing.ts, and that guard is refusing selections that corrupt the file: a rewrite
|
||||||
|
/// spanning a link loses its address, one spanning two table cells widens the table. The chords are
|
||||||
|
/// on this app's own Edit rows instead, in lib.rs, where they route through the command table and
|
||||||
|
/// meet the guard. Exactly one menu item still owns each chord.
|
||||||
|
pub fn install(_app: &tauri::AppHandle) {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
SUBMENU_SEEN.store(mac::available(), Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this machine can actually run a Writing Tool. Writing Tools needs macOS 15.1 and Apple
|
||||||
|
/// Intelligence turned on, and `minimumSystemVersion` for this app is 10.15, so an unavailable menu
|
||||||
|
/// is the ordinary case and not an error: the frontend turns it into a toast rather than a button
|
||||||
|
/// that silently does nothing.
|
||||||
|
///
|
||||||
|
/// Answered from the live menu rather than from a version number, because the version is necessary
|
||||||
|
/// and not sufficient: the feature is off until the user turns Apple Intelligence on, and the
|
||||||
|
/// submenu is the only thing that knows. This signature carries no `AppHandle` to hop threads with,
|
||||||
|
/// so off the main thread it answers with what `install` saw at launch instead of guessing.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn writing_available() -> Result<bool, String> {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
if mac::available() {
|
||||||
|
SUBMENU_SEEN.store(true, Ordering::Relaxed);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
Ok(SUBMENU_SEEN.load(Ordering::Relaxed))
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires one item on the system's Writing Tools submenu, by its English title.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn writing_run(app: tauri::AppHandle, tool: String) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
use objc2::MainThreadMarker;
|
||||||
|
// Performed here when this is already the main thread, so a missing row comes back as an
|
||||||
|
// error the frontend can say out loud. The hop is the fallback and it cannot report: waiting
|
||||||
|
// on its answer would deadlock exactly when the wait was unnecessary.
|
||||||
|
if MainThreadMarker::new().is_some() {
|
||||||
|
return mac::perform(&tool);
|
||||||
|
}
|
||||||
|
app.run_on_main_thread(move || {
|
||||||
|
if let Err(e) = mac::perform(&tool) {
|
||||||
|
eprintln!("writing tools: {e}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
let _ = (app, tool);
|
||||||
|
Err("Writing Tools is a macOS feature.".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Empty stand-in for `burn-cuda`, used via [patch.crates-io] in ../../Cargo.toml.
|
||||||
|
# harper-core pulls burn-cuda only as an optional, disabled CUDA backend. This stub keeps
|
||||||
|
# the heavy cubecl + tracel-llvm subtree out of the dependency graph. Never compiled.
|
||||||
|
# The feature names below must match the ones `burn` references via `burn-cuda?/...` so that
|
||||||
|
# Cargo's feature validation passes; they are all empty no-ops.
|
||||||
|
[package]
|
||||||
|
name = "burn-cuda"
|
||||||
|
version = "0.19.1"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
std = []
|
||||||
|
doc = []
|
||||||
|
fusion = []
|
||||||
|
autotune = []
|
||||||
|
autotune-checks = []
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// Intentionally empty. See Cargo.toml: this stubs out the disabled `burn-cuda` backend.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Empty stand-in for `cubecl-cpu`, used via [patch.crates-io] in ../../Cargo.toml.
|
||||||
|
# cubecl-cpu is the LLVM/MLIR JIT CPU runtime pulled (transitively, through burn's disabled GPU
|
||||||
|
# backends) by harper-core. It drags in the tracel-llvm / tracel-llvm-bundler crates, which build
|
||||||
|
# and bundle an LLVM toolchain and are by a wide margin the most expensive thing in the graph.
|
||||||
|
# Replacing it with this empty stub removes that whole subtree. Nothing here is ever compiled
|
||||||
|
# (GPU backends are off).
|
||||||
|
[package]
|
||||||
|
name = "cubecl-cpu"
|
||||||
|
version = "0.8.1"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// Intentionally empty. See Cargo.toml: this stubs out the disabled cubecl-cpu JIT runtime.
|
||||||
@@ -41,7 +41,8 @@
|
|||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
],
|
],
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"minimumSystemVersion": "10.15"
|
"minimumSystemVersion": "10.15",
|
||||||
|
"hardenedRuntime": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
// An export over a real folder of markdown, asked the same question src-tauri/tests/no_write_on_open.rs
|
||||||
|
// asks of a save: did anything in the folder move?
|
||||||
|
//
|
||||||
|
// src-tauri/tests/pdf.rs already covers what the compiler refuses and what it survives, against a
|
||||||
|
// TempDir with two files in it. That is the right shape for a question about diagnostics and the
|
||||||
|
// wrong shape for a question about bytes: a bag of files in a temporary folder cannot say that a
|
||||||
|
// folder somebody would plausibly have opened comes back from `git status` with no lines in it. So
|
||||||
|
// this suite runs against the same generated git repository the no-write suite does, built by
|
||||||
|
// tests/support/notes_repo.rs, and the oracle is the same one: `git status --porcelain`, plus a
|
||||||
|
// stat and content snapshot of every path under the root so that a rewrite with identical bytes is
|
||||||
|
// still caught.
|
||||||
|
//
|
||||||
|
// The tests share that one folder and `pristine()` resets it, so they hold a lock rather than
|
||||||
|
// needing `--test-threads=1`. Running the binary under any thread count is correct.
|
||||||
|
//
|
||||||
|
// The sharp question is at the bottom, and it is about `pdf_write` rather than about the compiler.
|
||||||
|
// `pdf_write` skips the open-roots guard on purpose, because its path came from a native save panel
|
||||||
|
// and is the user's own choice of destination. What that also means is that it will write to
|
||||||
|
// whatever it is given, and nothing on either side of the boundary checks that the destination is
|
||||||
|
// not one of the user's own documents. `a_save_panel_pointed_at_a_document_is_refused` is what
|
||||||
|
// happens then, written down so that it is a fact somebody decided rather than one nobody noticed.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Mutex, MutexGuard};
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use base64::Engine;
|
||||||
|
use margin_docs_lib::dto::ImageInput;
|
||||||
|
use margin_docs_lib::pdf::{compile, pdf_write};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[path = "support/notes_repo.rs"]
|
||||||
|
mod notes_repo;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- fixture
|
||||||
|
|
||||||
|
/// One test in the folder at a time. The suite shares a single repository and `pristine()` throws
|
||||||
|
/// away whatever the last test did to it, so this is what makes the sharing safe under `cargo
|
||||||
|
/// test`'s default thread count instead of a flag in CI that somebody has to remember.
|
||||||
|
static FIXTURE: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
/// Back to the committed state, with the lock held for as long as the guard lives.
|
||||||
|
///
|
||||||
|
/// A poisoned lock is taken anyway: it means an earlier test panicked, which is a failure that has
|
||||||
|
/// already been reported, and refusing to run the rest of the suite on top of it turns one red test
|
||||||
|
/// into a file of them.
|
||||||
|
fn pristine() -> (MutexGuard<'static, ()>, PathBuf) {
|
||||||
|
let guard = FIXTURE.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let root = notes_repo::path().to_path_buf();
|
||||||
|
assert!(
|
||||||
|
root.join(".git").is_dir(),
|
||||||
|
"the fixture repo is missing: {}",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
notes_repo::git(&["reset", "--hard", "-q"]);
|
||||||
|
notes_repo::git(&["clean", "-fdq"]);
|
||||||
|
let status = notes_repo::git(&["status", "--porcelain"]);
|
||||||
|
assert!(
|
||||||
|
status.is_empty(),
|
||||||
|
"the fixture repo did not start clean:\n{status}"
|
||||||
|
);
|
||||||
|
(guard, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_status() -> String {
|
||||||
|
notes_repo::git(&["status", "--porcelain"])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quoted(status: &str) -> String {
|
||||||
|
if status.is_empty() {
|
||||||
|
" <empty: working tree clean>".to_string()
|
||||||
|
} else {
|
||||||
|
status
|
||||||
|
.lines()
|
||||||
|
.map(|line| format!(" {line}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_clean(label: &str) {
|
||||||
|
let status = git_status();
|
||||||
|
println!(" [{label}] git status --porcelain:\n{}", quoted(&status));
|
||||||
|
assert!(status.is_empty(), "{label} left git dirty:\n{status}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- snapshots
|
||||||
|
|
||||||
|
/// Enough of a file to notice a rewrite that put the same bytes back. `git status` cannot see one
|
||||||
|
/// of those and it is exactly what an exporter tidying up after itself would leave.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
|
struct Stamp {
|
||||||
|
kind: &'static str,
|
||||||
|
len: u64,
|
||||||
|
mtime: (i64, i64),
|
||||||
|
ino: u64,
|
||||||
|
/// Content hash, for everything outside the vendored `node_modules`, where the stat is already
|
||||||
|
/// conclusive and hashing 13,000 files on every snapshot is not worth the second it costs.
|
||||||
|
hash: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Snapshot = BTreeMap<String, Stamp>;
|
||||||
|
|
||||||
|
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||||
|
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||||
|
for byte in bytes {
|
||||||
|
hash ^= *byte as u64;
|
||||||
|
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||||
|
}
|
||||||
|
hash
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every path under `root`, dotfiles, .git and node_modules included.
|
||||||
|
fn snapshot(root: &Path) -> Snapshot {
|
||||||
|
let mut out = Snapshot::new();
|
||||||
|
walk(root, root, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk(root: &Path, dir: &Path, out: &mut Snapshot) {
|
||||||
|
let Ok(entries) = fs::read_dir(dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let rel = path
|
||||||
|
.strip_prefix(root)
|
||||||
|
.unwrap_or(&path)
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
let Ok(meta) = fs::symlink_metadata(&path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let kind = if meta.is_dir() {
|
||||||
|
"dir"
|
||||||
|
} else if meta.is_symlink() {
|
||||||
|
"link"
|
||||||
|
} else {
|
||||||
|
"file"
|
||||||
|
};
|
||||||
|
let cheap = rel.starts_with("node_modules/") || rel.starts_with(".git/");
|
||||||
|
out.insert(
|
||||||
|
rel,
|
||||||
|
Stamp {
|
||||||
|
kind,
|
||||||
|
len: meta.len(),
|
||||||
|
mtime: (meta.mtime(), meta.mtime_nsec()),
|
||||||
|
ino: meta.ino(),
|
||||||
|
hash: if kind == "file" && !cheap {
|
||||||
|
fs::read(&path).ok().map(|bytes| fnv1a(&bytes))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if meta.is_dir() {
|
||||||
|
walk(root, &path, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What moved between two snapshots, ignoring nothing.
|
||||||
|
fn changes(before: &Snapshot, after: &Snapshot) -> (Vec<String>, Vec<String>, Vec<String>) {
|
||||||
|
let mut added = Vec::new();
|
||||||
|
let mut removed = Vec::new();
|
||||||
|
let mut changed = Vec::new();
|
||||||
|
for (path, stamp) in after {
|
||||||
|
match before.get(path) {
|
||||||
|
None => added.push(path.clone()),
|
||||||
|
Some(was) if was != stamp => {
|
||||||
|
changed.push(format!("{path}\n was {was:?}\n now {stamp:?}"))
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for path in before.keys() {
|
||||||
|
if !after.contains_key(path) {
|
||||||
|
removed.push(path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(added, removed, changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_untouched(label: &str, before: &Snapshot, after: &Snapshot) {
|
||||||
|
let (added, removed, changed) = changes(before, after);
|
||||||
|
assert!(
|
||||||
|
added.is_empty() && removed.is_empty() && changed.is_empty(),
|
||||||
|
"{label} touched the folder\n added: {added:?}\n removed: {removed:?}\n changed:\n {}",
|
||||||
|
changed.join("\n ")
|
||||||
|
);
|
||||||
|
println!(" [{label}] {} paths, none touched", before.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same, allowing exactly the paths named to appear and the folders holding them to have been
|
||||||
|
/// written into. A PDF exported next to the documents is a new file, and a new file is a new mtime
|
||||||
|
/// on its parent directory; nothing else is allowed to move.
|
||||||
|
fn assert_only_added(label: &str, before: &Snapshot, after: &Snapshot, expected: &[&str]) {
|
||||||
|
let (added, removed, changed) = changes(before, after);
|
||||||
|
assert_eq!(added, expected, "{label} added something unexpected");
|
||||||
|
assert!(removed.is_empty(), "{label} removed {removed:?}");
|
||||||
|
let parents: Vec<String> = expected
|
||||||
|
.iter()
|
||||||
|
.map(|p| match p.rfind('/') {
|
||||||
|
Some(at) => p[..at].to_string(),
|
||||||
|
None => String::new(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let unexpected: Vec<&String> = changed
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| {
|
||||||
|
let path = entry.lines().next().unwrap_or_default();
|
||||||
|
!parents.iter().any(|parent| parent == path)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
unexpected.is_empty(),
|
||||||
|
"{label} changed more than the folder it wrote into:\n {}",
|
||||||
|
unexpected
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n ")
|
||||||
|
);
|
||||||
|
println!(" [{label}] added {expected:?} and moved nothing else");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- the document
|
||||||
|
|
||||||
|
/// The head of what src/export/typst.ts writes: the mitex import that is the contract between the
|
||||||
|
/// converter and src-tauri/src/pdf.rs, and set rules naming no font, because which faces exist is
|
||||||
|
/// the backend's business.
|
||||||
|
const PREAMBLE: &str = r#"#import "/mitex/lib.typ": mitex, mi
|
||||||
|
#set document(title: "Handbook")
|
||||||
|
#set page(paper: "a4", margin: 2cm)
|
||||||
|
#set text(size: 11pt, lang: "en")
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// A drawn mermaid diagram as it crosses the boundary: SVG from the webview, with no file behind it.
|
||||||
|
const DIAGRAM: &[u8] =
|
||||||
|
br##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="8"><rect width="16" height="8" fill="#456"/></svg>"##;
|
||||||
|
|
||||||
|
/// The whole export, as the converter would have written it for a document in this folder: a
|
||||||
|
/// picture that is a real file inside the open root, a drawn diagram that is bytes, a table and a
|
||||||
|
/// formula. Every one of those is a path the exporter does extra work on, and three of them mean
|
||||||
|
/// the compiler opening something.
|
||||||
|
fn document_from(root: &Path) -> (String, Vec<ImageInput>) {
|
||||||
|
let picture = root.join("assets/logo.png");
|
||||||
|
assert!(picture.is_file(), "the fixture has an asset to point at");
|
||||||
|
|
||||||
|
let source = format!(
|
||||||
|
"{PREAMBLE}
|
||||||
|
= The handbook
|
||||||
|
|
||||||
|
Prose, then a picture that is a file in the open folder.
|
||||||
|
|
||||||
|
#image(\"{}\")
|
||||||
|
|
||||||
|
#figure(image(\"/inline/diagram-1.svg\"))
|
||||||
|
|
||||||
|
#table(columns: 2, [region], [total], [north], [12])
|
||||||
|
|
||||||
|
Inline #mi(\"a^2 + b^2 = c^2\") and a display one:
|
||||||
|
|
||||||
|
#mitex(\"\\\\frac{{1}}{{2}} \\\\int_0^1 x^2 dx\")
|
||||||
|
",
|
||||||
|
picture.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let images = vec![
|
||||||
|
ImageInput {
|
||||||
|
path: picture.to_string_lossy().into_owned(),
|
||||||
|
data: None,
|
||||||
|
},
|
||||||
|
ImageInput {
|
||||||
|
path: "/inline/diagram-1.svg".to_string(),
|
||||||
|
data: Some(STANDARD.encode(DIAGRAM)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
(source, images)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn roots_of(root: &Path) -> Vec<String> {
|
||||||
|
vec![root.to_string_lossy().into_owned()]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================ compiling
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compiling_a_document_out_of_a_real_folder_writes_nothing_in_it() {
|
||||||
|
let (_lock, root) = pristine();
|
||||||
|
let (source, images) = document_from(&root);
|
||||||
|
|
||||||
|
let before = snapshot(&root);
|
||||||
|
let (bytes, warnings) =
|
||||||
|
compile(source, &images, &roots_of(&root)).expect("the folder compiles");
|
||||||
|
let after = snapshot(&root);
|
||||||
|
|
||||||
|
assert_eq!(&bytes[..4], b"%PDF", "the answer is a PDF");
|
||||||
|
// The picture really was read off disk rather than worked around, so the one step of the
|
||||||
|
// compile that opens a file in the user's folder is a step this test actually took.
|
||||||
|
let worked_around: Vec<&str> = warnings
|
||||||
|
.iter()
|
||||||
|
.filter(|w| w.kind == "image")
|
||||||
|
.map(|w| w.message.as_str())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
worked_around.is_empty(),
|
||||||
|
"the image in the open folder was read: {worked_around:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_untouched("a compile", &before, &after);
|
||||||
|
assert_clean("a compile");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compiling_the_same_folder_ten_times_over_still_writes_nothing() {
|
||||||
|
// Once could be a compile that failed early and touched nothing because it did nothing. Ten
|
||||||
|
// laps, with the fonts loaded and the images opened every time, is the shape of the thing the
|
||||||
|
// user actually does: export, read it, change a line, export again.
|
||||||
|
let (_lock, root) = pristine();
|
||||||
|
let before = snapshot(&root);
|
||||||
|
|
||||||
|
for lap in 0..10 {
|
||||||
|
let (source, images) = document_from(&root);
|
||||||
|
let (bytes, _) =
|
||||||
|
compile(source, &images, &roots_of(&root)).unwrap_or_else(|e| panic!("lap {lap}: {e}"));
|
||||||
|
assert_eq!(&bytes[..4], b"%PDF");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_untouched("ten compiles", &before, &snapshot(&root));
|
||||||
|
assert_clean("ten compiles");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================ writing
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exporting_to_a_folder_of_its_own_leaves_the_documents_alone() {
|
||||||
|
// The ordinary export: the panel points somewhere outside the notes folder, which is where a
|
||||||
|
// PDF belongs. Nothing in the folder the document came from has any business moving, and the
|
||||||
|
// whole of `git status` is the evidence.
|
||||||
|
let (_lock, root) = pristine();
|
||||||
|
let (source, images) = document_from(&root);
|
||||||
|
let elsewhere = TempDir::new().expect("a temp dir");
|
||||||
|
let target = elsewhere.path().join("The handbook.pdf");
|
||||||
|
|
||||||
|
let before = snapshot(&root);
|
||||||
|
let (bytes, _) = compile(source, &images, &roots_of(&root)).expect("the folder compiles");
|
||||||
|
pdf_write(target.to_string_lossy().into_owned(), bytes.clone()).expect("the panel's path");
|
||||||
|
let after = snapshot(&root);
|
||||||
|
|
||||||
|
assert_eq!(fs::read(&target).expect("the PDF is there"), bytes);
|
||||||
|
assert_untouched("an export to another folder", &before, &after);
|
||||||
|
assert_clean("an export to another folder");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exporting_beside_the_documents_adds_the_pdf_and_moves_nothing_else() {
|
||||||
|
// The other ordinary export, and the one that cannot leave `git status` empty: a PDF written
|
||||||
|
// next to the document is a new file in the folder and shows up as one. What matters is that
|
||||||
|
// it is the only line, and that no document was rewritten to put it there.
|
||||||
|
let (_lock, root) = pristine();
|
||||||
|
let (source, images) = document_from(&root);
|
||||||
|
let target = root.join("docs/guides/setup.pdf");
|
||||||
|
|
||||||
|
let before = snapshot(&root);
|
||||||
|
let (bytes, _) = compile(source, &images, &roots_of(&root)).expect("the folder compiles");
|
||||||
|
pdf_write(target.to_string_lossy().into_owned(), bytes).expect("a path inside the root");
|
||||||
|
let after = snapshot(&root);
|
||||||
|
|
||||||
|
assert_only_added(
|
||||||
|
"an export into the folder",
|
||||||
|
&before,
|
||||||
|
&after,
|
||||||
|
&["docs/guides/setup.pdf"],
|
||||||
|
);
|
||||||
|
|
||||||
|
let status = git_status();
|
||||||
|
println!(
|
||||||
|
"git status --porcelain after an export into the folder:\n{}",
|
||||||
|
quoted(&status)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status.lines().collect::<Vec<_>>(),
|
||||||
|
vec!["?? docs/guides/setup.pdf"],
|
||||||
|
"the PDF is the only thing git can see"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the atomic write left nothing of its own behind: the temp file it renames through is
|
||||||
|
// gone, so there is no `.setup.pdf.tmp` for the next `git add -A` to sweep up.
|
||||||
|
let strays: Vec<String> = fs::read_dir(root.join("docs/guides"))
|
||||||
|
.expect("the guides folder")
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||||
|
.filter(|name| !name.ends_with(".md") && name != "setup.pdf")
|
||||||
|
.collect();
|
||||||
|
assert!(strays.is_empty(), "the write left {strays:?} behind");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================ the guard that is not there
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_save_panel_pointed_at_a_document_is_refused() {
|
||||||
|
// This test was written the other way up, pinning the overwrite as a fact somebody had decided.
|
||||||
|
// Nobody had: it was the one way an export could destroy markdown, and it needed only a user who
|
||||||
|
// reached the save panel and typed a `.md` name into it. It has not been seen because the panel
|
||||||
|
// carries a PDF filter and AppKit usually appends `.pdf` to a name typed with another extension,
|
||||||
|
// which is the panel being careful rather than this module, and the promise that the editor
|
||||||
|
// never writes a file the user did not edit is not the panel's to keep.
|
||||||
|
//
|
||||||
|
// So `pdf_write` now refuses a destination that already holds one of the user's documents, and
|
||||||
|
// this is the same test rewritten to the truth that replaced it. Everything about the rest of
|
||||||
|
// the decision still stands: the root guard is still off this path on purpose, which is what
|
||||||
|
// `pdf_write_will_also_write_outside_every_open_folder` below is about.
|
||||||
|
let (_lock, root) = pristine();
|
||||||
|
let (source, images) = document_from(&root);
|
||||||
|
let document = root.join("docs/design.md");
|
||||||
|
let markdown = fs::read(&document).expect("a document to aim at");
|
||||||
|
assert!(
|
||||||
|
markdown.starts_with(b"#") || markdown.len() > 100,
|
||||||
|
"the fixture document has real markdown in it"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (bytes, _) = compile(source, &images, &roots_of(&root)).expect("the folder compiles");
|
||||||
|
let answer = pdf_write(document.to_string_lossy().into_owned(), bytes.clone());
|
||||||
|
|
||||||
|
let message = answer.expect_err("a document is not a place to put a PDF");
|
||||||
|
assert!(
|
||||||
|
message.contains("design.md") && message.contains("document"),
|
||||||
|
"the refusal names the file and says why: {message}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(&document).expect("the document is still a file"),
|
||||||
|
markdown,
|
||||||
|
"the document is untouched, byte for byte"
|
||||||
|
);
|
||||||
|
|
||||||
|
let status = git_status();
|
||||||
|
assert_eq!(status, "", "the folder is clean:\n{}", quoted(&status));
|
||||||
|
|
||||||
|
// A .txt is a document too, and a .pdf that is already there is not: replacing one is what a
|
||||||
|
// save panel is for, and it has already asked.
|
||||||
|
let notes = root.join("docs/notes.txt");
|
||||||
|
fs::write(¬es, b"a plain text document\n").expect("a text file");
|
||||||
|
let again = bytes;
|
||||||
|
pdf_write(notes.to_string_lossy().into_owned(), again.clone())
|
||||||
|
.expect_err("a .txt is a document as much as a .md is");
|
||||||
|
|
||||||
|
let existing = root.join("docs/already.pdf");
|
||||||
|
fs::write(&existing, b"%PDF-1.7 an older export\n").expect("an older PDF");
|
||||||
|
pdf_write(existing.to_string_lossy().into_owned(), again.clone())
|
||||||
|
.expect("replacing a PDF is what the panel asked about");
|
||||||
|
assert_eq!(fs::read(&existing).expect("the newer export"), again);
|
||||||
|
|
||||||
|
let fresh = root.join("docs/never-existed.md");
|
||||||
|
pdf_write(fresh.to_string_lossy().into_owned(), again)
|
||||||
|
.expect("a name nothing holds destroys nothing, however odd it looks");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pdf_write_will_also_write_outside_every_open_folder() {
|
||||||
|
// The half of the same decision that is intended, kept next to the half that is not so the two
|
||||||
|
// are read together. A PDF belongs on the Desktop or in Downloads far more often than it
|
||||||
|
// belongs in the notes folder, so `resolve_in_roots` is not on this path and must not be. The
|
||||||
|
// fix for the test above, if there is one, is not a root guard.
|
||||||
|
let (_lock, root) = pristine();
|
||||||
|
let elsewhere = TempDir::new().expect("a temp dir");
|
||||||
|
let target = elsewhere.path().join("report.pdf");
|
||||||
|
|
||||||
|
pdf_write(
|
||||||
|
target.to_string_lossy().into_owned(),
|
||||||
|
b"%PDF-1.7\n".to_vec(),
|
||||||
|
)
|
||||||
|
.expect("somewhere the user never opened");
|
||||||
|
|
||||||
|
assert_eq!(fs::read(&target).expect("the PDF"), b"%PDF-1.7\n");
|
||||||
|
assert_clean("a write outside every root");
|
||||||
|
let _ = root;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// What the grammar checker promises the frontend, asserted against the real engine.
|
||||||
|
//
|
||||||
|
// Three things, and the first is the one that would be invisible everywhere else. Offsets are in
|
||||||
|
// characters, and src/editor/proofing.ts adds a ProseMirror position to them without converting
|
||||||
|
// anything, so an engine that counted bytes would draw its underlines further and further to the
|
||||||
|
// left of the words they are about for every non-ASCII character earlier in the paragraph. That is
|
||||||
|
// a bug nobody writing in English would ever see and everybody writing in French or Polish would
|
||||||
|
// see immediately, and no other test in this repository is in a position to notice it: the Rust
|
||||||
|
// suite otherwise asserts about files, and the browser suite talks to the fixture in
|
||||||
|
// src/dev/mockIpc.ts rather than to Harper.
|
||||||
|
//
|
||||||
|
// The second is that Harper's own spell rule is off. It is turned off in src-tauri/src/grammar.rs because
|
||||||
|
// NSSpellChecker already does the spelling and knows the user's own names and languages, and a
|
||||||
|
// second dictionary underlining "Yoshinari" would be exactly the noise that gets a checker switched
|
||||||
|
// off for good. Left on, everything would still be green: there would just be spelling issues
|
||||||
|
// arriving through the grammar command, drawn in the grammar colour, with no Learn item on them.
|
||||||
|
//
|
||||||
|
// The third is that the engine survives being asked twice. It is a static built on first use and a
|
||||||
|
// `LintGroup` that lints through `&mut self`, so a second call is the one that would find a lock
|
||||||
|
// held or a state left dirty by the first.
|
||||||
|
|
||||||
|
use margin_docs_lib::dto::GrammarIssue;
|
||||||
|
use margin_docs_lib::grammar::{grammar_available, grammar_check};
|
||||||
|
|
||||||
|
/// The text a lint is about, sliced the way the frontend slices it: by character.
|
||||||
|
fn flagged(text: &str, issue: &GrammarIssue) -> String {
|
||||||
|
text.chars()
|
||||||
|
.skip(issue.start)
|
||||||
|
.take(issue.end - issue.start)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(text: &str) -> Vec<GrammarIssue> {
|
||||||
|
grammar_check(text.to_string()).expect("the checker answered")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_build_with_harper_in_it_says_so() {
|
||||||
|
assert!(grammar_available().expect("availability answered"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_repeated_word_is_found_and_can_be_corrected() {
|
||||||
|
let issues = check("I put the the book down.");
|
||||||
|
let repeat = issues
|
||||||
|
.iter()
|
||||||
|
.find(|issue| flagged("I put the the book down.", issue).contains("the the"))
|
||||||
|
.expect("the repeated word was found");
|
||||||
|
|
||||||
|
assert!(!repeat.kind.is_empty(), "a lint carries the rule's category");
|
||||||
|
assert!(!repeat.message.is_empty(), "and something to show the reader");
|
||||||
|
assert!(
|
||||||
|
repeat.suggestions.iter().any(|s| s.trim() == "the"),
|
||||||
|
"with the correction on it: {:?}",
|
||||||
|
repeat.suggestions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn offsets_are_characters_and_not_bytes() {
|
||||||
|
// Every character before the mistake is three bytes in UTF-8, so a checker counting bytes would
|
||||||
|
// report a span three times too far along and the assertion below would slice the wrong words.
|
||||||
|
let text = "Zażółć gęślą jaźń, and then I put the the book down.";
|
||||||
|
let issues = check(text);
|
||||||
|
let repeat = issues
|
||||||
|
.iter()
|
||||||
|
.find(|issue| flagged(text, issue).contains("the the"))
|
||||||
|
.expect("the repeated word was found after a run of non-ASCII text");
|
||||||
|
|
||||||
|
assert_eq!(flagged(text, repeat), "the the");
|
||||||
|
assert!(
|
||||||
|
repeat.end <= text.chars().count(),
|
||||||
|
"and it ends inside the run it was told about"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spelling_is_left_to_the_system_checker() {
|
||||||
|
// Not a word in any dictionary, and Harper's curated one included. Nothing may come back about
|
||||||
|
// it, because the only rule that would have is the one src-tauri/src/grammar.rs turns off.
|
||||||
|
let text = "The flurbulent maglifter needs oiling.";
|
||||||
|
for issue in check(text) {
|
||||||
|
assert_ne!(
|
||||||
|
issue.kind, "Spelling",
|
||||||
|
"the grammar checker reported a spelling issue: {issue:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_to_say_about_nothing() {
|
||||||
|
assert!(check("").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Harper flags two spaces between sentences as "French spaces", and src-tauri/src/grammar.rs drops every
|
||||||
|
/// lint whose whole span is whitespace for the reasons written there: an underline nobody can see
|
||||||
|
/// or click on, and, when the spaces are the ones src/editor/proofing.ts writes in place of an
|
||||||
|
/// inline code span, an underline over the one thing that file went out of its way not to send.
|
||||||
|
///
|
||||||
|
/// These two inputs are the ones that reach the filter, so taking the filter out fails this test
|
||||||
|
/// rather than leaving it green. What it cannot see is a future Harper that stops raising the lint
|
||||||
|
/// at all, which would leave the guard unreached and this file none the wiser.
|
||||||
|
#[test]
|
||||||
|
fn a_lint_about_nothing_but_spaces_is_not_reported() {
|
||||||
|
assert!(
|
||||||
|
check("This is fine. Two spaces there.").is_empty(),
|
||||||
|
"the two spaces between the sentences were reported"
|
||||||
|
);
|
||||||
|
// The shape src/editor/proofing.ts produces from a blanked inline code span.
|
||||||
|
assert!(
|
||||||
|
check("The value is nine.").is_empty(),
|
||||||
|
"a blanked code span was reported as a run of spaces"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_engine_is_kept_and_answers_the_same_way_twice() {
|
||||||
|
let text = "I put the the book down.";
|
||||||
|
let first = check(text);
|
||||||
|
let second = check(text);
|
||||||
|
assert_eq!(first.len(), second.len());
|
||||||
|
assert_eq!(
|
||||||
|
first.first().map(|i| (i.start, i.end)),
|
||||||
|
second.first().map(|i| (i.start, i.end))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
// The exporter compiles a document nobody has checked, so the tests here are about what it refuses
|
||||||
|
// and what it survives rather than about the typesetting: a formula reaches mitex instead of the
|
||||||
|
// page as source, an image outside every open folder is never read, and neither a broken link nor a
|
||||||
|
// formula the converter choked on costs the user the whole PDF.
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use base64::Engine;
|
||||||
|
use margin_docs_lib::dto::ImageInput;
|
||||||
|
use margin_docs_lib::pdf::compile;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// A real 4x4 PNG, so an image that is meant to be read is one Typst can actually decode, and one
|
||||||
|
/// the placeholder cannot be mistaken for.
|
||||||
|
const PNG: &str =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAE0lEQVR4nGO8I2LDAANMcBZeDgA8PAE0qLfS9QAAAABJRU5ErkJggg==";
|
||||||
|
|
||||||
|
/// The shape of what src/export/typst.ts puts at the top of a document with a formula in it: the
|
||||||
|
/// mitex import, which is the contract between the converter and this module, and set rules that
|
||||||
|
/// name neither the monospace nor the maths face, because which of those exist is the backend's
|
||||||
|
/// business and a family named hopefully is a warning per export about fonts nobody chose. A
|
||||||
|
/// document set rule after the preamble pdf.rs prepends is part of what these fixtures prove.
|
||||||
|
const PREAMBLE: &str = r#"#import "/mitex/lib.typ": mitex, mi
|
||||||
|
#set document(title: "Fixture")
|
||||||
|
#set page(paper: "a4", margin: 2cm)
|
||||||
|
#set text(size: 11pt, lang: "en")
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn png_bytes() -> Vec<u8> {
|
||||||
|
STANDARD.decode(PNG).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root() -> (TempDir, Vec<String>) {
|
||||||
|
let dir = TempDir::new().expect("a temp dir");
|
||||||
|
let paths = vec![dir.path().to_string_lossy().into_owned()];
|
||||||
|
(dir, paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kinds<'a>(warnings: &'a [margin_docs_lib::dto::PdfWarning], kind: &str) -> Vec<&'a str> {
|
||||||
|
warnings
|
||||||
|
.iter()
|
||||||
|
.filter(|w| w.kind == kind)
|
||||||
|
.map(|w| w.message.as_str())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_document_with_an_image_and_a_formula_compiles() {
|
||||||
|
let (dir, roots) = root();
|
||||||
|
let photo = dir.path().join("photo.png");
|
||||||
|
std::fs::write(&photo, png_bytes()).unwrap();
|
||||||
|
|
||||||
|
let source = format!(
|
||||||
|
"{PREAMBLE}
|
||||||
|
= Fixture
|
||||||
|
|
||||||
|
Inline #mi(\"a^2 + b^2 = c^2\") in a sentence.
|
||||||
|
|
||||||
|
#mitex(\"\\\\frac{{1}}{{2}} \\\\int_0^1 x^2 dx\")
|
||||||
|
|
||||||
|
#image(\"{}\")
|
||||||
|
|
||||||
|
Diagram: #image(\"diagram.svg\")
|
||||||
|
",
|
||||||
|
photo.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let images = vec![
|
||||||
|
ImageInput {
|
||||||
|
path: photo.to_string_lossy().into_owned(),
|
||||||
|
data: None,
|
||||||
|
},
|
||||||
|
// How a mermaid diagram arrives: rendered to SVG in the webview, with no file behind it.
|
||||||
|
ImageInput {
|
||||||
|
path: "diagram.svg".to_string(),
|
||||||
|
data: Some(STANDARD.encode(
|
||||||
|
br##"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8" fill="#333"/></svg>"##,
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let (bytes, warnings) = compile(source, &images, &roots).expect("the fixture compiles");
|
||||||
|
|
||||||
|
assert_eq!(&bytes[..4], b"%PDF", "the answer is a PDF");
|
||||||
|
assert!(
|
||||||
|
kinds(&warnings, "image").is_empty(),
|
||||||
|
"no image was worked around: {:?}",
|
||||||
|
kinds(&warnings, "image")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
kinds(&warnings, "math").is_empty(),
|
||||||
|
"no formula was worked around: {:?}",
|
||||||
|
kinds(&warnings, "math")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_formula_typesets_through_mitex_rather_than_falling_back_to_source() {
|
||||||
|
let (_dir, roots) = root();
|
||||||
|
|
||||||
|
// Only the wasm plugin can turn `\frac{a}{b}` into Typst's own `frac(a, b )`, so asserting on
|
||||||
|
// what came back out of it proves the whole path resolved: the import, the relative imports
|
||||||
|
// underneath it, the specs and the plugin. An `assert` inside the document makes a wrong answer
|
||||||
|
// a compile error rather than a difference in bytes nobody would notice.
|
||||||
|
let source = format!(
|
||||||
|
"{PREAMBLE}#import \"/mitex/lib.typ\": mitex-convert
|
||||||
|
#assert.eq(mitex-convert(\"\\\\frac{{a}}{{b}}\"), \"frac(a ,b )\")
|
||||||
|
#mi(\"\\\\alpha + \\\\beta\")
|
||||||
|
"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (bytes, warnings) = compile(source, &[], &roots).expect("mitex loads and converts");
|
||||||
|
assert_eq!(&bytes[..4], b"%PDF");
|
||||||
|
assert!(
|
||||||
|
kinds(&warnings, "math").is_empty(),
|
||||||
|
"the formula typeset without a workaround: {:?}",
|
||||||
|
kinds(&warnings, "math")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_mitex_files_are_the_only_thing_the_compiler_can_import() {
|
||||||
|
let (_dir, roots) = root();
|
||||||
|
|
||||||
|
let source = format!("{PREAMBLE}#import \"/etc/passwd\": *\n");
|
||||||
|
let error = compile(source, &[], &roots).expect_err("nothing outside the vendored files loads");
|
||||||
|
assert!(
|
||||||
|
error.contains("file not found"),
|
||||||
|
"the compiler has no filesystem: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_image_outside_every_open_folder_is_never_read() {
|
||||||
|
let (dir, roots) = root();
|
||||||
|
// Somewhere the user did not open, which is what `` resolves to.
|
||||||
|
let elsewhere = TempDir::new().expect("a temp dir");
|
||||||
|
let secret = elsewhere.path().join("id_rsa");
|
||||||
|
std::fs::write(&secret, "PRIVATE KEY").unwrap();
|
||||||
|
let _ = dir;
|
||||||
|
|
||||||
|
let source = format!(
|
||||||
|
"{PREAMBLE}#image(\"{}\")\n",
|
||||||
|
secret.display()
|
||||||
|
);
|
||||||
|
let images = vec![ImageInput {
|
||||||
|
path: secret.to_string_lossy().into_owned(),
|
||||||
|
data: None,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let (bytes, warnings) = compile(source, &images, &roots).expect("the export still happens");
|
||||||
|
assert_eq!(&bytes[..4], b"%PDF");
|
||||||
|
|
||||||
|
let refused = kinds(&warnings, "image");
|
||||||
|
assert_eq!(refused.len(), 1, "one image was worked around: {refused:?}");
|
||||||
|
assert!(
|
||||||
|
refused[0].contains("outside every open folder"),
|
||||||
|
"the root guard is what refused it: {}",
|
||||||
|
refused[0]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!String::from_utf8_lossy(&bytes).contains("PRIVATE KEY"),
|
||||||
|
"nothing from the file reached the page"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_broken_image_does_not_cost_the_whole_export() {
|
||||||
|
let (dir, roots) = root();
|
||||||
|
let missing = dir.path().join("gone.jpg");
|
||||||
|
|
||||||
|
let source = format!(
|
||||||
|
"{PREAMBLE}#image(\"{0}\")\n\n#image(\"{0}\")\n",
|
||||||
|
missing.display()
|
||||||
|
);
|
||||||
|
let images = vec![
|
||||||
|
ImageInput {
|
||||||
|
path: missing.to_string_lossy().into_owned(),
|
||||||
|
data: None,
|
||||||
|
},
|
||||||
|
ImageInput {
|
||||||
|
path: missing.to_string_lossy().into_owned(),
|
||||||
|
data: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let (bytes, warnings) = compile(source, &images, &roots).expect("the export still happens");
|
||||||
|
assert_eq!(&bytes[..4], b"%PDF");
|
||||||
|
|
||||||
|
// Two broken links to one file are one problem with a number on it, not two toasts.
|
||||||
|
let counted: Vec<u32> = warnings
|
||||||
|
.iter()
|
||||||
|
.filter(|w| w.kind == "image")
|
||||||
|
.map(|w| w.count)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(counted, vec![2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_formula_nothing_can_typeset_does_not_cost_the_export() {
|
||||||
|
let (_dir, roots) = root();
|
||||||
|
|
||||||
|
// Unbalanced braces, which is what somebody halfway through typing a formula has. mitex cannot
|
||||||
|
// parse it and says so by stopping the compile, and the whole page of notes would go with it.
|
||||||
|
let source = format!("{PREAMBLE}#mi(\"\\\\frac{{\")\n");
|
||||||
|
|
||||||
|
let (bytes, warnings) = compile(source, &[], &roots).expect("the export still happens");
|
||||||
|
assert_eq!(&bytes[..4], b"%PDF");
|
||||||
|
assert_eq!(
|
||||||
|
kinds(&warnings, "math"),
|
||||||
|
vec!["a formula could not be typeset, so every formula is shown as the source it was written in"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_ordinary_document_says_nothing_at_all() {
|
||||||
|
let (_dir, roots) = root();
|
||||||
|
|
||||||
|
// Every construct that has ever made this module warn about its own furniture rather than about
|
||||||
|
// the document: the bundled variable fonts, the font families the preamble names, and mitex's
|
||||||
|
// own deprecations. A user who wrote none of that should see no toast.
|
||||||
|
let source = format!(
|
||||||
|
"{PREAMBLE}
|
||||||
|
= Heading
|
||||||
|
|
||||||
|
A paragraph with `inline code` and #mi(\"a^2 + b^2\") in it.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn main() {{}}
|
||||||
|
```
|
||||||
|
"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (bytes, warnings) = compile(source, &[], &roots).expect("the fixture compiles");
|
||||||
|
assert_eq!(&bytes[..4], b"%PDF");
|
||||||
|
assert!(warnings.is_empty(), "nothing to say: {warnings:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_broken_link_is_worked_around_in_every_format_the_editor_writes() {
|
||||||
|
let (dir, roots) = root();
|
||||||
|
|
||||||
|
// Typst decides how to decode an image from the extension and not from the bytes, so the
|
||||||
|
// stand-in for a file that could not be read has to be a real image of the format the link
|
||||||
|
// claimed. A png in place of a jpg is a decode error, which is the failure being avoided.
|
||||||
|
for extension in ["png", "jpg", "jpeg", "gif", "webp", "svg", "heic"] {
|
||||||
|
let missing = dir.path().join(format!("gone.{extension}"));
|
||||||
|
let source = format!("{PREAMBLE}#image(\"{}\")\n", missing.display());
|
||||||
|
let images = vec![ImageInput {
|
||||||
|
path: missing.to_string_lossy().into_owned(),
|
||||||
|
data: None,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let (bytes, warnings) = compile(source, &images, &roots)
|
||||||
|
.unwrap_or_else(|e| panic!("a missing .{extension} still exports: {e}"));
|
||||||
|
assert_eq!(&bytes[..4], b"%PDF");
|
||||||
|
assert_eq!(kinds(&warnings, "image").len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mitex's `\hspace`, `\vspace` and `\raisebox` handlers used to hand the text between the braces
|
||||||
|
/// to Typst's `eval`, which ran it as code. The text is whatever the author typed, so a markdown
|
||||||
|
/// file was a program, and the compiler runs in this process rather than beside it: the payload
|
||||||
|
/// vendor/mitex/PATCHES.md names took nine gigabytes and the app with it.
|
||||||
|
///
|
||||||
|
/// That payload is deliberately not the fixture here. A test that brings down the machine when it
|
||||||
|
/// fails is not a test anybody can run, and the property underneath it is smaller and exact: a
|
||||||
|
/// length literal is evaluated and nothing else is. `2cm*2` is the whole difference, because it is
|
||||||
|
/// arithmetic rather than a literal, and under `eval` it was four centimetres.
|
||||||
|
///
|
||||||
|
/// Proved by what lands on the page rather than by reading the guard, which needs one control: the
|
||||||
|
/// same source twice is the same bytes, so two documents differing only in a spacing command and
|
||||||
|
/// differing in bytes is that spacing command doing something.
|
||||||
|
#[test]
|
||||||
|
fn a_spacing_command_takes_a_length_and_never_an_expression() {
|
||||||
|
let (_dir, roots) = root();
|
||||||
|
let page = |length: &str| {
|
||||||
|
let source = format!("{PREAMBLE}x#mi(\"a\\\\hspace{{{length}}}b\")x\n");
|
||||||
|
compile(source, &[], &roots).expect("a spacing command typesets").0
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(page("0pt"), page("0pt"), "the compiler is deterministic");
|
||||||
|
assert_ne!(page("0pt"), page("4cm"), "4cm moved nothing on the page");
|
||||||
|
assert_eq!(page("0pt"), page("2cm*2"), "an expression reached eval");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every weight src/export/typst.ts asks for has to be a face of its own, or the hierarchy the
|
||||||
|
/// author sees on screen is not in the PDF.
|
||||||
|
///
|
||||||
|
/// This is what the static instances in src-tauri/fonts are for. Typst does not lay out a variable
|
||||||
|
/// axis: it takes the default instance, and every weight comes out at 400. There is no way to read a
|
||||||
|
/// weight back out of a PDF here, so the assertion is that the same word at two weights is two
|
||||||
|
/// different documents, which stops being true the moment two weights resolve to one face.
|
||||||
|
#[test]
|
||||||
|
fn every_weight_the_converter_asks_for_has_a_face_of_its_own() {
|
||||||
|
let (_dir, roots) = root();
|
||||||
|
let page = |markup: &str| {
|
||||||
|
let source = format!("{PREAMBLE}{markup}\n");
|
||||||
|
compile(source, &[], &roots).expect("a weight typesets").0
|
||||||
|
};
|
||||||
|
|
||||||
|
let cuts = [
|
||||||
|
"#text(weight: 400)[Margin]",
|
||||||
|
"#text(weight: 600)[Margin]",
|
||||||
|
"#text(weight: 700)[Margin]",
|
||||||
|
"#text(weight: 400, style: \"italic\")[Margin]",
|
||||||
|
"#text(weight: 700, style: \"italic\")[Margin]",
|
||||||
|
"#text(font: \"Hanken Grotesk\", weight: 400)[Margin]",
|
||||||
|
"#text(font: \"Hanken Grotesk\", weight: 700)[Margin]",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (i, one) in cuts.iter().enumerate() {
|
||||||
|
for two in &cuts[i + 1..] {
|
||||||
|
assert_ne!(page(one), page(two), "{one} and {two} are the same face");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+176
@@ -0,0 +1,176 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
Vendored
+20
@@ -0,0 +1,20 @@
|
|||||||
|
# Local changes to mitex 0.2.5
|
||||||
|
|
||||||
|
This copy is not stock. mitex is vendored rather than fetched through Typst's package system so
|
||||||
|
that an export needs no network, which also means an update is a manual copy and a patch applied
|
||||||
|
here is a patch that can be silently lost. Anyone replacing this directory has to re-apply what is
|
||||||
|
listed below, or re-check that it is no longer needed.
|
||||||
|
|
||||||
|
Upstream is https://github.com/mitex-rs/mitex, Apache-2.0, and LICENSE beside this file is theirs.
|
||||||
|
|
||||||
|
## specs/latex/standard.typ: `\hspace`, `\vspace` and `\raisebox` no longer evaluate their argument
|
||||||
|
|
||||||
|
Those three handlers passed the text between the braces to `eval`, which runs it as Typst code.
|
||||||
|
The text is whatever the author typed, so a markdown file containing
|
||||||
|
`$\hspace{range(999999999).len()*1pt}$` is a program rather than a formula. Typst is hermetic, so
|
||||||
|
this is not a way to read a file or reach the network, but the compiler runs inside the editor's
|
||||||
|
own process: measured, that document took nine gigabytes resident before the process died, and it
|
||||||
|
would have taken any unsaved buffer with it.
|
||||||
|
|
||||||
|
The three handlers now go through `mitex-safe-length`, which evaluates the argument only when it is
|
||||||
|
a plain length literal and answers `0pt` otherwise. `src-tauri/tests/pdf.rs` pins both halves.
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
#import "mitex.typ": mitex-wasm, mitex-convert, mitex-scope, mitex, mitext, mimath, mi
|
||||||
Vendored
+45
@@ -0,0 +1,45 @@
|
|||||||
|
#import "specs/mod.typ": mitex-scope
|
||||||
|
#let mitex-wasm = plugin("./mitex.wasm")
|
||||||
|
|
||||||
|
#let get-elem-text(it) = {
|
||||||
|
{
|
||||||
|
if type(it) == str {
|
||||||
|
it
|
||||||
|
} else if type(it) == content and it.has("text") {
|
||||||
|
it.text
|
||||||
|
} else {
|
||||||
|
panic("Unsupported type: " + str(type(it)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#let mitex-convert(it, mode: "math", spec: bytes(())) = {
|
||||||
|
if mode == "math" {
|
||||||
|
str(mitex-wasm.convert_math(bytes(get-elem-text(it)), spec))
|
||||||
|
} else {
|
||||||
|
str(mitex-wasm.convert_text(bytes(get-elem-text(it)), spec))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Math Mode
|
||||||
|
#let mimath(it, block: true, ..args) = {
|
||||||
|
let res = mitex-convert(mode: "math", it)
|
||||||
|
let eval-res = eval("$" + res + "$", scope: mitex-scope)
|
||||||
|
math.equation(block: block, eval-res, ..args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text Mode
|
||||||
|
#let mitext(it) = {
|
||||||
|
let res = mitex-convert(mode: "text", it)
|
||||||
|
eval(res, mode: "markup", scope: mitex-scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
#let mitex(it, mode: "math", ..args) = {
|
||||||
|
if mode == "math" {
|
||||||
|
mimath(it, ..args)
|
||||||
|
} else {
|
||||||
|
mitext(it, ..args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#let mi = mimath.with(block: false)
|
||||||
Vendored
BIN
Binary file not shown.
+1170
File diff suppressed because it is too large.
Load diff
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
|
||||||
|
#import "prelude.typ": *
|
||||||
|
#import "latex/standard.typ": package as latex-std
|
||||||
|
|
||||||
|
// 1. import all the packages and form a mitex-scope for mitex to use
|
||||||
|
#let packages = (latex-std,)
|
||||||
|
#let mitex-scope = packages.map(pkg => pkg.scope).sum()
|
||||||
|
|
||||||
|
// 2. export all packages with specs by metadata and <mitex-packages> label,
|
||||||
|
// mitex-cli can fetch them by
|
||||||
|
// `typst query --root . ./packages/mitex/specs/mod.typ "<mitex-packages>"`
|
||||||
|
#metadata(packages) <mitex-packages>
|
||||||
+230
@@ -0,0 +1,230 @@
|
|||||||
|
/// Define a normal symbol, as no-argument commands like \alpha
|
||||||
|
///
|
||||||
|
/// Arguments:
|
||||||
|
/// - s (str): Alias command for typst handler.
|
||||||
|
/// For example, alias `\prod` to typst's `product`.
|
||||||
|
/// - sym (content): The specific content, as the value of alias in mitex-scope.
|
||||||
|
/// For example, there is no direct alias for \negthinspace symbol in typst,
|
||||||
|
/// but we can add `h(-(3/18) * 1em)` ourselves
|
||||||
|
///
|
||||||
|
/// Return: A spec item and a scope item (none for no scope item)
|
||||||
|
#let define-sym(s, sym: none) = {
|
||||||
|
(
|
||||||
|
(kind: "alias-sym", alias: s),
|
||||||
|
if sym != none {
|
||||||
|
(alias: s, handle: sym)
|
||||||
|
} else {
|
||||||
|
none
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Define a greedy command, like \displaystyle
|
||||||
|
///
|
||||||
|
/// Arguments:
|
||||||
|
/// - s (str): Alias command for typst handler.
|
||||||
|
/// For example, alias `\displaystyle` to typst's `mitexdisplay`, as the key in mitex-scope.
|
||||||
|
/// - handle (function): The handler function, as the value of alias in mitex-scope.
|
||||||
|
/// It receives a content argument as all greedy matches to the content
|
||||||
|
/// For example, we define `mitexdisplay` to `math.display`
|
||||||
|
///
|
||||||
|
/// Return: A spec item and a scope item (none for no scope item)
|
||||||
|
#let define-greedy-cmd(s, handle: none) = {
|
||||||
|
(
|
||||||
|
(kind: "greedy-cmd", alias: s),
|
||||||
|
if handle != none {
|
||||||
|
(alias: s, handle: handle)
|
||||||
|
} else {
|
||||||
|
none
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Define an infix command, like \over
|
||||||
|
///
|
||||||
|
/// Arguments:
|
||||||
|
/// - s (str): Alias command for typst handler.
|
||||||
|
/// For example, alias `\over` to typst's `frac`, as the key in mitex-scope.
|
||||||
|
/// - handle (function): The handler function, as the value of alias in mitex-scope.
|
||||||
|
/// It receives two content arguments, as (prev, after) arguments.
|
||||||
|
/// For example, we define `\over` to `frac: (num, den) => $(num)/(den)$`
|
||||||
|
///
|
||||||
|
/// Return: A spec item and a scope item (none for no scope item)
|
||||||
|
#let define-infix-cmd(s, handle: none) = {
|
||||||
|
(
|
||||||
|
(kind: "infix-cmd", alias: s),
|
||||||
|
if handle != none {
|
||||||
|
(alias: s, handle: handle)
|
||||||
|
} else {
|
||||||
|
none
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Define a glob (Global Wildcard) match command with a specified pattern for matching args
|
||||||
|
/// Kind of item to match:
|
||||||
|
/// - Bracket/b: []
|
||||||
|
/// - Parenthesis/p: ()
|
||||||
|
/// - Term/t: any rest of terms, typically {} or single char
|
||||||
|
///
|
||||||
|
/// Arguments:
|
||||||
|
/// - pat (pattern): The pattern for glob-cmd
|
||||||
|
/// For example, `{,b}t` for `\sqrt` to support `\sqrt{2}` and `\sqrt[3]{2}`
|
||||||
|
/// - s (str): Alias command for typst handler.
|
||||||
|
/// For example, alias `\sqrt` to typst's `mitexsqrt`, as the key in mitex-scope.
|
||||||
|
/// - handle (function): The handler function, as the value of alias in mitex-scope.
|
||||||
|
/// It receives variable length arguments, for example `(2,)` or `([3], 2)` for sqrt.
|
||||||
|
/// Therefore you need to use `(.. arg) = > {..}` to receive them.
|
||||||
|
///
|
||||||
|
/// Return: A spec item and a scope item (none for no scope item)
|
||||||
|
#let define-glob-cmd(pat, s, handle: none) = {
|
||||||
|
(
|
||||||
|
(kind: "glob-cmd", pattern: pat, alias: s),
|
||||||
|
if handle != none {
|
||||||
|
(alias: s, handle: handle)
|
||||||
|
} else {
|
||||||
|
none
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Define a command with a fixed number of arguments, like \hat{x} and \frac{1}{2}
|
||||||
|
///
|
||||||
|
/// Arguments:
|
||||||
|
/// - num (int): The number of arguments for the command.
|
||||||
|
/// - alias (str): Alias command for typst handler.
|
||||||
|
/// For example, alias `\frac` to typst's `frac`, as the key in mitex-scope.
|
||||||
|
/// - handle (function): The handler function, as the value of alias in mitex-scope.
|
||||||
|
/// It receives fixed number of arguments, for example `frac(1, 2)` for `\frac{1}{2}`.
|
||||||
|
///
|
||||||
|
/// Return: A spec item and a scope item (none for no scope item)
|
||||||
|
#let define-cmd(num, alias: none, handle: none) = {
|
||||||
|
(
|
||||||
|
(
|
||||||
|
kind: "cmd",
|
||||||
|
args: ("kind": "right", "pattern": (kind: "fixed-len", len: num)),
|
||||||
|
alias: alias,
|
||||||
|
),
|
||||||
|
if handle != none {
|
||||||
|
(alias: alias, handle: handle)
|
||||||
|
} else {
|
||||||
|
none
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Define an environment with a fixed number of arguments, like \begin{alignedat}{2}
|
||||||
|
///
|
||||||
|
/// Arguments:
|
||||||
|
/// - num (int): The number of arguments as environment options for the environment.
|
||||||
|
/// - alias (str): Alias command for typst handler.
|
||||||
|
/// For example, alias `\begin{alignedat}{2}` to typst's `alignedat`,
|
||||||
|
/// and alias `\begin{aligned}` to typst's `aligned`, as the key in mitex-scope.
|
||||||
|
/// - kind (str): environment kind, it could be "is-math", "is-cases", "is-matrix",
|
||||||
|
/// "is-itemize", "is-enumerate"
|
||||||
|
/// - handle (function): The handler function, as the value of alias in mitex-scope.
|
||||||
|
/// It receives fixed number of named arguments as environment options,
|
||||||
|
/// for example `alignedat(arg0: ..)` or `alignedat(arg0: .., arg1: ..)`.
|
||||||
|
/// And it receives variable length arguments as environment body,
|
||||||
|
/// Therefore you need to use `(.. arg) = > {..}` to receive them.
|
||||||
|
///
|
||||||
|
/// Return: A spec item and a scope item (none for no scope item)
|
||||||
|
#let define-env(num, kind: "none", alias: none, handle: none) = {
|
||||||
|
(
|
||||||
|
(
|
||||||
|
kind: "env",
|
||||||
|
args: if num != none {
|
||||||
|
(kind: "fixed-len", len: num)
|
||||||
|
} else {
|
||||||
|
(kind: "none")
|
||||||
|
},
|
||||||
|
ctx_feature: (kind: kind),
|
||||||
|
alias: alias,
|
||||||
|
),
|
||||||
|
if handle != none {
|
||||||
|
(alias: alias, handle: handle)
|
||||||
|
} else {
|
||||||
|
none
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#let define-glob-env(pat, kind: "none", alias: none, handle: none) = {
|
||||||
|
(
|
||||||
|
(
|
||||||
|
kind: "glob-env",
|
||||||
|
pattern: pat,
|
||||||
|
ctx_feature: (kind: kind),
|
||||||
|
alias: alias,
|
||||||
|
),
|
||||||
|
if handle != none {
|
||||||
|
(alias: alias, handle: handle)
|
||||||
|
} else {
|
||||||
|
none
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Define a symbol without alias and without handler function, like \alpha => alpha
|
||||||
|
///
|
||||||
|
/// Return: A spec item and no scope item (none for no scope item)
|
||||||
|
#let sym = ((kind: "sym"), none)
|
||||||
|
|
||||||
|
/// Define a symbol without alias and with handler function,
|
||||||
|
/// like \negthinspace => h(-(3/18) * 1em)
|
||||||
|
///
|
||||||
|
/// Arguments:
|
||||||
|
/// - handle (function): The handler function, as the value of alias in mitex-scope.
|
||||||
|
/// For example, define `negthinspace` to handle `h(-(3/18) * 1em)` in mitex-scope
|
||||||
|
///
|
||||||
|
/// Return: A symbol spec and a scope item
|
||||||
|
#let of-sym(handle) = ((kind: "sym"), (handle: handle))
|
||||||
|
|
||||||
|
/// Define a left1-op command without handler, like `\limits` for `\sum\limits`
|
||||||
|
///
|
||||||
|
/// Arguments:
|
||||||
|
/// - alias (str): Alias command for typst handler.
|
||||||
|
/// For example, alias `\limits` to typst's `limits`
|
||||||
|
/// and alias `\nolimits` to typst's `scripts`
|
||||||
|
///
|
||||||
|
/// Return: A cmd spec and no scope item (none for no scope item)
|
||||||
|
#let left1-op(alias) = ((kind: "cmd", args: (kind: "left1"), alias: alias), none)
|
||||||
|
|
||||||
|
/// Define a cmd1 command like \hat{x} => hat(x)
|
||||||
|
///
|
||||||
|
/// Return: A cmd1 spec and a scope item (none for no scope item)
|
||||||
|
#let cmd1 = ((kind: "cmd1"), none)
|
||||||
|
|
||||||
|
/// Define a cmd2 command like \binom{1}{2} => binom(1, 2)
|
||||||
|
///
|
||||||
|
/// Return: A cmd2 spec and a scope item (none for no scope item)
|
||||||
|
#let cmd2 = ((kind: "cmd2"), none)
|
||||||
|
|
||||||
|
/// Define a matrix environment without handler
|
||||||
|
///
|
||||||
|
/// Return: A matrix-env spec and a scope item (none for no scope item)
|
||||||
|
#let matrix-env = ((kind: "matrix-env"), none)
|
||||||
|
|
||||||
|
/// Receives a list of definitions composed of the above functions, and processes them to return a dictionary containing spec and scope.
|
||||||
|
#let process-spec(definitions) = {
|
||||||
|
let spec = (:)
|
||||||
|
let scope = (:)
|
||||||
|
for (key, value) in definitions.pairs() {
|
||||||
|
let spec-item = value.at(0)
|
||||||
|
let scope-item = value.at(1)
|
||||||
|
spec.insert(key, spec-item)
|
||||||
|
if scope-item != none {
|
||||||
|
if "alias" in scope-item and type(scope-item.alias) == str {
|
||||||
|
let key = if scope-item.alias.starts-with("#") {
|
||||||
|
scope-item.alias.slice(1)
|
||||||
|
} else {
|
||||||
|
scope-item.alias
|
||||||
|
}
|
||||||
|
scope.insert(key, scope-item.handle)
|
||||||
|
} else {
|
||||||
|
scope.insert(key, scope-item.handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(spec: spec, scope: scope)
|
||||||
|
}
|
||||||
+11
-12
@@ -20,15 +20,17 @@ import { FindInFiles } from "./components/FindInFiles";
|
|||||||
import { ProofPopover } from "./components/ProofPopover";
|
import { ProofPopover } from "./components/ProofPopover";
|
||||||
import { QuickOpen } from "./components/QuickOpen";
|
import { QuickOpen } from "./components/QuickOpen";
|
||||||
import { Recents } from "./components/Recents";
|
import { Recents } from "./components/Recents";
|
||||||
|
import { Settings } from "./components/Settings";
|
||||||
import { Shortcuts } from "./components/Shortcuts";
|
import { Shortcuts } from "./components/Shortcuts";
|
||||||
import { Sidebar } from "./components/Sidebar";
|
import { Sidebar } from "./components/Sidebar";
|
||||||
import { Titlebar } from "./components/Titlebar";
|
import { Titlebar } from "./components/Titlebar";
|
||||||
import { Toast } from "./components/Toast";
|
import { Toast } from "./components/Toast";
|
||||||
|
import { UpdateDialog } from "./components/UpdateDialog";
|
||||||
import { flushPendingSave, keepBuffer } from "./document";
|
import { flushPendingSave, keepBuffer } from "./document";
|
||||||
import { DocumentEditor, PlainTextEditor, useDocumentFind } from "./editor";
|
import { DocumentEditor, PlainTextEditor, useDocumentFind } from "./editor";
|
||||||
import { Toolbar, type ToolbarSaveState } from "./editor/Toolbar";
|
import { Toolbar, type ToolbarSaveState } from "./editor/Toolbar";
|
||||||
import { MENU_ACTION_EVENT, isDesktop, isTauri } from "./ipc";
|
import { MENU_ACTION_EVENT, isDesktop, isTauri } from "./ipc";
|
||||||
import { onCommand, type CommandId } from "./keys/commands";
|
import { onCommand } from "./keys/commands";
|
||||||
import { useKeymap } from "./keys/keymap";
|
import { useKeymap } from "./keys/keymap";
|
||||||
import { handleMenuAction } from "./keys/menu";
|
import { handleMenuAction } from "./keys/menu";
|
||||||
import { openLink } from "./links";
|
import { openLink } from "./links";
|
||||||
@@ -36,20 +38,11 @@ import { documentKindForPath } from "./model/doc";
|
|||||||
import { useDocument } from "./store/useDocument";
|
import { useDocument } from "./store/useDocument";
|
||||||
import { notify } from "./store/useToast";
|
import { notify } from "./store/useToast";
|
||||||
import { useWorkspace } from "./store/useWorkspace";
|
import { useWorkspace } from "./store/useWorkspace";
|
||||||
|
import { startUpdateChecks } from "./update";
|
||||||
import { useCompact, useTouch } from "./useMedia";
|
import { useCompact, useTouch } from "./useMedia";
|
||||||
import { applyWidth } from "./width";
|
import { applyWidth } from "./width";
|
||||||
import { restoreSession, startWorkspaceEvents } from "./workspace";
|
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);
|
const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -109,13 +102,17 @@ function App() {
|
|||||||
onCommand("editor-width-narrow", () => applyWidth("narrow")),
|
onCommand("editor-width-narrow", () => applyWidth("narrow")),
|
||||||
onCommand("editor-width-normal", () => applyWidth("normal")),
|
onCommand("editor-width-normal", () => applyWidth("normal")),
|
||||||
onCommand("editor-width-wide", () => applyWidth("wide")),
|
onCommand("editor-width-wide", () => applyWidth("wide")),
|
||||||
...UNBUILT.map(([id, message]) => onCommand(id, () => notify(message))),
|
|
||||||
];
|
];
|
||||||
return () => {
|
return () => {
|
||||||
for (const stop of stops) stop();
|
for (const stop of stops) stop();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// The check the app makes on its own, which is off unless the setting says otherwise and silent
|
||||||
|
// unless it finds something. Pressing Check for Updates goes through the command table instead
|
||||||
|
// and answers either way, because somebody who asked is owed a sentence.
|
||||||
|
useEffect(() => startUpdateChecks(), []);
|
||||||
|
|
||||||
const conflict = externalChange === "changed-on-disk";
|
const conflict = externalChange === "changed-on-disk";
|
||||||
|
|
||||||
// Asked once, when the conflict appears. Dismissing it leaves the warning in the toolbar to
|
// Asked once, when the conflict appears. Dismissing it leaves the warning in the toolbar to
|
||||||
@@ -181,6 +178,8 @@ function App() {
|
|||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<ProofPopover />
|
<ProofPopover />
|
||||||
<Shortcuts />
|
<Shortcuts />
|
||||||
|
<Settings />
|
||||||
|
<UpdateDialog />
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|
||||||
{resolving && conflict && path !== null && (
|
{resolving && conflict && path !== null && (
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Grammar, which is Harper's.
|
||||||
|
//
|
||||||
|
// The same shape as src/api/spell.ts on purpose: a run of text goes over, problems come back with
|
||||||
|
// offsets counted from the start of that run, and neither side knows a document exists. That is
|
||||||
|
// what lets the editor send one paragraph and add its own base offset afterwards, and it is why
|
||||||
|
// the two checkers can share a decoration pipeline instead of growing a second one.
|
||||||
|
|
||||||
|
import { call, type GrammarIssue } from "../ipc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this build has a grammar engine behind it. False hides the underlines and the popover's
|
||||||
|
* grammar half rather than offering a check that answers nothing, exactly as `spellAvailable` does.
|
||||||
|
*/
|
||||||
|
export const grammarAvailable = () => call<boolean>("grammar_available");
|
||||||
|
|
||||||
|
/** Every grammar problem in one run of text, with offsets in characters from the start of it. */
|
||||||
|
export const grammarCheck = (text: string) => call<GrammarIssue[]>("grammar_check", { text });
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// PDF export. The source is built here and compiled there; these two calls are the boundary.
|
||||||
|
|
||||||
|
import { call, type ImageInput } from "../ipc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiles Typst source to PDF bytes. Warnings do not come back through the return value, because
|
||||||
|
* the answer is raw bytes and has no room for a second one: they arrive on `pdf-warnings` instead.
|
||||||
|
*/
|
||||||
|
export const pdfCompile = (source: string, images: readonly ImageInput[]) =>
|
||||||
|
call<ArrayBuffer>("pdf_compile", { source, images });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the finished file wherever the native save panel pointed.
|
||||||
|
*
|
||||||
|
* `Array.from` because the IPC boundary is JSON and a `Uint8Array` stringifies to an object with
|
||||||
|
* numeric keys, which is not a `Vec<u8>` on the other side. One export's worth of numbers is the
|
||||||
|
* cost of not inventing a second transport for a thing that happens once per document.
|
||||||
|
*/
|
||||||
|
export const pdfWrite = (path: string, bytes: Uint8Array) =>
|
||||||
|
call<void>("pdf_write", { path, bytes: Array.from(bytes) });
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Writing Tools, which is Apple's and not this app's.
|
||||||
|
//
|
||||||
|
// There is no API behind these two calls in the way there is behind spelling: the Rust side finds
|
||||||
|
// the system's own menu item and performs it, and the rewrite that follows lands in the webview by
|
||||||
|
// itself. Nothing here returns the new text, because nothing here is told what it is.
|
||||||
|
|
||||||
|
import { call } from "../ipc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this machine can run a Writing Tool at all. It needs macOS 15.1 with Apple Intelligence
|
||||||
|
* turned on, which is a much newer Mac than this app's minimum, so false is an ordinary answer and
|
||||||
|
* the caller turns it into a toast rather than a button that does nothing.
|
||||||
|
*/
|
||||||
|
export const writingAvailable = () => call<boolean>("writing_available");
|
||||||
|
|
||||||
|
/** Fires one item on the system's Writing Tools submenu, by its English title. */
|
||||||
|
export const writingRun = (tool: string) => call<void>("writing_run", { tool });
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
// The menu over a misspelled word: what the system thinks was meant, and the two ways of saying it
|
// The menu over an underlined word or phrase: what the checker thinks was meant, and the ways of
|
||||||
// was not a mistake.
|
// saying it was not a mistake.
|
||||||
|
//
|
||||||
|
// One menu for both checkers, because from the reader's side there is one underline under one piece
|
||||||
|
// of their writing and it is either wrong or it is not. What changes with the kind is the top of the
|
||||||
|
// menu and the bottom of it. Grammar gets a heading: Harper's category for the rule and the sentence
|
||||||
|
// it wrote, because a grammar correction is a judgement in a way a spelling one is not, and taking
|
||||||
|
// "their own" over "there own" without being told why is how a checker teaches somebody nothing.
|
||||||
|
// Spelling keeps "Learn Spelling", and grammar does not get it: the dictionary that item writes to
|
||||||
|
// is the Mac's own and it holds words, so offering it for a phrase would be offering something that
|
||||||
|
// cannot happen.
|
||||||
//
|
//
|
||||||
// Mounted once and drawing nothing until src/editor/proofing.ts puts a word in the store, so App.tsx
|
// 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
|
// holds one line for it rather than a piece of the feature. It renders into a portal because the
|
||||||
@@ -44,6 +53,23 @@ const MARGIN = 8;
|
|||||||
/** Everything the arrow keys walk, which is every item and not only the suggestions. */
|
/** Everything the arrow keys walk, which is every item and not only the suggestions. */
|
||||||
const ITEM = ".proof-suggestion, .proof-action";
|
const ITEM = ".proof-suggestion, .proof-action";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Harper's rule categories arrive as the names of its own enum: "WordChoice" is a serviceable key
|
||||||
|
* and a poor heading, so the words are split apart for the one place a person reads it.
|
||||||
|
*/
|
||||||
|
function readableKind(kind: string): string {
|
||||||
|
return kind.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A suggestion that deletes the text rather than replacing it arrives as an empty string, which is
|
||||||
|
* the correct thing to write and not something to put on a button. Harper offers it for a repeated
|
||||||
|
* word and for a redundancy, where what is being suggested really is "take this out".
|
||||||
|
*/
|
||||||
|
function suggestionLabel(suggestion: string): string {
|
||||||
|
return suggestion === "" ? "Remove" : suggestion;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chord's end of the feature, and the one line of explanation it owes when there is nothing
|
* The chord's end of the feature, and the one line of explanation it owes when there is nothing
|
||||||
* beside the caret to correct.
|
* beside the caret to correct.
|
||||||
@@ -148,6 +174,17 @@ function ProofMenu({ target }: { target: ProofTarget }) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const grammar = target.grammar;
|
||||||
|
// Everything in the menu that is prose rather than a choice, in the order it is drawn, so a
|
||||||
|
// reader is told what the checker said and then what the menu can do about it.
|
||||||
|
const described = [
|
||||||
|
grammar ? `${ids}-message` : null,
|
||||||
|
target.suggestions.length === 0 ? `${ids}-none` : null,
|
||||||
|
grammar ? null : `${ids}-note`,
|
||||||
|
]
|
||||||
|
.filter((id) => id !== null)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The walk, and the trap.
|
* The walk, and the trap.
|
||||||
*
|
*
|
||||||
@@ -172,18 +209,28 @@ function ProofMenu({ target }: { target: ProofTarget }) {
|
|||||||
ref={popRef}
|
ref={popRef}
|
||||||
className="proof-pop"
|
className="proof-pop"
|
||||||
role="menu"
|
role="menu"
|
||||||
aria-label={`Spelling suggestions for ${target.word}`}
|
aria-label={`${grammar ? "Grammar" : "Spelling"} suggestions for ${target.word}`}
|
||||||
aria-describedby={
|
aria-describedby={described}
|
||||||
target.suggestions.length === 0 ? `${ids}-none ${ids}-note` : `${ids}-note`
|
|
||||||
}
|
|
||||||
style={{ left: at.left, top: at.top }}
|
style={{ left: at.left, top: at.top }}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
{/* The two paragraphs and the rule are not items, so a menu announces the things that can be
|
{/* None of the paragraphs and none of the rules are items, so a menu announces the things that
|
||||||
chosen and nothing else. Neither line is thrown away with them: both are named on the
|
can be chosen and nothing else. None of the lines is thrown away with them: each is named on
|
||||||
menu's own aria-describedby above, which is where a sentence about a menu belongs and is
|
the menu's own aria-describedby above, which is where a sentence about a menu belongs and is
|
||||||
what gets them read once, on the way in, rather than as a row somebody has to arrow past. */}
|
what gets them read once, on the way in, rather than as a row somebody has to arrow past. */}
|
||||||
|
{grammar && (
|
||||||
|
<>
|
||||||
|
<p className="proof-kind" role="presentation">
|
||||||
|
{readableKind(grammar.kind)}
|
||||||
|
</p>
|
||||||
|
<p className="proof-message" role="presentation" id={`${ids}-message`}>
|
||||||
|
{grammar.message}
|
||||||
|
</p>
|
||||||
|
<div className="proof-sep" role="separator" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{target.suggestions.length === 0 ? (
|
{target.suggestions.length === 0 ? (
|
||||||
<p className="proof-none" role="presentation" id={`${ids}-none`}>
|
<p className="proof-none" role="presentation" id={`${ids}-none`}>
|
||||||
No suggestions
|
No suggestions
|
||||||
@@ -200,13 +247,14 @@ function ProofMenu({ target }: { target: ProofTarget }) {
|
|||||||
dismiss();
|
dismiss();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{suggestion}
|
{suggestionLabel(suggestion)}
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="proof-sep" role="separator" />
|
<div className="proof-sep" role="separator" />
|
||||||
|
|
||||||
|
{!grammar && (
|
||||||
<button
|
<button
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
className="proof-action"
|
className="proof-action"
|
||||||
@@ -219,10 +267,11 @@ function ProofMenu({ target }: { target: ProofTarget }) {
|
|||||||
>
|
>
|
||||||
Learn Spelling
|
Learn Spelling
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
className="proof-action"
|
className="proof-action"
|
||||||
title="Stops underlining this word until the app is next opened."
|
title={`Stops underlining this ${grammar ? "phrase" : "word"} until the app is next opened.`}
|
||||||
onMouseDown={keepFocus}
|
onMouseDown={keepFocus}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
ignoreWord(target.word);
|
ignoreWord(target.word);
|
||||||
@@ -232,9 +281,11 @@ function ProofMenu({ target }: { target: ProofTarget }) {
|
|||||||
Ignore
|
Ignore
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{!grammar && (
|
||||||
<p className="proof-note" role="presentation" id={`${ids}-note`}>
|
<p className="proof-note" role="presentation" id={`${ids}-note`}>
|
||||||
Learning a word teaches this Mac, not only Margin Docs.
|
Learning a word teaches this Mac, not only Margin Docs.
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// Cmd+, and the Settings row on the app menu. Four things, and deliberately not a fifth: which
|
||||||
|
// version is running, whether there is a newer one, and the two proofing checkers.
|
||||||
|
//
|
||||||
|
// Everything else this app can be told is already somewhere better. The theme is in the title bar,
|
||||||
|
// the editor width is in the toolbar, and both of them are one click from the thing they change.
|
||||||
|
// Moving either in here would be a second place to look for a setting that already has a first one,
|
||||||
|
// so this panel holds only what has nowhere else to live.
|
||||||
|
//
|
||||||
|
// A checker the machine does not have leaves its row on screen and turns it off. The store's own
|
||||||
|
// comment argues for taking a missing checker off the screen entirely, and that is right for
|
||||||
|
// underlines and for the correction menu: an underline that is absent explains itself. A settings
|
||||||
|
// panel is where somebody goes to look for a setting, and a row that is simply not there reads as a
|
||||||
|
// missing feature rather than as a missing checker, so the row stays and says which it is.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { onCommand } from "../keys/commands";
|
||||||
|
import { useKeyContext } from "../keys/keymap";
|
||||||
|
import { useProofing } from "../store/useProofing";
|
||||||
|
import { useUpdate } from "../store/useUpdate";
|
||||||
|
import { appVersion, checkForUpdates } from "../update";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which build this is, decided at bundle time. `pnpm dev`, and `tauri dev` on top of it, are the
|
||||||
|
* development one; a bundle built through `pnpm build` is the other. It is here because it is the
|
||||||
|
* first thing worth knowing when the updater says it is not enabled.
|
||||||
|
*/
|
||||||
|
const BUILD = import.meta.env.DEV ? "development build" : "release build";
|
||||||
|
|
||||||
|
function checkedLabel(at: number | null): string {
|
||||||
|
if (at === null) return "Not checked yet";
|
||||||
|
const seconds = Math.max(0, Math.round((Date.now() - at) / 1000));
|
||||||
|
if (seconds < 90) return "Checked just now";
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `Checked ${minutes} minutes ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
if (hours < 24) return hours === 1 ? "Checked an hour ago" : `Checked ${hours} hours ago`;
|
||||||
|
const days = Math.round(hours / 24);
|
||||||
|
if (days < 30) return days === 1 ? "Checked yesterday" : `Checked ${days} days ago`;
|
||||||
|
return `Checked on ${new Date(at).toLocaleDateString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingRowProps {
|
||||||
|
label: string;
|
||||||
|
/** The quiet second line. Empty means the row is one line tall. */
|
||||||
|
note: string;
|
||||||
|
on: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (on: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingRow({ label, note, on, disabled = false, onChange }: SettingRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="setting-row" data-disabled={disabled}>
|
||||||
|
<span className="setting-text">
|
||||||
|
<span className="setting-label">{label}</span>
|
||||||
|
{note !== "" && <span className="setting-note">{note}</span>}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="switch"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={on}
|
||||||
|
aria-label={label}
|
||||||
|
data-on={on}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(!on)}
|
||||||
|
>
|
||||||
|
<span className="switch-knob" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Settings() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [version, setVersion] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const phase = useUpdate((s) => s.phase);
|
||||||
|
const lastChecked = useUpdate((s) => s.lastChecked);
|
||||||
|
const automatic = useUpdate((s) => s.automatic);
|
||||||
|
const setAutomatic = useUpdate((s) => s.setAutomatic);
|
||||||
|
|
||||||
|
const spelling = useProofing((s) => s.enabled);
|
||||||
|
const setSpelling = useProofing((s) => s.setEnabled);
|
||||||
|
const spellingAvailability = useProofing((s) => s.availability);
|
||||||
|
const grammar = useProofing((s) => s.grammar);
|
||||||
|
const setGrammar = useProofing((s) => s.setGrammar);
|
||||||
|
const grammarAvailability = useProofing((s) => s.grammarAvailability);
|
||||||
|
const ensureAvailable = useProofing((s) => s.ensureAvailable);
|
||||||
|
|
||||||
|
useEffect(() => onCommand("settings", () => setOpen((v) => !v)), []);
|
||||||
|
useEscapeLayer(open, () => setOpen(false));
|
||||||
|
useKeyContext("overlay", open);
|
||||||
|
|
||||||
|
// Both asked on the way in rather than at launch. The editor asks the same two questions the
|
||||||
|
// first time it draws a document, and the store answers each of them once per run whichever of
|
||||||
|
// us gets there first.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
ensureAvailable();
|
||||||
|
let live = true;
|
||||||
|
void appVersion().then((v) => {
|
||||||
|
if (live) setVersion(v);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [open, ensureAvailable]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const checking = phase === "checking";
|
||||||
|
const spellingMissing = spellingAvailability === "missing";
|
||||||
|
const grammarMissing = grammarAvailability === "missing";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overlay" onClick={() => setOpen(false)}>
|
||||||
|
<div
|
||||||
|
className="panel panel-settings"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Settings"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="panel-head">
|
||||||
|
<h2>Settings</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">
|
||||||
|
<section className="setting-group">
|
||||||
|
<div className="setting-app">Margin Docs</div>
|
||||||
|
<div className="setting-build">
|
||||||
|
{version === null ? BUILD : `Version ${version}, ${BUILD}`}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="setting-group">
|
||||||
|
<div className="nav-label">Updates</div>
|
||||||
|
<div className="setting-row">
|
||||||
|
<span className="setting-text">
|
||||||
|
<span className="setting-label">Software update</span>
|
||||||
|
<span className="setting-note">{checkedLabel(lastChecked)}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-quiet"
|
||||||
|
disabled={checking}
|
||||||
|
onClick={() => void checkForUpdates()}
|
||||||
|
>
|
||||||
|
{checking ? "Checking…" : "Check Now"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SettingRow
|
||||||
|
label="Check automatically"
|
||||||
|
note="Once a day, in the background, on launch."
|
||||||
|
on={automatic}
|
||||||
|
onChange={setAutomatic}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="setting-group">
|
||||||
|
<div className="nav-label">Proofing</div>
|
||||||
|
<SettingRow
|
||||||
|
label="Check spelling while typing"
|
||||||
|
note={spellingMissing ? "This machine has no spell checker." : ""}
|
||||||
|
on={spelling && !spellingMissing}
|
||||||
|
disabled={spellingMissing}
|
||||||
|
onChange={setSpelling}
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
label="Check grammar while typing"
|
||||||
|
note={grammarMissing ? "This build has no grammar checker in it." : ""}
|
||||||
|
on={grammar && !grammarMissing}
|
||||||
|
disabled={grammarMissing}
|
||||||
|
onChange={setGrammar}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// There is a new version, and this is what the app says about it before it replaces itself.
|
||||||
|
//
|
||||||
|
// A toast was the earlier answer and it was the wrong one twice over: four seconds is not long
|
||||||
|
// enough to read release notes, and an update that installs itself the moment it is found takes a
|
||||||
|
// decision away from somebody who might be in the middle of a sentence. So the two answers are on
|
||||||
|
// screen at once, Later costs nothing, and the download is watched rather than waited on.
|
||||||
|
//
|
||||||
|
// Escape and the close button are live while the dialog is asking and gone once it is downloading,
|
||||||
|
// because dismissing a download that carries on in the background is a lie about what happened.
|
||||||
|
// There is no cancel: the plugin's download has no handle to stop it, and a button that only stops
|
||||||
|
// the dialog would be a worse promise than no button.
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { useKeyContext } from "../keys/keymap";
|
||||||
|
import { useUpdate } from "../store/useUpdate";
|
||||||
|
import { dismissUpdate, installUpdate } from "../update";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
/** One decimal from a megabyte up, none below it, so the number under the bar stops twitching. */
|
||||||
|
function bytes(count: number): string {
|
||||||
|
if (count < 1024) return `${count} B`;
|
||||||
|
if (count < 1024 * 1024) return `${Math.round(count / 1024)} kB`;
|
||||||
|
return `${(count / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdateDialog() {
|
||||||
|
const phase = useUpdate((s) => s.phase);
|
||||||
|
const version = useUpdate((s) => s.version);
|
||||||
|
const notes = useUpdate((s) => s.notes);
|
||||||
|
const downloaded = useUpdate((s) => s.downloaded);
|
||||||
|
const total = useUpdate((s) => s.total);
|
||||||
|
const error = useUpdate((s) => s.error);
|
||||||
|
|
||||||
|
const installRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const open =
|
||||||
|
phase === "available" || phase === "downloading" || phase === "installing" || phase === "error";
|
||||||
|
const busy = phase === "downloading" || phase === "installing";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === "available") installRef.current?.focus();
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
useEscapeLayer(open && !busy, dismissUpdate);
|
||||||
|
useKeyContext("overlay", open);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const percent = total === null || total === 0 ? null : Math.min(100, (downloaded / total) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overlay" onClick={busy ? undefined : dismissUpdate}>
|
||||||
|
<div
|
||||||
|
className="panel panel-update"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Software update"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="panel-head">
|
||||||
|
<h2>{version === null ? "Update" : `Margin Docs ${version}`}</h2>
|
||||||
|
{!busy && (
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
onClick={dismissUpdate}
|
||||||
|
title="Close (⎋)"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<Icon d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel-body">
|
||||||
|
{phase === "error" ? (
|
||||||
|
<p className="confirm-text">{error ?? "The update could not be installed."}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="confirm-text">
|
||||||
|
A new version is ready. Margin Docs will restart once it is installed, and anything
|
||||||
|
unsaved is written to disk first.
|
||||||
|
</p>
|
||||||
|
{notes !== null && notes.trim() !== "" && (
|
||||||
|
<div className="update-notes" aria-label="Release notes">
|
||||||
|
{notes.trim()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{busy && (
|
||||||
|
<div className="update-progress">
|
||||||
|
<div
|
||||||
|
className="update-track"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Download progress"
|
||||||
|
aria-valuenow={percent === null ? undefined : Math.round(percent)}
|
||||||
|
data-unknown={percent === null}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="update-bar"
|
||||||
|
style={percent === null ? undefined : { width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="update-count">
|
||||||
|
{phase === "installing"
|
||||||
|
? "Installing…"
|
||||||
|
: total === null
|
||||||
|
? `${bytes(downloaded)} downloaded`
|
||||||
|
: `${bytes(downloaded)} of ${bytes(total)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel-foot">
|
||||||
|
{phase === "error" ? (
|
||||||
|
<button className="btn-ghost" onClick={dismissUpdate}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!busy && (
|
||||||
|
<button className="btn-ghost" onClick={dismissUpdate}>
|
||||||
|
Later
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
ref={installRef}
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void installUpdate()}
|
||||||
|
>
|
||||||
|
{phase === "downloading"
|
||||||
|
? "Downloading…"
|
||||||
|
: phase === "installing"
|
||||||
|
? "Installing…"
|
||||||
|
: "Install and Relaunch"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
AssetResult,
|
AssetResult,
|
||||||
Backlink,
|
Backlink,
|
||||||
FileNode,
|
FileNode,
|
||||||
|
GrammarIssue,
|
||||||
IndexStatus,
|
IndexStatus,
|
||||||
MatchRange,
|
MatchRange,
|
||||||
QuickOpenHit,
|
QuickOpenHit,
|
||||||
@@ -210,6 +211,47 @@ const DEV_MISSPELLINGS: Record<string, string[]> = {
|
|||||||
/** Words `spell_learn` was told about this session. The real checker teaches the whole machine. */
|
/** Words `spell_learn` was told about this session. The real checker teaches the whole machine. */
|
||||||
const devLearned = new Set<string>();
|
const devLearned = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grammar in a browser is not Harper either. It is a handful of patterns, which is enough to put a
|
||||||
|
* real underline under a real phrase, open a real popover and take a real correction. The rule
|
||||||
|
* names are Harper's own, so the popover shows what it will show in the app.
|
||||||
|
*/
|
||||||
|
const DEV_GRAMMAR: ReadonlyArray<{ pattern: RegExp; kind: string; message: string; fix: string }> = [
|
||||||
|
{
|
||||||
|
pattern: /\bthe the\b/gi,
|
||||||
|
kind: "Repetition",
|
||||||
|
message: "Repeated word.",
|
||||||
|
fix: "the",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /\bshould of\b/gi,
|
||||||
|
kind: "WordChoice",
|
||||||
|
message: "“Should of” is not a phrase; “should have” is.",
|
||||||
|
fix: "should have",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /\bthere own\b/gi,
|
||||||
|
kind: "WordChoice",
|
||||||
|
message: "“There” is a place. The possessive is “their”.",
|
||||||
|
fix: "their own",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /\bis are\b/gi,
|
||||||
|
kind: "Agreement",
|
||||||
|
message: "Two verbs where one belongs.",
|
||||||
|
fix: "are",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Set to keep the dev fixture from claiming a Writing Tools menu the browser plainly has not got. */
|
||||||
|
const noWritingTools = (): boolean => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem("margindocs-dev-no-writing-tools") === "1";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export async function mockCall<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
export async function mockCall<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
const a = (args ?? {}) as Record<string, never>;
|
const a = (args ?? {}) as Record<string, never>;
|
||||||
switch (command) {
|
switch (command) {
|
||||||
@@ -480,6 +522,47 @@ export async function mockCall<T>(command: string, args?: Record<string, unknown
|
|||||||
devLearned.delete((a.word as unknown as string).toLowerCase());
|
devLearned.delete((a.word as unknown as string).toLowerCase());
|
||||||
return undefined as T;
|
return undefined as T;
|
||||||
|
|
||||||
|
case "grammar_available":
|
||||||
|
return true as unknown as T;
|
||||||
|
|
||||||
|
case "grammar_check": {
|
||||||
|
const text = (a.text as unknown as string) ?? "";
|
||||||
|
const issues: GrammarIssue[] = [];
|
||||||
|
for (const rule of DEV_GRAMMAR) {
|
||||||
|
// Fresh, because a shared regex with the global flag carries `lastIndex` between calls and
|
||||||
|
// the second paragraph of a document would start matching wherever the first one stopped.
|
||||||
|
for (const match of text.matchAll(new RegExp(rule.pattern.source, rule.pattern.flags))) {
|
||||||
|
issues.push({
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + match[0].length,
|
||||||
|
kind: rule.kind,
|
||||||
|
message: rule.message,
|
||||||
|
suggestions: [rule.fix],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issues.sort((x, y) => x.start - y.start);
|
||||||
|
return issues as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writing Tools has nothing behind it in a browser: there is no Edit menu to walk and no system
|
||||||
|
// to rewrite anything. What the fixture can serve is the two answers the UI has to handle, and
|
||||||
|
// which one it gives is a storage key, so a test can drive either.
|
||||||
|
case "writing_available":
|
||||||
|
return !noWritingTools() as unknown as T;
|
||||||
|
|
||||||
|
case "writing_run":
|
||||||
|
return undefined as T;
|
||||||
|
|
||||||
|
// A PDF the fixture cannot compile, and a header is enough: what the browser suite asserts about
|
||||||
|
// an export is that it asked, that it wrote nothing to the document, and that the bytes went to
|
||||||
|
// the panel's path. None of that needs a typesetter.
|
||||||
|
case "pdf_compile":
|
||||||
|
return new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37]).buffer as unknown as T;
|
||||||
|
|
||||||
|
case "pdf_write":
|
||||||
|
return undefined as T;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`dev mock has no handler for ${command}`);
|
throw new Error(`dev mock has no handler for ${command}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+204
-72
@@ -1,4 +1,4 @@
|
|||||||
// Spelling, drawn over the document as decorations and never as an edit to it.
|
// Spelling and grammar, drawn over the document as decorations and never as an edit to it.
|
||||||
//
|
//
|
||||||
// The whole of this file is paint and a menu. The only transaction in it that carries a step is the
|
// The whole of this file is paint and a menu. The only transaction in it that carries a step is the
|
||||||
// one a user makes by choosing a suggestion, and that one is a single transaction so a single undo
|
// one a user makes by choosing a suggestion, and that one is a single transaction so a single undo
|
||||||
@@ -8,14 +8,29 @@
|
|||||||
// rewrites text on its own would be a file damaging bug in this project's terms, so it does not have
|
// rewrites text on its own would be a file damaging bug in this project's terms, so it does not have
|
||||||
// the ability to.
|
// the ability to.
|
||||||
//
|
//
|
||||||
|
// TWO CHECKERS, ONE PIPELINE.
|
||||||
|
//
|
||||||
|
// Spelling is the system's and grammar is Harper's. They are separate settings, separate commands
|
||||||
|
// and separate answers, and they share everything between the document and the underline: the same
|
||||||
|
// blocks, the same batching, the same debounce, the same decoration set and the same popover. There
|
||||||
|
// is deliberately no second producer of strings and no second pass. The guard below is `proseBlocks`
|
||||||
|
// and it is one function; a guard that only one of the two checkers happened to go through is a
|
||||||
|
// guard that stops being there the day somebody adds a third call beside them.
|
||||||
|
//
|
||||||
|
// The caches are the one thing that is per checker, because the answers are. A block can have been
|
||||||
|
// asked about by one and not the other, which is exactly what the first launch after somebody turns
|
||||||
|
// grammar on looks like, and a single cache would either re-ask the whole document on every toggle
|
||||||
|
// or claim a block was checked for something it was never sent to.
|
||||||
|
//
|
||||||
// WHAT IS CHECKED, and where that guard actually sits.
|
// WHAT IS CHECKED, and where that guard actually sits.
|
||||||
//
|
//
|
||||||
// Prose only. A fenced code block, a raw block, a maths field and an inline code span are not prose,
|
// Prose only. A fenced code block, a raw block, a maths field and an inline code span are not prose,
|
||||||
// and underlining somebody's variable names is the fastest way to get a spell checker turned off for
|
// and underlining somebody's variable names is the fastest way to get a spell checker turned off for
|
||||||
// good. The guard is `proseBlocks` below, and it is the single producer of every string this file
|
// good. The guard is `proseBlocks` below, and it is the single producer of every string this file
|
||||||
// sends: `pass` checks only what `proseBlocks` returned, `spellCheck` is called from nowhere else in
|
// sends to either checker: `pass` checks only what `proseBlocks` returned, `spellCheck` and
|
||||||
// the app outside src/api/spell.ts itself, and a block that never went to the checker has no entry
|
// `grammarCheck` are called from nowhere else in the app outside src/api/spell.ts and
|
||||||
// in the cache and therefore no decoration. There is no second path to the checker to forget to
|
// src/api/grammar.ts themselves, and a block that never went to a checker has no entry in that
|
||||||
|
// checker's cache and therefore no decoration. There is no second path to either one to forget to
|
||||||
// guard, which is the shape of guard this project has now shipped four times unreached.
|
// guard, which is the shape of guard this project has now shipped four times unreached.
|
||||||
//
|
//
|
||||||
// A URL is the exception and it is deliberately not handled here. The Rust side asks AppKit to
|
// A URL is the exception and it is deliberately not handled here. The Rust side asks AppKit to
|
||||||
@@ -24,12 +39,13 @@
|
|||||||
//
|
//
|
||||||
// WHEN IT RUNS.
|
// WHEN IT RUNS.
|
||||||
//
|
//
|
||||||
// Not on every keystroke. Each check crosses the IPC boundary into AppKit, so typing schedules a
|
// Not on every keystroke. Each check crosses the IPC boundary, into AppKit for spelling and into
|
||||||
// pass a few hundred milliseconds out and every further keystroke pushes it back. A pass sends only
|
// Harper's linter for grammar, so typing schedules a pass a few hundred milliseconds out and every
|
||||||
// the blocks whose text the checker has not already answered about, so an ordinary edit is one
|
// further keystroke pushes it back. A pass sends only the blocks whose text a checker has not
|
||||||
// paragraph over the wire and a document nobody has touched is nothing at all. The cache is keyed by
|
// already answered about, so an ordinary edit is one paragraph over the wire and a document nobody
|
||||||
// the exact text of a block, which is what makes that true without any range tracking: text the user
|
// has touched is nothing at all. A cache is keyed by the exact text of a block, which is what makes
|
||||||
// has not touched is text that is still its own key.
|
// that true without any range tracking: text the user has not touched is text that is still its own
|
||||||
|
// key.
|
||||||
//
|
//
|
||||||
// AND WHY A STALE ANSWER CANNOT LAND.
|
// AND WHY A STALE ANSWER CANNOT LAND.
|
||||||
//
|
//
|
||||||
@@ -46,8 +62,9 @@ import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
|||||||
import type { EditorState, PluginView } from "@tiptap/pm/state";
|
import type { EditorState, PluginView } from "@tiptap/pm/state";
|
||||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||||
import type { EditorView } from "@tiptap/pm/view";
|
import type { EditorView } from "@tiptap/pm/view";
|
||||||
|
import { grammarCheck } from "../api/grammar";
|
||||||
import { spellCheck } from "../api/spell";
|
import { spellCheck } from "../api/spell";
|
||||||
import type { SpellIssue } from "../ipc";
|
import type { GrammarIssue, SpellIssue } from "../ipc";
|
||||||
import { useProofing, type ProofTarget } from "../store/useProofing";
|
import { useProofing, type ProofTarget } from "../store/useProofing";
|
||||||
|
|
||||||
/** How long after the last keystroke a pass runs. Long enough that typing a word is one check. */
|
/** How long after the last keystroke a pass runs. Long enough that typing a word is one check. */
|
||||||
@@ -79,6 +96,18 @@ interface Block {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The half of an answer this file's plumbing cares about, which is the same half for both checkers.
|
||||||
|
*
|
||||||
|
* Everything from the run batching to the cache to the offset arithmetic is about where a problem is
|
||||||
|
* and nothing about what it is, so it is written once against this and used twice, rather than
|
||||||
|
* copied and left to drift apart a fix at a time.
|
||||||
|
*/
|
||||||
|
interface Ranged {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Several blocks' text in one string, and where each of them starts in it. */
|
/** Several blocks' text in one string, and where each of them starts in it. */
|
||||||
interface Run {
|
interface Run {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -86,14 +115,15 @@ interface Run {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the checker has already said, keyed by the exact text it was said about.
|
* What each checker has already said, keyed by the exact text it was said about.
|
||||||
*
|
*
|
||||||
* Module level rather than per view because it is not about a document: two files that share a
|
* Module level rather than per view because it is not about a document: two files that share a
|
||||||
* paragraph share its answer, and switching away from a document and back does not re-ask anything.
|
* paragraph share its answers, and switching away from a document and back does not re-ask anything.
|
||||||
* It is pruned at the end of every pass down to the text that is actually in the document, so it
|
* Both are pruned at the end of every pass down to the text that is actually in the document, so
|
||||||
* cannot grow past the size of what is open.
|
* neither can grow past the size of what is open.
|
||||||
*/
|
*/
|
||||||
let cache = new Map<string, SpellIssue[]>();
|
const spellCache = new Map<string, SpellIssue[]>();
|
||||||
|
const grammarCache = new Map<string, GrammarIssue[]>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The view a menu acts on. There is one editor and one document (see src/editor/index.ts), so there
|
* The view a menu acts on. There is one editor and one document (see src/editor/index.ts), so there
|
||||||
@@ -156,8 +186,8 @@ function proseBlocks(doc: ProseMirrorNode): Block[] {
|
|||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The distinct texts in the document the checker has not answered about yet. */
|
/** The distinct texts in the document one checker has not answered about yet. */
|
||||||
function pending(blocks: readonly Block[]): string[] {
|
function pending<T>(cache: ReadonlyMap<string, T[]>, blocks: readonly Block[]): string[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
@@ -193,13 +223,18 @@ function runsOf(texts: readonly string[]): Run[] {
|
|||||||
* next pass would send again, so a paragraph the checker is happy with has to be recorded as
|
* next pass would send again, so a paragraph the checker is happy with has to be recorded as
|
||||||
* checked or the document with no misspellings in it is the one that never stops asking.
|
* checked or the document with no misspellings in it is the one that never stops asking.
|
||||||
*/
|
*/
|
||||||
function store(run: Run, issues: readonly SpellIssue[]): void {
|
function store<T extends Ranged>(
|
||||||
const byPart = run.parts.map(() => [] as SpellIssue[]);
|
cache: Map<string, T[]>,
|
||||||
|
run: Run,
|
||||||
|
issues: readonly T[],
|
||||||
|
): void {
|
||||||
|
const byPart = run.parts.map(() => [] as T[]);
|
||||||
for (const issue of issues) {
|
for (const issue of issues) {
|
||||||
const index = run.parts.findIndex(
|
const index = run.parts.findIndex(
|
||||||
(part) => issue.start >= part.at && issue.end <= part.at + part.text.length,
|
(part) => issue.start >= part.at && issue.end <= part.at + part.text.length,
|
||||||
);
|
);
|
||||||
// An issue that reaches across the join between two blocks is not a word in either of them.
|
// An issue that reaches across the join between two blocks is not about either of them: a
|
||||||
|
// misspelling is not a word there, and a sentence is not one sentence there.
|
||||||
if (index === -1) continue;
|
if (index === -1) continue;
|
||||||
const part = run.parts[index];
|
const part = run.parts[index];
|
||||||
byPart[index].push({ ...issue, start: issue.start - part.at, end: issue.end - part.at });
|
byPart[index].push({ ...issue, start: issue.start - part.at, end: issue.end - part.at });
|
||||||
@@ -207,49 +242,90 @@ function store(run: Run, issues: readonly SpellIssue[]): void {
|
|||||||
run.parts.forEach((part, index) => cache.set(part.text, byPart[index]));
|
run.parts.forEach((part, index) => cache.set(part.text, byPart[index]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Down to what is in the document, so the cache is never larger than what is open. */
|
/**
|
||||||
function prune(blocks: readonly Block[]): void {
|
* Down to what is in the document, so a cache is never larger than what is open.
|
||||||
const kept = new Map<string, SpellIssue[]>();
|
*
|
||||||
for (const block of blocks) {
|
* In place rather than by building a smaller map, so that the two maps below are the same two
|
||||||
const issues = cache.get(block.text);
|
* objects for the life of the module. A pass hands one of them to `store` and then waits on the IPC
|
||||||
if (issues) kept.set(block.text, issues);
|
* boundary, and a second pass can finish and prune in the meantime; if pruning swapped the map, the
|
||||||
|
* first pass would come back and file its answers into one nothing reads any more.
|
||||||
|
*/
|
||||||
|
function prune<T>(cache: Map<string, T[]>, blocks: readonly Block[]): void {
|
||||||
|
const alive = new Set(blocks.map((block) => block.text));
|
||||||
|
for (const text of cache.keys()) {
|
||||||
|
if (!alive.has(text)) cache.delete(text);
|
||||||
}
|
}
|
||||||
cache = kept;
|
}
|
||||||
|
|
||||||
|
/** Which underlines are wanted, and what has been waved away for this sitting. */
|
||||||
|
interface Drawing {
|
||||||
|
spelling: boolean;
|
||||||
|
grammar: boolean;
|
||||||
|
ignored: ReadonlySet<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The decorations for the document as it stands, built from what the checker has already said.
|
* Whether an issue's offsets sit inside the block it claims to be about.
|
||||||
|
*
|
||||||
|
* A checker is a foreign process walking the user's text, and a decoration running past the end of
|
||||||
|
* a node throws inside the view rather than merely looking wrong, so an answer that does not fit is
|
||||||
|
* dropped rather than clamped: an underline in the wrong place is a claim about the wrong words.
|
||||||
|
*/
|
||||||
|
function inside(issue: Ranged, block: Block): boolean {
|
||||||
|
return issue.start >= 0 && issue.end > issue.start && issue.end <= block.text.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The decorations for the document as it stands, built from what the checkers have already said.
|
||||||
*
|
*
|
||||||
* A block with nothing cached contributes nothing, which is what an underline that has not arrived
|
* A block with nothing cached contributes nothing, which is what an underline that has not arrived
|
||||||
* yet looks like, and an issue whose offsets do not sit inside the block they claim to be about is
|
* yet looks like. The two kinds are built in the same set and can overlap: a misspelled word inside
|
||||||
* dropped: the checker is a foreign process walking the user's text, and a decoration running past
|
* a phrase Harper does not like gets both marks, and `menuAt` below decides which of them a click
|
||||||
* the end of a node throws inside the view rather than merely looking wrong.
|
* is about.
|
||||||
*/
|
*/
|
||||||
function decorationsFor(
|
function decorationsFor(doc: ProseMirrorNode, blocks: readonly Block[], what: Drawing): DecorationSet {
|
||||||
doc: ProseMirrorNode,
|
|
||||||
blocks: readonly Block[],
|
|
||||||
ignored: ReadonlySet<string>,
|
|
||||||
): DecorationSet {
|
|
||||||
const decorations: Decoration[] = [];
|
const decorations: Decoration[] = [];
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
const issues = cache.get(block.text);
|
if (what.spelling) {
|
||||||
if (!issues) continue;
|
for (const issue of spellCache.get(block.text) ?? []) {
|
||||||
for (const issue of issues) {
|
if (!inside(issue, block) || what.ignored.has(issue.word.toLowerCase())) continue;
|
||||||
if (issue.start < 0 || issue.end <= issue.start || issue.end > block.text.length) continue;
|
|
||||||
if (ignored.has(issue.word.toLowerCase())) continue;
|
|
||||||
decorations.push(
|
decorations.push(
|
||||||
Decoration.inline(
|
Decoration.inline(
|
||||||
block.base + issue.start,
|
block.base + issue.start,
|
||||||
block.base + issue.end,
|
block.base + issue.end,
|
||||||
{ class: "proof-mark" },
|
{ class: "proof-mark" },
|
||||||
// The word and its suggestions ride on the decoration rather than in a list beside it, so
|
// The word and its suggestions ride on the decoration rather than in a list beside it,
|
||||||
// that mapping the set through an edit keeps the menu's offer attached to the word it was
|
// so that mapping the set through an edit keeps the menu's offer attached to the word it
|
||||||
// about instead of to a position that has moved.
|
// was about instead of to a position that has moved.
|
||||||
{ word: issue.word, suggestions: issue.suggestions },
|
{ word: issue.word, suggestions: issue.suggestions, grammar: null },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (what.grammar) {
|
||||||
|
for (const issue of grammarCache.get(block.text) ?? []) {
|
||||||
|
if (!inside(issue, block)) continue;
|
||||||
|
// Harper answers about a span rather than about a word, so what the popover is "about" is
|
||||||
|
// whatever that span covers. It is taken from the block's own text for the same reason the
|
||||||
|
// spelling half takes the word the checker sent back: it is what a suggestion will be
|
||||||
|
// checked against before anything is written.
|
||||||
|
const text = block.text.slice(issue.start, issue.end);
|
||||||
|
if (what.ignored.has(text.toLowerCase())) continue;
|
||||||
|
decorations.push(
|
||||||
|
Decoration.inline(
|
||||||
|
block.base + issue.start,
|
||||||
|
block.base + issue.end,
|
||||||
|
{ class: "proof-mark-grammar" },
|
||||||
|
{
|
||||||
|
word: text,
|
||||||
|
suggestions: issue.suggestions,
|
||||||
|
grammar: { kind: issue.kind, message: issue.message },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return DecorationSet.create(doc, decorations);
|
return DecorationSet.create(doc, decorations);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,41 +339,79 @@ function clear(view: EditorView): void {
|
|||||||
draw(view, DecorationSet.empty);
|
draw(view, DecorationSet.empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What is turned on right now, and available to be turned on at all. */
|
||||||
|
function wanted(): Drawing {
|
||||||
|
const state = useProofing.getState();
|
||||||
|
return {
|
||||||
|
spelling: state.enabled && state.availability === "ready",
|
||||||
|
grammar: state.grammar && state.grammarAvailability === "ready",
|
||||||
|
ignored: state.ignored,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One check of whatever the checker has not seen, and then a redraw.
|
* One check of whatever the checkers have not seen, and then a redraw.
|
||||||
*
|
*
|
||||||
* The decorations are built from `view.state.doc` after the awaits rather than from the document the
|
* The decorations are built from `view.state.doc` after the awaits rather than from the document the
|
||||||
* pass started on. By then the sequence number has already answered whether anything moved, so the
|
* pass started on. By then the sequence number has already answered whether anything moved, so the
|
||||||
* two agree; building from what is on screen is what makes that a fact about the code rather than a
|
* two agree; building from what is on screen is what makes that a fact about the code rather than a
|
||||||
* fact about the timing.
|
* fact about the timing.
|
||||||
|
*
|
||||||
|
* Spelling first and grammar after, rather than both at once. The two calls are cheap and the user
|
||||||
|
* is typing while they run, so the useful thing is that the commoner of the two answers first and
|
||||||
|
* its underlines land while the other is still being asked, not that the pair finishes a few
|
||||||
|
* milliseconds sooner.
|
||||||
*/
|
*/
|
||||||
async function pass(view: EditorView): Promise<void> {
|
async function pass(view: EditorView): Promise<void> {
|
||||||
const seq = passSeq;
|
const seq = passSeq;
|
||||||
const state = useProofing.getState();
|
const what = wanted();
|
||||||
if (!state.enabled || state.availability !== "ready") {
|
if (!what.spelling && !what.grammar) {
|
||||||
clear(view);
|
clear(view);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const run of runsOf(pending(proseBlocks(view.state.doc)))) {
|
const blocks = proseBlocks(view.state.doc);
|
||||||
|
// A checker that is not there, which on a build without one is what the first call finds out,
|
||||||
|
// ends its own half of the pass and says nothing. There is no toast: "this build cannot check
|
||||||
|
// grammar" is what the availability question the store asks once per launch is for, and not
|
||||||
|
// something worth repeating on every keystroke. The other checker's loop is untouched, which is
|
||||||
|
// why each has its own catch rather than the pair sharing one, and the redraw below still happens
|
||||||
|
// from whatever the two of them had already answered.
|
||||||
|
if (what.spelling) {
|
||||||
|
for (const run of runsOf(pending(spellCache, blocks))) {
|
||||||
try {
|
try {
|
||||||
store(run, await spellCheck(run.text));
|
store(spellCache, run, await spellCheck(run.text));
|
||||||
} catch {
|
} catch {
|
||||||
// The checker went away mid document, which on a build without one is what the first call
|
break;
|
||||||
// does. Nothing is drawn and nothing is said: what is already underlined stays, and the next
|
}
|
||||||
// edit asks again.
|
}
|
||||||
return;
|
}
|
||||||
|
if (what.grammar) {
|
||||||
|
for (const run of runsOf(pending(grammarCache, blocks))) {
|
||||||
|
try {
|
||||||
|
store(grammarCache, run, await grammarCheck(run.text));
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seq !== passSeq || view.isDestroyed) return;
|
if (seq !== passSeq || view.isDestroyed) return;
|
||||||
const blocks = proseBlocks(view.state.doc);
|
const current = proseBlocks(view.state.doc);
|
||||||
draw(view, decorationsFor(view.state.doc, blocks, useProofing.getState().ignored));
|
draw(view, decorationsFor(view.state.doc, current, wanted()));
|
||||||
prune(blocks);
|
prune(spellCache, current);
|
||||||
|
prune(grammarCache, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Harper's half of a decoration's spec, and null for a spelling one. */
|
||||||
|
function grammarOf(value: unknown): ProofTarget["grammar"] {
|
||||||
|
if (typeof value !== "object" || value === null) return null;
|
||||||
|
const { kind, message } = value as { kind?: unknown; message?: unknown };
|
||||||
|
return typeof kind === "string" && typeof message === "string" ? { kind, message } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Puts the menu over one underlined word, whichever of the three ways in found it.
|
* Puts the menu over one underlined run of text, whichever of the three ways in found it.
|
||||||
*
|
*
|
||||||
* `fromKeyboard` is the only thing that separates a chord from a pointer once the word is known,
|
* `fromKeyboard` is the only thing that separates a chord from a pointer once the word is known,
|
||||||
* and the menu reads it to decide whether to take focus. A click has already put the caret where
|
* and the menu reads it to decide whether to take focus. A click has already put the caret where
|
||||||
@@ -306,7 +420,7 @@ async function pass(view: EditorView): Promise<void> {
|
|||||||
* offered.
|
* offered.
|
||||||
*/
|
*/
|
||||||
function openFor(view: EditorView, hit: Decoration, fromKeyboard: boolean): boolean {
|
function openFor(view: EditorView, hit: Decoration, fromKeyboard: boolean): boolean {
|
||||||
const spec = hit.spec as { word?: unknown; suggestions?: unknown };
|
const spec = hit.spec as { word?: unknown; suggestions?: unknown; grammar?: unknown };
|
||||||
if (typeof spec.word !== "string") return false;
|
if (typeof spec.word !== "string") return false;
|
||||||
|
|
||||||
const start = view.coordsAtPos(hit.from);
|
const start = view.coordsAtPos(hit.from);
|
||||||
@@ -319,6 +433,7 @@ function openFor(view: EditorView, hit: Decoration, fromKeyboard: boolean): bool
|
|||||||
0,
|
0,
|
||||||
MAX_SUGGESTIONS,
|
MAX_SUGGESTIONS,
|
||||||
),
|
),
|
||||||
|
grammar: grammarOf(spec.grammar),
|
||||||
left: (start.left + end.left) / 2,
|
left: (start.left + end.left) / 2,
|
||||||
top: start.top,
|
top: start.top,
|
||||||
// A word that wraps across two lines ends on the lower one, which is where the menu belongs.
|
// A word that wraps across two lines ends on the lower one, which is where the menu belongs.
|
||||||
@@ -328,7 +443,7 @@ function openFor(view: EditorView, hit: Decoration, fromKeyboard: boolean): bool
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The misspelling under a position, if the menu should open over one. */
|
/** The underlined text under a position, if the menu should open over it. */
|
||||||
function menuAt(view: EditorView, pos: number): boolean {
|
function menuAt(view: EditorView, pos: number): boolean {
|
||||||
// A document held open while a conflict is resolved is one nothing may edit, and a menu whose
|
// A document held open while a conflict is resolved is one nothing may edit, and a menu whose
|
||||||
// every item is an edit has nothing to offer there. The underlines stay; the menu does not open.
|
// every item is an edit has nothing to offer there. The underlines stay; the menu does not open.
|
||||||
@@ -337,14 +452,20 @@ function menuAt(view: EditorView, pos: number): boolean {
|
|||||||
if (!decorations) return false;
|
if (!decorations) return false;
|
||||||
const found = decorations.find(pos, pos);
|
const found = decorations.find(pos, pos);
|
||||||
if (found.length === 0) return false;
|
if (found.length === 0) return false;
|
||||||
// A position at the seam between two words touches both, so a hit that actually contains it wins.
|
// A position at the seam between two words touches both, so hits that actually contain it win.
|
||||||
const hit = found.find((deco) => deco.from < pos && pos < deco.to) ?? found[0];
|
// Among those, the shortest: a misspelled word inside a phrase Harper flagged carries both marks,
|
||||||
|
// and a click on that word is about the word. The phrase is still one click away, on any of the
|
||||||
|
// characters the word does not cover.
|
||||||
|
const containing = found.filter((deco) => deco.from < pos && pos < deco.to);
|
||||||
|
const hit = (containing.length > 0 ? containing : found).reduce((best, deco) =>
|
||||||
|
deco.to - deco.from < best.to - best.from ? deco : best,
|
||||||
|
);
|
||||||
return openFor(view, hit, false);
|
return openFor(view, hit, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The same menu, opened by a chord instead of by a pointer, over the misspelling at the caret or
|
* The same menu, opened by a chord instead of by a pointer, over the underlined text at the caret
|
||||||
* the nearest one to it.
|
* or the nearest to it, of either kind.
|
||||||
*
|
*
|
||||||
* Nearest within the caret's own paragraph and no further. The menu is placed at the viewport
|
* Nearest within the caret's own paragraph and no further. The menu is placed at the viewport
|
||||||
* coordinates of the word it is about, so a search that ran to the end of the document would open a
|
* coordinates of the word it is about, so a search that ran to the end of the document would open a
|
||||||
@@ -397,12 +518,16 @@ class Proofreader implements PluginView {
|
|||||||
|
|
||||||
this.unsubscribe = useProofing.subscribe((next, previous) => {
|
this.unsubscribe = useProofing.subscribe((next, previous) => {
|
||||||
// A learned word changes the answer for text nobody has touched, so it is the one thing that
|
// A learned word changes the answer for text nobody has touched, so it is the one thing that
|
||||||
// throws away what the checker already said. Ignoring a word does not: it is filtered when the
|
// throws away what a checker already said. Only the spelling half: Harper's own spell rule is
|
||||||
// decorations are built, so putting it back costs nothing.
|
// off (see src-tauri/src/grammar.rs), so the system dictionary growing a word cannot change a
|
||||||
if (next.revision !== previous.revision) cache.clear();
|
// single thing it said. Ignoring a word throws away nothing either way, since it is filtered
|
||||||
|
// when the decorations are built and putting it back costs nothing.
|
||||||
|
if (next.revision !== previous.revision) spellCache.clear();
|
||||||
if (
|
if (
|
||||||
next.enabled === previous.enabled &&
|
next.enabled === previous.enabled &&
|
||||||
next.availability === previous.availability &&
|
next.availability === previous.availability &&
|
||||||
|
next.grammar === previous.grammar &&
|
||||||
|
next.grammarAvailability === previous.grammarAvailability &&
|
||||||
next.ignored === previous.ignored &&
|
next.ignored === previous.ignored &&
|
||||||
next.revision === previous.revision
|
next.revision === previous.revision
|
||||||
) {
|
) {
|
||||||
@@ -441,18 +566,25 @@ class Proofreader implements PluginView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Puts a suggestion in place of the word the menu was opened over.
|
* Puts a suggestion in place of the text the menu was opened over, a word for spelling and a phrase
|
||||||
|
* for grammar.
|
||||||
*
|
*
|
||||||
* One transaction, so one undo takes it back, and refused outright unless the word is still exactly
|
* One transaction, so one undo takes it back, and refused outright unless that text is still exactly
|
||||||
* where the menu said it was. The menu closes on every document change, so that check should never
|
* where the menu said it was. The menu closes on every document change, so that check should never
|
||||||
* fail; it is here because this is the one function in the file that can write to somebody's
|
* fail; it is here because this is the one function in the file that can write to somebody's
|
||||||
* document, and it is worth being unable to write to the wrong part of it.
|
* document, and it is worth being unable to write to the wrong part of it.
|
||||||
*
|
*
|
||||||
|
* The one case where it refuses something a user meant is a grammar span reaching across an inline
|
||||||
|
* code span. `blockText` blanked the code into spaces and the document has the code itself there, so
|
||||||
|
* the two do not match and nothing is written. That is the right way round: a phrase Harper judged
|
||||||
|
* without being shown the code in the middle of it is a phrase whose correction would have eaten the
|
||||||
|
* code, and refusing costs a gesture where writing it would cost the line.
|
||||||
|
*
|
||||||
* No `fits` guard, unlike every insert in src/editor/Editor.tsx, and for the reason src/editor/
|
* No `fits` guard, unlike every insert in src/editor/Editor.tsx, and for the reason src/editor/
|
||||||
* fits.test.ts gives the find bar's replace: this is text going into the one textblock it came out
|
* fits.test.ts gives the find bar's replace: this is text going into the one textblock it came out
|
||||||
* of, not a node being placed somewhere it may not go. The range is inside a block that `proseBlocks`
|
* of, not a node being placed somewhere it may not go. The range is inside a block that `proseBlocks`
|
||||||
* already refused to send if it was code or raw, and a suggestion is one word, so there is no blank
|
* already refused to send if it was code or raw, and a suggestion is a few words at most, so there
|
||||||
* line in it for `holdsText` to be about.
|
* is no blank line in it for `holdsText` to be about.
|
||||||
*/
|
*/
|
||||||
export function replaceSpelling(target: ProofTarget, suggestion: string): void {
|
export function replaceSpelling(target: ProofTarget, suggestion: string): void {
|
||||||
const view = activeView;
|
const view = activeView;
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// Writing Tools, from the editor's side.
|
||||||
|
//
|
||||||
|
// The system rewrites the selection by mutating the DOM under prosemirror-view, which reparses it.
|
||||||
|
// That is the same seam docs/architecture.md blames for the hard break bug, so this module is where
|
||||||
|
// the guard belongs: what may be handed to a rewrite, and what has to be refused before one starts.
|
||||||
|
//
|
||||||
|
// WHAT THE BYTES ACTUALLY DO, because none of the below is a guess. tests/writing-tools.spec.ts
|
||||||
|
// makes the mutation the system makes, in a running editor, and reads the file afterwards.
|
||||||
|
//
|
||||||
|
// Prose survives. A hand wrapped paragraph rewritten across its wraps comes back as one line and
|
||||||
|
// no backslash anywhere; a heading, a list item and a single table cell all keep their spelling;
|
||||||
|
// markdown characters that arrive in the new text are escaped, so a rewrite that opens with "1." or
|
||||||
|
// "-" stays the paragraph it was. A newline in the new text is a newline in the file, which is the
|
||||||
|
// paragraph's own contract, and a pipe inside a cell comes out `\|`.
|
||||||
|
//
|
||||||
|
// Structure does not. A selection with an end in each of two blocks is where every real failure
|
||||||
|
// was: two paragraphs became three with ` ` in the middle of the user's words, a heading and
|
||||||
|
// the paragraph under it became a truncated heading and an invented paragraph, two list items
|
||||||
|
// became three, and two table cells became a table one column wider whose rows disagree, which is
|
||||||
|
// not a table any more. So a rewrite gets one block, and it is refused rather than trimmed to fit:
|
||||||
|
// the selection is the user's and silently rewriting a different range than the one they made is
|
||||||
|
// its own bug.
|
||||||
|
//
|
||||||
|
// Inside one block, the danger is anything whose spelling is not in the words on screen. A range
|
||||||
|
// crossing a link is replaced by plain text and the address goes with it, invisibly, and the same
|
||||||
|
// gesture eats an inline picture. A range wholly inside a link is safe and stays allowed, because
|
||||||
|
// the mark is an ancestor of it rather than something it can delete. Code and formulae are refused
|
||||||
|
// the way src/editor/proofing.ts refuses them, and for its reason rather than a byte one: a fenced
|
||||||
|
// block, a raw block and a code span all round trip cleanly, and a tool that rewrites somebody's
|
||||||
|
// variable names is a tool they turn off. A raw block is the file's own bytes and the only correct
|
||||||
|
// thing to do with them is nothing.
|
||||||
|
//
|
||||||
|
// Nothing here writes to the document. The rewrite arrives as a DOM mutation, TipTap sees the
|
||||||
|
// transaction it becomes and the shell's debounce saves it, exactly as if the user had typed.
|
||||||
|
//
|
||||||
|
// AND THE SELECTION IS READ, NEVER PUT BACK. Writing Tools rewrites whatever holds the keyboard, so
|
||||||
|
// the document has to be holding it before one starts, and this refuses rather than reaching over
|
||||||
|
// and focusing the document itself. That is not squeamishness, it was measured: the command palette
|
||||||
|
// runs its row and then closes on to `<body>`, and focusing the editor at that moment is what
|
||||||
|
// destroys the selection rather than what saves it, because prosemirror-view takes the collapsed
|
||||||
|
// selection left behind by the panel that just unmounted as the document's new one. A refusal that
|
||||||
|
// says which gesture to use costs the user one click. A rewrite aimed at a caret it moved itself
|
||||||
|
// costs them a paragraph.
|
||||||
|
|
||||||
|
import { writingAvailable, writingRun } from "../api/writing";
|
||||||
|
import { notify } from "../store/useToast";
|
||||||
|
|
||||||
|
/** The two items this app puts on the menu, named as the system's own submenu names them. */
|
||||||
|
const TITLES = { proofread: "Proofread", rewrite: "Rewrite" } as const;
|
||||||
|
|
||||||
|
export type WritingTool = keyof typeof TITLES;
|
||||||
|
|
||||||
|
const UNAVAILABLE =
|
||||||
|
"Writing Tools needs macOS 15.1 or later with Apple Intelligence turned on.";
|
||||||
|
|
||||||
|
/** The two editables a document is ever open in. Exactly one of them is on screen at a time. */
|
||||||
|
const MARKDOWN = ".prose";
|
||||||
|
const PLAIN = "textarea.plain-text";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every block a selection's two ends are measured against, prose or not. The ones that may not be
|
||||||
|
* rewritten are in the list on purpose: a range inside a fence has to resolve to the fence so the
|
||||||
|
* refusal can name it, rather than resolving to nothing and being reported as spanning blocks.
|
||||||
|
*/
|
||||||
|
const BLOCKS = "p, h1, h2, h3, h4, h5, h6, td, th, pre, [data-math-block]";
|
||||||
|
|
||||||
|
/** What a range inside one prose block may not contain, and what a partly covered one clones as. */
|
||||||
|
const FRAGILE = "a, code, img, .math-inline";
|
||||||
|
|
||||||
|
const REFUSED = {
|
||||||
|
closed: "Open a document first.",
|
||||||
|
unfocused:
|
||||||
|
"Writing Tools rewrites whatever holds the keyboard. Click into the document, select the text you want changed, and use the Edit menu.",
|
||||||
|
empty: "Select the text you want Writing Tools to change first.",
|
||||||
|
blocks:
|
||||||
|
"Writing Tools rewrites one paragraph at a time, and that selection covers more than one.",
|
||||||
|
raw: "That block is the file's own bytes, kept exactly as they were read, so Writing Tools is not offered it.",
|
||||||
|
code: "Writing Tools is not offered code.",
|
||||||
|
math: "Writing Tools is not offered a formula.",
|
||||||
|
summary: "Writing Tools is not offered a toggle's summary.",
|
||||||
|
link: "That selection covers a link, and a rewrite would take its address with it. Select the words either side instead.",
|
||||||
|
image: "That selection covers a picture, and a rewrite would take it out of the document.",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function elementOf(node: Node | null): Element | null {
|
||||||
|
if (!node) return null;
|
||||||
|
return node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The block a range end is in, or null when it is somewhere with no block under it at all. */
|
||||||
|
const blockOf = (node: Node): Element | null => elementOf(node)?.closest(BLOCKS) ?? null;
|
||||||
|
|
||||||
|
/** Why this block may not be rewritten, or null when it is prose. */
|
||||||
|
function blockRefusal(block: Element): string | null {
|
||||||
|
if (block.matches("pre[data-raw]")) return REFUSED.raw;
|
||||||
|
if (block.matches("pre")) return REFUSED.code;
|
||||||
|
if (block.matches("[data-math-block]")) return REFUSED.math;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the run inside one prose block may not be rewritten, or null when it may.
|
||||||
|
*
|
||||||
|
* Two questions, not one. `cloneContents` reports what the range covers, including an element it
|
||||||
|
* covers only part of, which is the case that destroys a link. It says nothing about an element the
|
||||||
|
* range sits wholly inside, since that is an ancestor rather than content, so the marks around the
|
||||||
|
* range are asked for separately.
|
||||||
|
*/
|
||||||
|
function contentRefusal(range: Range): string | null {
|
||||||
|
const around = elementOf(range.commonAncestorContainer);
|
||||||
|
if (around?.closest("code")) return REFUSED.code;
|
||||||
|
if (around?.closest(".math-inline")) return REFUSED.math;
|
||||||
|
|
||||||
|
const covered = range.cloneContents().querySelector(FRAGILE);
|
||||||
|
if (!covered) return null;
|
||||||
|
if (covered.matches("code")) return REFUSED.code;
|
||||||
|
if (covered.matches("img")) return REFUSED.image;
|
||||||
|
if (covered.matches(".math-inline")) return REFUSED.math;
|
||||||
|
return REFUSED.link;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why the selection on screen may not be handed to a rewrite, or null when it may. */
|
||||||
|
function selectionRefusal(): string | null {
|
||||||
|
const surface = document.querySelector<HTMLElement>(`${MARKDOWN}, ${PLAIN}`);
|
||||||
|
if (!surface) return REFUSED.closed;
|
||||||
|
|
||||||
|
const active = document.activeElement;
|
||||||
|
|
||||||
|
// A .txt file is a textarea with no markdown in it to lose, so there is nothing here to guard
|
||||||
|
// beyond the document holding the keyboard with something selected in it.
|
||||||
|
if (surface.matches(PLAIN)) {
|
||||||
|
const field = surface as HTMLTextAreaElement;
|
||||||
|
if (active !== field) return REFUSED.unfocused;
|
||||||
|
return field.selectionStart === field.selectionEnd ? REFUSED.empty : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A formula's source field and a toggle's title are editables of their own nested inside the
|
||||||
|
// document, so each takes the keyboard away from it. Neither is prose in a block of the file, and
|
||||||
|
// saying which one the cursor is in is worth more than the general answer below.
|
||||||
|
if (active && active !== surface && surface.contains(active)) {
|
||||||
|
if (active.closest("[data-math-block]")) return REFUSED.math;
|
||||||
|
if (active.closest("[data-toggle-summary]")) return REFUSED.summary;
|
||||||
|
return REFUSED.unfocused;
|
||||||
|
}
|
||||||
|
if (active !== surface) return REFUSED.unfocused;
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) return REFUSED.empty;
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
if (range.collapsed || !surface.contains(range.commonAncestorContainer)) return REFUSED.empty;
|
||||||
|
|
||||||
|
const block = blockOf(range.startContainer);
|
||||||
|
if (!block || block !== blockOf(range.endContainer)) return REFUSED.blocks;
|
||||||
|
|
||||||
|
return blockRefusal(block) ?? contentRefusal(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runWritingTool(tool: WritingTool): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!(await writingAvailable())) {
|
||||||
|
notify(UNAVAILABLE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const refusal = selectionRefusal();
|
||||||
|
if (refusal) {
|
||||||
|
notify(refusal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await writingRun(TITLES[tool]);
|
||||||
|
} catch (e) {
|
||||||
|
notify(`Could not run Writing Tools: ${String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// Running an export: the document on screen becomes Typst source, the source becomes PDF bytes,
|
||||||
|
// and the bytes go wherever the native save panel points.
|
||||||
|
//
|
||||||
|
// Nothing in here writes the user's markdown or touches the open buffer. An export of a document
|
||||||
|
// with unsaved edits exports what is on screen, and leaves the file alone: this module never calls
|
||||||
|
// `save`, never dispatches a transaction, and never asks the document store for anything but the
|
||||||
|
// tree it is already holding. That is not an accident of the current code, it is the requirement.
|
||||||
|
// A save before an export would mean choosing to export a document turns into writing over a file
|
||||||
|
// the user had not decided to write yet, and there is no undo for that on disk.
|
||||||
|
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { save as savePanel } from "@tauri-apps/plugin-dialog";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { pdfCompile, pdfWrite } from "../api/pdf";
|
||||||
|
import { PDF_WARNINGS_EVENT, isDesktop, isTauri, type PdfWarning } from "../ipc";
|
||||||
|
import { useDocument } from "../store/useDocument";
|
||||||
|
import { notify } from "../store/useToast";
|
||||||
|
import { diagramSources, documentToTypst, renderDiagrams, type Diagrams, type TypstDocument } from "./typst";
|
||||||
|
|
||||||
|
type Phase = "idle" | "converting" | "compiling" | "writing";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the export has got to. A phase rather than a boolean because there are four answers and
|
||||||
|
* only one of them is "nothing is happening", and because the second Cmd-E while a fifty page
|
||||||
|
* document is compiling has to be refused rather than queued behind the first.
|
||||||
|
*/
|
||||||
|
let phase: Phase = "idle";
|
||||||
|
|
||||||
|
const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path;
|
||||||
|
|
||||||
|
/** The same name with a .pdf on it, which is what the save panel opens with. */
|
||||||
|
function suggestedName(path: string): string {
|
||||||
|
const name = baseName(path);
|
||||||
|
const dot = name.lastIndexOf(".");
|
||||||
|
return `${dot > 0 ? name.slice(0, dot) : name}.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(warnings: readonly PdfWarning[]): string {
|
||||||
|
const total = warnings.reduce((sum, warning) => sum + Math.max(1, warning.count), 0);
|
||||||
|
const first = warnings[0]?.message ?? "";
|
||||||
|
if (total <= 1) return first;
|
||||||
|
return `${first} (and ${total - 1} more)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warnings arrive on an event rather than with the bytes, so the listener has to be up before the
|
||||||
|
* compile starts and down again after it. Anything that arrives outside that window belongs to
|
||||||
|
* somebody else's export and is none of this call's business.
|
||||||
|
*/
|
||||||
|
async function withWarnings<T>(work: () => Promise<T>): Promise<{ result: T; warnings: PdfWarning[] }> {
|
||||||
|
const warnings: PdfWarning[] = [];
|
||||||
|
// In a browser there is no Tauri event bus to listen on. The dev fixture answers the two commands
|
||||||
|
// so the path can be walked, and it has no warnings to send.
|
||||||
|
const unlisten = isTauri
|
||||||
|
? await listen<PdfWarning[]>(PDF_WARNINGS_EVENT, (event) => {
|
||||||
|
if (Array.isArray(event.payload)) warnings.push(...event.payload);
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
try {
|
||||||
|
return { result: await work(), warnings };
|
||||||
|
} finally {
|
||||||
|
unlisten?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a document has anything for the maths package to choke on. */
|
||||||
|
function hasMath(doc: ProseMirrorNode): boolean {
|
||||||
|
let found = false;
|
||||||
|
doc.descendants((node) => {
|
||||||
|
if (node.type.name === "mathInline" || node.type.name === "mathBlock") found = true;
|
||||||
|
return !found;
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The compile, and the one retry worth making.
|
||||||
|
*
|
||||||
|
* mitex fails a compile rather than a formula: LaTeX it cannot parse is a hard error out of the
|
||||||
|
* plugin, and there is no try in Typst to put around it. So a document whose maths will not typeset
|
||||||
|
* would otherwise cost the whole PDF, over one expression, with no way for the author to tell which.
|
||||||
|
* The second attempt writes every formula as the monospace source the editor already shows on
|
||||||
|
* screen, which is a page the author can read and act on rather than a failure they cannot.
|
||||||
|
*
|
||||||
|
* Only for a document that actually has a formula in it. Anything else that fails to compile failed
|
||||||
|
* for a reason a second identical compile will not fix.
|
||||||
|
*
|
||||||
|
* This is the second line and not the first. src-tauri/src/pdf.rs already retries a compile that
|
||||||
|
* mitex brought down, by serving a stand-in library that sets each formula as the source it was
|
||||||
|
* written in, and that catches almost everything: reaching here means even that failed. The two
|
||||||
|
* are worth having separately because they fail differently. That one still goes through mitex's
|
||||||
|
* own files; this one regenerates the source without asking mitex anything at all.
|
||||||
|
*/
|
||||||
|
async function compileWithFallback(
|
||||||
|
doc: ProseMirrorNode,
|
||||||
|
path: string,
|
||||||
|
diagrams: Diagrams,
|
||||||
|
first: TypstDocument,
|
||||||
|
): Promise<{ bytes: ArrayBuffer; warnings: PdfWarning[]; retried: boolean }> {
|
||||||
|
try {
|
||||||
|
const attempt = await withWarnings(() => pdfCompile(first.source, first.images));
|
||||||
|
return { bytes: attempt.result, warnings: attempt.warnings, retried: false };
|
||||||
|
} catch (e) {
|
||||||
|
if (!hasMath(doc)) throw e;
|
||||||
|
const literal = documentToTypst(doc, path, { diagrams, math: "literal" });
|
||||||
|
const attempt = await withWarnings(() => pdfCompile(literal.source, literal.images));
|
||||||
|
return { bytes: attempt.result, warnings: attempt.warnings, retried: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole export, from the tree in the editor to a file on disk.
|
||||||
|
*
|
||||||
|
* Bound to the `export-pdf` command in src/keys/commands.ts, which is the only caller.
|
||||||
|
*/
|
||||||
|
export async function exportPdf(): Promise<void> {
|
||||||
|
if (phase !== "idle") {
|
||||||
|
notify("An export is already running.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path, content } = useDocument.getState();
|
||||||
|
if (path === null || content === null) {
|
||||||
|
notify("Open a document to export it.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The save panel is a window API, so it is not merely absent in a browser, it is refused. There
|
||||||
|
// is no point compiling a PDF this build has nowhere to put.
|
||||||
|
if (!isDesktop) {
|
||||||
|
notify("PDF export needs the desktop app.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
phase = "converting";
|
||||||
|
try {
|
||||||
|
// Drawn before the conversion rather than during it, because mermaid is asynchronous and wants
|
||||||
|
// a DOM, and the converter is neither. A diagram that will not draw is simply missing from the
|
||||||
|
// map and comes out of the converter as the fence it always was.
|
||||||
|
const drawings = await renderDiagrams(content);
|
||||||
|
const undrawn = diagramSources(content).length - drawings.size;
|
||||||
|
|
||||||
|
const converted = documentToTypst(content, path, { diagrams: drawings });
|
||||||
|
|
||||||
|
phase = "compiling";
|
||||||
|
const { bytes, warnings, retried } = await compileWithFallback(content, path, drawings, converted);
|
||||||
|
|
||||||
|
phase = "writing";
|
||||||
|
const target = await savePanel({
|
||||||
|
title: "Export as PDF",
|
||||||
|
defaultPath: suggestedName(path),
|
||||||
|
filters: [{ name: "PDF", extensions: ["pdf"] }],
|
||||||
|
});
|
||||||
|
if (target === null) return;
|
||||||
|
|
||||||
|
await pdfWrite(target, new Uint8Array(bytes));
|
||||||
|
|
||||||
|
const trouble = [
|
||||||
|
...converted.warnings,
|
||||||
|
...(undrawn > 0 ? [`${undrawn} diagram${undrawn === 1 ? "" : "s"} could not be drawn`] : []),
|
||||||
|
...(retried ? ["every formula shown as the source it was written in"] : []),
|
||||||
|
...(warnings.length > 0 ? [describe(warnings)] : []),
|
||||||
|
];
|
||||||
|
notify(
|
||||||
|
trouble.length === 0
|
||||||
|
? `Exported ${baseName(target)}`
|
||||||
|
: `Exported ${baseName(target)}, with ${trouble.join("; ")}`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
notify(`Could not export: ${String(e)}`);
|
||||||
|
} finally {
|
||||||
|
phase = "idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,773 @@
|
|||||||
|
// The ProseMirror document to Typst converter.
|
||||||
|
//
|
||||||
|
// Every node in src/model/schema.ts needs an answer here, and escaping is a security boundary
|
||||||
|
// rather than a formatting detail: a document containing `#let`, `$` or `@` must reach the
|
||||||
|
// compiler as text and never as Typst.
|
||||||
|
//
|
||||||
|
// The whole defence is one rule, and it is the reason this file has no `esc` that sprinkles
|
||||||
|
// backslashes through markup the way the sibling book exporter does. Nothing the document holds is
|
||||||
|
// ever written into markup. Every character that came out of a user's file leaves this module
|
||||||
|
// through `str`, which produces a Typst *string literal*, and a string literal has exactly five
|
||||||
|
// escapes and no syntax inside it: `#"= #let x $y$ @z"` is eight words on a page and cannot be
|
||||||
|
// anything else. So the grep that proves the boundary is short. If a template literal below
|
||||||
|
// interpolates a value that did not come from `str`, from `hex` or from a constant declared in this
|
||||||
|
// file, that is the bug.
|
||||||
|
//
|
||||||
|
// The corollary is the shape of the output. Inline content is a run of `#`-prefixed expressions
|
||||||
|
// with nothing between them, because a string literal followed by a bare `[` or `(` would be read
|
||||||
|
// as a call and a string literal followed by a space would put a space on the page. Blocks are
|
||||||
|
// single expressions joined by blank lines. Anything that needs real layout is a `#let` in the
|
||||||
|
// preamble taking its text as a parameter, so the preamble is the only markup in the file and it
|
||||||
|
// is written here rather than derived from anything.
|
||||||
|
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import type { ImageInput } from "../ipc";
|
||||||
|
import { resolveRelative } from "../links";
|
||||||
|
import { CALLOUT_LABELS, calloutKindFromLabel, type CalloutKind } from "../model/doc";
|
||||||
|
|
||||||
|
export interface TypstDocument {
|
||||||
|
source: string;
|
||||||
|
images: ImageInput[];
|
||||||
|
/** What the converter worked around: an image with nowhere to read it from, a src it cannot use. */
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A mermaid fence's text, mapped to the SVG it drew. Missing means the fence stays code. */
|
||||||
|
export type Diagrams = ReadonlyMap<string, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a formula is written out.
|
||||||
|
*
|
||||||
|
* `typeset` hands the LaTeX to mitex, which is the point of having maths at all. `literal` writes
|
||||||
|
* it as its own source in monospace, which is what the editor shows on screen and what an export
|
||||||
|
* falls back to when mitex will not take the document's LaTeX: mitex fails a compile rather than
|
||||||
|
* a formula, so one expression it cannot parse would otherwise cost the whole PDF.
|
||||||
|
*/
|
||||||
|
export type MathMode = "typeset" | "literal";
|
||||||
|
|
||||||
|
export interface TypstOptions {
|
||||||
|
diagrams?: Diagrams;
|
||||||
|
math?: MathMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MERMAID = "mermaid";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where an image with no file behind it is put.
|
||||||
|
*
|
||||||
|
* The leading slash is load bearing. Typst resolves `image("x")` against the file that wrote it and
|
||||||
|
* the backend's resolver answers on the resolved path, so a relative name in a document compiled at
|
||||||
|
* the root would be looked up as `/x` and not found. Everything this module names is rooted, which
|
||||||
|
* is also true of the file-backed images: those carry the absolute path the backend reads them from.
|
||||||
|
*/
|
||||||
|
const INLINE = "/inline/";
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
// The boundary
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lone surrogate is half of a character, and it survives neither the JSON of the IPC boundary nor
|
||||||
|
* UTF-8 on the other side of it. Nothing in a real document has one; a paste out of a broken tool
|
||||||
|
* does, and it must not be the thing that decides whether an export happens.
|
||||||
|
*/
|
||||||
|
function sanitize(value: string): string {
|
||||||
|
let out = "";
|
||||||
|
for (const ch of value) {
|
||||||
|
const code = ch.codePointAt(0) ?? 0;
|
||||||
|
out += code >= 0xd800 && code <= 0xdfff ? "�" : ch;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place a string from the document becomes Typst source.
|
||||||
|
*
|
||||||
|
* A Typst string literal knows `\\`, `\"`, `\n`, `\r`, `\t` and `\u{...}` and nothing else, so a
|
||||||
|
* control character is written as its escape rather than passed through, and every other character
|
||||||
|
* stands for itself. There is no construct inside a string literal for a `#`, a `$` or an `@` to
|
||||||
|
* open, which is the whole point: the escaping here has one rule instead of a list of them, and a
|
||||||
|
* list is what goes out of date when Typst gains syntax.
|
||||||
|
*/
|
||||||
|
export function str(value: string): string {
|
||||||
|
let out = '"';
|
||||||
|
for (const ch of sanitize(value)) {
|
||||||
|
const code = ch.codePointAt(0) ?? 0;
|
||||||
|
if (ch === "\\") out += "\\\\";
|
||||||
|
else if (ch === '"') out += '\\"';
|
||||||
|
else if (ch === "\n") out += "\\n";
|
||||||
|
else if (ch === "\r") out += "\\r";
|
||||||
|
else if (ch === "\t") out += "\\t";
|
||||||
|
else if (code < 0x20 || code === 0x7f) out += `\\u{${code.toString(16)}}`;
|
||||||
|
else out += ch;
|
||||||
|
}
|
||||||
|
return `${out}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A colour from the table below, never from the document. */
|
||||||
|
function hex(value: string): string {
|
||||||
|
return `rgb(${str(value)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline text as a page reads it rather than as the file stores it.
|
||||||
|
*
|
||||||
|
* A soft wrap inside a paragraph is a decision the author made about the file, not about the page,
|
||||||
|
* and a PDF has its own measure, so it becomes a space. Runs of whitespace collapse for the same
|
||||||
|
* reason every markdown renderer collapses them. A hard break is a separate node and keeps its
|
||||||
|
* break. The non-breaking space is deliberately not in the class: it is a character somebody typed.
|
||||||
|
*/
|
||||||
|
function flow(value: string): string {
|
||||||
|
return value.replace(/[ \t\n\r\f\v\u2028\u2029]+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
// The palette and the faces
|
||||||
|
//
|
||||||
|
// Both are the app's own, read off src/styles/tokens.css and src/styles/prose.css rather than
|
||||||
|
// chosen here, because an export is the document on screen and not a second design. The light
|
||||||
|
// palette specifically: a PDF is a white page whatever `data-theme` says.
|
||||||
|
//
|
||||||
|
// Literata and Hanken Grotesk are the two families src-tauri/src/pdf.rs compiles into the binary,
|
||||||
|
// so they are always there and can be named here. Monospace and the maths face are not named here
|
||||||
|
// at all: neither is bundled, both come off the machine, and Typst warns once for every family it
|
||||||
|
// was asked for and could not find, so a hopeful list written on this side would end every export
|
||||||
|
// with a toast about fonts nobody chose. src-tauri/src/fonts.rs asks the system what it actually
|
||||||
|
// has, and pdf.rs puts the answer in front of this preamble.
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const BODY_FONT = "Literata";
|
||||||
|
const UI_FONT = "Hanken Grotesk";
|
||||||
|
|
||||||
|
// The light palette of src/styles/tokens.css, written out because Typst source cannot read a CSS
|
||||||
|
// custom property. Each one names the token it is a copy of, so a token that moves can be found
|
||||||
|
// again from here; nothing checks that they still agree, and a fork between the two is a PDF that
|
||||||
|
// stops looking like the screen it was exported from.
|
||||||
|
const INK = "#23201b"; // --ink
|
||||||
|
const INK_SOFT = "#6b6458"; // --ink-soft
|
||||||
|
const INK_FAINT = "#9b9484"; // --ink-faint
|
||||||
|
const DOC_RULE = "#e3ddce"; // --doc-rule
|
||||||
|
const DOC_RULE_STRONG = "#cdc4ae"; // --doc-rule-strong
|
||||||
|
const DOC_WASH = "#23201b0d"; // --ink at 5 percent
|
||||||
|
const CODE_SURFACE = "#f2ece0"; // --code-surface
|
||||||
|
const CODE_LINE = "#e6dfce"; // --code-line
|
||||||
|
const CODE_WASH = "#23201b14"; // --ink at 8 percent
|
||||||
|
const DANGER = "#b4453a"; // --danger
|
||||||
|
const DANGER_INK = "#963327"; // --danger-ink
|
||||||
|
const DANGER_WASH = "#b4453a1a"; // --danger at 10 percent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The five callouts, in ink tones and the one warning colour rather than in five new hues.
|
||||||
|
*
|
||||||
|
* This is prose.css's decision, not a fresh one: the palette has a single danger colour and
|
||||||
|
* inventing four more here would fork the visual language between the screen and the page.
|
||||||
|
*/
|
||||||
|
const CALLOUT_COLOURS: Record<CalloutKind, { edge: string; fill: string; label: string }> = {
|
||||||
|
note: { edge: INK_FAINT, fill: DOC_WASH, label: INK_SOFT },
|
||||||
|
tip: { edge: INK_FAINT, fill: DOC_WASH, label: INK_SOFT },
|
||||||
|
important: { edge: INK_FAINT, fill: DOC_WASH, label: INK_SOFT },
|
||||||
|
warning: { edge: DANGER, fill: DANGER_WASH, label: DANGER_INK },
|
||||||
|
caution: { edge: DANGER, fill: DANGER_WASH, label: DANGER_INK },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
// The preamble
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The document's furniture, and the only markup in this file.
|
||||||
|
*
|
||||||
|
* A document editor, not a book: one page size, no chapter openers, no trim.
|
||||||
|
*
|
||||||
|
* `mitex` is imported only when the document has a formula in it. The import path is the contract
|
||||||
|
* with the backend, and a missing package is a hard compile error rather than a warning, so a
|
||||||
|
* document with no maths in it is not made to depend on it.
|
||||||
|
*/
|
||||||
|
function preamble(title: string, math: boolean): string {
|
||||||
|
const mitex = math ? `#import "/mitex/lib.typ": mitex, mi\n\n` : "";
|
||||||
|
return `${mitex}#set document(title: ${str(title)})
|
||||||
|
#set page(paper: "a4", margin: (x: 2.4cm, y: 2.6cm), numbering: "1", number-align: center)
|
||||||
|
#set text(font: ${str(BODY_FONT)}, size: 11pt, fill: ${hex(INK)}, lang: "en")
|
||||||
|
#set par(leading: 0.7em, spacing: 1.05em, justify: false)
|
||||||
|
#set heading(numbering: none)
|
||||||
|
|
||||||
|
// Six levels do not come from six sizes: by the fourth step the difference is under a point and
|
||||||
|
// the reader stops seeing a hierarchy. Size for the first three, a weight step for the fourth, the
|
||||||
|
// UI face for the last two, case for the sixth. prose.css's scale, in print units.
|
||||||
|
#show heading: set block(above: 1.9em, below: 0.55em)
|
||||||
|
#show heading.where(level: 1): set text(size: 1.77em, weight: 600, tracking: -0.016em)
|
||||||
|
#show heading.where(level: 2): set text(size: 1.35em, weight: 600, tracking: -0.008em)
|
||||||
|
#show heading.where(level: 3): set text(size: 1.12em, weight: 600)
|
||||||
|
#show heading.where(level: 4): set text(size: 1em, weight: 700)
|
||||||
|
#show heading.where(level: 5): set text(font: ${str(UI_FONT)}, size: 0.88em, weight: 700)
|
||||||
|
#show heading.where(level: 6): set text(
|
||||||
|
font: ${str(UI_FONT)},
|
||||||
|
size: 0.77em,
|
||||||
|
weight: 700,
|
||||||
|
tracking: 0.1em,
|
||||||
|
fill: ${hex(INK_SOFT)},
|
||||||
|
)
|
||||||
|
#show heading.where(level: 6): upper
|
||||||
|
|
||||||
|
// This palette has no link colour, because the book it came from had no links. A link is body text
|
||||||
|
// with a permanent underline instead: unmistakable in a scan, and it keeps the colour of whatever
|
||||||
|
// block it sits in.
|
||||||
|
#show link: it => underline(stroke: 0.6pt + ${hex(INK_FAINT)}, offset: 0.16em, it)
|
||||||
|
#show strike: set text(fill: ${hex(INK_SOFT)})
|
||||||
|
|
||||||
|
#show table: set text(size: 0.88em)
|
||||||
|
#show raw.where(block: true): it => block(
|
||||||
|
width: 100%,
|
||||||
|
fill: ${hex(CODE_SURFACE)},
|
||||||
|
stroke: 0.5pt + ${hex(CODE_LINE)},
|
||||||
|
radius: 5pt,
|
||||||
|
inset: (x: 0.9em, y: 0.75em),
|
||||||
|
above: 1.5em,
|
||||||
|
below: 1.5em,
|
||||||
|
breakable: true,
|
||||||
|
text(size: 0.82em, it),
|
||||||
|
)
|
||||||
|
#show raw.where(block: false): it => box(
|
||||||
|
fill: ${hex(CODE_WASH)},
|
||||||
|
radius: 2.5pt,
|
||||||
|
inset: (x: 0.3em),
|
||||||
|
outset: (y: 0.2em),
|
||||||
|
text(size: 0.88em, it),
|
||||||
|
)
|
||||||
|
|
||||||
|
#let doc-rule = block(
|
||||||
|
width: 100%,
|
||||||
|
above: 2.6em,
|
||||||
|
below: 2.6em,
|
||||||
|
line(length: 100%, stroke: 0.7pt + ${hex(DOC_RULE_STRONG)}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Not italic, unlike a book's. A quote in a technical document is as likely to be a paragraph of a
|
||||||
|
// spec or somebody's bug report as an epigraph, and the bar and the ink carry it instead.
|
||||||
|
#let doc-quote(body) = block(
|
||||||
|
width: 100%,
|
||||||
|
inset: (left: 1.55em),
|
||||||
|
stroke: (left: 2pt + ${hex(DOC_RULE_STRONG)}),
|
||||||
|
above: 1.5em,
|
||||||
|
below: 1.5em,
|
||||||
|
text(fill: ${hex(INK_SOFT)}, body),
|
||||||
|
)
|
||||||
|
|
||||||
|
#let doc-callout(edge, fill, label-ink, label, body) = block(
|
||||||
|
width: 100%,
|
||||||
|
fill: fill,
|
||||||
|
radius: 8pt,
|
||||||
|
stroke: (rest: 0.5pt + ${hex(DOC_RULE)}, left: 3pt + edge),
|
||||||
|
inset: (x: 1.15em, y: 0.95em),
|
||||||
|
above: 1.5em,
|
||||||
|
below: 1.5em,
|
||||||
|
{
|
||||||
|
text(font: ${str(UI_FONT)}, size: 0.68em, weight: 700, tracking: 0.12em, fill: label-ink, upper(label))
|
||||||
|
v(0.5em, weak: true)
|
||||||
|
body
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Always open. A PDF has no disclosure to click, so a closed toggle would be a paragraph the export
|
||||||
|
// lost, and the summary is a line of its own above the body exactly as the <summary> is on screen.
|
||||||
|
#let doc-toggle(summary, body) = block(
|
||||||
|
width: 100%,
|
||||||
|
stroke: 0.5pt + ${hex(DOC_RULE)},
|
||||||
|
radius: 8pt,
|
||||||
|
inset: (x: 1.1em, y: 0.85em),
|
||||||
|
above: 1.5em,
|
||||||
|
below: 1.5em,
|
||||||
|
{
|
||||||
|
text(weight: 600, summary)
|
||||||
|
v(0.5em, weak: true)
|
||||||
|
body
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Drawn rather than written, because a checkbox glyph is only a checkbox in a font that has one,
|
||||||
|
// and the faces behind this compile are not this module's to choose.
|
||||||
|
#let doc-check(done) = box(
|
||||||
|
width: 0.8em,
|
||||||
|
height: 0.8em,
|
||||||
|
radius: 2.5pt,
|
||||||
|
baseline: 0.08em,
|
||||||
|
stroke: 0.8pt + ${hex(INK_FAINT)},
|
||||||
|
fill: if done { ${hex(INK_SOFT)} } else { none },
|
||||||
|
)
|
||||||
|
|
||||||
|
#let doc-figure(body) = block(width: 100%, above: 1.8em, below: 1.8em, align(center, body))
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
// Images
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Context {
|
||||||
|
/** The document's own path, which every relative link and image src is resolved against. */
|
||||||
|
readonly file: string;
|
||||||
|
readonly diagrams: Diagrams;
|
||||||
|
readonly mathMode: MathMode;
|
||||||
|
readonly images: ImageInput[];
|
||||||
|
/** A src as the document wrote it, mapped to how the source refers to it, or null for a refusal. */
|
||||||
|
readonly seen: Map<string, string | null>;
|
||||||
|
readonly warnings: string[];
|
||||||
|
math: boolean;
|
||||||
|
drawings: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionOf(dataUrl: string): string {
|
||||||
|
const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl);
|
||||||
|
const kind = (match?.[1] ?? "png").toLowerCase().split("+")[0];
|
||||||
|
return kind === "jpeg" ? "jpg" : kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the source should refer to this image, and the entry the backend needs to find it. Null when
|
||||||
|
* there is nothing to point at: a picture on the web, which this app does not fetch, or an src that
|
||||||
|
* is not a path at all.
|
||||||
|
*
|
||||||
|
* A file-backed image travels as a path and the backend opens it, which is also where it is checked
|
||||||
|
* against the open roots, because an src in a document is untrusted input. Bytes only travel inline
|
||||||
|
* when there is no file behind them.
|
||||||
|
*/
|
||||||
|
function imagePath(ctx: Context, src: string): string | null {
|
||||||
|
const known = ctx.seen.get(src);
|
||||||
|
if (known !== undefined) return known;
|
||||||
|
|
||||||
|
let path: string | null = null;
|
||||||
|
if (src.startsWith("data:image/")) {
|
||||||
|
const comma = src.indexOf(",");
|
||||||
|
const data = comma === -1 ? "" : src.slice(comma + 1);
|
||||||
|
if (data) {
|
||||||
|
path = `${INLINE}image-${ctx.images.length + 1}.${extensionOf(src)}`;
|
||||||
|
ctx.images.push({ path, data });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Sanitised once, here, so the path the source names and the path the backend is handed are the
|
||||||
|
// same string. They are compared for equality on the other side, and a path that went through
|
||||||
|
// this function twice has to come back the same both times.
|
||||||
|
const resolved = resolveRelative(ctx.file, src);
|
||||||
|
if (resolved !== null) {
|
||||||
|
path = sanitize(resolved);
|
||||||
|
ctx.images.push({ path, data: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === null) ctx.warnings.push(`${src || "an image with no source"} could not be included`);
|
||||||
|
ctx.seen.set(src, path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64(value: string): string {
|
||||||
|
const bytes = new TextEncoder().encode(value);
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A drawn diagram, which has no file behind it and so travels as bytes. */
|
||||||
|
function diagramPath(ctx: Context, svg: string): string {
|
||||||
|
ctx.drawings += 1;
|
||||||
|
const path = `${INLINE}diagram-${ctx.drawings}.svg`;
|
||||||
|
ctx.images.push({ path, data: base64(svg) });
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
// Inline content
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function marked(node: ProseMirrorNode, body: string): string {
|
||||||
|
let out = body;
|
||||||
|
// Code is innermost and the emphasis goes around it, which is the direction src/markdown's
|
||||||
|
// serializer already picked for the same flat mark set.
|
||||||
|
if (node.marks.some((m) => m.type.name === "em")) out = `#emph[${out}]`;
|
||||||
|
if (node.marks.some((m) => m.type.name === "strong")) out = `#strong[${out}]`;
|
||||||
|
if (node.marks.some((m) => m.type.name === "strikethrough")) out = `#strike[${out}]`;
|
||||||
|
const href = node.marks.find((m) => m.type.name === "link")?.attrs.href;
|
||||||
|
if (typeof href === "string" && href) out = `#link(${str(href)})[${out}]`;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inline(ctx: Context, node: ProseMirrorNode): string {
|
||||||
|
switch (node.type.name) {
|
||||||
|
case "text": {
|
||||||
|
const text = node.text ?? "";
|
||||||
|
if (!text) return "";
|
||||||
|
const code = node.marks.some((m) => m.type.name === "code");
|
||||||
|
// A code span keeps its whitespace: it is the one inline place where a run of spaces is
|
||||||
|
// content. Everything else flows.
|
||||||
|
return marked(node, code ? `#raw(${str(sanitize(text))})` : `#${str(flow(text))}`);
|
||||||
|
}
|
||||||
|
case "hardBreak":
|
||||||
|
return "#linebreak()";
|
||||||
|
case "image": {
|
||||||
|
const path = imagePath(ctx, String(node.attrs.src ?? ""));
|
||||||
|
if (path === null) return altText(node);
|
||||||
|
// Sized to the line rather than left at its natural size, because an image sitting inside a
|
||||||
|
// sentence is a badge or an icon and a photograph does not go there.
|
||||||
|
return `#box(image(${str(path)}, height: 1em), baseline: 0.15em)`;
|
||||||
|
}
|
||||||
|
case "mathInline": {
|
||||||
|
const latex = String(node.attrs.latex ?? "");
|
||||||
|
if (ctx.mathMode === "literal") return `#raw(${str(sanitize(latex))})`;
|
||||||
|
ctx.math = true;
|
||||||
|
return `#mi(${str(latex)})`;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Nothing else is inline in this schema. An unknown node still gets its text on the page,
|
||||||
|
// because a construct nobody thought of is worth less than a construct silently dropped.
|
||||||
|
return node.textContent ? `#${str(flow(node.textContent))}` : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What is left of an image that has no file: the words the author wrote in its place. */
|
||||||
|
function altText(node: ProseMirrorNode): string {
|
||||||
|
const alt = String(node.attrs.alt ?? "").trim();
|
||||||
|
return alt ? `#emph[#${str(flow(alt))}]` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function inlines(ctx: Context, node: ProseMirrorNode): string {
|
||||||
|
let out = "";
|
||||||
|
node.forEach((child) => {
|
||||||
|
out += inline(ctx, child);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inline content as a Typst content block, which is what every layout helper takes. */
|
||||||
|
function content(ctx: Context, node: ProseMirrorNode): string {
|
||||||
|
return `[${inlines(ctx, node)}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
// Blocks
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The children of a container, as one content block. */
|
||||||
|
function body(ctx: Context, node: ProseMirrorNode): string {
|
||||||
|
return `[${blocks(ctx, node)}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blocks(ctx: Context, node: ProseMirrorNode): string {
|
||||||
|
const out: string[] = [];
|
||||||
|
node.forEach((child) => {
|
||||||
|
const rendered = block(ctx, child);
|
||||||
|
if (rendered) out.push(rendered);
|
||||||
|
});
|
||||||
|
return out.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageOnly(node: ProseMirrorNode): boolean {
|
||||||
|
let images = 0;
|
||||||
|
let others = 0;
|
||||||
|
node.forEach((child) => {
|
||||||
|
if (child.type.name === "image") images += 1;
|
||||||
|
else if (child.type.name !== "text" || (child.text ?? "").trim()) others += 1;
|
||||||
|
});
|
||||||
|
return images === 1 && others === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraph(ctx: Context, node: ProseMirrorNode): string {
|
||||||
|
// Markdown has no block image: a picture on its own line is a paragraph holding nothing else, and
|
||||||
|
// that is the one this exporter gives a figure to rather than a line's worth of height.
|
||||||
|
if (isImageOnly(node)) {
|
||||||
|
let picture = "";
|
||||||
|
node.forEach((child) => {
|
||||||
|
if (child.type.name === "image") picture = blockImage(ctx, child);
|
||||||
|
});
|
||||||
|
if (picture) return picture;
|
||||||
|
}
|
||||||
|
const rendered = inlines(ctx, node);
|
||||||
|
// An empty paragraph is a blank line in a plain text file, and a blank line is content there.
|
||||||
|
return rendered || "#v(0.9em, weak: true)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockImage(ctx: Context, node: ProseMirrorNode): string {
|
||||||
|
const path = imagePath(ctx, String(node.attrs.src ?? ""));
|
||||||
|
if (path === null) {
|
||||||
|
const alt = altText(node);
|
||||||
|
return alt || "#v(0.9em, weak: true)";
|
||||||
|
}
|
||||||
|
const title = String(node.attrs.title ?? "").trim();
|
||||||
|
const picture = `image(${str(path)})`;
|
||||||
|
if (!title) return `#doc-figure(${picture})`;
|
||||||
|
return `#doc-figure(figure(${picture}, caption: [#${str(flow(title))}]))`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listItems(ctx: Context, node: ProseMirrorNode): string[] {
|
||||||
|
const items: string[] = [];
|
||||||
|
node.forEach((child) => {
|
||||||
|
// A GFM list may mix "- [ ] a" with ordinary items, so a checkbox can turn up inside a plain
|
||||||
|
// list. It rides at the head of the item rather than being dropped.
|
||||||
|
const box = child.type.name === "taskItem" ? `#doc-check(${child.attrs.checked ? "true" : "false"})#h(0.45em)` : "";
|
||||||
|
items.push(`[${box}${blocks(ctx, child)}]`);
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskList(ctx: Context, node: ProseMirrorNode): string {
|
||||||
|
const cells: string[] = [];
|
||||||
|
node.forEach((child) => {
|
||||||
|
// No leading `#`: a grid's arguments are already code, and the marker there is a call rather
|
||||||
|
// than an escape back into markup.
|
||||||
|
cells.push(`doc-check(${child.attrs.checked ? "true" : "false"})`);
|
||||||
|
cells.push(body(ctx, child));
|
||||||
|
});
|
||||||
|
if (cells.length === 0) return "";
|
||||||
|
const gutter = node.attrs.tight ? "0.4em" : "0.85em";
|
||||||
|
return `#grid(columns: (1.35em, 1fr), row-gutter: ${gutter}, align: (left + top, left + top), ${cells.join(", ")})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALIGNMENTS: Record<string, string> = { left: "left", center: "center", right: "right" };
|
||||||
|
|
||||||
|
function table(ctx: Context, node: ProseMirrorNode): string {
|
||||||
|
const rows: ProseMirrorNode[] = [];
|
||||||
|
node.forEach((row) => rows.push(row));
|
||||||
|
if (rows.length === 0) return "";
|
||||||
|
|
||||||
|
const widthOf = (row: ProseMirrorNode): number => {
|
||||||
|
let width = 0;
|
||||||
|
row.forEach((cell) => {
|
||||||
|
width += Math.max(1, Number(cell.attrs.colspan) || 1);
|
||||||
|
});
|
||||||
|
return width;
|
||||||
|
};
|
||||||
|
const columns = Math.max(1, ...rows.map(widthOf));
|
||||||
|
|
||||||
|
// GFM carries one alignment per column on the delimiter row and the bridge copies it on to every
|
||||||
|
// cell in that column, so the first row is as good a place to read it as any.
|
||||||
|
const aligns: string[] = [];
|
||||||
|
rows[0].forEach((cell) => {
|
||||||
|
const align = ALIGNMENTS[String(cell.attrs.align ?? "")] ?? "left";
|
||||||
|
for (let i = 0; i < Math.max(1, Number(cell.attrs.colspan) || 1); i += 1) aligns.push(align);
|
||||||
|
});
|
||||||
|
while (aligns.length < columns) aligns.push("left");
|
||||||
|
aligns.length = columns;
|
||||||
|
|
||||||
|
const cellsOf = (row: ProseMirrorNode): string[] => {
|
||||||
|
const out: string[] = [];
|
||||||
|
row.forEach((cell) => {
|
||||||
|
const inner = cell.type.name === "tableHeader" ? `[#strong[${inlines(ctx, cell)}]]` : content(ctx, cell);
|
||||||
|
const colspan = Math.max(1, Number(cell.attrs.colspan) || 1);
|
||||||
|
const rowspan = Math.max(1, Number(cell.attrs.rowspan) || 1);
|
||||||
|
out.push(
|
||||||
|
colspan === 1 && rowspan === 1
|
||||||
|
? inner
|
||||||
|
: `table.cell(colspan: ${colspan}, rowspan: ${rowspan})${inner}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
let heads = 0;
|
||||||
|
rows[0].forEach((cell) => {
|
||||||
|
if (cell.type.name === "tableHeader") heads += 1;
|
||||||
|
});
|
||||||
|
const headed = heads > 0 && heads === rows[0].childCount;
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
const cells = cellsOf(row);
|
||||||
|
if (cells.length === 0) return;
|
||||||
|
if (index === 0 && headed) parts.push(`table.header(${cells.join(", ")})`);
|
||||||
|
else parts.push(cells.join(", "));
|
||||||
|
});
|
||||||
|
if (parts.length === 0) return "";
|
||||||
|
|
||||||
|
// Horizontal rules and a washed header row: prose.css's table, which has no vertical rules at all
|
||||||
|
// because a document table is not a spreadsheet.
|
||||||
|
const fill = headed ? `\n fill: (_, y) => if y == 0 { ${hex(DOC_WASH)} },` : "";
|
||||||
|
return `#table(
|
||||||
|
columns: ${columns},
|
||||||
|
align: (${aligns.join(", ")}),
|
||||||
|
stroke: (x: none, y: 0.5pt + ${hex(DOC_RULE)}),
|
||||||
|
inset: (x: 0.8em, y: 0.5em),${fill}
|
||||||
|
${parts.join(",\n ")},
|
||||||
|
)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function callout(ctx: Context, node: ProseMirrorNode): string {
|
||||||
|
const declared = String(node.attrs.kind ?? "note");
|
||||||
|
const kind = calloutKindFromLabel(declared);
|
||||||
|
const colours = CALLOUT_COLOURS[kind ?? "note"];
|
||||||
|
// A kind nobody has heard of keeps its own word as the label. It is text off disk either way, so
|
||||||
|
// it goes through `str` either way; only the colours are chosen here, and never by the document.
|
||||||
|
const label = kind === null ? declared.trim() || "note" : CALLOUT_LABELS[kind];
|
||||||
|
return `#doc-callout(${hex(colours.edge)}, ${hex(colours.fill)}, ${hex(colours.label)}, ${str(flow(label))}, ${body(ctx, node)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeBlock(ctx: Context, node: ProseMirrorNode): string {
|
||||||
|
const text = sanitize(node.textContent);
|
||||||
|
const language = typeof node.attrs.language === "string" ? node.attrs.language.trim() : "";
|
||||||
|
|
||||||
|
if (language === MERMAID) {
|
||||||
|
const svg = ctx.diagrams.get(node.textContent);
|
||||||
|
// A diagram that would not draw stays the fence the user wrote, which is the same answer the
|
||||||
|
// editor gives on screen: a broken diagram is a line to go and fix, not a block to hide.
|
||||||
|
if (svg) return `#doc-figure(image(${str(diagramPath(ctx, svg))}))`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lang = language ? `, lang: ${str(language)}` : "";
|
||||||
|
return `#raw(${str(text)}${lang}, block: true)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function block(ctx: Context, node: ProseMirrorNode): string {
|
||||||
|
switch (node.type.name) {
|
||||||
|
case "paragraph":
|
||||||
|
return paragraph(ctx, node);
|
||||||
|
case "heading": {
|
||||||
|
const level = Math.min(6, Math.max(1, Number(node.attrs.level) || 1));
|
||||||
|
return `#heading(level: ${level})${content(ctx, node)}`;
|
||||||
|
}
|
||||||
|
case "blockquote":
|
||||||
|
return `#doc-quote(${body(ctx, node)})`;
|
||||||
|
case "bulletList": {
|
||||||
|
const items = listItems(ctx, node);
|
||||||
|
return items.length ? `#list(tight: ${node.attrs.tight ? "true" : "false"}, ${items.join(", ")})` : "";
|
||||||
|
}
|
||||||
|
case "orderedList": {
|
||||||
|
const items = listItems(ctx, node);
|
||||||
|
const start = Number(node.attrs.start);
|
||||||
|
const from = Number.isFinite(start) ? Math.max(0, Math.trunc(start)) : 1;
|
||||||
|
return items.length
|
||||||
|
? `#enum(start: ${from}, tight: ${node.attrs.tight ? "true" : "false"}, ${items.join(", ")})`
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
case "taskList":
|
||||||
|
return taskList(ctx, node);
|
||||||
|
case "codeBlock":
|
||||||
|
return codeBlock(ctx, node);
|
||||||
|
case "horizontalRule":
|
||||||
|
return "#doc-rule";
|
||||||
|
case "table":
|
||||||
|
return table(ctx, node);
|
||||||
|
case "callout":
|
||||||
|
return callout(ctx, node);
|
||||||
|
case "toggle":
|
||||||
|
// Open, always. A PDF has no disclosure to click, so a closed toggle would be a paragraph the
|
||||||
|
// export lost.
|
||||||
|
return `#doc-toggle(${str(flow(String(node.attrs.summary ?? "")))}, ${body(ctx, node)})`;
|
||||||
|
case "mathBlock": {
|
||||||
|
// Bare, with none of the surface the editor draws around it. On screen a formula is a box of
|
||||||
|
// monospace source because that is what is being edited; on the page it is typeset, and
|
||||||
|
// typeset mathematics does not want a code block's border around it.
|
||||||
|
const latex = String(node.attrs.latex ?? "");
|
||||||
|
if (ctx.mathMode === "literal") return `#raw(${str(sanitize(latex))}, block: true)`;
|
||||||
|
ctx.math = true;
|
||||||
|
return `#mitex(${str(latex)})`;
|
||||||
|
}
|
||||||
|
case "raw":
|
||||||
|
// The bytes the bridge could not model, shown as what they are. No language: this is source
|
||||||
|
// in no particular language, and guessing one would colour it wrong.
|
||||||
|
return `#raw(${str(sanitize(node.textContent))}, block: true)`;
|
||||||
|
default: {
|
||||||
|
// A node this file has never heard of still reaches the page. Its own children are walked
|
||||||
|
// where it has any, so a future container does not take its contents down with it.
|
||||||
|
if (node.isTextblock) return inlines(ctx, node) || "";
|
||||||
|
const inner = blocks(ctx, node);
|
||||||
|
return inner || (node.textContent ? `#${str(flow(node.textContent))}` : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
// Mermaid
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Every mermaid fence in the document, in order, deduplicated. Pure, so a test can call it. */
|
||||||
|
export function diagramSources(doc: ProseMirrorNode): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
doc.descendants((node) => {
|
||||||
|
if (node.type.name !== "codeBlock") return true;
|
||||||
|
if (node.attrs.language === MERMAID && node.textContent.trim() && !out.includes(node.textContent)) {
|
||||||
|
out.push(node.textContent);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let configured = false;
|
||||||
|
let drawn = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mermaid is several megabytes, so this import is dynamic exactly as the editor's own is: a user who
|
||||||
|
* never exports a document with a diagram in it never fetches the library.
|
||||||
|
*
|
||||||
|
* The theme is `neutral` rather than the app's own palette, and that is the one place this differs
|
||||||
|
* from the block on screen. A drawn diagram bakes its colours in, the page it is going on to is
|
||||||
|
* white whatever the app's theme is, and mermaid's neutral theme is the one meant for a document
|
||||||
|
* that will be printed. Exporting in dark mode must not produce a black rectangle on white paper.
|
||||||
|
*/
|
||||||
|
async function draw(text: string): Promise<string> {
|
||||||
|
const mermaid = (await import("mermaid")).default;
|
||||||
|
if (!configured) {
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
securityLevel: "strict",
|
||||||
|
suppressErrorRendering: true,
|
||||||
|
theme: "neutral",
|
||||||
|
});
|
||||||
|
configured = true;
|
||||||
|
}
|
||||||
|
await mermaid.parse(text);
|
||||||
|
drawn += 1;
|
||||||
|
const { svg } = await mermaid.render(`typst-diagram-${drawn}`, text);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every diagram in the document, drawn. Needs a DOM, which is why it is not part of the conversion:
|
||||||
|
* a fence whose render failed is simply absent from the map and comes out of the converter as the
|
||||||
|
* code it always was.
|
||||||
|
*/
|
||||||
|
export async function renderDiagrams(doc: ProseMirrorNode): Promise<Map<string, string>> {
|
||||||
|
const svgs = new Map<string, string>();
|
||||||
|
for (const text of diagramSources(doc)) {
|
||||||
|
try {
|
||||||
|
svgs.set(text, await draw(text));
|
||||||
|
} catch {
|
||||||
|
// Deliberately swallowed. The caller counts what is missing; one diagram that will not parse
|
||||||
|
// is not a reason to refuse the other forty pages.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return svgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
// The document
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One document, as Typst source and the images it refers to.
|
||||||
|
*
|
||||||
|
* `path` is the file the document came from. It is what relative image sources resolve against, and
|
||||||
|
* it is the PDF's title: a markdown file's identity is its path and the H1 is content, so a
|
||||||
|
* document whose first heading disagrees with its filename is not renamed on the way out. There is
|
||||||
|
* no frontmatter here to strip, because the bridge never put it in the tree.
|
||||||
|
*
|
||||||
|
* `diagrams` comes from `renderDiagrams`, which is async and wants a DOM. Everything below is
|
||||||
|
* neither, so the whole conversion is a pure function of the document.
|
||||||
|
*/
|
||||||
|
export function documentToTypst(doc: ProseMirrorNode, path: string, options: TypstOptions = {}): TypstDocument {
|
||||||
|
const ctx: Context = {
|
||||||
|
file: path,
|
||||||
|
diagrams: options.diagrams ?? new Map(),
|
||||||
|
mathMode: options.math ?? "typeset",
|
||||||
|
images: [],
|
||||||
|
seen: new Map(),
|
||||||
|
warnings: [],
|
||||||
|
math: false,
|
||||||
|
drawings: 0,
|
||||||
|
};
|
||||||
|
const rendered = blocks(ctx, doc);
|
||||||
|
return {
|
||||||
|
source: `${preamble(baseName(path), ctx.math)}\n${rendered}\n`,
|
||||||
|
images: ctx.images,
|
||||||
|
warnings: ctx.warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
+57
-1
@@ -48,6 +48,9 @@ export const WATCH_EVENT = "watch-event";
|
|||||||
/** The `index-progress` event, whose payload is an `IndexStatus`. */
|
/** The `index-progress` event, whose payload is an `IndexStatus`. */
|
||||||
export const INDEX_PROGRESS_EVENT = "index-progress";
|
export const INDEX_PROGRESS_EVENT = "index-progress";
|
||||||
|
|
||||||
|
/** The `pdf-warnings` event, whose payload is a `PdfWarning[]`. */
|
||||||
|
export const PDF_WARNINGS_EVENT = "pdf-warnings";
|
||||||
|
|
||||||
export type MenuAction =
|
export type MenuAction =
|
||||||
| "open-folder"
|
| "open-folder"
|
||||||
| "new-doc"
|
| "new-doc"
|
||||||
@@ -61,7 +64,10 @@ export type MenuAction =
|
|||||||
| "command-palette"
|
| "command-palette"
|
||||||
| "toggle-sidebar"
|
| "toggle-sidebar"
|
||||||
| "check-updates"
|
| "check-updates"
|
||||||
| "report-issue";
|
| "report-issue"
|
||||||
|
| "export-pdf"
|
||||||
|
| "writing-proofread"
|
||||||
|
| "writing-rewrite";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One open folder. `id` is derived from the path, so it survives a relaunch and a root can be
|
* One open folder. `id` is derived from the path, so it survives a relaunch and a root can be
|
||||||
@@ -220,6 +226,56 @@ export interface SpellIssue {
|
|||||||
suggestions: string[];
|
suggestions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An image the PDF exporter has to put on a page.
|
||||||
|
*
|
||||||
|
* `data` present means the bytes travel with the request, base64 encoded, which is the only way a
|
||||||
|
* mermaid diagram can arrive: it is rendered to SVG here and there is no file behind it. `data`
|
||||||
|
* absent means the backend reads the file at `path` itself, which is what an ordinary
|
||||||
|
* `` is and what most images are, because base64 encoding a folder of photographs
|
||||||
|
* through the IPC boundary costs a third again in bytes for files the backend can already open.
|
||||||
|
*
|
||||||
|
* A path read this way is checked against the open roots on the Rust side, because a link in a
|
||||||
|
* document is untrusted input.
|
||||||
|
*/
|
||||||
|
export interface ImageInput {
|
||||||
|
/** How the Typst source refers to it, which is also the key the file resolver answers on. */
|
||||||
|
path: string;
|
||||||
|
data: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something the exporter worked around rather than something it refused to do. Payload of
|
||||||
|
* `pdf-warnings`, which is how these travel: a compile answers with raw bytes and has nowhere to
|
||||||
|
* put a second value.
|
||||||
|
*
|
||||||
|
* `count` is here because the alternative is forty toasts. A document with forty formulas that
|
||||||
|
* would not typeset has one problem, not forty.
|
||||||
|
*/
|
||||||
|
export interface PdfWarning {
|
||||||
|
kind: "math" | "image" | "typst";
|
||||||
|
message: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One grammar problem in a run of text handed to the checker.
|
||||||
|
*
|
||||||
|
* `start` and `end` are half-open offsets in characters, for the same reason `SpellIssue` gives:
|
||||||
|
* they address a ProseMirror document and ProseMirror counts code points. Harper counts that way
|
||||||
|
* too, so this path has no conversion in it at all.
|
||||||
|
*
|
||||||
|
* `kind` is Harper's own name for the rule that fired, which the popover shows above the message
|
||||||
|
* so a correction can be judged before it is taken. `suggestions` can be empty.
|
||||||
|
*/
|
||||||
|
export interface GrammarIssue {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
kind: string;
|
||||||
|
message: string;
|
||||||
|
suggestions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In Tauri this is `invoke`. Opened in a browser during development it is served from the dev
|
* In Tauri this is `invoke`. Opened in a browser during development it is served from the dev
|
||||||
* fixture instead, so the real UI can be driven and looked at without a build of the Rust side
|
* fixture instead, so the real UI can be driven and looked at without a build of the Rust side
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ export const BINDINGS: readonly Binding[] = [
|
|||||||
{ keys: ["cmd+o"], command: "open-folder", context: "document", group: "File", allowInInput: true },
|
{ keys: ["cmd+o"], command: "open-folder", context: "document", group: "File", allowInInput: true },
|
||||||
{ keys: ["cmd+n"], command: "new-doc", context: "document", group: "File", allowInInput: true },
|
{ keys: ["cmd+n"], command: "new-doc", context: "document", group: "File", allowInInput: true },
|
||||||
{ keys: ["cmd+s"], command: "save", context: "document", group: "File", allowInInput: true },
|
{ keys: ["cmd+s"], command: "save", context: "document", group: "File", allowInInput: true },
|
||||||
|
{
|
||||||
|
keys: ["cmd+E"],
|
||||||
|
command: "export-pdf",
|
||||||
|
context: "document",
|
||||||
|
group: "File",
|
||||||
|
allowInInput: true,
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
keys: ["cmd+p"],
|
keys: ["cmd+p"],
|
||||||
@@ -94,6 +101,29 @@ export const BINDINGS: readonly Binding[] = [
|
|||||||
allowInInput: true,
|
allowInInput: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Writing Tools' two chords. Like every accelerator this app puts on a native menu item, macOS
|
||||||
|
// fires the menu row before the webview sees a keydown, so in practice these are dispatched
|
||||||
|
// through src/keys/menu.ts rather than by the listener in keymap.ts. They are rows here for the
|
||||||
|
// same reason Cmd+O is: the sheet is generated from this table, and a key the app answers to
|
||||||
|
// that is missing from it would be the one thing this table exists to prevent.
|
||||||
|
//
|
||||||
|
// They are on this app's own Edit rows rather than on Apple's, so that they pass the selection
|
||||||
|
// guard in src/editor/writing.ts. src-tauri/src/writingtools.rs says what that is protecting.
|
||||||
|
{
|
||||||
|
keys: ["alt+F"],
|
||||||
|
command: "writing-proofread",
|
||||||
|
context: "document",
|
||||||
|
group: "Editing",
|
||||||
|
allowInInput: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: ["alt+R"],
|
||||||
|
command: "writing-rewrite",
|
||||||
|
context: "document",
|
||||||
|
group: "Editing",
|
||||||
|
allowInInput: true,
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
keys: ["cmd+\\"],
|
keys: ["cmd+\\"],
|
||||||
command: "toggle-sidebar",
|
command: "toggle-sidebar",
|
||||||
|
|||||||
+33
-23
@@ -12,8 +12,9 @@
|
|||||||
// correctly.
|
// correctly.
|
||||||
|
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { relaunch } from "@tauri-apps/plugin-process";
|
import { runWritingTool } from "../editor/writing";
|
||||||
import { check } from "@tauri-apps/plugin-updater";
|
import { exportPdf } from "../export/run";
|
||||||
|
import { checkForUpdates } from "../update";
|
||||||
import { useDocument } from "../store/useDocument";
|
import { useDocument } from "../store/useDocument";
|
||||||
import { useProofing } from "../store/useProofing";
|
import { useProofing } from "../store/useProofing";
|
||||||
import { useTheme } from "../store/useTheme";
|
import { useTheme } from "../store/useTheme";
|
||||||
@@ -28,6 +29,7 @@ export type CommandId =
|
|||||||
| "new-folder"
|
| "new-folder"
|
||||||
| "close-folder"
|
| "close-folder"
|
||||||
| "save"
|
| "save"
|
||||||
|
| "export-pdf"
|
||||||
| "rename-file"
|
| "rename-file"
|
||||||
| "duplicate-file"
|
| "duplicate-file"
|
||||||
| "delete-file"
|
| "delete-file"
|
||||||
@@ -44,7 +46,10 @@ export type CommandId =
|
|||||||
| "editor-width-normal"
|
| "editor-width-normal"
|
||||||
| "editor-width-wide"
|
| "editor-width-wide"
|
||||||
| "toggle-spelling"
|
| "toggle-spelling"
|
||||||
|
| "toggle-grammar"
|
||||||
| "correct-spelling"
|
| "correct-spelling"
|
||||||
|
| "writing-proofread"
|
||||||
|
| "writing-rewrite"
|
||||||
| "shortcuts"
|
| "shortcuts"
|
||||||
| "settings"
|
| "settings"
|
||||||
| "check-updates"
|
| "check-updates"
|
||||||
@@ -217,27 +222,6 @@ async function goForward(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let checkingForUpdates = false;
|
|
||||||
|
|
||||||
async function checkForUpdates(): Promise<void> {
|
|
||||||
if (checkingForUpdates) return;
|
|
||||||
checkingForUpdates = true;
|
|
||||||
try {
|
|
||||||
const update = await check();
|
|
||||||
if (!update) {
|
|
||||||
notify("Margin Docs is up to date");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notify(`Installing ${update.version}…`);
|
|
||||||
await update.downloadAndInstall();
|
|
||||||
await relaunch();
|
|
||||||
} catch (e) {
|
|
||||||
notify(`Could not check for updates: ${String(e)}`);
|
|
||||||
} finally {
|
|
||||||
checkingForUpdates = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const listeners = new Map<CommandId, Set<() => void>>();
|
const listeners = new Map<CommandId, Set<() => void>>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -264,6 +248,7 @@ const TABLE: Record<CommandId, Omit<Command, "id">> = {
|
|||||||
"new-folder": { label: "New Folder", palette: true, run: () => void createFolder() },
|
"new-folder": { label: "New Folder", palette: true, run: () => void createFolder() },
|
||||||
"close-folder": { label: "Close Folder", palette: false, run: closeActiveFolder },
|
"close-folder": { label: "Close Folder", palette: false, run: closeActiveFolder },
|
||||||
save: { label: "Save", palette: true, run: () => void saveDocument() },
|
save: { label: "Save", palette: true, run: () => void saveDocument() },
|
||||||
|
"export-pdf": { label: "Export as PDF…", palette: true, run: () => void exportPdf() },
|
||||||
"rename-file": { label: "Rename", palette: false, run: () => void renameSelected() },
|
"rename-file": { label: "Rename", palette: false, run: () => void renameSelected() },
|
||||||
"duplicate-file": { label: "Duplicate", palette: false, run: () => void duplicateSelected() },
|
"duplicate-file": { label: "Duplicate", palette: false, run: () => void duplicateSelected() },
|
||||||
"delete-file": { label: "Delete", palette: false, run: () => void deleteSelected() },
|
"delete-file": { label: "Delete", palette: false, run: () => void deleteSelected() },
|
||||||
@@ -320,6 +305,15 @@ const TABLE: Record<CommandId, Omit<Command, "id">> = {
|
|||||||
run: () => useProofing.getState().toggle(),
|
run: () => useProofing.getState().toggle(),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Its twin, and turned over the same way. The two are separate settings because the checkers
|
||||||
|
// are: spelling is the system's and grammar is Harper's, and a user who wants one without the
|
||||||
|
// other is not asking for anything strange.
|
||||||
|
"toggle-grammar": {
|
||||||
|
label: "Check Grammar While Typing",
|
||||||
|
palette: true,
|
||||||
|
run: () => useProofing.getState().toggleGrammar(),
|
||||||
|
},
|
||||||
|
|
||||||
// Dispatched, unlike the one above it, because its whole result is the correction menu on screen
|
// Dispatched, unlike the one above it, because its whole result is the correction menu on screen
|
||||||
// and the component that draws that menu is the only thing here that knows where the caret is.
|
// and the component that draws that menu is the only thing here that knows where the caret is.
|
||||||
//
|
//
|
||||||
@@ -332,6 +326,22 @@ const TABLE: Record<CommandId, Omit<Command, "id">> = {
|
|||||||
run: () => dispatch("correct-spelling"),
|
run: () => dispatch("correct-spelling"),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Writing Tools is the system's, but these two commands are not, and that is the point: the
|
||||||
|
// chords and the menu rows are this app's own, so every way in passes the selection guard in
|
||||||
|
// src/editor/writing.ts before the system is asked to rewrite anything. On a Mac without Apple
|
||||||
|
// Intelligence there is no submenu to perform, and the honest answer is a sentence rather than a
|
||||||
|
// gesture that quietly does nothing.
|
||||||
|
"writing-proofread": {
|
||||||
|
label: "Proofread with Writing Tools",
|
||||||
|
palette: true,
|
||||||
|
run: () => void runWritingTool("proofread"),
|
||||||
|
},
|
||||||
|
"writing-rewrite": {
|
||||||
|
label: "Rewrite with Writing Tools",
|
||||||
|
palette: true,
|
||||||
|
run: () => void runWritingTool("rewrite"),
|
||||||
|
},
|
||||||
|
|
||||||
shortcuts: { label: "Keyboard Shortcuts", palette: true, run: () => dispatch("shortcuts") },
|
shortcuts: { label: "Keyboard Shortcuts", palette: true, run: () => dispatch("shortcuts") },
|
||||||
settings: { label: "Settings…", palette: true, run: () => dispatch("settings") },
|
settings: { label: "Settings…", palette: true, run: () => dispatch("settings") },
|
||||||
"check-updates": {
|
"check-updates": {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const MENU_IDS: readonly CommandId[] = [
|
|||||||
"new-doc",
|
"new-doc",
|
||||||
"new-folder",
|
"new-folder",
|
||||||
"save",
|
"save",
|
||||||
|
"export-pdf",
|
||||||
"close-folder",
|
"close-folder",
|
||||||
"settings",
|
"settings",
|
||||||
"find",
|
"find",
|
||||||
@@ -21,6 +22,8 @@ export const MENU_IDS: readonly CommandId[] = [
|
|||||||
"toggle-sidebar",
|
"toggle-sidebar",
|
||||||
"check-updates",
|
"check-updates",
|
||||||
"report-issue",
|
"report-issue",
|
||||||
|
"writing-proofread",
|
||||||
|
"writing-rewrite",
|
||||||
];
|
];
|
||||||
|
|
||||||
const known = new Set<string>(MENU_IDS);
|
const known = new Set<string>(MENU_IDS);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import "./styles/width-menu.css";
|
|||||||
import "./styles/link-picker.css";
|
import "./styles/link-picker.css";
|
||||||
import "./styles/tree.css";
|
import "./styles/tree.css";
|
||||||
import "./styles/palette.css";
|
import "./styles/palette.css";
|
||||||
|
import "./styles/settings.css";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
|||||||
+56
-12
@@ -1,6 +1,10 @@
|
|||||||
// Spelling, as far as the app outside the editor is concerned: whether the machine has a checker at
|
// Proofing, as far as the app outside the editor is concerned: whether the machine has a checker at
|
||||||
// all, whether the user wants underlines, which words they have waved away this session, and which
|
// all, whether the user wants underlines, which words they have waved away this session, and which
|
||||||
// misspelling the menu is currently open over.
|
// underline the menu is currently open over.
|
||||||
|
//
|
||||||
|
// Two checkers, one store. Spelling is the system's and grammar is Harper's, they are asked
|
||||||
|
// separately and can be turned on separately, but they share a decoration pipeline, a popover and
|
||||||
|
// this state, because from the reader's side they are one underline under one word.
|
||||||
//
|
//
|
||||||
// The checker itself is the system's (see src/api/spell.ts), which is why availability is a piece of
|
// The checker itself is the system's (see src/api/spell.ts), which is why availability is a piece of
|
||||||
// state rather than a constant. `spellAvailable` is asked once per launch and never again: it
|
// state rather than a constant. `spellAvailable` is asked once per launch and never again: it
|
||||||
@@ -19,10 +23,12 @@
|
|||||||
// the cache: it filters what is drawn, so waving a word away and putting it back costs nothing.
|
// the cache: it filters what is drawn, so waving a word away and putting it back costs nothing.
|
||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { grammarAvailable } from "../api/grammar";
|
||||||
import { spellAvailable, spellLearn } from "../api/spell";
|
import { spellAvailable, spellLearn } from "../api/spell";
|
||||||
import { notify } from "./useToast";
|
import { notify } from "./useToast";
|
||||||
|
|
||||||
const KEY = "margindocs-spelling";
|
const KEY = "margindocs-spelling";
|
||||||
|
const GRAMMAR_KEY = "margindocs-grammar";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the menu is, and what it is about.
|
* Where the menu is, and what it is about.
|
||||||
@@ -34,11 +40,18 @@ const KEY = "margindocs-spelling";
|
|||||||
export interface ProofTarget {
|
export interface ProofTarget {
|
||||||
from: number;
|
from: number;
|
||||||
to: number;
|
to: number;
|
||||||
/** What the checker flagged, which is also what "Learn Spelling" and "Ignore" are about. */
|
/** What the checker flagged, which is also what "Learn Spelling" and "Ignore" are about. A word
|
||||||
|
* for spelling and however much of the sentence Harper was talking about for grammar. */
|
||||||
word: string;
|
word: string;
|
||||||
/** At most five, already trimmed by the editor. Can be empty: the system often knows a word is
|
/** At most five, already trimmed by the editor. Can be empty: the system often knows a word is
|
||||||
* wrong without knowing what was meant. */
|
* wrong without knowing what was meant, and a grammar rule can see a sentence is wrong without
|
||||||
|
* knowing how to fix it. */
|
||||||
suggestions: readonly string[];
|
suggestions: readonly string[];
|
||||||
|
/** Harper's category for the rule that fired and the sentence it wrote about it, and null when
|
||||||
|
* this underline is a misspelling. It is what the popover shows above the suggestions, and it is
|
||||||
|
* also what decides which items are offered: "Learn Spelling" teaches the Mac's dictionary a word
|
||||||
|
* and has nothing to say about a phrase. */
|
||||||
|
grammar: { kind: string; message: string } | null;
|
||||||
/** Viewport coordinates of the word itself, for placing the menu under it. */
|
/** Viewport coordinates of the word itself, for placing the menu under it. */
|
||||||
left: number;
|
left: number;
|
||||||
top: number;
|
top: number;
|
||||||
@@ -53,10 +66,18 @@ export type SpellAvailability = "unknown" | "asking" | "ready" | "missing";
|
|||||||
|
|
||||||
interface ProofingState {
|
interface ProofingState {
|
||||||
availability: SpellAvailability;
|
availability: SpellAvailability;
|
||||||
|
/** The same question about the other checker, asked once per launch alongside it. Grammar is
|
||||||
|
* Harper's and compiled in, so "missing" here means a build without it rather than a machine
|
||||||
|
* without it, and it hides the setting rather than offering one that does nothing. */
|
||||||
|
grammarAvailability: SpellAvailability;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
/** Grammar, which is a separate setting because it is a separate checker. A user who wants
|
||||||
|
* spelling underlined and grammar left alone is not asking for anything strange. */
|
||||||
|
grammar: boolean;
|
||||||
/** Lower cased, and only for this run of the app. Ignoring is not learning and is not written
|
/** Lower cased, and only for this run of the app. Ignoring is not learning and is not written
|
||||||
* anywhere: the system checker owns the dictionary, and a word waved away in one sitting is not a
|
* anywhere: the system checker owns the dictionary, and a word waved away in one sitting is not a
|
||||||
* word the user has taught their Mac. */
|
* word the user has taught their Mac. Grammar shares the set and puts whole phrases in it, which
|
||||||
|
* cannot collide with a word: nothing the spell checker flags has a space in it. */
|
||||||
ignored: ReadonlySet<string>;
|
ignored: ReadonlySet<string>;
|
||||||
revision: number;
|
revision: number;
|
||||||
target: ProofTarget | null;
|
target: ProofTarget | null;
|
||||||
@@ -64,6 +85,8 @@ interface ProofingState {
|
|||||||
ensureAvailable: () => void;
|
ensureAvailable: () => void;
|
||||||
setEnabled: (enabled: boolean) => void;
|
setEnabled: (enabled: boolean) => void;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
|
setGrammar: (enabled: boolean) => void;
|
||||||
|
toggleGrammar: () => void;
|
||||||
ignoreWord: (word: string) => void;
|
ignoreWord: (word: string) => void;
|
||||||
learnWord: (word: string) => Promise<void>;
|
learnWord: (word: string) => Promise<void>;
|
||||||
openMenu: (target: ProofTarget) => void;
|
openMenu: (target: ProofTarget) => void;
|
||||||
@@ -75,19 +98,19 @@ interface ProofingState {
|
|||||||
// because applying one writes an attribute on to the document element. This is one boolean that
|
// because applying one writes an attribute on to the document element. This is one boolean that
|
||||||
// nothing outside this store touches, and guarded the same way they are, for the Node test
|
// nothing outside this store touches, and guarded the same way they are, for the Node test
|
||||||
// environment that reaches this file through src/keys/commands.ts.
|
// environment that reaches this file through src/keys/commands.ts.
|
||||||
function readEnabled(): boolean {
|
function readSetting(key: string): boolean {
|
||||||
if (typeof localStorage === "undefined") return true;
|
if (typeof localStorage === "undefined") return true;
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(KEY) !== "off";
|
return localStorage.getItem(key) !== "off";
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeEnabled(enabled: boolean): void {
|
function writeSetting(key: string, enabled: boolean): void {
|
||||||
if (typeof localStorage === "undefined") return;
|
if (typeof localStorage === "undefined") return;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(KEY, enabled ? "on" : "off");
|
localStorage.setItem(key, enabled ? "on" : "off");
|
||||||
} catch {
|
} catch {
|
||||||
// A webview with storage denied still spell checks, it just forgets between launches.
|
// A webview with storage denied still spell checks, it just forgets between launches.
|
||||||
}
|
}
|
||||||
@@ -95,28 +118,49 @@ function writeEnabled(enabled: boolean): void {
|
|||||||
|
|
||||||
export const useProofing = create<ProofingState>((set, get) => ({
|
export const useProofing = create<ProofingState>((set, get) => ({
|
||||||
availability: "unknown",
|
availability: "unknown",
|
||||||
enabled: readEnabled(),
|
grammarAvailability: "unknown",
|
||||||
|
enabled: readSetting(KEY),
|
||||||
|
grammar: readSetting(GRAMMAR_KEY),
|
||||||
ignored: new Set<string>(),
|
ignored: new Set<string>(),
|
||||||
revision: 0,
|
revision: 0,
|
||||||
target: null,
|
target: null,
|
||||||
|
|
||||||
|
// Both checkers, each guarded on its own answer rather than on the pair. They are asked together
|
||||||
|
// today, so one guard would do; a guard that reads the wrong field is how the second checker ends
|
||||||
|
// up stuck on "unknown" the first time somebody asks them apart.
|
||||||
ensureAvailable: () => {
|
ensureAvailable: () => {
|
||||||
if (get().availability !== "unknown") return;
|
if (get().availability === "unknown") {
|
||||||
set({ availability: "asking" });
|
set({ availability: "asking" });
|
||||||
spellAvailable()
|
spellAvailable()
|
||||||
.then((ok) => set({ availability: ok ? "ready" : "missing" }))
|
.then((ok) => set({ availability: ok ? "ready" : "missing" }))
|
||||||
.catch(() => set({ availability: "missing" }));
|
.catch(() => set({ availability: "missing" }));
|
||||||
|
}
|
||||||
|
if (get().grammarAvailability === "unknown") {
|
||||||
|
set({ grammarAvailability: "asking" });
|
||||||
|
grammarAvailable()
|
||||||
|
.then((ok) => set({ grammarAvailability: ok ? "ready" : "missing" }))
|
||||||
|
.catch(() => set({ grammarAvailability: "missing" }));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setEnabled: (enabled) =>
|
setEnabled: (enabled) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
if (s.enabled === enabled) return {};
|
if (s.enabled === enabled) return {};
|
||||||
writeEnabled(enabled);
|
writeSetting(KEY, enabled);
|
||||||
return { enabled, target: null };
|
return { enabled, target: null };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
toggle: () => get().setEnabled(!get().enabled),
|
toggle: () => get().setEnabled(!get().enabled),
|
||||||
|
|
||||||
|
setGrammar: (enabled) =>
|
||||||
|
set((s) => {
|
||||||
|
if (s.grammar === enabled) return {};
|
||||||
|
writeSetting(GRAMMAR_KEY, enabled);
|
||||||
|
return { grammar: enabled, target: null };
|
||||||
|
}),
|
||||||
|
|
||||||
|
toggleGrammar: () => get().setGrammar(!get().grammar),
|
||||||
|
|
||||||
ignoreWord: (word) =>
|
ignoreWord: (word) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const ignored = new Set(s.ignored);
|
const ignored = new Set(s.ignored);
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// What the updater is doing, plus the two preferences that sit next to it in the settings panel.
|
||||||
|
//
|
||||||
|
// The work itself is in src/update.ts, which is this store's sibling: checking, downloading and
|
||||||
|
// relaunching all cross the IPC boundary, and the handle `check()` hands back is a resource on the
|
||||||
|
// Rust side rather than a value, so it cannot be kept here beside the version string it arrived
|
||||||
|
// with. This file is only what the dialog and the settings panel read.
|
||||||
|
//
|
||||||
|
// `lastChecked` and `automatic` are read and written here rather than in a module of their own, the
|
||||||
|
// same way src/store/useProofing.ts keeps its two settings. Nothing outside this store touches
|
||||||
|
// either key, and the guard around localStorage is for the Node test environment, which reaches
|
||||||
|
// this file through src/keys/commands.ts.
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
const CHECKED_KEY = "margindocs-update-checked";
|
||||||
|
const AUTOMATIC_KEY = "margindocs-update-automatic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "available" is an update the user has been shown and has not answered yet, which is the dialog
|
||||||
|
* sitting open. "downloading" and "installing" are the two halves of what one press of Install does,
|
||||||
|
* and they are separate because only the first of them has a number to draw.
|
||||||
|
*
|
||||||
|
* There is no "done": the last thing installing does is relaunch, so the successful end of this
|
||||||
|
* union is the process going away.
|
||||||
|
*/
|
||||||
|
export type UpdatePhase =
|
||||||
|
| "idle"
|
||||||
|
| "checking"
|
||||||
|
| "available"
|
||||||
|
| "downloading"
|
||||||
|
| "installing"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
interface UpdateState {
|
||||||
|
phase: UpdatePhase;
|
||||||
|
/** The version on offer, not the one running. Null outside "available" and what follows it. */
|
||||||
|
version: string | null;
|
||||||
|
/** The release notes, as the release wrote them. Plain text, and shown as plain text. */
|
||||||
|
notes: string | null;
|
||||||
|
downloaded: number;
|
||||||
|
/**
|
||||||
|
* What the server said the whole download is, or null when it did not say. A server that sends no
|
||||||
|
* content length leaves this null for the entire download, which the dialog draws as a bar with
|
||||||
|
* no end rather than as nought percent forever.
|
||||||
|
*/
|
||||||
|
total: number | null;
|
||||||
|
error: string | null;
|
||||||
|
/** Epoch milliseconds of the last check that actually reached the manifest. */
|
||||||
|
lastChecked: number | null;
|
||||||
|
automatic: boolean;
|
||||||
|
|
||||||
|
begin: () => void;
|
||||||
|
offer: (version: string, notes: string | null) => void;
|
||||||
|
progress: (downloaded: number, total: number | null) => void;
|
||||||
|
installing: () => void;
|
||||||
|
failed: (message: string) => void;
|
||||||
|
/** Back to nothing on screen, whether that is Later, Escape or a check that found nothing. */
|
||||||
|
dismiss: () => void;
|
||||||
|
markChecked: () => void;
|
||||||
|
setAutomatic: (automatic: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAutomatic(): boolean {
|
||||||
|
if (typeof localStorage === "undefined") return true;
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(AUTOMATIC_KEY) !== "off";
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readChecked(): number | null {
|
||||||
|
if (typeof localStorage === "undefined") return null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CHECKED_KEY);
|
||||||
|
if (raw === null) return null;
|
||||||
|
const at = Number(raw);
|
||||||
|
return Number.isFinite(at) ? at : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(key: string, value: string): void {
|
||||||
|
if (typeof localStorage === "undefined") return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
} catch {
|
||||||
|
// A webview with storage denied still checks for updates, it just forgets when it last did.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpdate = create<UpdateState>((set) => ({
|
||||||
|
phase: "idle",
|
||||||
|
version: null,
|
||||||
|
notes: null,
|
||||||
|
downloaded: 0,
|
||||||
|
total: null,
|
||||||
|
error: null,
|
||||||
|
lastChecked: readChecked(),
|
||||||
|
automatic: readAutomatic(),
|
||||||
|
|
||||||
|
begin: () => set({ phase: "checking", error: null }),
|
||||||
|
|
||||||
|
offer: (version, notes) =>
|
||||||
|
set({ phase: "available", version, notes, downloaded: 0, total: null, error: null }),
|
||||||
|
|
||||||
|
progress: (downloaded, total) => set({ phase: "downloading", downloaded, total }),
|
||||||
|
|
||||||
|
installing: () => set({ phase: "installing" }),
|
||||||
|
|
||||||
|
failed: (message) => set({ phase: "error", error: message }),
|
||||||
|
|
||||||
|
dismiss: () =>
|
||||||
|
set({ phase: "idle", version: null, notes: null, downloaded: 0, total: null, error: null }),
|
||||||
|
|
||||||
|
markChecked: () => {
|
||||||
|
const at = Date.now();
|
||||||
|
write(CHECKED_KEY, String(at));
|
||||||
|
return set({ lastChecked: at });
|
||||||
|
},
|
||||||
|
|
||||||
|
setAutomatic: (automatic) =>
|
||||||
|
set((s) => {
|
||||||
|
if (s.automatic === automatic) return {};
|
||||||
|
write(AUTOMATIC_KEY, automatic ? "on" : "off");
|
||||||
|
return { automatic };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
+49
-5
@@ -1,4 +1,4 @@
|
|||||||
/* Spelling: the underline on the page, and the menu that opens over it.
|
/* Spelling and grammar: the underlines on the page, and the menu that opens over one.
|
||||||
*
|
*
|
||||||
* The underline is a text decoration rather than a border or a background, because it is the one
|
* The underline is a text decoration rather than a border or a background, because it is the one
|
||||||
* way of drawing under a word that costs the line nothing: a decoration is painted inside the line
|
* way of drawing under a word that costs the line nothing: a decoration is painted inside the line
|
||||||
@@ -8,20 +8,40 @@
|
|||||||
* red rather than a hex value of this file's own. skip-ink is off so a descender does not cut a gap
|
* red rather than a hex value of this file's own. skip-ink is off so a descender does not cut a gap
|
||||||
* in the dots and leave a word looking half underlined.
|
* in the dots and leave a word looking half underlined.
|
||||||
*
|
*
|
||||||
|
* Grammar is the second mark and it is told apart three ways: dashed rather than dotted, quieter,
|
||||||
|
* and sitting a little lower. The pattern is doing as much of that work as the colour, so the two
|
||||||
|
* are still two marks to a reader who cannot tell the colours apart, and the lower offset is what
|
||||||
|
* keeps both visible when they land on the same word, which they do whenever a misspelling sits
|
||||||
|
* inside a phrase Harper flagged. The colour is --ink-soft rather than a hue of its own: this app's
|
||||||
|
* palette is a warm monochrome with one red in it, spending that red on the commoner and more
|
||||||
|
* certain of the two marks is the right way round, and a blue borrowed from another app's grammar
|
||||||
|
* checker would be the only blue anywhere in the product.
|
||||||
|
*
|
||||||
* The menu borrows .row-menu-pop's shape from app.css, which is the app's one popup surface: same
|
* The menu borrows .row-menu-pop's shape from app.css, which is the app's one popup surface: same
|
||||||
* paper, same hairline, same shadow, same radius, same layer. The suggestions are set in the
|
* paper, same hairline, same shadow, same radius, same layer. The suggestions are set in the
|
||||||
* document's own face and not the UI one, since a suggestion is a word from the user's prose and
|
* document's own face and not the UI one, since a suggestion is a word from the user's prose and
|
||||||
* seeing it in the type it will be set in is half of choosing it. */
|
* seeing it in the type it will be set in is half of choosing it. Everything above them is the UI
|
||||||
|
* face, because Harper's message is the app talking rather than the document. */
|
||||||
|
|
||||||
.prose .proof-mark {
|
.prose .proof-mark,
|
||||||
text-decoration: underline dotted var(--danger);
|
.prose .proof-mark-grammar {
|
||||||
-webkit-text-decoration: underline dotted var(--danger);
|
|
||||||
text-decoration-skip-ink: none;
|
text-decoration-skip-ink: none;
|
||||||
text-decoration-thickness: 1.5px;
|
text-decoration-thickness: 1.5px;
|
||||||
-webkit-text-decoration-thickness: 1.5px;
|
-webkit-text-decoration-thickness: 1.5px;
|
||||||
text-underline-offset: 0.16em;
|
text-underline-offset: 0.16em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose .proof-mark {
|
||||||
|
text-decoration: underline dotted var(--danger);
|
||||||
|
-webkit-text-decoration: underline dotted var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose .proof-mark-grammar {
|
||||||
|
text-decoration: underline dashed var(--ink-soft);
|
||||||
|
-webkit-text-decoration: underline dashed var(--ink-soft);
|
||||||
|
text-underline-offset: 0.28em;
|
||||||
|
}
|
||||||
|
|
||||||
.proof-pop {
|
.proof-pop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
@@ -75,6 +95,30 @@
|
|||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Harper's category, drawn as app.css draws .menu-label, which is this app's one way of putting a
|
||||||
|
heading above a group of items in a popup. */
|
||||||
|
.proof-kind {
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 10px 2px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-1);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The message itself, in full ink and wrapping, because it is the thing the menu was opened to
|
||||||
|
read. The max-width on .proof-pop above is what keeps it to a few short lines. */
|
||||||
|
.proof-message {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 10px 6px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-3);
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
.proof-none {
|
.proof-none {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/* The settings panel and the update dialog.
|
||||||
|
*
|
||||||
|
* Both borrow .overlay, .panel, .panel-head, .panel-body and .panel-foot from app.css, and the
|
||||||
|
* three buttons from tree.css, for the same reason the width menu borrows the menu: a dialog that
|
||||||
|
* looked like its own app would be the only thing on screen that did. What is here is the two
|
||||||
|
* layouts those shells hold, the switch, and the progress bar.
|
||||||
|
*
|
||||||
|
* The switch is the one control this app has that nothing else needed, so it is drawn here rather
|
||||||
|
* than in app.css. It is a button with role="switch" and its state is data-on, which is what the
|
||||||
|
* whole app does with a control that is either on or off. */
|
||||||
|
|
||||||
|
.panel-settings {
|
||||||
|
width: min(520px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-settings .panel-body {
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The label is spaced for a sidebar over in app.css, and the panel has its own padding already. */
|
||||||
|
.panel-settings .nav-label {
|
||||||
|
padding: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-app {
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-size: var(--t-5);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-build {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-3);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row + .setting-row {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-4);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-note {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-2);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row[data-disabled="true"] .setting-label {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A button that carries an action rather than an answer: Check Now sits where a switch sits, so it
|
||||||
|
is sized to match one rather than to match the buttons along the foot of a dialog. */
|
||||||
|
.btn-quiet {
|
||||||
|
flex: none;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--raised);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-3);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
transition: background 120ms var(--ease), color 120ms var(--ease),
|
||||||
|
border-color 120ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-quiet:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-quiet:disabled {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
flex: none;
|
||||||
|
position: relative;
|
||||||
|
width: 38px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--shell);
|
||||||
|
transition: background 140ms var(--ease), border-color 140ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch[data-on="true"] {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-knob {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--paper);
|
||||||
|
box-shadow: var(--shadow-raised);
|
||||||
|
transition: transform 140ms var(--ease), background 140ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch[data-on="true"] .switch-knob {
|
||||||
|
transform: translateX(16px);
|
||||||
|
background: var(--accent-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The update dialog */
|
||||||
|
|
||||||
|
.panel-update {
|
||||||
|
width: min(460px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The release notes as the release wrote them, which is text and not markup. Preserved rather than
|
||||||
|
parsed: this is the one string in the app that arrives from outside it, and running it through a
|
||||||
|
renderer would be the only place where somebody else's bytes decide what is drawn. */
|
||||||
|
.update-notes {
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--raised);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-3);
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-track {
|
||||||
|
position: relative;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--accent-wash);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 180ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A server that sends no content length leaves nothing to measure, so the bar says "still going"
|
||||||
|
rather than claiming a fraction it does not know. */
|
||||||
|
.update-track[data-unknown="true"] .update-bar {
|
||||||
|
width: 34%;
|
||||||
|
animation: update-sweep 1400ms var(--ease) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes update-sweep {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-110%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(320%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-count {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-2);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
// The updater, from the frontend's side: asking whether there is one, downloading it with progress
|
||||||
|
// somebody can watch, and relaunching without losing an edit made half a second ago.
|
||||||
|
//
|
||||||
|
// `relaunch()` does not go through the window close handler that protects a dirty buffer, so
|
||||||
|
// anything here that relaunches flushes a pending save first.
|
||||||
|
//
|
||||||
|
// Everything Tauri-shaped lives here and the state it produces lives in src/store/useUpdate.ts,
|
||||||
|
// which is what the dialog and the settings panel read. The one thing that cannot go in that store
|
||||||
|
// is `offered` below: the value `check()` hands back is a resource handle, a number with a download
|
||||||
|
// behind it on the Rust side, so it is held here for as long as the dialog is open and closed when
|
||||||
|
// the dialog is not.
|
||||||
|
|
||||||
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
|
import { relaunch } from "@tauri-apps/plugin-process";
|
||||||
|
import { check, type Update } from "@tauri-apps/plugin-updater";
|
||||||
|
import { flushPendingSave } from "./document";
|
||||||
|
import { isDesktop, isTauri } from "./ipc";
|
||||||
|
import { useUpdate } from "./store/useUpdate";
|
||||||
|
import { notify } from "./store/useToast";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dev build has no `plugins.updater` in tauri.conf.json, so the plugin is never registered and
|
||||||
|
* `check()` fails with a raw plugin error. That is a sentence about the build, not a fault, and it
|
||||||
|
* reads as one.
|
||||||
|
*/
|
||||||
|
const NOT_ENABLED = "Updates are not enabled in this build.";
|
||||||
|
|
||||||
|
/** How stale the last check has to be before an automatic one is worth making on launch. */
|
||||||
|
const AUTOMATIC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long after launch the automatic check waits. The first paint, the session restore and the
|
||||||
|
* first index pass all want the main thread and the network before anything asks GitHub a question
|
||||||
|
* nobody has asked for.
|
||||||
|
*/
|
||||||
|
const LAUNCH_DELAY_MS = 6_000;
|
||||||
|
|
||||||
|
let offered: Update | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a rejection means "this build has no updater in it" rather than "the check failed".
|
||||||
|
*
|
||||||
|
* src-tauri/src/lib.rs registers the plugin only when the config it needs is present, so in a dev
|
||||||
|
* build there is nothing behind `plugin:updater|check` and Tauri rejects it from its own plugin
|
||||||
|
* store: `PluginStore::extend_api` formats exactly `plugin updater not found`. The ACL arrives at
|
||||||
|
* the same fact from the other side, ending its longer sentence with `Plugin not found`, for a
|
||||||
|
* build whose capability no longer grants `updater:default`. Both mean the same thing to somebody
|
||||||
|
* looking at the screen and neither is theirs to act on, so both become the one sentence above.
|
||||||
|
*
|
||||||
|
* Matched against the exact strings rather than against the word "updater", which also appears in
|
||||||
|
* every ordinary failure this function has to let through: a bad signature, an unreachable endpoint
|
||||||
|
* and a malformed manifest all name the plugin in their message.
|
||||||
|
*/
|
||||||
|
function updaterMissing(e: unknown): boolean {
|
||||||
|
const message = String(e);
|
||||||
|
return message === "plugin updater not found" || message.endsWith("Plugin not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCheck(quiet: boolean): Promise<void> {
|
||||||
|
// The updater and the process plugin are both desktop-only, in lib.rs and in the capability, so
|
||||||
|
// on a phone and in a browser this is a fact about the build rather than a command that failed.
|
||||||
|
if (!isDesktop) {
|
||||||
|
if (!quiet) notify(NOT_ENABLED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Anything other than idle is a check already running or a dialog already answering the last one.
|
||||||
|
if (useUpdate.getState().phase !== "idle") return;
|
||||||
|
|
||||||
|
useUpdate.getState().begin();
|
||||||
|
try {
|
||||||
|
const update = await check();
|
||||||
|
useUpdate.getState().markChecked();
|
||||||
|
if (!update) {
|
||||||
|
useUpdate.getState().dismiss();
|
||||||
|
if (!quiet) notify("Margin Docs is up to date");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
offered = update;
|
||||||
|
useUpdate.getState().offer(update.version, update.body ?? null);
|
||||||
|
} catch (e) {
|
||||||
|
useUpdate.getState().dismiss();
|
||||||
|
if (quiet) return;
|
||||||
|
notify(updaterMissing(e) ? NOT_ENABLED : `Could not check for updates: ${String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `check-updates` command, and the button in the settings panel. Says something either way:
|
||||||
|
* somebody who pressed it is owed an answer even when the answer is that there is nothing to do.
|
||||||
|
*/
|
||||||
|
export async function checkForUpdates(): Promise<void> {
|
||||||
|
await runCheck(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same check, made on the app's own initiative and saying nothing unless there is an update to
|
||||||
|
* show. Off entirely when the user has turned the preference off, and skipped when the last check
|
||||||
|
* is recent enough that another one would only be noise.
|
||||||
|
*
|
||||||
|
* Returns its own cancel, so a shell that unmounts before the delay is up does not leave a check
|
||||||
|
* behind it.
|
||||||
|
*/
|
||||||
|
export function startUpdateChecks(): () => void {
|
||||||
|
const { automatic, lastChecked } = useUpdate.getState();
|
||||||
|
if (!isDesktop || !automatic) return () => {};
|
||||||
|
if (lastChecked !== null && Date.now() - lastChecked < AUTOMATIC_INTERVAL_MS) return () => {};
|
||||||
|
const timer = window.setTimeout(() => void runCheck(true), LAUNCH_DELAY_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads the update the dialog is showing and restarts into it.
|
||||||
|
*
|
||||||
|
* The progress callback is the whole reason this is not one line: `downloadAndInstall` reports
|
||||||
|
* every chunk and the dialog draws them, so a hundred megabytes over a slow connection is a bar
|
||||||
|
* moving rather than an app that has stopped answering.
|
||||||
|
*
|
||||||
|
* Nothing runs after `relaunch()`, which is why the flush is in front of it. The window close
|
||||||
|
* handler in src/App.tsx is what normally saves a buffer half a second old, and a relaunch does not
|
||||||
|
* go anywhere near it.
|
||||||
|
*/
|
||||||
|
export async function installUpdate(): Promise<void> {
|
||||||
|
const update = offered;
|
||||||
|
if (update === null) return;
|
||||||
|
|
||||||
|
let downloaded = 0;
|
||||||
|
let total: number | null = null;
|
||||||
|
useUpdate.getState().progress(0, null);
|
||||||
|
try {
|
||||||
|
await update.downloadAndInstall((event) => {
|
||||||
|
if (event.event === "Started") {
|
||||||
|
total = event.data.contentLength ?? null;
|
||||||
|
useUpdate.getState().progress(0, total);
|
||||||
|
} else if (event.event === "Progress") {
|
||||||
|
downloaded += event.data.chunkLength;
|
||||||
|
useUpdate.getState().progress(downloaded, total);
|
||||||
|
} else {
|
||||||
|
// The bytes are in and the bundle is being swapped over. There is no number for this part
|
||||||
|
// and it is not instant, so it is a phase rather than a bar sitting full.
|
||||||
|
useUpdate.getState().installing();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
offered = null;
|
||||||
|
await flushPendingSave();
|
||||||
|
await relaunch();
|
||||||
|
} catch (e) {
|
||||||
|
useUpdate.getState().failed(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Later, Escape, or the close button. The handle goes with the dialog. */
|
||||||
|
export function dismissUpdate(): void {
|
||||||
|
const update = offered;
|
||||||
|
offered = null;
|
||||||
|
useUpdate.getState().dismiss();
|
||||||
|
if (update !== null) void update.close().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the running bundle says its version is, which is the number the updater compares against the
|
||||||
|
* manifest. Asked of Tauri rather than read out of package.json so that the settings panel cannot
|
||||||
|
* disagree with the app it is inside. Null in a browser, where there is no bundle to ask.
|
||||||
|
*/
|
||||||
|
export async function appVersion(): Promise<string | null> {
|
||||||
|
if (!isTauri) return null;
|
||||||
|
try {
|
||||||
|
return await getVersion();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
// An export writes the PDF and nothing else.
|
||||||
|
//
|
||||||
|
// The claim in src/export/run.ts is a claim about what an export does NOT do: it never calls
|
||||||
|
// `save`, never dispatches a transaction, and asks the document store for nothing but the path and
|
||||||
|
// the tree it is already holding. Every other suite reads that off the screen. This one reads it
|
||||||
|
// off the wire and off the fixture, the way tests/bytes.spec.ts does, because the promise is about
|
||||||
|
// bytes: choosing to export a document must not turn into writing over a file the user had not
|
||||||
|
// decided to write yet, and there is no undo for that on disk.
|
||||||
|
//
|
||||||
|
// The document exported here has an unsaved edit in it, which is the case that can actually hurt.
|
||||||
|
// A save on the way into an export would look harmless on a clean buffer and would silently commit
|
||||||
|
// half a paragraph on a dirty one. Writes are held through `external.pauseWrites` so the buffer
|
||||||
|
// stays dirty for as long as the export takes rather than racing the 500ms autosave, and the edit
|
||||||
|
// is let through afterwards to prove it was real: a file that did not change because the edit had
|
||||||
|
// evaporated would prove nothing at all.
|
||||||
|
//
|
||||||
|
// It also carries an image, a table, a formula and a mermaid fence, because those are the four
|
||||||
|
// blocks the converter does extra work for, and three of them put something in the compile that no
|
||||||
|
// other block does: the image travels as an absolute path for the backend to open, the diagram is
|
||||||
|
// drawn to SVG and travels as bytes, and the formula becomes a mitex call. The Typst source and the
|
||||||
|
// image list handed to `pdf_compile` are asserted here for exactly that reason. An export that
|
||||||
|
// wrote nothing because it never got past the first paragraph is the shape of green this suite is
|
||||||
|
// built to refuse.
|
||||||
|
//
|
||||||
|
// WHAT A BROWSER CANNOT REACH. `exportPdf` gates on `isDesktop`, which is `isTauri` and not a
|
||||||
|
// phone. `isTauri` reads `__TAURI_INTERNALS__` off the window and tests/disk.ts puts that there at
|
||||||
|
// document start, so the gate opens and the whole path runs: convert, draw, compile, panel, write.
|
||||||
|
// What is behind it is the dev fixture, so `pdf_compile` answers with a PDF header instead of a
|
||||||
|
// typeset document and `pdf_write` puts nothing anywhere. That the compiler produces a real PDF and
|
||||||
|
// that the write lands where it was pointed are Rust questions, and they are asked in
|
||||||
|
// src-tauri/tests/export_writes_only_the_pdf.rs. The one piece nothing can drive from either side
|
||||||
|
// is the native save panel itself: it is an AppKit window, so this spec answers the command for it
|
||||||
|
// and cannot say what a real panel would hand back for a name the user typed. That distinction
|
||||||
|
// matters, and the Rust suite is where it is followed up.
|
||||||
|
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { putCaret } from "./caret";
|
||||||
|
import { ask, change, installTauriShim } from "./disk";
|
||||||
|
|
||||||
|
const HANDBOOK = "/Users/you/Documents/Handbook";
|
||||||
|
const README = `${HANDBOOK}/README.md`;
|
||||||
|
/** The picture the document points at, which the fixture really has. */
|
||||||
|
const PICTURE = `${HANDBOOK}/reference/assets/diagram.png`;
|
||||||
|
/** Where the save panel is pointed: outside the folder, which is the ordinary case. */
|
||||||
|
const TARGET = "/Users/you/Desktop/README.pdf";
|
||||||
|
|
||||||
|
const row = (path: string) => `.tree-row[data-path="${path}"]`;
|
||||||
|
|
||||||
|
/** Longer than the 500ms autosave debounce in src/document.ts, with room for the write itself. */
|
||||||
|
const SAVED = 1500;
|
||||||
|
|
||||||
|
/** Mermaid is several megabytes and is fetched on the first diagram, so the first export is slow. */
|
||||||
|
const EXPORTED = 20_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One document holding all four of the blocks the converter does extra work for. Built by joining
|
||||||
|
* lines rather than as a template literal because a mermaid fence is three backticks.
|
||||||
|
*/
|
||||||
|
const SOURCE = [
|
||||||
|
"# Quarterly report",
|
||||||
|
"",
|
||||||
|
"Prose before anything difficult.",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"| region | total |",
|
||||||
|
"| ------ | ----- |",
|
||||||
|
"| north | 12 |",
|
||||||
|
"| south | 9 |",
|
||||||
|
"",
|
||||||
|
"$$",
|
||||||
|
"E = mc^2",
|
||||||
|
"$$",
|
||||||
|
"",
|
||||||
|
"```mermaid",
|
||||||
|
"graph TD",
|
||||||
|
" A[Start] --> B[Finish]",
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
"Closing line.",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
/** One command as it crossed the boundary, with the fields worth keeping off the ones that have them. */
|
||||||
|
interface Sent {
|
||||||
|
command: string;
|
||||||
|
/** The destination, for the two commands that name one: `file_write` and `pdf_write`. */
|
||||||
|
path: string | null;
|
||||||
|
/** The Typst source, for the one command that carries it. */
|
||||||
|
source: string | null;
|
||||||
|
/** What the compile was handed to open, and whether the bytes came with it or are on disk. */
|
||||||
|
images: { path: string; inline: boolean }[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One `listen` the app has registered: which event, and the callback id the shim gave it. */
|
||||||
|
interface Listen {
|
||||||
|
event: string;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Internals {
|
||||||
|
invoke: (command: string, args?: Record<string, unknown>) => unknown;
|
||||||
|
runCallback: (id: number, data: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__sent: Sent[];
|
||||||
|
__listens: Listen[];
|
||||||
|
/** What the save panel answers with. `null` is the user pressing Cancel. */
|
||||||
|
__savePanel: string | null;
|
||||||
|
__TAURI_INTERNALS__: Internals;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records every command the app sends, and answers the one a browser has no window for.
|
||||||
|
*
|
||||||
|
* Added after `installTauriShim`, which puts `__TAURI_INTERNALS__` on the window synchronously.
|
||||||
|
* Wrapping that object's `invoke` rather than the app's own wrapper is what makes this a
|
||||||
|
* measurement of what was sent rather than of what src/api/pdf.ts meant to send: a `file_write`
|
||||||
|
* from anywhere in the app, on any path, lands in this list.
|
||||||
|
*/
|
||||||
|
function recordIpc(): void {
|
||||||
|
window.__sent = [];
|
||||||
|
window.__listens = [];
|
||||||
|
window.__savePanel = null;
|
||||||
|
const internals = window.__TAURI_INTERNALS__;
|
||||||
|
const real = internals.invoke;
|
||||||
|
internals.invoke = (command, args) => {
|
||||||
|
const images = (args?.images as { path: string; data: string | null }[] | undefined) ?? null;
|
||||||
|
window.__sent.push({
|
||||||
|
command,
|
||||||
|
path: (args?.path as string) ?? null,
|
||||||
|
source: (args?.source as string) ?? null,
|
||||||
|
images: images === null ? null : images.map((i) => ({ path: i.path, inline: i.data !== null })),
|
||||||
|
});
|
||||||
|
if (command === "plugin:event|listen") {
|
||||||
|
window.__listens.push({ event: args?.event as string, id: args?.handler as number });
|
||||||
|
}
|
||||||
|
// The save panel is an AppKit window. This is the one thing in the whole path a browser cannot
|
||||||
|
// have, so it is answered here and everything either side of it is the running program.
|
||||||
|
if (command === "plugin:dialog|save") return Promise.resolve(window.__savePanel);
|
||||||
|
return real(command, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the README with the given bytes in it and waits for them to be on screen. */
|
||||||
|
async function open(page: Page, source: string): Promise<void> {
|
||||||
|
await page.addInitScript(installTauriShim);
|
||||||
|
await page.addInitScript(recordIpc);
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
localStorage.setItem("margindocs-recents", JSON.stringify(["/Users/you/Documents/Handbook"]));
|
||||||
|
});
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator(row(HANDBOOK))).toBeVisible();
|
||||||
|
await page.locator(row(README)).click();
|
||||||
|
await expect(page.locator(".prose")).toBeVisible();
|
||||||
|
|
||||||
|
// Written from outside rather than typed, so the app has not registered a self-write and this is
|
||||||
|
// a file it has only ever read.
|
||||||
|
await change(page, "write", README, source);
|
||||||
|
await expect.poll(() => ask<string>(page, "read", README)).toBe(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What is on disk now. */
|
||||||
|
const disk = (page: Page, path = README): Promise<string> => ask<string>(page, "read", path);
|
||||||
|
|
||||||
|
const sent = (page: Page): Promise<Sent[]> => page.evaluate(() => window.__sent);
|
||||||
|
|
||||||
|
const commands = async (page: Page): Promise<string[]> =>
|
||||||
|
(await sent(page)).map((call) => call.command);
|
||||||
|
|
||||||
|
const toast = (page: Page) => page.locator(".toast");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gesture, which is the File menu and not a call to `exportPdf`.
|
||||||
|
*
|
||||||
|
* The same route tests/writing-tools.spec.ts takes and for the same reason: src-tauri/src/lib.rs
|
||||||
|
* answers its own row by emitting `menu-action` with the command id, App.tsx has a `listen` on that
|
||||||
|
* event, and src/keys/menu.ts turns the payload into a command. Everything from the event on is the
|
||||||
|
* running program.
|
||||||
|
*/
|
||||||
|
async function runFromMenu(page: Page, command: string): Promise<void> {
|
||||||
|
const delivered = await page.evaluate((id) => {
|
||||||
|
let count = 0;
|
||||||
|
for (const subscription of window.__listens) {
|
||||||
|
if (subscription.event !== "menu-action") continue;
|
||||||
|
window.__TAURI_INTERNALS__.runCallback(subscription.id, {
|
||||||
|
event: "menu-action",
|
||||||
|
id: subscription.id,
|
||||||
|
payload: id,
|
||||||
|
});
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}, command);
|
||||||
|
// An event nobody is subscribed to is the shape of test that passes because nothing happened.
|
||||||
|
expect(delivered, "the app has no menu-action listener to fire at").toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types a character into the first paragraph and holds the save that would follow, so the buffer is
|
||||||
|
* dirty and the file is not, for as long as the caller needs. Answers with the log as it stood the
|
||||||
|
* moment the buffer settled, which is the line everything after it is measured from.
|
||||||
|
*/
|
||||||
|
async function dirtyWithHeldSave(page: Page): Promise<number> {
|
||||||
|
await ask(page, "pauseWrites");
|
||||||
|
|
||||||
|
const paragraph = page.locator(".prose p").first();
|
||||||
|
await paragraph.click();
|
||||||
|
await putCaret(paragraph, "end");
|
||||||
|
await page.keyboard.type("Z");
|
||||||
|
|
||||||
|
await expect(page.locator(".dirty-dot")).toBeVisible();
|
||||||
|
// The autosave has fired and is sitting in the gate. Waiting for it rather than for a timeout is
|
||||||
|
// what makes the count below a line the export is measured against instead of a race with it.
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await commands(page)).filter((c) => c === "file_write").length)
|
||||||
|
.toBe(1);
|
||||||
|
|
||||||
|
return (await sent(page)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("an export of a document with unsaved edits writes nothing but the PDF", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
await open(page, SOURCE);
|
||||||
|
|
||||||
|
// All four of the blocks the converter does extra work for are really on screen, so the export
|
||||||
|
// below is the export this test is about and not a page of prose that happens to be green.
|
||||||
|
// Not every `img` under `.prose` is the document's: prosemirror-view puts a zero width
|
||||||
|
// `ProseMirror-separator` after an inline node that ends a textblock, and it is one too.
|
||||||
|
await expect(page.locator(".prose img:not(.ProseMirror-separator)")).toHaveCount(1);
|
||||||
|
await expect(page.locator(".prose table")).toHaveCount(1);
|
||||||
|
await expect(page.locator(".prose [data-math-block]")).toHaveCount(1);
|
||||||
|
await expect(page.locator(".prose .mermaid-block")).toHaveCount(1);
|
||||||
|
|
||||||
|
const line = await dirtyWithHeldSave(page);
|
||||||
|
const before = await page.locator(".prose").innerHTML();
|
||||||
|
await page.evaluate((target) => {
|
||||||
|
window.__savePanel = target;
|
||||||
|
}, TARGET);
|
||||||
|
|
||||||
|
await runFromMenu(page, "export-pdf");
|
||||||
|
await expect(toast(page)).toContainText("Exported README.pdf", { timeout: EXPORTED });
|
||||||
|
|
||||||
|
const during = (await sent(page)).slice(line);
|
||||||
|
|
||||||
|
// 1. Exactly these commands crossed the boundary, in this order, and no other. `file_write` is
|
||||||
|
// not among them, and neither is `file_read`: the export asked the document store for the
|
||||||
|
// tree it was already holding and went to the compiler with it.
|
||||||
|
expect(during.map((call) => call.command)).toEqual([
|
||||||
|
"plugin:event|listen",
|
||||||
|
"pdf_compile",
|
||||||
|
"plugin:event|unlisten",
|
||||||
|
"plugin:dialog|save",
|
||||||
|
"pdf_write",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. The bytes went where the panel pointed, and nowhere else.
|
||||||
|
expect(during.find((call) => call.command === "pdf_write")?.path).toBe(TARGET);
|
||||||
|
|
||||||
|
// 3. The file on disk is the one that was read, byte for byte. This is the whole test.
|
||||||
|
expect(await disk(page)).toBe(SOURCE);
|
||||||
|
|
||||||
|
// 4. And the buffer is still dirty, which is the other half of it: an export that had quietly
|
||||||
|
// saved on the way past would leave a clean buffer and an unchanged-looking file, and the
|
||||||
|
// unchanged-looking file would be the one with the edit in it. The document on screen is the
|
||||||
|
// one that went in, too: `documentToTypst` is handed the live tree rather than a copy, so a
|
||||||
|
// converter that mutated it, or a transaction that changed the document on the way past,
|
||||||
|
// would show up here as a paragraph that is not the one that was exported.
|
||||||
|
await expect(page.locator(".dirty-dot")).toBeVisible();
|
||||||
|
expect(await page.locator(".prose").innerHTML()).toBe(before);
|
||||||
|
|
||||||
|
// 5. The four paths did their extra work. The image travels as the absolute path the backend
|
||||||
|
// opens under the root guard; the diagram was drawn and travels as bytes because it has no
|
||||||
|
// file behind it; the formula is a mitex call; the table is a Typst table. A compile handed
|
||||||
|
// none of that would have written nothing either, and would have proved nothing.
|
||||||
|
const compile = during.find((call) => call.command === "pdf_compile");
|
||||||
|
expect(compile?.images).toEqual([
|
||||||
|
{ path: PICTURE, inline: false },
|
||||||
|
{ path: "/inline/diagram-1.svg", inline: true },
|
||||||
|
]);
|
||||||
|
expect(compile?.source).toContain(PICTURE);
|
||||||
|
expect(compile?.source).toContain("#table(");
|
||||||
|
expect(compile?.source).toContain("mitex");
|
||||||
|
|
||||||
|
// 6. The edit was real all along. Letting the held save through writes the character that was
|
||||||
|
// typed, so the file that did not move during the export was a file with a pending edit
|
||||||
|
// against it rather than a buffer that had lost one.
|
||||||
|
await ask(page, "resumeWrites");
|
||||||
|
await expect.poll(() => disk(page)).toContain("difficult.Z");
|
||||||
|
expect(await disk(page)).not.toBe(SOURCE);
|
||||||
|
|
||||||
|
// 7. One write for the whole session, which is the autosave's, and it went out before the export
|
||||||
|
// started rather than because of it.
|
||||||
|
//
|
||||||
|
// What this cannot see, said plainly rather than left for the next person to assume. Holding
|
||||||
|
// the write is what keeps the buffer dirty for the length of an export, and it is also the
|
||||||
|
// one state in which a fire and forget `void save()` inside `exportPdf` would leave no trace:
|
||||||
|
// `saveNow` in src/document.ts folds a request that arrives while a write is on the wire into
|
||||||
|
// a single next lap, and by the time the gate opens that lap finds the buffer clean and skips.
|
||||||
|
// Measured, by putting that line into the module and watching this test stay green. An
|
||||||
|
// `await`ed one is caught, loudly, because the export then never reaches the panel at all.
|
||||||
|
// The reason the gap is narrow rather than alarming is the debounce itself: the buffer can
|
||||||
|
// only be dirty while a save is pending or in flight, so an export that saved would be
|
||||||
|
// writing the bytes the debounce was about to write anyway. What is worth proving is that the
|
||||||
|
// export adds no write of its own, and that is what steps 1 to 6 are.
|
||||||
|
await page.waitForTimeout(SAVED);
|
||||||
|
expect((await commands(page)).filter((c) => c === "file_write")).toEqual(["file_write"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancelling the save panel leaves the document exactly where it was", async ({ page }) => {
|
||||||
|
// The branch that returns halfway through, after a compile and before a write. It is worth its
|
||||||
|
// own test because it is the one place an export ends without a toast to say so, and a tidy-up
|
||||||
|
// on that path that reached for `save` would be invisible on screen and permanent on disk.
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
await open(page, SOURCE);
|
||||||
|
await expect(page.locator(".prose .mermaid-block")).toHaveCount(1);
|
||||||
|
|
||||||
|
const line = await dirtyWithHeldSave(page);
|
||||||
|
// Left as null, which is what the panel answers when the user presses Cancel.
|
||||||
|
|
||||||
|
await runFromMenu(page, "export-pdf");
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await commands(page)).filter((c) => c === "plugin:dialog|save").length, {
|
||||||
|
timeout: EXPORTED,
|
||||||
|
})
|
||||||
|
.toBe(1);
|
||||||
|
|
||||||
|
const during = (await sent(page)).slice(line);
|
||||||
|
expect(during.map((call) => call.command)).toEqual([
|
||||||
|
"plugin:event|listen",
|
||||||
|
"pdf_compile",
|
||||||
|
"plugin:event|unlisten",
|
||||||
|
"plugin:dialog|save",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(await disk(page)).toBe(SOURCE);
|
||||||
|
await expect(page.locator(".dirty-dot")).toBeVisible();
|
||||||
|
await expect(toast(page)).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
// Writing Tools, which nothing in this suite can actually run, and the two halves of it that can
|
||||||
|
// still be proved here.
|
||||||
|
//
|
||||||
|
// The feature needs macOS 15.1 with Apple Intelligence turned on, and what it does when it runs is
|
||||||
|
// not a call this app makes: the system finds the selection in the webview and rewrites it by
|
||||||
|
// mutating the DOM, and the first this app hears of it is prosemirror-view reparsing its own
|
||||||
|
// document. So the risk is not "does the menu item fire". It is what those bytes become, and that
|
||||||
|
// is reproducible in Chromium without a single line of AppKit, because the mutation is an ordinary
|
||||||
|
// DOM edit: take the range out, put text in.
|
||||||
|
//
|
||||||
|
// The first half of this file does exactly that and then reads the file off the fixture, the way
|
||||||
|
// tests/bytes.spec.ts does, because the promise being made is about the file rather than about the
|
||||||
|
// screen. The second half drives the gesture, the Edit menu row, and asserts what
|
||||||
|
// src/editor/writing.ts refuses and that a refusal really did stop the call. A guard asserted by
|
||||||
|
// calling it would prove nothing about whether the running program asks it, and this project has
|
||||||
|
// shipped four that did not.
|
||||||
|
//
|
||||||
|
// What is deliberately NOT claimed here: that the mutation below is byte for byte the one macOS
|
||||||
|
// makes. Nobody outside Apple can promise that. It is the worst plausible shape of it, a range
|
||||||
|
// deleted and replaced, which is what a browser does for any programmatic text replacement, and a
|
||||||
|
// guard drawn against the worst shape is a guard that holds for the gentler ones.
|
||||||
|
|
||||||
|
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||||
|
import { putCaret, settle, type Where } from "./caret";
|
||||||
|
import { ask, change, installTauriShim } from "./disk";
|
||||||
|
|
||||||
|
const HANDBOOK = "/Users/you/Documents/Handbook";
|
||||||
|
const README = `${HANDBOOK}/README.md`;
|
||||||
|
const NOTES = `${HANDBOOK}/notes.txt`;
|
||||||
|
|
||||||
|
const row = (path: string) => `.tree-row[data-path="${path}"]`;
|
||||||
|
|
||||||
|
/** Longer than the 500ms autosave debounce in src/document.ts, with room for the write itself. */
|
||||||
|
const SAVED = 1500;
|
||||||
|
|
||||||
|
interface WritingCall {
|
||||||
|
command: string;
|
||||||
|
tool: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One `listen` the app has registered: which event, and the callback id the shim gave it. */
|
||||||
|
interface Subscription {
|
||||||
|
event: string;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Internals {
|
||||||
|
invoke: (command: string, args?: Record<string, unknown>) => unknown;
|
||||||
|
runCallback: (id: number, data: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__writing: WritingCall[];
|
||||||
|
__listening: Subscription[];
|
||||||
|
__TAURI_INTERNALS__: Internals;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records what crosses the IPC boundary: every writing_* command, so a refusal can be shown to have
|
||||||
|
* stopped one, and every event the app subscribes to, so the menu can be fired at it below.
|
||||||
|
*
|
||||||
|
* Added after `installTauriShim`, which puts `__TAURI_INTERNALS__` on the window synchronously.
|
||||||
|
* Wrapping that object's `invoke` rather than the app's own wrapper is what makes this a
|
||||||
|
* measurement of what was sent rather than of what src/api/writing.ts meant to send.
|
||||||
|
*/
|
||||||
|
function recordIpc(): void {
|
||||||
|
window.__writing = [];
|
||||||
|
window.__listening = [];
|
||||||
|
const internals = window.__TAURI_INTERNALS__;
|
||||||
|
const real = internals.invoke;
|
||||||
|
internals.invoke = (command, args) => {
|
||||||
|
if (command.startsWith("writing_")) {
|
||||||
|
window.__writing.push({ command, tool: (args?.tool as string) ?? null });
|
||||||
|
}
|
||||||
|
if (command === "plugin:event|listen") {
|
||||||
|
window.__listening.push({ event: args?.event as string, id: args?.handler as number });
|
||||||
|
}
|
||||||
|
return real(command, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenOptions {
|
||||||
|
/** The file to open. The README unless a test wants the .txt surface. */
|
||||||
|
path?: string;
|
||||||
|
/** Sets the fixture's own switch for a Mac with no Writing Tools menu. */
|
||||||
|
unavailable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens a document with the given bytes in it and waits for them to be on screen. */
|
||||||
|
async function open(page: Page, source: string, options: OpenOptions = {}): Promise<void> {
|
||||||
|
const path = options.path ?? README;
|
||||||
|
await page.addInitScript(installTauriShim);
|
||||||
|
await page.addInitScript(recordIpc);
|
||||||
|
await page.addInitScript((unavailable) => {
|
||||||
|
localStorage.clear();
|
||||||
|
localStorage.setItem("margindocs-recents", JSON.stringify(["/Users/you/Documents/Handbook"]));
|
||||||
|
if (unavailable) localStorage.setItem("margindocs-dev-no-writing-tools", "1");
|
||||||
|
}, options.unavailable === true);
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator(row(HANDBOOK))).toBeVisible();
|
||||||
|
await page.locator(row(path)).click();
|
||||||
|
await expect(page.locator(path === NOTES ? "textarea.plain-text" : ".prose")).toBeVisible();
|
||||||
|
|
||||||
|
// Written from outside rather than typed, so the app has not registered a self-write and this is
|
||||||
|
// a file it has only ever read.
|
||||||
|
await change(page, "write", path, source);
|
||||||
|
await expect.poll(() => ask<string>(page, "read", path)).toBe(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What is on disk now. */
|
||||||
|
const disk = (page: Page, path = README): Promise<string> => ask<string>(page, "read", path);
|
||||||
|
|
||||||
|
const calls = (page: Page): Promise<WritingCall[]> => page.evaluate(() => window.__writing);
|
||||||
|
|
||||||
|
const commands = async (page: Page): Promise<string[]> =>
|
||||||
|
(await calls(page)).map((call) => call.command);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the system does to the selection, as far as anything outside Apple can say: the range comes
|
||||||
|
* out and one run of text goes in. No input event, no ProseMirror transaction, nothing the editor
|
||||||
|
* was asked about first. prosemirror-view's own MutationObserver is what notices.
|
||||||
|
*/
|
||||||
|
async function rewriteRange(
|
||||||
|
page: Page,
|
||||||
|
selector: string,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
text: string,
|
||||||
|
end = selector,
|
||||||
|
): Promise<void> {
|
||||||
|
await page.evaluate(
|
||||||
|
(job) => {
|
||||||
|
const at = (selector: string, offset: number): [Node, number] => {
|
||||||
|
const host = document.querySelector(selector);
|
||||||
|
if (!host) throw new Error(`nothing matches ${selector}`);
|
||||||
|
const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT);
|
||||||
|
let seen = 0;
|
||||||
|
while (walker.nextNode()) {
|
||||||
|
const node = walker.currentNode as Text;
|
||||||
|
if (offset <= seen + node.data.length) return [node, offset - seen];
|
||||||
|
seen += node.data.length;
|
||||||
|
}
|
||||||
|
throw new Error(`offset ${offset} is past the end of ${selector}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
const [startNode, startOffset] = at(job.selector, job.from);
|
||||||
|
const [endNode, endOffset] = at(job.end, job.to);
|
||||||
|
range.setStart(startNode, startOffset);
|
||||||
|
range.setEnd(endNode, endOffset);
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(document.createTextNode(job.text));
|
||||||
|
},
|
||||||
|
{ selector, from, to, text, end },
|
||||||
|
);
|
||||||
|
await settle(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A selection a person could have made: a caret, then Shift and the arrow key, held down. */
|
||||||
|
async function selectFrom(target: Locator, at: Where, characters: number): Promise<void> {
|
||||||
|
const page = target.page();
|
||||||
|
await target.click();
|
||||||
|
await putCaret(target, at);
|
||||||
|
for (let i = 0; i < characters; i += 1) await page.keyboard.press("Shift+ArrowRight");
|
||||||
|
await settle(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The blocks the two ends of the live selection are in, named by tag. */
|
||||||
|
function selectionBlocks(page: Page): Promise<string[]> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) return [];
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const block = (node: Node): string => {
|
||||||
|
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
|
||||||
|
return (
|
||||||
|
element?.closest("p, h1, h2, h3, h4, h5, h6, td, th, pre, [data-math-block]")?.tagName ?? "?"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return [block(range.startContainer), block(range.endContainer)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gesture, which is the Edit menu and not a call to `runWritingTool`.
|
||||||
|
*
|
||||||
|
* A native menu cannot be clicked from a browser, but everything downstream of the click can be
|
||||||
|
* driven exactly as it happens: src-tauri/src/lib.rs answers its own row by emitting `menu-action`
|
||||||
|
* with the command id, App.tsx has a `listen` on that event, and src/keys/menu.ts turns the payload
|
||||||
|
* into a command. So this delivers that event to the app's own subscriber through the shim's
|
||||||
|
* callback registry, and everything from there on is the running program. It is also the one path
|
||||||
|
* that leaves the selection alone, which is why it is the path this feature is built around: a
|
||||||
|
* native menu does not take the keyboard off the webview.
|
||||||
|
*/
|
||||||
|
async function runFromMenu(page: Page, command: string): Promise<void> {
|
||||||
|
const delivered = await page.evaluate((id) => {
|
||||||
|
let count = 0;
|
||||||
|
for (const subscription of window.__listening) {
|
||||||
|
if (subscription.event !== "menu-action") continue;
|
||||||
|
window.__TAURI_INTERNALS__.runCallback(subscription.id, {
|
||||||
|
event: "menu-action",
|
||||||
|
id: subscription.id,
|
||||||
|
payload: id,
|
||||||
|
});
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}, command);
|
||||||
|
// An event nobody is subscribed to is the shape of test that passes because nothing happened.
|
||||||
|
expect(delivered, "the app has no menu-action listener to fire at").toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The other way in, and the one that cannot preserve a selection. */
|
||||||
|
async function runFromPalette(page: Page, label: string): Promise<void> {
|
||||||
|
await page.keyboard.press("Meta+k");
|
||||||
|
await page.locator(".palette-field").fill(label);
|
||||||
|
// The row about to run is the row this test means, rather than whatever fuzzy matching put at the
|
||||||
|
// top today.
|
||||||
|
await expect(page.locator(".palette-row").first()).toContainText(label);
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
}
|
||||||
|
|
||||||
|
const REWRITE = "writing-rewrite";
|
||||||
|
const PROOFREAD = "writing-proofread";
|
||||||
|
|
||||||
|
const toast = (page: Page): Locator => page.locator(".toast");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// What the bytes do. No guard involved: this is the ground the guard below is drawn on.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test("a rewrite across a hand wrapped paragraph leaves no backslash behind", async ({ page }) => {
|
||||||
|
// The one that would matter most, because a hand wrapped file is what a text editor leaves
|
||||||
|
// behind and a rewrite is asked for on whole sentences. The paragraph is declared
|
||||||
|
// `whitespace: "pre"` in src/model/schema.ts so prosemirror-view reparses the wraps as newlines
|
||||||
|
// rather than as hard breaks; without that field the wraps the rewrite did not touch would come
|
||||||
|
// back as `\` at the end of every line, which is the bug docs/architecture.md is about.
|
||||||
|
await open(page, "alpha beta\ngamma delta\n");
|
||||||
|
|
||||||
|
await rewriteRange(page, ".prose p", 0, 22, "One rewritten sentence.");
|
||||||
|
await page.waitForTimeout(SAVED);
|
||||||
|
|
||||||
|
expect(await disk(page)).toBe("One rewritten sentence.\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rewrite that only covers the first wrapped line leaves the second one alone", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await open(page, "alpha beta\ngamma delta\n");
|
||||||
|
|
||||||
|
await rewriteRange(page, ".prose p", 0, 10, "Alpha Beta");
|
||||||
|
await page.waitForTimeout(SAVED);
|
||||||
|
|
||||||
|
expect(await disk(page)).toBe("Alpha Beta\ngamma delta\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rewrite inside a heading is still a heading, and inside a list item still an item", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await open(page, "# Title Here\n\n- one\n- two\n");
|
||||||
|
|
||||||
|
await rewriteRange(page, ".prose h1", 0, 10, "Another Title");
|
||||||
|
await rewriteRange(page, ".prose li p", 0, 3, "ONE rewritten");
|
||||||
|
await page.waitForTimeout(SAVED);
|
||||||
|
|
||||||
|
expect(await disk(page)).toBe("# Another Title\n\n- ONE rewritten\n- two\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("markdown the rewrite invents is escaped rather than obeyed", async ({ page }) => {
|
||||||
|
// A rewrite is prose from a language model and it will happily open a sentence with "1." or "-".
|
||||||
|
// Those are the file's own syntax, so if they went in raw the paragraph would come back as a list
|
||||||
|
// the next time the file was read, which is a block the user never asked for.
|
||||||
|
await open(page, "plain sentence\n");
|
||||||
|
|
||||||
|
await rewriteRange(page, ".prose p", 0, 14, "- a list? *maybe* [really](no)");
|
||||||
|
await page.waitForTimeout(SAVED);
|
||||||
|
|
||||||
|
expect(await disk(page)).toBe("\\- a list? \\*maybe\\* \\[really]\\(no)\n");
|
||||||
|
await expect(page.locator(".prose ul")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rewrite whose ends are in two paragraphs damages both of them", async ({ page }) => {
|
||||||
|
// The evidence for the guard, reproduced with the guard out of the way. Two paragraphs become
|
||||||
|
// three, and ` ` lands in the middle of words nobody selected: the serializer's honest
|
||||||
|
// spelling for a leading space markdown has no other way to keep, in a place no author would
|
||||||
|
// have put one. Nothing here is a serializer bug. It is the wrong range to hand a rewrite.
|
||||||
|
await open(page, "one alpha\n\ntwo beta\n");
|
||||||
|
|
||||||
|
await rewriteRange(page, ".prose p:nth-of-type(1)", 4, 3, "MERGED", ".prose p:nth-of-type(2)");
|
||||||
|
await page.waitForTimeout(SAVED);
|
||||||
|
|
||||||
|
expect(await disk(page)).toBe("one \n\nMERGED\n\n beta\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rewrite whose ends are in two table cells is not a table any more", async ({ page }) => {
|
||||||
|
// The worst of them. GFM has one header row and every row has to be the same width; this comes
|
||||||
|
// back a column wider with an empty first cell in both rows, so the file now says something about
|
||||||
|
// the user's data that the user did not.
|
||||||
|
await open(page, "| a | b |\n| - | - |\n| c | d |\n");
|
||||||
|
|
||||||
|
await rewriteRange(page, ".prose td:nth-of-type(1)", 0, 1, "X", ".prose td:nth-of-type(2)");
|
||||||
|
await page.waitForTimeout(SAVED);
|
||||||
|
|
||||||
|
expect(await disk(page)).toBe("| | a | b |\n| - | - | - |\n| | X | |\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rewrite across a link takes the address with it, and one inside the link does not", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Both halves in one test because the pair is the rule. The address is not in the words on
|
||||||
|
// screen, so a range that swallows the whole anchor loses something the user cannot see they had.
|
||||||
|
// A range wholly inside the anchor is safe: the mark is an ancestor of the range rather than
|
||||||
|
// content the range can delete, so the destination is still there afterwards.
|
||||||
|
await open(page, "see [docs page](x.md) now\n");
|
||||||
|
|
||||||
|
await rewriteRange(page, ".prose a", 0, 9, "the guide");
|
||||||
|
await page.waitForTimeout(SAVED);
|
||||||
|
expect(await disk(page)).toBe("see [the guide](x.md) now\n");
|
||||||
|
|
||||||
|
await rewriteRange(page, ".prose p", 0, 13, "rewritten");
|
||||||
|
await page.waitForTimeout(SAVED);
|
||||||
|
expect(await disk(page)).toBe("rewritten now\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// The guard, through the gesture.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test("an ordinary sentence is handed to the system", async ({ page }) => {
|
||||||
|
// The control. Without it every refusal below would still pass on a build where nothing ever
|
||||||
|
// reaches the system at all, which is the same green as a guard that works.
|
||||||
|
await open(page, "just a plain sentence here\n");
|
||||||
|
|
||||||
|
await selectFrom(page.locator(".prose p"), 0, 4);
|
||||||
|
await runFromMenu(page, REWRITE);
|
||||||
|
|
||||||
|
await expect.poll(() => commands(page)).toEqual(["writing_available", "writing_run"]);
|
||||||
|
expect((await calls(page))[1].tool).toBe("Rewrite");
|
||||||
|
await expect(toast(page)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Proofread asks for the item the system calls Proofread", async ({ page }) => {
|
||||||
|
await open(page, "just a plain sentence here\n");
|
||||||
|
|
||||||
|
await selectFrom(page.locator(".prose p"), 0, 4);
|
||||||
|
await runFromMenu(page, PROOFREAD);
|
||||||
|
|
||||||
|
await expect.poll(() => commands(page)).toEqual(["writing_available", "writing_run"]);
|
||||||
|
expect((await calls(page))[1].tool).toBe("Proofread");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a selection covering two paragraphs is refused and nothing is asked", async ({ page }) => {
|
||||||
|
await open(page, "one alpha\n\ntwo beta\n");
|
||||||
|
|
||||||
|
await selectFrom(page.locator(".prose p").first(), 4, 8);
|
||||||
|
// The gesture really did cross the boundary. Without this the test passes on a build where
|
||||||
|
// Shift+ArrowRight quietly stopped at the end of the paragraph, and the refusal below would be
|
||||||
|
// answering a question nobody asked.
|
||||||
|
expect(await selectionBlocks(page)).toEqual(["P", "P"]);
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const range = window.getSelection()!.getRangeAt(0);
|
||||||
|
const block = (node: Node) =>
|
||||||
|
(node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement)?.closest("p");
|
||||||
|
return block(range.startContainer) === block(range.endContainer);
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
await runFromMenu(page, REWRITE);
|
||||||
|
|
||||||
|
await expect(toast(page)).toContainText("one paragraph at a time");
|
||||||
|
await expect.poll(() => commands(page)).toEqual(["writing_available"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a selection covering a link is refused and nothing is asked", async ({ page }) => {
|
||||||
|
await open(page, "see [docs](x.md) now\n");
|
||||||
|
|
||||||
|
await selectFrom(page.locator(".prose p"), 0, 8);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => window.getSelection()!.getRangeAt(0).cloneContents().querySelector("a") !== null),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
await runFromMenu(page, REWRITE);
|
||||||
|
|
||||||
|
await expect(toast(page)).toContainText("address");
|
||||||
|
await expect.poll(() => commands(page)).toEqual(["writing_available"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a selection covering a picture or a formula is refused", async ({ page }) => {
|
||||||
|
// Both are atoms: a range that covers one deletes it, and neither the file name nor the LaTeX is
|
||||||
|
// in the words on screen for a rewrite to put back. Inline maths is `$$…$$` here, not `$…$`:
|
||||||
|
// src/markdown/handlers.ts turns single dollar text maths off, so a lone `$` is a dollar sign.
|
||||||
|
await open(page, "see  now\n\nand $$x+1$$ too\n");
|
||||||
|
|
||||||
|
await selectFrom(page.locator(".prose p").first(), 0, 6);
|
||||||
|
await runFromMenu(page, REWRITE);
|
||||||
|
await expect(toast(page)).toContainText("picture");
|
||||||
|
|
||||||
|
await selectFrom(page.locator(".prose p").nth(1), 0, 6);
|
||||||
|
await runFromMenu(page, REWRITE);
|
||||||
|
await expect(toast(page)).toContainText("formula");
|
||||||
|
|
||||||
|
await expect.poll(() => commands(page)).toEqual([
|
||||||
|
"writing_available",
|
||||||
|
"writing_available",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a selection wholly inside a code span is refused", async ({ page }) => {
|
||||||
|
// The bytes survive this one, which is exactly why it needs its own test: the range is inside the
|
||||||
|
// `code` element rather than covering it, so `cloneContents` reports nothing and the refusal
|
||||||
|
// comes from the marks around the range instead.
|
||||||
|
await open(page, "a `some code` b\n");
|
||||||
|
|
||||||
|
await selectFrom(page.locator(".prose code"), 0, 4);
|
||||||
|
await runFromMenu(page, REWRITE);
|
||||||
|
|
||||||
|
await expect(toast(page)).toContainText("code");
|
||||||
|
await expect.poll(() => commands(page)).toEqual(["writing_available"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with no document open it says to open one", async ({ page }) => {
|
||||||
|
await page.addInitScript(installTauriShim);
|
||||||
|
await page.addInitScript(recordIpc);
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
localStorage.setItem("margindocs-recents", JSON.stringify(["/Users/you/Documents/Handbook"]));
|
||||||
|
});
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator(row(HANDBOOK))).toBeVisible();
|
||||||
|
await expect(page.locator(".prose")).toHaveCount(0);
|
||||||
|
|
||||||
|
await runFromMenu(page, REWRITE);
|
||||||
|
|
||||||
|
await expect(toast(page)).toContainText("Open a document");
|
||||||
|
await expect.poll(() => commands(page)).toEqual(["writing_available"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a selection inside a fenced code block is refused and nothing is asked", async ({ page }) => {
|
||||||
|
// Not a byte argument: a fence round trips a rewrite cleanly. It is the argument
|
||||||
|
// src/editor/proofing.ts already made for the spell checker, which is that a tool rewriting
|
||||||
|
// somebody's variable names is a tool they turn off, and a rewriter is the louder version of it.
|
||||||
|
await open(page, "```js\nconst a = 1;\n```\n");
|
||||||
|
|
||||||
|
await selectFrom(page.locator(".prose pre code"), 0, 5);
|
||||||
|
await runFromMenu(page, REWRITE);
|
||||||
|
|
||||||
|
await expect(toast(page)).toContainText("code");
|
||||||
|
await expect.poll(() => commands(page)).toEqual(["writing_available"]);
|
||||||
|
expect(await disk(page)).toBe("```js\nconst a = 1;\n```\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a selection inside a raw block is refused and nothing is asked", async ({ page }) => {
|
||||||
|
// A raw block is the file's own bytes, held twice so the editor can prove it has not touched
|
||||||
|
// them. Handing them to a rewriter is the one thing they must never be handed to.
|
||||||
|
await open(page, 'para\n\n<div class="x">raw</div>\n');
|
||||||
|
|
||||||
|
await selectFrom(page.locator(".prose pre.raw-block code"), 0, 5);
|
||||||
|
await runFromMenu(page, REWRITE);
|
||||||
|
|
||||||
|
await expect(toast(page)).toContainText("the file's own bytes");
|
||||||
|
await expect.poll(() => commands(page)).toEqual(["writing_available"]);
|
||||||
|
expect(await disk(page)).toBe('para\n\n<div class="x">raw</div>\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the cursor in a formula's source is refused, and the message says so", async ({ page }) => {
|
||||||
|
// A formula is not prose and its field is not the document: it is a textarea of its own inside
|
||||||
|
// it, so it takes the keyboard away and the general answer would be to click into the document.
|
||||||
|
// That answer is true and unhelpful, hence the branch and hence this test, which is here because
|
||||||
|
// a refusal nothing can reach is the shape of guard this project has shipped four times.
|
||||||
|
const source = "before\n\n$$\ny = 2\n$$\n";
|
||||||
|
await open(page, source);
|
||||||
|
|
||||||
|
await page.locator(".prose .math-block").click();
|
||||||
|
await expect(page.locator(".prose textarea.math-source")).toBeFocused();
|
||||||
|
|
||||||
|
await runFromMenu(page, REWRITE);
|
||||||
|
|
||||||
|
await expect(toast(page)).toContainText("formula");
|
||||||
|
await expect.poll(() => commands(page)).toEqual(["writing_available"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a drag across a rendered formula is refused, and the message says so", async ({ page }) => {
|
||||||
|
// The other way into a display equation, and the one the cursor never enters: the block is
|
||||||
|
// `contenteditable=false`, so clicking it opens the source field above, but a drag across the
|
||||||
|
// rendered maths still leaves a real selection with both ends inside the block and the document
|
||||||
|
// holding the keyboard. A mouse drag because that is the only gesture that makes one.
|
||||||
|
await open(page, "before\n\n$$\ny = 2 + 3 + 4\n$$\n\nafter\n");
|
||||||
|
|
||||||
|
const box = await page.locator(".prose .math-render").boundingBox();
|
||||||
|
await page.mouse.move(box!.x + 4, box!.y + box!.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(box!.x + box!.width - 4, box!.y + box!.height / 2, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
// The drag really made a selection inside the block, rather than a caret somewhere near it.
|
||||||
|
expect(await page.evaluate(() => window.getSelection()!.isCollapsed)).toBe(false);
|
||||||
|
expect(await selectionBlocks(page)).toEqual(["DIV", "DIV"]);
|
||||||
|
|
||||||
|
await runFromMenu(page, REWRITE);
|
||||||
|
|
||||||
|
await expect(toast(page)).toContainText("formula");
|
||||||
|
await expect.poll(() => commands(page)).toEqual(["writing_available"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the cursor in a toggle's title is refused, and the message says so", async ({ page }) => {
|
||||||
|
// The same shape. A toggle's title is an attribute on the node rather than text in a block, and
|
||||||
|
// the span holding it is its own editable inside the document.
|
||||||
|
await open(page, "<details>\n<summary>Sum</summary>\n\nbody\n\n</details>\n");
|
||||||
|
|
||||||
|
await page.locator(".prose [data-toggle-summary]").click();
|
||||||
|
await expect(page.locator(".prose [data-toggle-summary]")).toBeFocused();
|
||||||
|
|
||||||
|
await runFromMenu(page, REWRITE);
|
||||||
|
|
||||||
|
await expect(toast(page)).toContainText("toggle");
|
||||||
|
await expect.poll(() => commands(page)).toEqual(["writing_available"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a caret with nothing selected is refused", async ({ page }) => {
|
||||||
|
await open(page, "hello there\n");
|
||||||
|
|
||||||
|
await page.locator(".prose p").click();
|
||||||
|
await putCaret(page.locator(".prose p"), 3);
|
||||||
|
await runFromMenu(page, REWRITE);
|
||||||
|
|
||||||
|
await expect(toast(page)).toContainText("Select the text");
|
||||||
|
await expect.poll(() => commands(page)).toEqual(["writing_available"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a Mac with no Writing Tools menu says so and asks for nothing", async ({ page }) => {
|
||||||
|
await open(page, "just a plain sentence here\n", { unavailable: true });
|
||||||
|
|
||||||
|
await selectFrom(page.locator(".prose p"), 0, 4);
|
||||||
|
await runFromMenu(page, REWRITE);
|
||||||
|
|
||||||
|
await expect(toast(page)).toContainText("Apple Intelligence");
|
||||||
|
await expect.poll(() => commands(page)).toEqual(["writing_available"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a .txt file has no markdown to lose, so its selection goes straight through", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await open(page, "some plain notes\n", { path: NOTES });
|
||||||
|
|
||||||
|
const field = page.locator("textarea.plain-text");
|
||||||
|
await field.click();
|
||||||
|
await field.press("Meta+a");
|
||||||
|
expect(
|
||||||
|
await field.evaluate((e: HTMLTextAreaElement) => e.selectionEnd - e.selectionStart),
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await runFromMenu(page, REWRITE);
|
||||||
|
|
||||||
|
await expect.poll(() => commands(page)).toEqual(["writing_available", "writing_run"]);
|
||||||
|
await expect(toast(page)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the palette says which gesture to use, rather than rewriting a caret it moved itself", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// The palette runs its row and then closes on to `<body>`, so by the time the command reaches
|
||||||
|
// src/editor/writing.ts the document holds neither the keyboard nor the selection: measured here
|
||||||
|
// rather than assumed, because the tempting fix, focusing the editor back, is what destroys the
|
||||||
|
// selection outright. prosemirror-view takes the collapsed one the panel left behind as the
|
||||||
|
// document's own, and nothing brings the user's back afterwards. So this path says so and stops.
|
||||||
|
await open(page, "just a plain sentence here\n");
|
||||||
|
|
||||||
|
await selectFrom(page.locator(".prose p"), 0, 4);
|
||||||
|
await runFromPalette(page, "Rewrite with Writing Tools");
|
||||||
|
|
||||||
|
await expect(toast(page)).toContainText("holds the keyboard");
|
||||||
|
await expect.poll(() => commands(page)).toEqual(["writing_available"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("running the tool writes nothing by itself", async ({ page }) => {
|
||||||
|
// The system's rewrite is an edit and dirties the buffer like any other. Asking for one must not.
|
||||||
|
const source = "# Title\n\none wrapped\nline here\n";
|
||||||
|
await open(page, source);
|
||||||
|
|
||||||
|
await selectFrom(page.locator(".prose p"), 0, 3);
|
||||||
|
await runFromMenu(page, REWRITE);
|
||||||
|
await expect.poll(() => commands(page)).toEqual(["writing_available", "writing_run"]);
|
||||||
|
await page.waitForTimeout(SAVED * 2);
|
||||||
|
|
||||||
|
expect(await disk(page)).toBe(source);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user