This commit is contained in:
pj committed 2026-06-22 09:56:20 -04:00
1 parent 78fff9b6f4
commit 7cc36e3e40
37 files changed
+55361 -103

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: "Release version, e.g. 0.2.0. Leave empty to bump the patch number."
required: false
type: string
permissions:
contents: write
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
release_id: ${{ steps.release.outputs.release_id }}
steps:
- uses: actions/checkout@v4
- name: Determine version
id: version
run: |
if [ -n "${{ inputs.version }}" ]; then
VERSION="${{ inputs.version }}"
VERSION="${VERSION#v}"
else
CURRENT=$(jq -r .version src-tauri/tauri.conf.json)
IFS=. read -r MAJOR MINOR PATCH <<< "$CURRENT"
VERSION="$MAJOR.$MINOR.$((PATCH + 1))"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
echo "Releasing v$VERSION"
- name: Bump version in manifests
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
tmp=$(mktemp)
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > "$tmp" && mv "$tmp" src-tauri/tauri.conf.json
jq --arg v "$VERSION" '.version = $v' package.json > "$tmp" && mv "$tmp" package.json
sed -i "0,/^version = \".*\"/s//version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Commit and tag
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add src-tauri/tauri.conf.json package.json src-tauri/Cargo.toml
git commit -m "chore(release): $TAG"
git tag "$TAG"
git push origin HEAD
git push origin "$TAG"
- name: Create draft release
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
run: |
gh release create "$TAG" --draft --title "margin $TAG" --notes "Release $TAG"
ID=$(gh release view "$TAG" --json databaseId --jq .databaseId)
echo "release_id=$ID" >> "$GITHUB_OUTPUT"
build:
needs: prepare
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- os: macos-latest
args: "--target universal-apple-darwin --config src-tauri/tauri.release.conf.json"
rust-targets: "aarch64-apple-darwin,x86_64-apple-darwin"
- os: ubuntu-latest
args: "--config src-tauri/tauri.release.conf.json"
rust-targets: ""
- os: windows-latest
args: "--config src-tauri/tauri.release.conf.json"
rust-targets: ""
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.tag }}
- name: Install Linux dependencies
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
libxdo-dev \
libssl-dev \
build-essential \
curl \
wget \
file \
rpm
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: pnpm/action-setup@v4
with:
version: 10
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust-targets }}
- uses: swatinem/rust-cache@v2
with:
workspaces: src-tauri -> target
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Build and upload
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
releaseId: ${{ needs.prepare.outputs.release_id }}
args: ${{ matrix.args }}
publish:
needs: [prepare, build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare.outputs.tag }}
run: gh release edit "$TAG" --draft=false --latest
+2
View File
@@ -14,6 +14,8 @@
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-updater": "^2",
"@tiptap/core": "^3.27.1",
"@tiptap/extension-placeholder": "^3.27.1",
"@tiptap/pm": "^3.27.1",
+20
View File
@@ -17,6 +17,12 @@ importers:
'@tauri-apps/plugin-opener':
specifier: ^2
version: 2.5.4
'@tauri-apps/plugin-process':
specifier: ^2
version: 2.3.1
'@tauri-apps/plugin-updater':
specifier: ^2
version: 2.10.1
'@tiptap/core':
specifier: ^3.27.1
version: 3.27.1(@tiptap/[email protected])
@@ -608,6 +614,12 @@ packages:
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==}
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
'@tiptap/[email protected]':
resolution: {integrity: sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ==}
peerDependencies:
@@ -1467,6 +1479,14 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.11.1
'@tauri-apps/[email protected]':
dependencies:
'@tauri-apps/api': 2.11.1
'@tauri-apps/[email protected]':
dependencies:
'@tauri-apps/api': 2.11.1
'@tiptap/[email protected](@tiptap/[email protected])':
dependencies:
'@tiptap/pm': 3.27.1
+3181 -79
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -21,6 +21,8 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
@@ -28,4 +30,25 @@ zip = "2"
typst = "0.14.2"
typst-pdf = "0.14.2"
typst-as-lib = "0.15.5"
spellbook = "0.4"
# Pinned exactly: the [patch] stubs below are tied to this version's burn/cubecl graph. A minor
# bump could silently invalidate the patch ("unused") and pull the broken CUDA subtree back in,
# so bump deliberately and re-audit the stubs. (see [patch.crates-io])
harper-core = { version = "=2.5.0", features = ["concurrent"] }
# harper-core transitively declares an optional, disabled `burn-cuda` (CUDA backend).
# Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm
# prereleases) and links-collides with `zip`'s lzma. Replace it with an empty stub so the
# whole cubecl/tracel/CUDA subtree leaves the graph. The `cuda` feature is off, so nothing here
# is ever compiled.
[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 its burn-ndarray POS
# tagger) is ~10x slower unoptimized, which made `tauri dev` grammar checks take seconds. This
# compiles deps at opt-level 3 while keeping margin-app itself unoptimized, so incremental
# rebuilds of our own code stay fast. First build after this is slower (deps recompile once).
[profile.dev.package."*"]
opt-level = 3
+3 -1
View File
@@ -6,6 +6,8 @@
"permissions": [
"core:default",
"opener:default",
"dialog:default"
"dialog:default",
"updater:default",
"process:allow-restart"
]
}
@@ -0,0 +1,347 @@
en_US Hunspell Dictionary
Version 2020.12.07
Mon Dec 7 20:14:35 2020 -0500 [5ef55f9]
http://wordlist.sourceforge.net
README file for English Hunspell dictionaries derived from SCOWL.
These dictionaries are created using the speller/make-hunspell-dict
script in SCOWL.
The following dictionaries are available:
en_US (American)
en_CA (Canadian)
en_GB-ise (British with "ise" spelling)
en_GB-ize (British with "ize" spelling)
en_AU (Australian)
en_US-large
en_CA-large
en_GB-large (with both "ise" and "ize" spelling)
en_AU-large
The normal (non-large) dictionaries correspond to SCOWL size 60 and,
to encourage consistent spelling, generally only include one spelling
variant for a word. The large dictionaries correspond to SCOWL size
70 and may include multiple spelling for a word when both variants are
considered almost equal. The larger dictionaries however (1) have not
been as carefully checked for errors as the normal dictionaries and
thus may contain misspelled or invalid words; and (2) contain
uncommon, yet valid, words that might cause problems as they are
likely to be misspellings of more common words (for example, "ort" and
"calender").
To get an idea of the difference in size, here are 25 random words
only found in the large dictionary for American English:
Bermejo Freyr's Guenevere Hatshepsut Nottinghamshire arrestment
crassitudes crural dogwatches errorless fetial flaxseeds godroon
incretion jalapeño's kelpie kishkes neuroglias pietisms pullulation
stemwinder stenoses syce thalassic zees
The en_US, en_CA and en_AU are the official dictionaries for Hunspell.
The en_GB and large dictionaries are made available on an experimental
basis. If you find them useful please send me a quick email at
[email protected].
If none of these dictionaries suite you (for example, maybe you want
the normal dictionary that also includes common variants) additional
dictionaries can be generated at http://app.aspell.net/create or by
modifying speller/make-hunspell-dict in SCOWL. Please do let me know
if you end up publishing a customized dictionary.
If a word is not found in the dictionary or a word is there you think
shouldn't be, you can lookup the word up at http://app.aspell.net/lookup
to help determine why that is.
General comments on these list can be sent directly to me at
[email protected] or to the wordlist-devel mailing lists
(https://lists.sourceforge.net/lists/listinfo/wordlist-devel). If you
have specific issues with any of these dictionaries please file a bug
report at https://github.com/kevina/wordlist/issues.
IMPORTANT CHANGES INTRODUCED In 2016.11.20:
New Australian dictionaries thanks to the work of Benjamin Titze
([email protected]).
IMPORTANT CHANGES INTRODUCED IN 2016.04.24:
The dictionaries are now in UTF-8 format instead of ISO-8859-1. This
was required to handle smart quotes correctly.
IMPORTANT CHANGES INTRODUCED IN 2016.01.19:
"SET UTF8" was changes to "SET UTF-8" in the affix file as some
versions of Hunspell do not recognize "UTF8".
ADDITIONAL NOTES:
The NOSUGGEST flag was added to certain taboo words. While I made an
honest attempt to flag the strongest taboo words with the NOSUGGEST
flag, I MAKE NO GUARANTEE THAT I FLAGGED EVERY POSSIBLE TABOO WORD.
The list was originally derived from Németh László, however I removed
some words which, while being considered taboo by some dictionaries,
are not really considered swear words in today's society.
COPYRIGHT, SOURCES, and CREDITS:
The English dictionaries come directly from SCOWL
and is thus under the same copyright of SCOWL. The affix file is
a heavily modified version of the original english.aff file which was
released as part of Geoff Kuenning's Ispell and as such is covered by
his BSD license. Part of SCOWL is also based on Ispell thus the
Ispell copyright is included with the SCOWL copyright.
The collective work is Copyright 2000-2018 by Kevin Atkinson as well
as any of the copyrights mentioned below:
Copyright 2000-2018 by Kevin Atkinson
Permission to use, copy, modify, distribute and sell these word
lists, the associated scripts, the output created from the scripts,
and its documentation for any purpose is hereby granted without fee,
provided that the above copyright notice appears in all copies and
that both that copyright notice and this permission notice appear in
supporting documentation. Kevin Atkinson makes no representations
about the suitability of this array for any purpose. It is provided
"as is" without express or implied warranty.
Alan Beale <[email protected]> also deserves special credit as he has,
in addition to providing the 12Dicts package and being a major
contributor to the ENABLE word list, given me an incredible amount of
feedback and created a number of special lists (those found in the
Supplement) in order to help improve the overall quality of SCOWL.
The 10 level includes the 1000 most common English words (according to
the Moby (TM) Words II [MWords] package), a subset of the 1000 most
common words on the Internet (again, according to Moby Words II), and
frequently class 16 from Brian Kelk's "UK English Wordlist
with Frequency Classification".
The MWords package was explicitly placed in the public domain:
The Moby lexicon project is complete and has
been place into the public domain. Use, sell,
rework, excerpt and use in any way on any platform.
Placing this material on internal or public servers is
also encouraged. The compiler is not aware of any
export restrictions so freely distribute world-wide.
You can verify the public domain status by contacting
Grady Ward
3449 Martha Ct.
Arcata, CA 95521-4884
[email protected]
[email protected]
The "UK English Wordlist With Frequency Classification" is also in the
Public Domain:
Date: Sat, 08 Jul 2000 20:27:21 +0100
From: Brian Kelk <[email protected]>
> I was wondering what the copyright status of your "UK English
> Wordlist With Frequency Classification" word list as it seems to
> be lacking any copyright notice.
There were many many sources in total, but any text marked
"copyright" was avoided. Locally-written documentation was one
source. An earlier version of the list resided in a filespace called
PUBLIC on the University mainframe, because it was considered public
domain.
Date: Tue, 11 Jul 2000 19:31:34 +0100
> So are you saying your word list is also in the public domain?
That is the intention.
The 20 level includes frequency classes 7-15 from Brian's word list.
The 35 level includes frequency classes 2-6 and words appearing in at
least 11 of 12 dictionaries as indicated in the 12Dicts package. All
words from the 12Dicts package have had likely inflections added via
my inflection database.
The 12Dicts package and Supplement is in the Public Domain.
The WordNet database, which was used in the creation of the
Inflections database, is under the following copyright:
This software and database is being provided to you, the LICENSEE,
by Princeton University under the following license. By obtaining,
using and/or copying this software and database, you agree that you
have read, understood, and will comply with these terms and
conditions.:
Permission to use, copy, modify and distribute this software and
database and its documentation for any purpose and without fee or
royalty is hereby granted, provided that you agree to comply with
the following copyright notice and statements, including the
disclaimer, and that the same appear on ALL copies of the software,
database and documentation, including modifications that you make
for internal use or for distribution.
WordNet 1.6 Copyright 1997 by Princeton University. All rights
reserved.
THIS SOFTWARE AND DATABASE IS PROVIDED "AS IS" AND PRINCETON
UNIVERSITY MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PRINCETON
UNIVERSITY MAKES NO REPRESENTATIONS OR WARRANTIES OF MERCHANT-
ABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE
LICENSED SOFTWARE, DATABASE OR DOCUMENTATION WILL NOT INFRINGE ANY
THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS.
The name of Princeton University or Princeton may not be used in
advertising or publicity pertaining to distribution of the software
and/or database. Title to copyright in this software, database and
any associated documentation shall at all times remain with
Princeton University and LICENSEE agrees to preserve same.
The 40 level includes words from Alan's 3esl list found in version 4.0
of his 12dicts package. Like his other stuff the 3esl list is also in the
public domain.
The 50 level includes Brian's frequency class 1, words appearing
in at least 5 of 12 of the dictionaries as indicated in the 12Dicts
package, and uppercase words in at least 4 of the previous 12
dictionaries. A decent number of proper names is also included: The
top 1000 male, female, and Last names from the 1990 Census report; a
list of names sent to me by Alan Beale; and a few names that I added
myself. Finally a small list of abbreviations not commonly found in
other word lists is included.
The name files form the Census report is a government document which I
don't think can be copyrighted.
The file special-jargon.50 uses common.lst and word.lst from the
"Unofficial Jargon File Word Lists" which is derived from "The Jargon
File". All of which is in the Public Domain. This file also contain
a few extra UNIX terms which are found in the file "unix-terms" in the
special/ directory.
The 55 level includes words from Alan's 2of4brif list found in version
4.0 of his 12dicts package. Like his other stuff the 2of4brif is also
in the public domain.
The 60 level includes all words appearing in at least 2 of the 12
dictionaries as indicated by the 12Dicts package.
The 70 level includes Brian's frequency class 0 and the 74,550 common
dictionary words from the MWords package. The common dictionary words,
like those from the 12Dicts package, have had all likely inflections
added. The 70 level also included the 5desk list from version 4.0 of
the 12Dics package which is in the public domain.
The 80 level includes the ENABLE word list, all the lists in the
ENABLE supplement package (except for ABLE), the "UK Advanced Cryptics
Dictionary" (UKACD), the list of signature words from the YAWL package,
and the 10,196 places list from the MWords package.
The ENABLE package, mainted by M\Cooper <[email protected]>,
is in the Public Domain:
The ENABLE master word list, WORD.LST, is herewith formally released
into the Public Domain. Anyone is free to use it or distribute it in
any manner they see fit. No fee or registration is required for its
use nor are "contributions" solicited (if you feel you absolutely
must contribute something for your own peace of mind, the authors of
the ENABLE list ask that you make a donation on their behalf to your
favorite charity). This word list is our gift to the Scrabble
community, as an alternate to "official" word lists. Game designers
may feel free to incorporate the WORD.LST into their games. Please
mention the source and credit us as originators of the list. Note
that if you, as a game designer, use the WORD.LST in your product,
you may still copyright and protect your product, but you may *not*
legally copyright or in any way restrict redistribution of the
WORD.LST portion of your product. This *may* under law restrict your
rights to restrict your users' rights, but that is only fair.
UKACD, by J Ross Beresford <[email protected]>, is under the
following copyright:
Copyright (c) J Ross Beresford 1993-1999. All Rights Reserved.
The following restriction is placed on the use of this publication:
if The UK Advanced Cryptics Dictionary is used in a software package
or redistributed in any form, the copyright notice must be
prominently displayed and the text of this document must be included
verbatim.
There are no other restrictions: I would like to see the list
distributed as widely as possible.
The 95 level includes the 354,984 single words, 256,772 compound
words, 4,946 female names and the 3,897 male names, and 21,986 names
from the MWords package, ABLE.LST from the ENABLE Supplement, and some
additional words found in my part-of-speech database that were not
found anywhere else.
Accent information was taken from UKACD.
The VarCon package was used to create the American, British, Canadian,
and Australian word list. It is under the following copyright:
Copyright 2000-2016 by Kevin Atkinson
Permission to use, copy, modify, distribute and sell this array, the
associated software, and its documentation for any purpose is hereby
granted without fee, provided that the above copyright notice appears
in all copies and that both that copyright notice and this permission
notice appear in supporting documentation. Kevin Atkinson makes no
representations about the suitability of this array for any
purpose. It is provided "as is" without express or implied warranty.
Copyright 2016 by Benjamin Titze
Permission to use, copy, modify, distribute and sell this array, the
associated software, and its documentation for any purpose is hereby
granted without fee, provided that the above copyright notice appears
in all copies and that both that copyright notice and this permission
notice appear in supporting documentation. Benjamin Titze makes no
representations about the suitability of this array for any
purpose. It is provided "as is" without express or implied warranty.
Since the original words lists come from the Ispell distribution:
Copyright 1993, Geoff Kuenning, Granada Hills, CA
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. All modifications to the source code must be clearly marked as
such. Binary redistributions based on modified source code
must be clearly marked as modified versions in the documentation
and/or other materials provided with the distribution.
(clause 4 removed with permission from Geoff Kuenning)
5. The name of Geoff Kuenning may not be used to endorse or promote
products derived from this software without specific prior
written permission.
THIS SOFTWARE IS PROVIDED BY GEOFF KUENNING AND CONTRIBUTORS ``AS IS'' AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL GEOFF KUENNING OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
SUCH DAMAGE.
Build Date: Mon Dec 7 20:19:27 EST 2020
Wordlist Command: mk-list --accents=strip en_US 60
@@ -0,0 +1,205 @@
SET UTF-8
TRY esianrtolcdugmphbyfvkwzESIANRTOLCDUGMPHBYFVKWZ'
ICONV 1
ICONV ’ '
NOSUGGEST !
# ordinal numbers
COMPOUNDMIN 1
# only in compounds: 1th, 2th, 3th
ONLYINCOMPOUND c
# compound rules:
# 1. [0-9]*1[0-9]th (10th, 11th, 12th, 56714th, etc.)
# 2. [0-9]*[02-9](1st|2nd|3rd|[4-9]th) (21st, 22nd, 123rd, 1234th, etc.)
COMPOUNDRULE 2
COMPOUNDRULE n*1t
COMPOUNDRULE n*mp
WORDCHARS 0123456789
PFX A Y 1
PFX A 0 re .
PFX I Y 1
PFX I 0 in .
PFX U Y 1
PFX U 0 un .
PFX C Y 1
PFX C 0 de .
PFX E Y 1
PFX E 0 dis .
PFX F Y 1
PFX F 0 con .
PFX K Y 1
PFX K 0 pro .
SFX V N 2
SFX V e ive e
SFX V 0 ive [^e]
SFX N Y 3
SFX N e ion e
SFX N y ication y
SFX N 0 en [^ey]
SFX X Y 3
SFX X e ions e
SFX X y ications y
SFX X 0 ens [^ey]
SFX H N 2
SFX H y ieth y
SFX H 0 th [^y]
SFX Y Y 1
SFX Y 0 ly .
SFX G Y 2
SFX G e ing e
SFX G 0 ing [^e]
SFX J Y 2
SFX J e ings e
SFX J 0 ings [^e]
SFX D Y 4
SFX D 0 d e
SFX D y ied [^aeiou]y
SFX D 0 ed [^ey]
SFX D 0 ed [aeiou]y
SFX T N 4
SFX T 0 st e
SFX T y iest [^aeiou]y
SFX T 0 est [aeiou]y
SFX T 0 est [^ey]
SFX R Y 4
SFX R 0 r e
SFX R y ier [^aeiou]y
SFX R 0 er [aeiou]y
SFX R 0 er [^ey]
SFX Z Y 4
SFX Z 0 rs e
SFX Z y iers [^aeiou]y
SFX Z 0 ers [aeiou]y
SFX Z 0 ers [^ey]
SFX S Y 4
SFX S y ies [^aeiou]y
SFX S 0 s [aeiou]y
SFX S 0 es [sxzh]
SFX S 0 s [^sxzhy]
SFX P Y 3
SFX P y iness [^aeiou]y
SFX P 0 ness [aeiou]y
SFX P 0 ness [^y]
SFX M Y 1
SFX M 0 's .
SFX B Y 3
SFX B 0 able [^aeiou]
SFX B 0 able ee
SFX B e able [^aeiou]e
SFX L Y 1
SFX L 0 ment .
REP 90
REP a ei
REP ei a
REP a ey
REP ey a
REP ai ie
REP ie ai
REP alot a_lot
REP are air
REP are ear
REP are eir
REP air are
REP air ere
REP ere air
REP ere ear
REP ere eir
REP ear are
REP ear air
REP ear ere
REP eir are
REP eir ere
REP ch te
REP te ch
REP ch ti
REP ti ch
REP ch tu
REP tu ch
REP ch s
REP s ch
REP ch k
REP k ch
REP f ph
REP ph f
REP gh f
REP f gh
REP i igh
REP igh i
REP i uy
REP uy i
REP i ee
REP ee i
REP j di
REP di j
REP j gg
REP gg j
REP j ge
REP ge j
REP s ti
REP ti s
REP s ci
REP ci s
REP k cc
REP cc k
REP k qu
REP qu k
REP kw qu
REP o eau
REP eau o
REP o ew
REP ew o
REP oo ew
REP ew oo
REP ew ui
REP ui ew
REP oo ui
REP ui oo
REP ew u
REP u ew
REP oo u
REP u oo
REP u oe
REP oe u
REP u ieu
REP ieu u
REP ue ew
REP ew ue
REP uff ough
REP oo ieu
REP ieu oo
REP ier ear
REP ear ier
REP ear air
REP air ear
REP w qu
REP qu w
REP z ss
REP ss z
REP shun tion
REP shun sion
REP shun cion
REP size cise
File diff suppressed because it is too large. Load diff
+51 -10
View File
@@ -2,8 +2,9 @@ mod epub;
mod library;
mod pdf;
mod project;
mod proofing;
use tauri::menu::{Menu, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder};
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder};
use tauri::{Emitter, Runtime};
fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
@@ -13,13 +14,45 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
.build(handle)?;
let export_pdf = MenuItemBuilder::with_id("export-pdf", "Export as PDF…").build(handle)?;
let export_epub = MenuItemBuilder::with_id("export-epub", "Export as EPUB…").build(handle)?;
let file_submenu = SubmenuBuilder::new(handle, "File")
.item(&new_book)
.item(&PredefinedMenuItem::separator(handle)?)
.item(&export_pdf)
.item(&export_epub)
.build()?;
menu.insert(&file_submenu, 1)?;
let check_updates =
MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?;
let file_submenu = menu
.items()?
.into_iter()
.find_map(|item| match item {
MenuItemKind::Submenu(submenu)
if submenu.text().map(|t| t == "File").unwrap_or(false) =>
{
Some(submenu)
}
_ => None,
});
match file_submenu {
Some(submenu) => {
submenu.prepend_items(&[
&new_book,
&PredefinedMenuItem::separator(handle)?,
&export_pdf,
&export_epub,
&PredefinedMenuItem::separator(handle)?,
&check_updates,
&PredefinedMenuItem::separator(handle)?,
])?;
}
None => {
let submenu = SubmenuBuilder::new(handle, "File")
.item(&new_book)
.item(&PredefinedMenuItem::separator(handle)?)
.item(&export_pdf)
.item(&export_epub)
.item(&PredefinedMenuItem::separator(handle)?)
.item(&check_updates)
.build()?;
menu.insert(&submenu, 1)?;
}
}
Ok(menu)
}
@@ -28,9 +61,15 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.manage(proofing::new_state())
.menu(|handle| build_menu(handle))
.on_menu_event(|app, event| {
if matches!(event.id().0.as_str(), "new-book" | "export-pdf" | "export-epub") {
if matches!(
event.id().0.as_str(),
"new-book" | "export-pdf" | "export-epub" | "check-updates"
) {
app.emit("menu-action", event.id().0.as_str()).ok();
}
})
@@ -44,7 +83,9 @@ pub fn run() {
library::list_books,
library::load_book,
library::save_book,
library::delete_book
library::delete_book,
proofing::proof_text,
proofing::remember_word
])
.run(tauri::generate_context!())
.expect("error while running margin");
+234
View File
@@ -0,0 +1,234 @@
use std::collections::HashSet;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use harper_core::linting::{LintGroup, Linter, Suggestion};
use harper_core::spell::FstDictionary;
use harper_core::{Dialect, Document};
use serde::Serialize;
use tauri::path::BaseDirectory;
use tauri::Manager;
#[derive(Serialize)]
pub struct Issue {
start: usize,
end: usize,
kind: String,
category: String,
message: String,
suggestions: Vec<String>,
}
struct Harper {
linter: LintGroup,
dict: Arc<FstDictionary>,
}
pub struct Engine {
speller: Option<spellbook::Dictionary>,
harper: Option<Harper>,
custom: HashSet<String>,
}
pub type ProofState = Mutex<Option<Engine>>;
pub fn new_state() -> ProofState {
Mutex::new(None)
}
fn dict_file(app: &tauri::AppHandle, name: &str) -> Result<String, String> {
let rel = format!("resources/dictionaries/en/{name}");
if let Ok(path) = app.path().resolve(&rel, BaseDirectory::Resource) {
if let Ok(contents) = fs::read_to_string(&path) {
return Ok(contents);
}
}
let dev = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(&rel);
fs::read_to_string(&dev).map_err(|e| format!("{name}: {e}"))
}
fn custom_dict_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir.join("custom-dictionary.txt"))
}
fn read_words(contents: &str, set: &mut HashSet<String>) {
for line in contents.lines() {
let word = line.trim();
if !word.is_empty() && !word.starts_with('#') {
set.insert(word.to_string());
}
}
}
fn load_custom(app: &tauri::AppHandle) -> HashSet<String> {
let mut set = HashSet::new();
if let Ok(path) = custom_dict_path(app) {
if let Ok(contents) = fs::read_to_string(&path) {
read_words(&contents, &mut set);
}
}
#[cfg(target_os = "macos")]
if let Ok(home) = app.path().home_dir() {
let local = home.join("Library/Spelling/LocalDictionary");
if let Ok(contents) = fs::read_to_string(&local) {
read_words(&contents, &mut set);
}
}
set
}
fn build_speller(app: &tauri::AppHandle) -> Result<spellbook::Dictionary, String> {
let aff = dict_file(app, "index.aff")?;
let dic = dict_file(app, "index.dic")?;
spellbook::Dictionary::new(&aff, &dic).map_err(|e| format!("dictionary: {e:?}"))
}
fn build_harper() -> Harper {
let dict = FstDictionary::curated();
let mut linter = LintGroup::new_curated(dict.clone(), Dialect::American);
linter.config.set_rule_enabled("SpellCheck", false);
Harper { linter, dict }
}
fn is_word_char(c: char) -> bool {
c.is_alphabetic()
}
fn is_apostrophe(c: char) -> bool {
c == '\'' || c == '\u{2019}'
}
fn collect_spelling(
speller: &spellbook::Dictionary,
custom: &HashSet<String>,
chars: &[char],
issues: &mut Vec<Issue>,
) {
let n = chars.len();
let mut i = 0;
while i < n {
if !is_word_char(chars[i]) {
i += 1;
continue;
}
let start = i;
i += 1;
while i < n {
if is_word_char(chars[i]) {
i += 1;
} else if is_apostrophe(chars[i]) && i + 1 < n && is_word_char(chars[i + 1]) {
i += 1;
} else {
break;
}
}
let end = i;
let word: String = chars[start..end].iter().collect();
if speller.check(&word) || custom.contains(&word) {
continue;
}
let mut suggestions = Vec::new();
speller.suggest(&word, &mut suggestions);
suggestions.truncate(5);
issues.push(Issue {
start,
end,
kind: "spelling".into(),
category: "Spelling".into(),
message: format!("“{word}” may be misspelled"),
suggestions,
});
}
}
fn collect_grammar(harper: &mut Harper, text: &str, chars: &[char], issues: &mut Vec<Issue>) {
let doc = Document::new_plain_english(text, harper.dict.as_ref());
for lint in harper.linter.lint(&doc) {
let start = lint.span.start.min(chars.len());
let end = lint.span.end.min(chars.len());
let existing: String = chars[start..end].iter().collect();
let mut suggestions = Vec::new();
for suggestion in &lint.suggestions {
match suggestion {
Suggestion::ReplaceWith(replacement) => suggestions.push(replacement.iter().collect()),
Suggestion::InsertAfter(insertion) => {
suggestions.push(format!("{existing}{}", insertion.iter().collect::<String>()))
}
Suggestion::Remove => suggestions.push(String::new()),
}
}
suggestions.truncate(5);
issues.push(Issue {
start,
end,
kind: "grammar".into(),
category: format!("{:?}", lint.lint_kind),
message: lint.message,
suggestions,
});
}
}
#[tauri::command]
pub async fn proof_text(
app: tauri::AppHandle,
state: tauri::State<'_, ProofState>,
text: String,
spelling: bool,
grammar: bool,
) -> Result<Vec<Issue>, String> {
let mut guard = state.inner().lock().map_err(|e| e.to_string())?;
if guard.is_none() {
*guard = Some(Engine {
speller: None,
harper: None,
custom: load_custom(&app),
});
}
let engine = guard.as_mut().unwrap();
let chars: Vec<char> = text.chars().collect();
let mut issues = Vec::new();
if spelling {
if engine.speller.is_none() {
engine.speller = Some(build_speller(&app)?);
}
collect_spelling(engine.speller.as_ref().unwrap(), &engine.custom, &chars, &mut issues);
}
if grammar {
if engine.harper.is_none() {
engine.harper = Some(build_harper());
}
collect_grammar(engine.harper.as_mut().unwrap(), &text, &chars, &mut issues);
}
Ok(issues)
}
#[tauri::command]
pub fn remember_word(
app: tauri::AppHandle,
state: tauri::State<'_, ProofState>,
word: String,
) -> Result<(), String> {
let word = word.trim().to_string();
if word.is_empty() {
return Ok(());
}
let path = custom_dict_path(&app)?;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| e.to_string())?;
writeln!(file, "{word}").map_err(|e| e.to_string())?;
let mut guard = state.inner().lock().map_err(|e| e.to_string())?;
if let Some(engine) = guard.as_mut() {
engine.custom.insert(word);
}
Ok(())
}
+21
View File
@@ -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 broken/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 = []
+1
View File
@@ -0,0 +1 @@
// Intentionally empty. See Cargo.toml — this stubs out the disabled `burn-cuda` backend.
+16
View File
@@ -0,0 +1,16 @@
# 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, whose
# current prereleases fail to resolve and link-collide with `zip`'s lzma. 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 = []
+1
View File
@@ -0,0 +1 @@
// Intentionally empty. See Cargo.toml — this stubs out the disabled cubecl-cpu JIT runtime.
+1
View File
@@ -27,6 +27,7 @@
"bundle": {
"active": true,
"targets": "all",
"resources": ["resources/dictionaries/en/*"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"pubkey": "REPLACE_WITH_YOUR_TAURI_PUBLIC_KEY",
"endpoints": [
"https://github.com/priyanshujain/margin/releases/latest/download/latest.json"
]
}
}
}
+3
View File
@@ -6,6 +6,7 @@ import { useBook } from "./store/useBook";
import { isDesktop } from "./ipc";
import { newBook } from "./library";
import { runExport } from "./export/run";
import { checkForUpdates } from "./updater";
function App() {
const book = useBook((s) => s.book);
@@ -13,11 +14,13 @@ function App() {
useEffect(() => {
if (!isDesktop) return;
checkForUpdates(true);
const unlisten = listen<string>("menu-action", (event) => {
const state = useBook.getState();
if (event.payload === "new-book") state.openBook(newBook());
else if (event.payload === "export-pdf") runExport("pdf");
else if (event.payload === "export-epub") runExport("epub");
else if (event.payload === "check-updates") checkForUpdates(false);
});
return () => {
unlisten.then((stop) => stop());
+1 -1
View File
@@ -133,7 +133,7 @@ function HtmlDock() {
<div className="page">
<div className="p-opener">
<div className="p-num">{eyebrow}</div>
<div className="p-title">{chapter.title || "Untitled"}</div>
{!chapter.noTitle && <div className="p-title">{chapter.title || "Untitled"}</div>}
</div>
<div className="page-body" dangerouslySetInnerHTML={{ __html: html }} />
<div className="folio">{idx * 8 + 7}</div>
+127 -4
View File
@@ -6,15 +6,20 @@ import { ResizeHandle } from "./ResizeHandle";
import { Icon } from "./Icon";
import { Settings } from "./Settings";
import { CoverView } from "./CoverView";
import { FindBar } from "./FindBar";
import { ProofPopover } from "./ProofPopover";
import { Editor } from "../editor/Editor";
import { FloatingToolbar } from "../editor/FloatingToolbar";
import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing";
import { COVER_ID, useBook } from "../store/useBook";
import { useProofing } from "../store/useProofing";
import { useTheme } from "../store/useTheme";
import { useWidth } from "../store/useWidth";
import { WIDTH_OPTIONS } from "../width";
import { bodyNumber, chapterKind } from "../model/book";
import { saveBook } from "../library";
import { isDesktop } from "../ipc";
import { issueSignature, rememberWord, runProof } from "../proofing";
import { runExport } from "../export/run";
export function EditorView() {
@@ -26,6 +31,7 @@ export function EditorView() {
const setNotice = useBook((s) => s.setNotice);
const setChapterContent = useBook((s) => s.setChapterContent);
const setChapterTitle = useBook((s) => s.setChapterTitle);
const setChapterNoTitle = useBook((s) => s.setChapterNoTitle);
const markSaved = useBook((s) => s.markSaved);
const theme = useTheme((s) => s.theme);
const toggleTheme = useTheme((s) => s.toggle);
@@ -37,6 +43,35 @@ export function EditorView() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [widthOpen, setWidthOpen] = useState(false);
const [findOpen, setFindOpen] = useState(false);
const [findExpanded, setFindExpanded] = useState(false);
const [proofPopover, setProofPopover] = useState<{ issue: ProofIssue; coords: ProofCoords } | null>(null);
const spelling = useProofing((s) => s.spelling);
const grammar = useProofing((s) => s.grammar);
const ignored = useProofing((s) => s.ignored);
const toggleSpelling = useProofing((s) => s.toggleSpelling);
const toggleGrammar = useProofing((s) => s.toggleGrammar);
const coverActive = activeChapterId === COVER_ID;
const proofingAvailable = isDesktop && book?.metadata.language === "en";
const openFind = useCallback((expanded: boolean) => {
setFindExpanded(expanded);
setFindOpen(true);
}, []);
const reproof = useCallback(() => {
if (!editor) return;
const state = useProofing.getState();
if (!proofingAvailable || (!state.spelling && !state.grammar)) {
editor.commands.clearProofIssues();
return;
}
runProof(editor.state.doc, { spelling: state.spelling, grammar: state.grammar }, state.ignored)
.then((issues) => editor.commands.setProofIssues(issues))
.catch(() => {});
}, [editor, proofingAvailable]);
const saveNow = useCallback(() => {
const current = useBook.getState().book;
@@ -54,11 +89,14 @@ export function EditorView() {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
saveNow();
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") {
e.preventDefault();
openFind(e.altKey);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [saveNow]);
}, [saveNow, openFind]);
useEffect(() => {
if (!notice) return;
@@ -66,13 +104,45 @@ export function EditorView() {
return () => clearTimeout(timer);
}, [notice]);
useEffect(() => {
if (!editor) return;
const storage = (editor.storage as unknown as Record<string, ProofingStorage>).proofing;
storage.onClickIssue = (issue, coords) => setProofPopover({ issue, coords });
const closePopover = () => setProofPopover(null);
editor.on("update", closePopover);
return () => {
storage.onClickIssue = null;
editor.off("update", closePopover);
};
}, [editor]);
useEffect(() => {
if (!editor) return;
if (!proofingAvailable || coverActive || (!spelling && !grammar)) {
editor.commands.clearProofIssues();
return;
}
let timer: ReturnType<typeof setTimeout>;
const schedule = (delay: number) => {
clearTimeout(timer);
timer = setTimeout(reproof, delay);
};
editor.commands.clearProofIssues();
schedule(150);
const onUpdate = () => schedule(500);
editor.on("update", onUpdate);
return () => {
clearTimeout(timer);
editor.off("update", onUpdate);
};
}, [editor, spelling, grammar, ignored, activeChapterId, coverActive, proofingAvailable, reproof]);
const handleExport = (format: "pdf" | "epub") => {
setExportOpen(false);
runExport(format);
};
if (!book) return null;
const coverActive = activeChapterId === COVER_ID;
const idx = book.chapters.findIndex((c) => c.id === activeChapterId);
const chapter = book.chapters[idx] ?? book.chapters[0];
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
@@ -88,6 +158,19 @@ export function EditorView() {
{dirty && <span className="dirty-dot" />}
</button>
<div className="actions">
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)">
<Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" />
</button>
{proofingAvailable && !coverActive && (
<>
<button className="icon-btn" data-on={spelling} onClick={toggleSpelling} title="Check spelling">
<Icon d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" />
</button>
<button className="icon-btn" data-on={grammar} onClick={toggleGrammar} title="Check grammar">
<Icon d="M4 7h16M4 12h12M4 17h7M13.5 18.5c1-1.2 2-1.2 3 0s2 1.2 3 0" />
</button>
</>
)}
<div className="menu-wrap">
<button className="icon-btn" data-on={widthOpen} onClick={() => setWidthOpen((v) => !v)} title="Editor width">
<Icon d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" />
@@ -142,6 +225,13 @@ export function EditorView() {
</div>
</header>
<FindBar
editor={coverActive ? null : editor}
open={findOpen && !coverActive}
initialExpanded={findExpanded}
onClose={() => setFindOpen(false)}
/>
<div className="body">
<Sidebar />
<ResizeHandle pane="sidebar" />
@@ -155,11 +245,22 @@ export function EditorView() {
<div className="chapter-num">{eyebrow}</div>
<input
className="chapter-title-input"
value={chapter.title}
placeholder={kind === "body" ? "Chapter title" : "Page title"}
value={chapter.noTitle ? "" : chapter.title}
placeholder={chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : "Page title"}
spellCheck={false}
disabled={chapter.noTitle}
onChange={(e) => setChapterTitle(chapter.id, e.target.value)}
/>
{(chapter.noTitle || !chapter.title.trim()) && (
<label className="no-title-check">
<input
type="checkbox"
checked={!!chapter.noTitle}
onChange={(e) => setChapterNoTitle(chapter.id, e.target.checked)}
/>
No title
</label>
)}
</header>
<Editor
chapterId={chapter.id}
@@ -176,6 +277,28 @@ export function EditorView() {
{dock && <Dock />}
</div>
{proofPopover && editor && (
<ProofPopover
issue={proofPopover.issue}
coords={proofPopover.coords}
onReplace={(suggestion) => {
const { from, to } = proofPopover.issue;
if (suggestion === "") editor.chain().focus().deleteRange({ from, to }).run();
else editor.chain().focus().insertContentAt({ from, to }, suggestion).run();
setProofPopover(null);
}}
onIgnore={() => {
useProofing.getState().ignore(issueSignature(proofPopover.issue));
setProofPopover(null);
}}
onRemember={() => {
rememberWord(proofPopover.issue.word).then(reproof).catch(() => {});
setProofPopover(null);
}}
onClose={() => setProofPopover(null)}
/>
)}
{settingsOpen && <Settings onClose={() => setSettingsOpen(false)} onSave={saveNow} />}
{exporting && (
<div className="export-overlay">
+283
View File
@@ -0,0 +1,283 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { Editor as TiptapEditor } from "@tiptap/react";
import { buildRegex, getSearchState } from "../editor/search";
import { countMatches, replaceInContent } from "../search-book";
import { useBook } from "../store/useBook";
import { Icon } from "./Icon";
interface FindBarProps {
editor: TiptapEditor | null;
open: boolean;
initialExpanded: boolean;
onClose: () => void;
}
type Scope = "chapter" | "book";
type PendingNav = "first" | "last" | null;
export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps) {
const book = useBook((s) => s.book);
const activeChapterId = useBook((s) => s.activeChapterId);
const setActiveChapter = useBook((s) => s.setActiveChapter);
const setChapterContent = useBook((s) => s.setChapterContent);
const setNotice = useBook((s) => s.setNotice);
const [query, setQuery] = useState("");
const [replacement, setReplacement] = useState("");
const [caseSensitive, setCaseSensitive] = useState(false);
const [wholeWord, setWholeWord] = useState(false);
const [expanded, setExpanded] = useState(initialExpanded);
const [scope, setScope] = useState<Scope>("chapter");
const [active, setActive] = useState({ count: 0, current0: 0 });
const [pendingNav, setPendingNav] = useState<PendingNav>(null);
const findInputRef = useRef<HTMLInputElement>(null);
const chapters = useMemo(() => book?.chapters ?? [], [book]);
const activeIdx = chapters.findIndex((c) => c.id === activeChapterId);
const options = { caseSensitive, wholeWord };
const jsonCounts = useMemo(() => {
const regex = query ? buildRegex(query, options) : null;
if (!regex) return chapters.map(() => 0);
return chapters.map((c) => countMatches(c.content, regex));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chapters, query, caseSensitive, wholeWord]);
useEffect(() => {
if (open) setExpanded(initialExpanded);
}, [open, initialExpanded]);
useEffect(() => {
if (!editor) return;
const update = () => {
const s = getSearchState(editor.state);
setActive({ count: s?.matches.length ?? 0, current0: s?.current ?? 0 });
};
editor.on("transaction", update);
update();
return () => {
editor.off("transaction", update);
};
}, [editor]);
useEffect(() => {
if (!editor) return;
if (open) {
editor.commands.setSearch(query, { caseSensitive, wholeWord });
} else {
editor.commands.clearSearch();
}
}, [editor, open, query, caseSensitive, wholeWord]);
useEffect(() => {
if (open) {
const input = findInputRef.current;
input?.focus();
input?.select();
}
}, [open]);
useEffect(() => {
if (!editor || !pendingNav) return;
let done = false;
const go = () => {
if (done) return;
done = true;
const s = getSearchState(editor.state);
if (s && s.matches.length) editor.commands.goToMatch(pendingNav === "first" ? 0 : s.matches.length - 1);
setPendingNav(null);
};
editor.on("transaction", go);
const raf = requestAnimationFrame(go);
return () => {
editor.off("transaction", go);
cancelAnimationFrame(raf);
};
}, [editor, pendingNav, activeChapterId]);
if (!open || !editor) return null;
const before = jsonCounts.slice(0, Math.max(0, activeIdx)).reduce((a, b) => a + b, 0);
const after = jsonCounts.slice(activeIdx + 1).reduce((a, b) => a + b, 0);
const total = before + active.count + after;
const globalCurrent = before + active.current0 + 1;
const otherHasMatches = chapters.some((c, i) => c.id !== activeChapterId && jsonCounts[i] > 0);
const countLabel = !query
? ""
: total === 0
? "No results"
: active.count === 0
? `${total} found`
: `${globalCurrent} of ${total}`;
const nextChapterWith = (dir: 1 | -1): string | null => {
const n = chapters.length;
if (!n || activeIdx < 0) return null;
for (let step = 1; step <= n; step++) {
const i = (((activeIdx + dir * step) % n) + n) % n;
if (chapters[i].id !== activeChapterId && jsonCounts[i] > 0) return chapters[i].id;
}
return null;
};
const next = () => {
const s = getSearchState(editor.state);
const count = s?.matches.length ?? 0;
const cur = s?.current ?? 0;
if (count > 0 && cur < count - 1) {
editor.commands.findNext();
return;
}
if (otherHasMatches) {
const id = nextChapterWith(1);
if (id) {
setPendingNav("first");
setActiveChapter(id);
return;
}
}
editor.commands.findNext();
};
const prev = () => {
const s = getSearchState(editor.state);
const count = s?.matches.length ?? 0;
const cur = s?.current ?? 0;
if (count > 0 && cur > 0) {
editor.commands.findPrev();
return;
}
if (otherHasMatches) {
const id = nextChapterWith(-1);
if (id) {
setPendingNav("last");
setActiveChapter(id);
return;
}
}
editor.commands.findPrev();
};
const onFindKey = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
e.shiftKey ? prev() : next();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
const replaceOne = () => {
editor.commands.replaceCurrent(replacement);
editor.commands.focus();
};
const replaceAll = () => {
if (scope === "chapter") {
editor.commands.replaceAllInChapter(replacement);
editor.commands.focus();
return;
}
const regex = buildRegex(query, options);
if (!regex) return;
let totalReplaced = 0;
let touched = 0;
const s = getSearchState(editor.state);
const activeCount = s?.matches.length ?? 0;
if (activeCount > 0) {
editor.commands.replaceAllInChapter(replacement);
totalReplaced += activeCount;
touched++;
}
for (const c of chapters) {
if (c.id === activeChapterId) continue;
const result = replaceInContent(c.content, regex, replacement);
if (result.count > 0) {
setChapterContent(c.id, result.content);
totalReplaced += result.count;
touched++;
}
}
setNotice(`Replaced ${totalReplaced} across ${touched} chapter${touched === 1 ? "" : "s"}`);
editor.commands.focus();
};
return (
<div className="find-bar glass">
<button
className="find-expand"
data-on={expanded}
title={expanded ? "Hide replace" : "Show replace"}
onClick={() => setExpanded((v) => !v)}
>
<Icon d={expanded ? "M6 9l6 6 6-6" : "M9 6l6 6-6 6"} size={14} />
</button>
<div className="find-stack">
<div className="find-row">
<input
ref={findInputRef}
className="find-input"
value={query}
placeholder="Find"
spellCheck={false}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onFindKey}
/>
<span className="find-count">{countLabel}</span>
<button className="find-btn" title="Previous (⇧⏎)" disabled={!total} onClick={prev}>
<Icon d="M6 15l6-6 6 6" size={14} />
</button>
<button className="find-btn" title="Next (⏎)" disabled={!total} onClick={next}>
<Icon d="M6 9l6 6 6-6" size={14} />
</button>
<button className="find-toggle" data-on={caseSensitive} title="Match case" onClick={() => setCaseSensitive((v) => !v)}>
Aa
</button>
<button className="find-toggle" data-on={wholeWord} title="Whole word" onClick={() => setWholeWord((v) => !v)}>
<span className="find-ww">ab</span>
</button>
<button className="find-btn" title="Close (Esc)" onClick={onClose}>
<Icon d="M18 6L6 18M6 6l12 12" size={14} />
</button>
</div>
{expanded && (
<div className="find-row">
<input
className="find-input"
value={replacement}
placeholder="Replace"
spellCheck={false}
onChange={(e) => setReplacement(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
replaceOne();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}}
/>
<button className="find-action" disabled={!active.count} onClick={replaceOne} title="Replace current match">
Replace
</button>
<button className="find-action" disabled={!total} onClick={replaceAll} title="Replace all matches">
Replace All
</button>
<button
className="find-scope"
title="Replace All scope"
onClick={() => setScope((s) => (s === "chapter" ? "book" : "chapter"))}
>
{scope === "chapter" ? "This chapter" : "Whole book"}
</button>
</div>
)}
</div>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { severityFor, type ProofCoords, type ProofIssue } from "../editor/proofing";
function humanize(category: string): string {
return category.replace(/([a-z])([A-Z])/g, "$1 $2");
}
interface ProofPopoverProps {
issue: ProofIssue;
coords: ProofCoords;
onReplace: (suggestion: string) => void;
onIgnore: () => void;
onRemember: () => void;
onClose: () => void;
}
const POP_WIDTH = 264;
export function ProofPopover({ issue, coords, onReplace, onIgnore, onRemember, onClose }: ProofPopoverProps) {
const popRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (popRef.current?.contains(e.target as Node)) return;
onClose();
};
const close = () => onClose();
document.addEventListener("mousedown", onDown, true);
document.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("mousedown", onDown, true);
document.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
}, [onClose]);
const left = Math.min(Math.max(8, coords.left - POP_WIDTH / 2), window.innerWidth - POP_WIDTH - 8);
const top = coords.bottom + 6;
return createPortal(
<div
ref={popRef}
className="proof-pop"
style={{ left, top, width: POP_WIDTH }}
onMouseDown={(e) => e.stopPropagation()}
>
<div className={`proof-pop-kind sev-${severityFor(issue.category)}`}>{humanize(issue.category)}</div>
<div className="proof-pop-msg">{issue.message}</div>
{issue.suggestions.length > 0 && (
<div className="proof-pop-suggestions">
{issue.suggestions.map((suggestion, i) => (
<button key={i} className="proof-suggestion" onClick={() => onReplace(suggestion)}>
{suggestion === "" ? "Remove" : suggestion}
</button>
))}
</div>
)}
<div className="proof-pop-actions">
<button className="proof-action" onClick={onIgnore}>
Ignore
</button>
{issue.kind === "spelling" && (
<button className="proof-action" onClick={onRemember}>
Remember
</button>
)}
</div>
</div>,
document.body
);
}
+14 -1
View File
@@ -4,11 +4,12 @@ import { Icon } from "./Icon";
interface RowMenuProps {
label: string;
onDuplicate?: () => void;
onDelete: () => void;
className?: string;
}
export function RowMenu({ label, onDelete, className = "" }: RowMenuProps) {
export function RowMenu({ label, onDuplicate, onDelete, className = "" }: RowMenuProps) {
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState({ top: 0, right: 0 });
const btnRef = useRef<HTMLButtonElement>(null);
@@ -40,6 +41,12 @@ export function RowMenu({ label, onDelete, className = "" }: RowMenuProps) {
};
}, [open]);
const duplicate = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(false);
onDuplicate?.();
};
const remove = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(false);
@@ -61,6 +68,12 @@ export function RowMenu({ label, onDelete, className = "" }: RowMenuProps) {
{open &&
createPortal(
<div ref={popRef} className="row-menu-pop" style={{ top: coords.top, right: coords.right }}>
{onDuplicate && (
<button className="row-menu-item" onClick={duplicate}>
<Icon d="M9 9h11v11h-11z M6 15V5h9" size={14} />
Duplicate
</button>
)}
<button className="row-menu-item danger" onClick={remove}>
<Icon d="M5 7h14M10 7V5h4v2M7 7l1 13h8l1-13M10 11v6M14 11v6" size={14} />
Delete
+2
View File
@@ -43,6 +43,7 @@ export function Sidebar() {
const setActiveChapter = useBook((s) => s.setActiveChapter);
const addChapter = useBook((s) => s.addChapter);
const addPage = useBook((s) => s.addPage);
const duplicateChapter = useBook((s) => s.duplicateChapter);
const deleteChapter = useBook((s) => s.deleteChapter);
const moveChapter = useBook((s) => s.moveChapter);
const closeBook = useBook((s) => s.closeBook);
@@ -195,6 +196,7 @@ export function Sidebar() {
</span>
<RowMenu
label="Page options"
onDuplicate={() => duplicateChapter(row.chapter.id)}
onDelete={() => setPendingDelete({ id: row.chapter.id, title: row.chapter.title || "Untitled" })}
/>
</li>
+11 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useReducer, useRef, useState, type ReactNode } from "react";
import type { Editor } from "@tiptap/react";
import { Icon } from "../components/Icon";
@@ -23,11 +23,21 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
const linkInputRef = useRef<HTMLInputElement>(null);
const [linkOpen, setLinkOpen] = useState(false);
const [linkValue, setLinkValue] = useState("");
const [, forceUpdate] = useReducer((x) => x + 1, 0);
useEffect(() => {
if (linkOpen) linkInputRef.current?.focus();
}, [linkOpen]);
useEffect(() => {
if (!editor) return;
const update = () => forceUpdate();
editor.on("transaction", update);
return () => {
editor.off("transaction", update);
};
}, [editor]);
if (!editor) return null;
const insertImage = async (file: File) => {
+4
View File
@@ -3,6 +3,8 @@ import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { Figure } from "./figure";
import { ParagraphIndent } from "./indent";
import { SearchHighlight } from "./search";
import { Proofing } from "./proofing";
export const editorExtensions: Extensions = [
StarterKit.configure({
@@ -15,4 +17,6 @@ export const editorExtensions: Extensions = [
}),
Figure,
ParagraphIndent,
SearchHighlight,
Proofing,
];
+133
View File
@@ -0,0 +1,133 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import type { Node as PMNode } from "@tiptap/pm/model";
export interface ProofIssue {
from: number;
to: number;
kind: "spelling" | "grammar";
category: string;
message: string;
suggestions: string[];
word: string;
}
export type ProofSeverity = "error" | "warn" | "suggest";
const WARN_CATEGORIES = new Set(["Redundancy", "Repetition", "Formatting", "Regionalism"]);
const SUGGEST_CATEGORIES = new Set(["Style", "Readability", "Enhancement", "Miscellaneous"]);
export function severityFor(category: string): ProofSeverity {
if (WARN_CATEGORIES.has(category)) return "warn";
if (SUGGEST_CATEGORIES.has(category)) return "suggest";
return "error";
}
export interface ProofCoords {
left: number;
top: number;
bottom: number;
}
interface ProofingState {
issues: ProofIssue[];
decorations: DecorationSet;
}
export const proofingKey = new PluginKey<ProofingState>("proofing");
function buildDecorations(doc: PMNode, issues: ProofIssue[]): DecorationSet {
const size = doc.content.size;
const decos = issues
.filter((i) => i.from < i.to && i.to <= size)
.map((i) => Decoration.inline(i.from, i.to, { class: `proof-mark sev-${severityFor(i.category)}` }));
return DecorationSet.create(doc, decos);
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
proofing: {
setProofIssues: (issues: ProofIssue[]) => ReturnType;
clearProofIssues: () => ReturnType;
};
}
}
export interface ProofingStorage {
onClickIssue: ((issue: ProofIssue, coords: ProofCoords) => void) | null;
}
export const Proofing = Extension.create<unknown, ProofingStorage>({
name: "proofing",
addStorage() {
return { onClickIssue: null };
},
addProseMirrorPlugins() {
const storage = this.storage;
return [
new Plugin<ProofingState>({
key: proofingKey,
state: {
init: () => ({ issues: [], decorations: DecorationSet.empty }),
apply(tr, value, _old, newState) {
const meta = tr.getMeta(proofingKey) as { issues: ProofIssue[] } | { clear: true } | undefined;
if (meta && "issues" in meta) {
return { issues: meta.issues, decorations: buildDecorations(newState.doc, meta.issues) };
}
if (meta && "clear" in meta) {
return { issues: [], decorations: DecorationSet.empty };
}
if (tr.docChanged && value.issues.length) {
const issues = value.issues
.map((i) => ({ ...i, from: tr.mapping.map(i.from), to: tr.mapping.map(i.to) }))
.filter((i) => i.to > i.from);
return { issues, decorations: value.decorations.map(tr.mapping, tr.doc) };
}
return value;
},
},
props: {
decorations(state) {
return proofingKey.getState(state)?.decorations ?? DecorationSet.empty;
},
handleClick(view, pos) {
const s = proofingKey.getState(view.state);
if (!s || !s.issues.length) return false;
const hits = s.issues.filter((i) => pos >= i.from && pos <= i.to);
if (!hits.length) return false;
const issue = hits.find((i) => i.kind === "spelling") ?? hits[0];
if (!storage.onClickIssue) return false;
const start = view.coordsAtPos(issue.from);
const end = view.coordsAtPos(issue.to);
storage.onClickIssue(issue, {
left: (start.left + end.left) / 2,
top: start.top,
bottom: end.bottom,
});
return false;
},
},
}),
];
},
addCommands() {
return {
setProofIssues:
(issues) =>
({ tr, dispatch }) => {
if (dispatch) dispatch(tr.setMeta(proofingKey, { issues }));
return true;
},
clearProofIssues:
() =>
({ tr, dispatch }) => {
if (dispatch) dispatch(tr.setMeta(proofingKey, { clear: true }));
return true;
},
};
},
});
+233
View File
@@ -0,0 +1,233 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view";
import type { Node as PMNode } from "@tiptap/pm/model";
export interface SearchOptions {
caseSensitive: boolean;
wholeWord: boolean;
}
export interface SearchMatch {
from: number;
to: number;
}
interface SearchState {
query: string;
options: SearchOptions;
matches: SearchMatch[];
current: number;
decorations: DecorationSet;
}
export const searchKey = new PluginKey<SearchState>("search");
const EMPTY: SearchState = {
query: "",
options: { caseSensitive: false, wholeWord: false },
matches: [],
current: 0,
decorations: DecorationSet.empty,
};
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function buildRegex(query: string, options: SearchOptions): RegExp | null {
if (!query) return null;
let pattern = escapeRegExp(query);
if (options.wholeWord) pattern = `\\b${pattern}\\b`;
try {
return new RegExp(pattern, options.caseSensitive ? "g" : "gi");
} catch {
return null;
}
}
export function findMatches(doc: PMNode, regex: RegExp): SearchMatch[] {
const matches: SearchMatch[] = [];
let runText = "";
let runStart = 0;
const flush = () => {
if (!runText) return;
regex.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = regex.exec(runText)) !== null) {
const from = runStart + m.index;
matches.push({ from, to: from + m[0].length });
if (m.index === regex.lastIndex) regex.lastIndex++;
}
runText = "";
};
doc.descendants((node, pos) => {
if (node.isText) {
if (!runText) runStart = pos;
runText += node.text ?? "";
} else {
flush();
}
return true;
});
flush();
return matches;
}
function buildDecorations(doc: PMNode, matches: SearchMatch[], current: number): DecorationSet {
if (!matches.length) return DecorationSet.empty;
const decos = matches.map((m, i) =>
Decoration.inline(m.from, m.to, {
class: i === current ? "search-match search-match-current" : "search-match",
})
);
return DecorationSet.create(doc, decos);
}
function recompute(doc: PMNode, query: string, options: SearchOptions, desiredCurrent: number): SearchState {
const regex = buildRegex(query, options);
if (!regex) return { ...EMPTY, query, options };
const matches = findMatches(doc, regex);
const current = matches.length ? Math.max(0, Math.min(desiredCurrent, matches.length - 1)) : 0;
return { query, options, matches, current, decorations: buildDecorations(doc, matches, current) };
}
function scrollMatchIntoView(view: EditorView, match: SearchMatch) {
const { node } = view.domAtPos(match.from);
const el = node.nodeType === Node.TEXT_NODE ? (node as Text).parentElement : (node as HTMLElement);
el?.scrollIntoView({ block: "center", behavior: "smooth" });
}
export function getSearchState(state: { plugins: unknown } | unknown): SearchState | undefined {
return searchKey.getState(state as never);
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
search: {
setSearch: (query: string, options: SearchOptions) => ReturnType;
clearSearch: () => ReturnType;
findNext: () => ReturnType;
findPrev: () => ReturnType;
goToMatch: (index: number) => ReturnType;
replaceCurrent: (replacement: string) => ReturnType;
replaceAllInChapter: (replacement: string) => ReturnType;
};
}
}
export const SearchHighlight = Extension.create({
name: "searchHighlight",
addProseMirrorPlugins() {
return [
new Plugin<SearchState>({
key: searchKey,
state: {
init: () => EMPTY,
apply(tr, value, _old, newState) {
const meta = tr.getMeta(searchKey) as
| { type: "search"; query: string; options: SearchOptions }
| { type: "nav"; current: number }
| { type: "clear" }
| undefined;
if (meta?.type === "search") {
return recompute(newState.doc, meta.query, meta.options, 0);
}
if (meta?.type === "nav" && value.matches.length) {
const current = ((meta.current % value.matches.length) + value.matches.length) % value.matches.length;
return { ...value, current, decorations: buildDecorations(newState.doc, value.matches, current) };
}
if (meta?.type === "clear") {
return { ...EMPTY };
}
if (tr.docChanged && value.query) {
return recompute(newState.doc, value.query, value.options, value.current);
}
return value;
},
},
props: {
decorations(state) {
return searchKey.getState(state)?.decorations ?? DecorationSet.empty;
},
},
}),
];
},
addCommands() {
return {
setSearch:
(query, options) =>
({ tr, dispatch }) => {
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "search", query, options }));
return true;
},
clearSearch:
() =>
({ tr, dispatch }) => {
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "clear" }));
return true;
},
findNext:
() =>
({ state, dispatch, view, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
const current = (s.current + 1) % s.matches.length;
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "nav", current }));
scrollMatchIntoView(view, s.matches[current]);
return true;
},
findPrev:
() =>
({ state, dispatch, view, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
const current = (s.current - 1 + s.matches.length) % s.matches.length;
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "nav", current }));
scrollMatchIntoView(view, s.matches[current]);
return true;
},
goToMatch:
(index) =>
({ state, dispatch, view, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
const current = ((index % s.matches.length) + s.matches.length) % s.matches.length;
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "nav", current }));
scrollMatchIntoView(view, s.matches[current]);
return true;
},
replaceCurrent:
(replacement) =>
({ state, dispatch, view, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
const m = s.matches[s.current];
if (dispatch) {
tr.insertText(replacement, m.from, m.to);
dispatch(tr);
const ns = searchKey.getState(view.state);
if (ns && ns.matches.length) scrollMatchIntoView(view, ns.matches[ns.current]);
}
return true;
},
replaceAllInChapter:
(replacement) =>
({ state, dispatch, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
if (dispatch) {
for (let i = s.matches.length - 1; i >= 0; i--) {
const m = s.matches[i];
tr.insertText(replacement, m.from, m.to);
}
dispatch(tr);
}
return true;
},
};
},
});
+6 -2
View File
@@ -284,9 +284,12 @@ function contentOpf(
function navXhtml(book: Book): string {
const items = book.chapters
.map((chapter, i) => {
if (chapter.noTitle && chapterKind(chapter) !== "body") return "";
const fallback = chapterKind(chapter) === "body" ? `Chapter ${bodyNumber(book.chapters, i)}` : "Untitled";
return ` <li><a href="chapter-${i + 1}.xhtml">${esc(chapter.title || fallback)}</a></li>`;
const label = chapter.noTitle ? fallback : chapter.title || fallback;
return ` <li><a href="chapter-${i + 1}.xhtml">${esc(label)}</a></li>`;
})
.filter(Boolean)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
@@ -311,6 +314,7 @@ function chapterXhtml(book: Book, index: number, paths: Map<string, string>): st
const num = bodyNumber(book.chapters, index);
const title = chapter.title || (kind === "body" ? `Chapter ${num}` : "Untitled");
const eyebrow = kind === "body" ? `<p class="eyebrow">Chapter ${num}</p>` : "";
const heading = chapter.noTitle ? "" : `<h1>${esc(title)}</h1>`;
return `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
<head>
@@ -320,7 +324,7 @@ function chapterXhtml(book: Book, index: number, paths: Map<string, string>): st
<body epub:type="${kind === "body" ? "chapter" : kind === "front" ? "frontmatter" : "backmatter"}">
<header class="chapter-opener">
${eyebrow}
<h1>${esc(title)}</h1>
${heading}
</header>
${chapterBody(chapter.content, paths)}
</body>
+8 -4
View File
@@ -139,9 +139,11 @@ function preamble(book: Book): string {
align(center)[
#if info.kind == "body" {
text(font: "Hanken Grotesk", size: 8.5pt, weight: "semibold", tracking: 0.28em)[#upper("Chapter " + info.num)]
v(0.7em)
if not info.notitle { v(0.7em) }
}
#if not info.notitle {
heading(level: 1, numbering: none, outlined: info.toc)[#info.title]
}
#heading(level: 1, numbering: none, outlined: info.toc)[#info.title]
]
v(1.5em)
}
@@ -262,6 +264,7 @@ function normTitle(title: string): string {
function inToc(book: Book, index: number): boolean {
const chapter = book.chapters[index];
if (chapter.noTitle && chapterKind(chapter) !== "body") return false;
const title = cleanTitle(chapter.title) || "Untitled";
return !(chapterKind(chapter) === "front" && normTitle(title) === normTitle(book.metadata.title));
}
@@ -270,8 +273,9 @@ function openerCall(book: Book, index: number): string {
const chapter = book.chapters[index];
const kind = chapterKind(chapter);
const num = kind === "body" ? String(bodyNumber(book.chapters, index)) : "";
const title = cleanTitle(chapter.title) || "Untitled";
return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, toc: ${inToc(book, index)}))`;
const notitle = !!chapter.noTitle;
const title = notitle ? "" : cleanTitle(chapter.title) || "Untitled";
return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, notitle: ${notitle}, toc: ${inToc(book, index)}))`;
}
export function bookToTypst(book: Book, paths: Map<string, string> = new Map(), coverPath?: string): string {
+6
View File
@@ -70,6 +70,7 @@ export interface Chapter {
content: JSONContent;
updatedAt: number;
kind?: ChapterKind;
noTitle?: boolean;
}
export interface PageType {
@@ -130,6 +131,11 @@ export function createPage(group: "front" | "back", title: string): Chapter {
return { id: crypto.randomUUID(), title, content: emptyDoc(), kind: group, updatedAt: Date.now() };
}
export function cloneChapter(chapter: Chapter): Chapter {
const title = chapter.noTitle || !chapter.title ? chapter.title : `${chapter.title} (copy)`;
return { ...chapter, id: crypto.randomUUID(), title, content: structuredClone(chapter.content), updatedAt: Date.now() };
}
export function createCover(): Cover {
return { kind: "default", image: "", bg: COVER_PALETTES[0].bg, ink: COVER_PALETTES[0].ink };
}
+112
View File
@@ -0,0 +1,112 @@
import { invoke } from "@tauri-apps/api/core";
import type { Node as PMNode } from "@tiptap/pm/model";
import { isDesktop } from "./ipc";
import type { ProofIssue } from "./editor/proofing";
export interface Issue {
start: number;
end: number;
kind: "spelling" | "grammar";
category: string;
message: string;
suggestions: string[];
}
export interface Segment {
from: number;
text: string;
cpStart: number;
cpLen: number;
}
function codepointLength(text: string): number {
let n = 0;
for (const _ of text) n++;
return n;
}
function utf16Offset(text: string, codepoints: number): number {
let units = 0;
let count = 0;
for (const ch of text) {
if (count >= codepoints) break;
units += ch.length;
count++;
}
return units;
}
export function docText(doc: PMNode): { text: string; segments: Segment[] } {
const segments: Segment[] = [];
let text = "";
let cp = 0;
let first = true;
doc.descendants((node, pos) => {
if (!node.isTextblock) return true;
if (!first) {
text += "\n\n";
cp += 2;
}
first = false;
node.forEach((child, offset) => {
if (!child.isText) return;
const t = child.text ?? "";
const len = codepointLength(t);
segments.push({ from: pos + 1 + offset, text: t, cpStart: cp, cpLen: len });
text += t;
cp += len;
});
return false;
});
return { text, segments };
}
export function mapOffset(segments: Segment[], cpOffset: number): number {
for (const seg of segments) {
if (cpOffset < seg.cpStart) return seg.from;
if (cpOffset <= seg.cpStart + seg.cpLen) {
return seg.from + utf16Offset(seg.text, cpOffset - seg.cpStart);
}
}
const last = segments[segments.length - 1];
return last ? last.from + last.text.length : 0;
}
export async function proofText(text: string, opts: { spelling: boolean; grammar: boolean }): Promise<Issue[]> {
if (!isDesktop) return [];
return invoke<Issue[]>("proof_text", { text, spelling: opts.spelling, grammar: opts.grammar });
}
export async function rememberWord(word: string): Promise<void> {
if (!isDesktop) return;
await invoke("remember_word", { word });
}
export function issueSignature(issue: { kind: string; word: string; message: string }): string {
return `${issue.kind}|${issue.word}|${issue.message}`;
}
export async function runProof(
doc: PMNode,
opts: { spelling: boolean; grammar: boolean },
ignored: Set<string>
): Promise<ProofIssue[]> {
const { text, segments } = docText(doc);
if (!text.trim()) return [];
const issues = await proofText(text, opts);
const chars = [...text];
return issues
.map((iss) => {
const word = chars.slice(iss.start, iss.end).join("");
return {
from: mapOffset(segments, iss.start),
to: mapOffset(segments, iss.end),
kind: iss.kind,
category: iss.category,
message: iss.message,
suggestions: iss.suggestions,
word,
};
})
.filter((i) => i.to > i.from && !ignored.has(issueSignature(i)));
}
+61
View File
@@ -0,0 +1,61 @@
import type { JSONContent } from "@tiptap/core";
import { buildRegex, type SearchOptions } from "./editor/search";
function collectRuns(node: JSONContent, out: string[]) {
const children = node.content;
if (!children) return;
let run = "";
for (const child of children) {
if (child.type === "text") {
run += child.text ?? "";
} else {
if (run) {
out.push(run);
run = "";
}
collectRuns(child, out);
}
}
if (run) out.push(run);
}
export function countMatches(content: JSONContent, regex: RegExp): number {
const runs: string[] = [];
collectRuns(content, runs);
let count = 0;
for (const run of runs) {
regex.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = regex.exec(run)) !== null) {
count++;
if (m.index === regex.lastIndex) regex.lastIndex++;
}
}
return count;
}
export function replaceInContent(
content: JSONContent,
regex: RegExp,
replacement: string
): { content: JSONContent; count: number } {
let count = 0;
const walk = (node: JSONContent): JSONContent => {
if (node.type === "text" && node.text) {
regex.lastIndex = 0;
const text = node.text.replace(regex, () => {
count++;
return replacement;
});
return text === node.text ? node : { ...node, text };
}
if (node.content) return { ...node, content: node.content.map(walk) };
return node;
};
const next = walk(content);
return { content: next, count };
}
export function bookRegex(query: string, options: SearchOptions): RegExp | null {
return buildRegex(query, options);
}
+19
View File
@@ -7,6 +7,7 @@ import {
type ChapterKind,
type Cover,
chapterKind,
cloneChapter,
createChapter,
createPage,
normalizeBook,
@@ -27,8 +28,10 @@ interface BookState {
setActiveChapter: (id: string) => void;
setChapterContent: (id: string, content: JSONContent) => void;
setChapterTitle: (id: string, title: string) => void;
setChapterNoTitle: (id: string, noTitle: boolean) => void;
addChapter: () => void;
addPage: (group: "front" | "back", title: string) => void;
duplicateChapter: (id: string) => void;
deleteChapter: (id: string) => void;
moveChapter: (from: number, to: number, toKind?: ChapterKind) => void;
setMetadata: (patch: Partial<BookMetadata>) => void;
@@ -63,6 +66,12 @@ export const useBook = create<BookState>((set) => ({
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, title, updatedAt: Date.now() } : c)) } }
: {}
),
setChapterNoTitle: (id, noTitle) =>
set((s) =>
s.book
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, noTitle, title: noTitle ? "" : c.title, updatedAt: Date.now() } : c)) } }
: {}
),
addChapter: () =>
set((s) => {
if (!s.book) return {};
@@ -82,6 +91,16 @@ export const useBook = create<BookState>((set) => ({
chapters.splice(insertAt, 0, page);
return { activeChapterId: page.id, dirty: true, book: { ...s.book, chapters } };
}),
duplicateChapter: (id) =>
set((s) => {
if (!s.book) return {};
const index = s.book.chapters.findIndex((c) => c.id === id);
if (index === -1) return {};
const copy = cloneChapter(s.book.chapters[index]);
const chapters = [...s.book.chapters];
chapters.splice(index + 1, 0, copy);
return { activeChapterId: copy.id, dirty: true, book: { ...s.book, chapters } };
}),
deleteChapter: (id) =>
set((s) => {
if (!s.book) return {};
+26
View File
@@ -0,0 +1,26 @@
import { create } from "zustand";
interface ProofingState {
spelling: boolean;
grammar: boolean;
ignored: Set<string>;
toggleSpelling: () => void;
toggleGrammar: () => void;
ignore: (signature: string) => void;
resetIgnored: () => void;
}
export const useProofing = create<ProofingState>((set) => ({
spelling: true,
grammar: false,
ignored: new Set(),
toggleSpelling: () => set((s) => ({ spelling: !s.spelling })),
toggleGrammar: () => set((s) => ({ grammar: !s.grammar })),
ignore: (signature) =>
set((s) => {
const ignored = new Set(s.ignored);
ignored.add(signature);
return { ignored };
}),
resetIgnored: () => set({ ignored: new Set() }),
}));
+371
View File
@@ -80,6 +80,8 @@ button {
.icon-btn[data-on="true"] {
color: var(--accent);
background: var(--accent-wash);
box-shadow: inset 0 0 0 1px var(--line-strong);
}
.dirty-dot {
@@ -355,12 +357,18 @@ body.resizing {
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.row-menu-item:hover {
background: var(--accent-wash);
color: var(--ink);
}
.row-menu-item.danger {
color: var(--danger);
}
.row-menu-item.danger:hover {
background: var(--danger-wash);
color: var(--danger);
}
.add-chapter {
@@ -628,6 +636,30 @@ body.resizing {
color: var(--ink-faint);
}
.chapter-title-input:disabled {
color: var(--ink-faint);
-webkit-text-fill-color: var(--ink-faint);
}
.no-title-check {
display: inline-flex;
align-items: center;
gap: 7px;
margin-top: 14px;
font-family: var(--font-ui);
font-size: var(--t-2);
color: var(--ink-faint);
cursor: pointer;
user-select: none;
}
.no-title-check input {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: var(--ink-soft);
}
.editor-host,
.ProseMirror {
outline: none;
@@ -1621,3 +1653,342 @@ body.resizing {
color: var(--accent);
background: var(--accent-wash);
}
.find-bar {
position: fixed;
top: 54px;
right: 18px;
z-index: 30;
display: flex;
align-items: flex-start;
gap: 4px;
padding: 7px;
background: var(--glass);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--shadow-pop);
animation: find-drop 140ms var(--ease);
}
@keyframes find-drop {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.find-expand {
flex: none;
width: 20px;
align-self: stretch;
display: grid;
place-items: center;
border-radius: var(--r-sm);
color: var(--ink-faint);
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.find-expand:hover {
background: var(--accent-wash);
color: var(--ink);
}
.find-stack {
display: flex;
flex-direction: column;
gap: 6px;
}
.find-row {
display: flex;
align-items: center;
gap: 4px;
}
.find-input {
width: 196px;
height: 28px;
padding: 0 10px;
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
background: var(--raised);
color: var(--ink);
font-family: var(--font-ui);
font-size: var(--t-2);
}
.find-input::placeholder {
color: var(--ink-faint);
}
.find-input:focus {
outline: none;
border-color: var(--accent);
}
.find-count {
min-width: 66px;
padding: 0 4px;
font-size: var(--t-1);
color: var(--ink-faint);
white-space: nowrap;
text-align: right;
}
.find-btn {
width: 26px;
height: 26px;
flex: none;
display: grid;
place-items: center;
border-radius: var(--r-sm);
color: var(--ink-soft);
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.find-btn:hover:not(:disabled) {
background: var(--accent-wash);
color: var(--ink);
}
.find-btn:disabled {
opacity: 0.4;
cursor: default;
}
.find-toggle {
height: 26px;
min-width: 28px;
padding: 0 6px;
display: grid;
place-items: center;
border-radius: var(--r-sm);
color: var(--ink-soft);
font-family: var(--font-ui);
font-size: var(--t-2);
font-weight: 600;
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.find-toggle:hover {
background: var(--accent-wash);
color: var(--ink);
}
.find-toggle[data-on="true"] {
background: var(--accent);
color: var(--accent-contrast);
}
.find-ww {
text-decoration: underline;
text-underline-offset: 2px;
}
.find-action {
height: 28px;
padding: 0 12px;
border-radius: var(--r-sm);
background: var(--accent-wash);
color: var(--ink-soft);
font-family: var(--font-ui);
font-size: var(--t-2);
font-weight: 600;
white-space: nowrap;
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.find-action:hover:not(:disabled) {
background: var(--accent);
color: var(--accent-contrast);
}
.find-action:disabled {
opacity: 0.4;
cursor: default;
}
.find-scope {
height: 28px;
padding: 0 10px;
border-radius: var(--r-sm);
border: 1px solid var(--line-strong);
color: var(--ink-soft);
font-family: var(--font-ui);
font-size: var(--t-1);
white-space: nowrap;
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.find-scope:hover {
background: var(--accent-wash);
color: var(--ink);
}
.search-match {
background: var(--accent-wash);
border-radius: 2px;
}
.search-match-current {
background: var(--selection);
border-radius: 2px;
box-shadow: 0 0 0 1.5px var(--accent);
}
.proof-mark {
cursor: pointer;
border-radius: 2px;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
transition: filter 120ms var(--ease);
}
.proof-mark.sev-error {
background: rgba(180, 69, 58, 0.13);
text-decoration-color: #b4453a;
}
.proof-mark.sev-warn {
background: rgba(168, 119, 24, 0.16);
text-decoration-color: #9c6e16;
}
.proof-mark.sev-suggest {
background: rgba(47, 110, 79, 0.13);
text-decoration-color: #2f6e4f;
}
.proof-mark:hover {
filter: brightness(0.96);
}
[data-theme="dark"] .proof-mark.sev-error {
background: rgba(217, 122, 111, 0.2);
text-decoration-color: #d97a6f;
}
[data-theme="dark"] .proof-mark.sev-warn {
background: rgba(217, 164, 65, 0.2);
text-decoration-color: #d9a441;
}
[data-theme="dark"] .proof-mark.sev-suggest {
background: rgba(111, 174, 132, 0.2);
text-decoration-color: #6fae84;
}
[data-theme="dark"] .proof-mark:hover {
filter: brightness(1.18);
}
.proof-pop {
position: fixed;
z-index: 50;
padding: 8px;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--shadow-pop);
}
.proof-pop-kind {
display: inline-block;
margin: 0 0 6px 2px;
padding: 2px 8px;
border-radius: 999px;
font-family: var(--font-ui);
font-size: var(--t-1);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.proof-pop-kind.sev-error {
background: rgba(180, 69, 58, 0.14);
color: var(--danger-ink);
}
.proof-pop-kind.sev-warn {
background: rgba(168, 119, 24, 0.16);
color: #8a6012;
}
.proof-pop-kind.sev-suggest {
background: rgba(47, 110, 79, 0.14);
color: #2c6048;
}
[data-theme="dark"] .proof-pop-kind.sev-error {
color: #e58e84;
}
[data-theme="dark"] .proof-pop-kind.sev-warn {
color: #e0bb6b;
}
[data-theme="dark"] .proof-pop-kind.sev-suggest {
color: #8fc6a3;
}
.proof-pop-msg {
padding: 2px 4px 8px;
font-family: var(--font-ui);
font-size: var(--t-2);
color: var(--ink-soft);
line-height: 1.4;
}
.proof-pop-suggestions {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.proof-suggestion {
padding: 5px 10px;
border-radius: var(--r-sm);
background: var(--accent-wash);
color: var(--ink);
font-family: var(--font-ui);
font-size: var(--t-2);
font-weight: 600;
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.proof-suggestion:hover {
background: var(--accent);
color: var(--accent-contrast);
}
.proof-pop-actions {
display: flex;
gap: 4px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--line);
}
.proof-action {
flex: 1;
padding: 6px 8px;
border-radius: var(--r-sm);
font-family: var(--font-ui);
font-size: var(--t-1);
color: var(--ink-soft);
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.proof-action:hover {
background: var(--accent-wash);
color: var(--ink);
}
+31
View File
@@ -0,0 +1,31 @@
import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { ask, message } from "@tauri-apps/plugin-dialog";
let running = false;
export async function checkForUpdates(silent: boolean) {
if (running) return;
running = true;
try {
const update = await check();
if (!update) {
if (!silent) await message("margin is up to date.", { title: "Check for Updates" });
return;
}
const notes = update.body ? `\n\n${update.body}` : "";
const install = await ask(`margin ${update.version} is available.${notes}\n\nInstall now and restart?`, {
title: "Update Available",
kind: "info",
});
if (!install) return;
await update.downloadAndInstall();
await relaunch();
} catch (err) {
if (!silent) {
await message(`Could not check for updates.\n\n${err}`, { title: "Update Failed", kind: "error" });
}
} finally {
running = false;
}
}