mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
changes
This commit is contained in:
1 parent
78fff9b6f4
commit
7cc36e3e40
37 files changed
+55361
-103
No files matched your search
@@ -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
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@tauri-apps/plugin-process": "^2",
|
||||||
|
"@tauri-apps/plugin-updater": "^2",
|
||||||
"@tiptap/core": "^3.27.1",
|
"@tiptap/core": "^3.27.1",
|
||||||
"@tiptap/extension-placeholder": "^3.27.1",
|
"@tiptap/extension-placeholder": "^3.27.1",
|
||||||
"@tiptap/pm": "^3.27.1",
|
"@tiptap/pm": "^3.27.1",
|
||||||
|
|||||||
Generated
+20
@@ -17,6 +17,12 @@ importers:
|
|||||||
'@tauri-apps/plugin-opener':
|
'@tauri-apps/plugin-opener':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.5.4
|
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':
|
'@tiptap/core':
|
||||||
specifier: ^3.27.1
|
specifier: ^3.27.1
|
||||||
version: 3.27.1(@tiptap/[email protected])
|
version: 3.27.1(@tiptap/[email protected])
|
||||||
@@ -608,6 +614,12 @@ packages:
|
|||||||
'@tauri-apps/[email protected]':
|
'@tauri-apps/[email protected]':
|
||||||
resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==}
|
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]':
|
'@tiptap/[email protected]':
|
||||||
resolution: {integrity: sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ==}
|
resolution: {integrity: sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1467,6 +1479,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.11.1
|
'@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])':
|
'@tiptap/[email protected](@tiptap/[email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/pm': 3.27.1
|
'@tiptap/pm': 3.27.1
|
||||||
|
|||||||
Generated
+3181
-79
File diff suppressed because it is too large.
Load diff
@@ -21,6 +21,8 @@ tauri-build = { version = "2", features = [] }
|
|||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-updater = "2"
|
||||||
|
tauri-plugin-process = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
@@ -28,4 +30,25 @@ zip = "2"
|
|||||||
typst = "0.14.2"
|
typst = "0.14.2"
|
||||||
typst-pdf = "0.14.2"
|
typst-pdf = "0.14.2"
|
||||||
typst-as-lib = "0.15.5"
|
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
|
||||||
|
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"opener: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
@@ -2,8 +2,9 @@ mod epub;
|
|||||||
mod library;
|
mod library;
|
||||||
mod pdf;
|
mod pdf;
|
||||||
mod project;
|
mod project;
|
||||||
|
mod proofing;
|
||||||
|
|
||||||
use tauri::menu::{Menu, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder};
|
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder};
|
||||||
use tauri::{Emitter, Runtime};
|
use tauri::{Emitter, Runtime};
|
||||||
|
|
||||||
fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
|
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)?;
|
.build(handle)?;
|
||||||
let export_pdf = MenuItemBuilder::with_id("export-pdf", "Export as PDF…").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 export_epub = MenuItemBuilder::with_id("export-epub", "Export as EPUB…").build(handle)?;
|
||||||
let file_submenu = SubmenuBuilder::new(handle, "File")
|
let check_updates =
|
||||||
.item(&new_book)
|
MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?;
|
||||||
.item(&PredefinedMenuItem::separator(handle)?)
|
|
||||||
.item(&export_pdf)
|
let file_submenu = menu
|
||||||
.item(&export_epub)
|
.items()?
|
||||||
.build()?;
|
.into_iter()
|
||||||
menu.insert(&file_submenu, 1)?;
|
.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)
|
Ok(menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,9 +61,15 @@ pub fn run() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::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))
|
.menu(|handle| build_menu(handle))
|
||||||
.on_menu_event(|app, event| {
|
.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();
|
app.emit("menu-action", event.id().0.as_str()).ok();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -44,7 +83,9 @@ pub fn run() {
|
|||||||
library::list_books,
|
library::list_books,
|
||||||
library::load_book,
|
library::load_book,
|
||||||
library::save_book,
|
library::save_book,
|
||||||
library::delete_book
|
library::delete_book,
|
||||||
|
proofing::proof_text,
|
||||||
|
proofing::remember_word
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running margin");
|
.expect("error while running margin");
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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 = []
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// Intentionally empty. See Cargo.toml — this stubs out the disabled `burn-cuda` backend.
|
||||||
@@ -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 = []
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// Intentionally empty. See Cargo.toml — this stubs out the disabled cubecl-cpu JIT runtime.
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
|
"resources": ["resources/dictionaries/en/*"],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useBook } from "./store/useBook";
|
|||||||
import { isDesktop } from "./ipc";
|
import { isDesktop } from "./ipc";
|
||||||
import { newBook } from "./library";
|
import { newBook } from "./library";
|
||||||
import { runExport } from "./export/run";
|
import { runExport } from "./export/run";
|
||||||
|
import { checkForUpdates } from "./updater";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const book = useBook((s) => s.book);
|
const book = useBook((s) => s.book);
|
||||||
@@ -13,11 +14,13 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDesktop) return;
|
if (!isDesktop) return;
|
||||||
|
checkForUpdates(true);
|
||||||
const unlisten = listen<string>("menu-action", (event) => {
|
const unlisten = listen<string>("menu-action", (event) => {
|
||||||
const state = useBook.getState();
|
const state = useBook.getState();
|
||||||
if (event.payload === "new-book") state.openBook(newBook());
|
if (event.payload === "new-book") state.openBook(newBook());
|
||||||
else if (event.payload === "export-pdf") runExport("pdf");
|
else if (event.payload === "export-pdf") runExport("pdf");
|
||||||
else if (event.payload === "export-epub") runExport("epub");
|
else if (event.payload === "export-epub") runExport("epub");
|
||||||
|
else if (event.payload === "check-updates") checkForUpdates(false);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
unlisten.then((stop) => stop());
|
unlisten.then((stop) => stop());
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ function HtmlDock() {
|
|||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="p-opener">
|
<div className="p-opener">
|
||||||
<div className="p-num">{eyebrow}</div>
|
<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>
|
||||||
<div className="page-body" dangerouslySetInnerHTML={{ __html: html }} />
|
<div className="page-body" dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
<div className="folio">{idx * 8 + 7}</div>
|
<div className="folio">{idx * 8 + 7}</div>
|
||||||
|
|||||||
@@ -6,15 +6,20 @@ import { ResizeHandle } from "./ResizeHandle";
|
|||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { Settings } from "./Settings";
|
import { Settings } from "./Settings";
|
||||||
import { CoverView } from "./CoverView";
|
import { CoverView } from "./CoverView";
|
||||||
|
import { FindBar } from "./FindBar";
|
||||||
|
import { ProofPopover } from "./ProofPopover";
|
||||||
import { Editor } from "../editor/Editor";
|
import { Editor } from "../editor/Editor";
|
||||||
import { FloatingToolbar } from "../editor/FloatingToolbar";
|
import { FloatingToolbar } from "../editor/FloatingToolbar";
|
||||||
|
import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing";
|
||||||
import { COVER_ID, useBook } from "../store/useBook";
|
import { COVER_ID, useBook } from "../store/useBook";
|
||||||
|
import { useProofing } from "../store/useProofing";
|
||||||
import { useTheme } from "../store/useTheme";
|
import { useTheme } from "../store/useTheme";
|
||||||
import { useWidth } from "../store/useWidth";
|
import { useWidth } from "../store/useWidth";
|
||||||
import { WIDTH_OPTIONS } from "../width";
|
import { WIDTH_OPTIONS } from "../width";
|
||||||
import { bodyNumber, chapterKind } from "../model/book";
|
import { bodyNumber, chapterKind } from "../model/book";
|
||||||
import { saveBook } from "../library";
|
import { saveBook } from "../library";
|
||||||
import { isDesktop } from "../ipc";
|
import { isDesktop } from "../ipc";
|
||||||
|
import { issueSignature, rememberWord, runProof } from "../proofing";
|
||||||
import { runExport } from "../export/run";
|
import { runExport } from "../export/run";
|
||||||
|
|
||||||
export function EditorView() {
|
export function EditorView() {
|
||||||
@@ -26,6 +31,7 @@ export function EditorView() {
|
|||||||
const setNotice = useBook((s) => s.setNotice);
|
const setNotice = useBook((s) => s.setNotice);
|
||||||
const setChapterContent = useBook((s) => s.setChapterContent);
|
const setChapterContent = useBook((s) => s.setChapterContent);
|
||||||
const setChapterTitle = useBook((s) => s.setChapterTitle);
|
const setChapterTitle = useBook((s) => s.setChapterTitle);
|
||||||
|
const setChapterNoTitle = useBook((s) => s.setChapterNoTitle);
|
||||||
const markSaved = useBook((s) => s.markSaved);
|
const markSaved = useBook((s) => s.markSaved);
|
||||||
const theme = useTheme((s) => s.theme);
|
const theme = useTheme((s) => s.theme);
|
||||||
const toggleTheme = useTheme((s) => s.toggle);
|
const toggleTheme = useTheme((s) => s.toggle);
|
||||||
@@ -37,6 +43,35 @@ export function EditorView() {
|
|||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [exportOpen, setExportOpen] = useState(false);
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
const [widthOpen, setWidthOpen] = 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 saveNow = useCallback(() => {
|
||||||
const current = useBook.getState().book;
|
const current = useBook.getState().book;
|
||||||
@@ -54,11 +89,14 @@ export function EditorView() {
|
|||||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
saveNow();
|
saveNow();
|
||||||
|
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") {
|
||||||
|
e.preventDefault();
|
||||||
|
openFind(e.altKey);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [saveNow]);
|
}, [saveNow, openFind]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!notice) return;
|
if (!notice) return;
|
||||||
@@ -66,13 +104,45 @@ export function EditorView() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [notice]);
|
}, [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") => {
|
const handleExport = (format: "pdf" | "epub") => {
|
||||||
setExportOpen(false);
|
setExportOpen(false);
|
||||||
runExport(format);
|
runExport(format);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!book) return null;
|
if (!book) return null;
|
||||||
const coverActive = activeChapterId === COVER_ID;
|
|
||||||
const idx = book.chapters.findIndex((c) => c.id === activeChapterId);
|
const idx = book.chapters.findIndex((c) => c.id === activeChapterId);
|
||||||
const chapter = book.chapters[idx] ?? book.chapters[0];
|
const chapter = book.chapters[idx] ?? book.chapters[0];
|
||||||
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
|
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
|
||||||
@@ -88,6 +158,19 @@ export function EditorView() {
|
|||||||
{dirty && <span className="dirty-dot" />}
|
{dirty && <span className="dirty-dot" />}
|
||||||
</button>
|
</button>
|
||||||
<div className="actions">
|
<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">
|
<div className="menu-wrap">
|
||||||
<button className="icon-btn" data-on={widthOpen} onClick={() => setWidthOpen((v) => !v)} title="Editor width">
|
<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" />
|
<Icon d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" />
|
||||||
@@ -142,6 +225,13 @@ export function EditorView() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<FindBar
|
||||||
|
editor={coverActive ? null : editor}
|
||||||
|
open={findOpen && !coverActive}
|
||||||
|
initialExpanded={findExpanded}
|
||||||
|
onClose={() => setFindOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="body">
|
<div className="body">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<ResizeHandle pane="sidebar" />
|
<ResizeHandle pane="sidebar" />
|
||||||
@@ -155,11 +245,22 @@ export function EditorView() {
|
|||||||
<div className="chapter-num">{eyebrow}</div>
|
<div className="chapter-num">{eyebrow}</div>
|
||||||
<input
|
<input
|
||||||
className="chapter-title-input"
|
className="chapter-title-input"
|
||||||
value={chapter.title}
|
value={chapter.noTitle ? "" : chapter.title}
|
||||||
placeholder={kind === "body" ? "Chapter title" : "Page title"}
|
placeholder={chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : "Page title"}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
|
disabled={chapter.noTitle}
|
||||||
onChange={(e) => setChapterTitle(chapter.id, e.target.value)}
|
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>
|
</header>
|
||||||
<Editor
|
<Editor
|
||||||
chapterId={chapter.id}
|
chapterId={chapter.id}
|
||||||
@@ -176,6 +277,28 @@ export function EditorView() {
|
|||||||
{dock && <Dock />}
|
{dock && <Dock />}
|
||||||
</div>
|
</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} />}
|
{settingsOpen && <Settings onClose={() => setSettingsOpen(false)} onSave={saveNow} />}
|
||||||
{exporting && (
|
{exporting && (
|
||||||
<div className="export-overlay">
|
<div className="export-overlay">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,11 +4,12 @@ import { Icon } from "./Icon";
|
|||||||
|
|
||||||
interface RowMenuProps {
|
interface RowMenuProps {
|
||||||
label: string;
|
label: string;
|
||||||
|
onDuplicate?: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RowMenu({ label, onDelete, className = "" }: RowMenuProps) {
|
export function RowMenu({ label, onDuplicate, onDelete, className = "" }: RowMenuProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [coords, setCoords] = useState({ top: 0, right: 0 });
|
const [coords, setCoords] = useState({ top: 0, right: 0 });
|
||||||
const btnRef = useRef<HTMLButtonElement>(null);
|
const btnRef = useRef<HTMLButtonElement>(null);
|
||||||
@@ -40,6 +41,12 @@ export function RowMenu({ label, onDelete, className = "" }: RowMenuProps) {
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
const duplicate = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(false);
|
||||||
|
onDuplicate?.();
|
||||||
|
};
|
||||||
|
|
||||||
const remove = (e: React.MouseEvent) => {
|
const remove = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -61,6 +68,12 @@ export function RowMenu({ label, onDelete, className = "" }: RowMenuProps) {
|
|||||||
{open &&
|
{open &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div ref={popRef} className="row-menu-pop" style={{ top: coords.top, right: coords.right }}>
|
<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}>
|
<button className="row-menu-item danger" onClick={remove}>
|
||||||
<Icon d="M5 7h14M10 7V5h4v2M7 7l1 13h8l1-13M10 11v6M14 11v6" size={14} />
|
<Icon d="M5 7h14M10 7V5h4v2M7 7l1 13h8l1-13M10 11v6M14 11v6" size={14} />
|
||||||
Delete
|
Delete
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function Sidebar() {
|
|||||||
const setActiveChapter = useBook((s) => s.setActiveChapter);
|
const setActiveChapter = useBook((s) => s.setActiveChapter);
|
||||||
const addChapter = useBook((s) => s.addChapter);
|
const addChapter = useBook((s) => s.addChapter);
|
||||||
const addPage = useBook((s) => s.addPage);
|
const addPage = useBook((s) => s.addPage);
|
||||||
|
const duplicateChapter = useBook((s) => s.duplicateChapter);
|
||||||
const deleteChapter = useBook((s) => s.deleteChapter);
|
const deleteChapter = useBook((s) => s.deleteChapter);
|
||||||
const moveChapter = useBook((s) => s.moveChapter);
|
const moveChapter = useBook((s) => s.moveChapter);
|
||||||
const closeBook = useBook((s) => s.closeBook);
|
const closeBook = useBook((s) => s.closeBook);
|
||||||
@@ -195,6 +196,7 @@ export function Sidebar() {
|
|||||||
</span>
|
</span>
|
||||||
<RowMenu
|
<RowMenu
|
||||||
label="Page options"
|
label="Page options"
|
||||||
|
onDuplicate={() => duplicateChapter(row.chapter.id)}
|
||||||
onDelete={() => setPendingDelete({ id: row.chapter.id, title: row.chapter.title || "Untitled" })}
|
onDelete={() => setPendingDelete({ id: row.chapter.id, title: row.chapter.title || "Untitled" })}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -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 type { Editor } from "@tiptap/react";
|
||||||
import { Icon } from "../components/Icon";
|
import { Icon } from "../components/Icon";
|
||||||
|
|
||||||
@@ -23,11 +23,21 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
|||||||
const linkInputRef = useRef<HTMLInputElement>(null);
|
const linkInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [linkOpen, setLinkOpen] = useState(false);
|
const [linkOpen, setLinkOpen] = useState(false);
|
||||||
const [linkValue, setLinkValue] = useState("");
|
const [linkValue, setLinkValue] = useState("");
|
||||||
|
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (linkOpen) linkInputRef.current?.focus();
|
if (linkOpen) linkInputRef.current?.focus();
|
||||||
}, [linkOpen]);
|
}, [linkOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const update = () => forceUpdate();
|
||||||
|
editor.on("transaction", update);
|
||||||
|
return () => {
|
||||||
|
editor.off("transaction", update);
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
if (!editor) return null;
|
if (!editor) return null;
|
||||||
|
|
||||||
const insertImage = async (file: File) => {
|
const insertImage = async (file: File) => {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import StarterKit from "@tiptap/starter-kit";
|
|||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
import { Figure } from "./figure";
|
import { Figure } from "./figure";
|
||||||
import { ParagraphIndent } from "./indent";
|
import { ParagraphIndent } from "./indent";
|
||||||
|
import { SearchHighlight } from "./search";
|
||||||
|
import { Proofing } from "./proofing";
|
||||||
|
|
||||||
export const editorExtensions: Extensions = [
|
export const editorExtensions: Extensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
@@ -15,4 +17,6 @@ export const editorExtensions: Extensions = [
|
|||||||
}),
|
}),
|
||||||
Figure,
|
Figure,
|
||||||
ParagraphIndent,
|
ParagraphIndent,
|
||||||
|
SearchHighlight,
|
||||||
|
Proofing,
|
||||||
];
|
];
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
@@ -284,9 +284,12 @@ function contentOpf(
|
|||||||
function navXhtml(book: Book): string {
|
function navXhtml(book: Book): string {
|
||||||
const items = book.chapters
|
const items = book.chapters
|
||||||
.map((chapter, i) => {
|
.map((chapter, i) => {
|
||||||
|
if (chapter.noTitle && chapterKind(chapter) !== "body") return "";
|
||||||
const fallback = chapterKind(chapter) === "body" ? `Chapter ${bodyNumber(book.chapters, i)}` : "Untitled";
|
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");
|
.join("\n");
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
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")}">
|
<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 num = bodyNumber(book.chapters, index);
|
||||||
const title = chapter.title || (kind === "body" ? `Chapter ${num}` : "Untitled");
|
const title = chapter.title || (kind === "body" ? `Chapter ${num}` : "Untitled");
|
||||||
const eyebrow = kind === "body" ? `<p class="eyebrow">Chapter ${num}</p>` : "";
|
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"?>
|
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")}">
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
|
||||||
<head>
|
<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"}">
|
<body epub:type="${kind === "body" ? "chapter" : kind === "front" ? "frontmatter" : "backmatter"}">
|
||||||
<header class="chapter-opener">
|
<header class="chapter-opener">
|
||||||
${eyebrow}
|
${eyebrow}
|
||||||
<h1>${esc(title)}</h1>
|
${heading}
|
||||||
</header>
|
</header>
|
||||||
${chapterBody(chapter.content, paths)}
|
${chapterBody(chapter.content, paths)}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+8
-4
@@ -139,9 +139,11 @@ function preamble(book: Book): string {
|
|||||||
align(center)[
|
align(center)[
|
||||||
#if info.kind == "body" {
|
#if info.kind == "body" {
|
||||||
text(font: "Hanken Grotesk", size: 8.5pt, weight: "semibold", tracking: 0.28em)[#upper("Chapter " + info.num)]
|
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)
|
v(1.5em)
|
||||||
}
|
}
|
||||||
@@ -262,6 +264,7 @@ function normTitle(title: string): string {
|
|||||||
|
|
||||||
function inToc(book: Book, index: number): boolean {
|
function inToc(book: Book, index: number): boolean {
|
||||||
const chapter = book.chapters[index];
|
const chapter = book.chapters[index];
|
||||||
|
if (chapter.noTitle && chapterKind(chapter) !== "body") return false;
|
||||||
const title = cleanTitle(chapter.title) || "Untitled";
|
const title = cleanTitle(chapter.title) || "Untitled";
|
||||||
return !(chapterKind(chapter) === "front" && normTitle(title) === normTitle(book.metadata.title));
|
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 chapter = book.chapters[index];
|
||||||
const kind = chapterKind(chapter);
|
const kind = chapterKind(chapter);
|
||||||
const num = kind === "body" ? String(bodyNumber(book.chapters, index)) : "";
|
const num = kind === "body" ? String(bodyNumber(book.chapters, index)) : "";
|
||||||
const title = cleanTitle(chapter.title) || "Untitled";
|
const notitle = !!chapter.noTitle;
|
||||||
return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, toc: ${inToc(book, index)}))`;
|
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 {
|
export function bookToTypst(book: Book, paths: Map<string, string> = new Map(), coverPath?: string): string {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export interface Chapter {
|
|||||||
content: JSONContent;
|
content: JSONContent;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
kind?: ChapterKind;
|
kind?: ChapterKind;
|
||||||
|
noTitle?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageType {
|
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() };
|
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 {
|
export function createCover(): Cover {
|
||||||
return { kind: "default", image: "", bg: COVER_PALETTES[0].bg, ink: COVER_PALETTES[0].ink };
|
return { kind: "default", image: "", bg: COVER_PALETTES[0].bg, ink: COVER_PALETTES[0].ink };
|
||||||
}
|
}
|
||||||
|
|||||||
+112
@@ -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)));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type ChapterKind,
|
type ChapterKind,
|
||||||
type Cover,
|
type Cover,
|
||||||
chapterKind,
|
chapterKind,
|
||||||
|
cloneChapter,
|
||||||
createChapter,
|
createChapter,
|
||||||
createPage,
|
createPage,
|
||||||
normalizeBook,
|
normalizeBook,
|
||||||
@@ -27,8 +28,10 @@ interface BookState {
|
|||||||
setActiveChapter: (id: string) => void;
|
setActiveChapter: (id: string) => void;
|
||||||
setChapterContent: (id: string, content: JSONContent) => void;
|
setChapterContent: (id: string, content: JSONContent) => void;
|
||||||
setChapterTitle: (id: string, title: string) => void;
|
setChapterTitle: (id: string, title: string) => void;
|
||||||
|
setChapterNoTitle: (id: string, noTitle: boolean) => void;
|
||||||
addChapter: () => void;
|
addChapter: () => void;
|
||||||
addPage: (group: "front" | "back", title: string) => void;
|
addPage: (group: "front" | "back", title: string) => void;
|
||||||
|
duplicateChapter: (id: string) => void;
|
||||||
deleteChapter: (id: string) => void;
|
deleteChapter: (id: string) => void;
|
||||||
moveChapter: (from: number, to: number, toKind?: ChapterKind) => void;
|
moveChapter: (from: number, to: number, toKind?: ChapterKind) => void;
|
||||||
setMetadata: (patch: Partial<BookMetadata>) => 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)) } }
|
? { 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: () =>
|
addChapter: () =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
if (!s.book) return {};
|
if (!s.book) return {};
|
||||||
@@ -82,6 +91,16 @@ export const useBook = create<BookState>((set) => ({
|
|||||||
chapters.splice(insertAt, 0, page);
|
chapters.splice(insertAt, 0, page);
|
||||||
return { activeChapterId: page.id, dirty: true, book: { ...s.book, chapters } };
|
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) =>
|
deleteChapter: (id) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
if (!s.book) return {};
|
if (!s.book) return {};
|
||||||
|
|||||||
@@ -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() }),
|
||||||
|
}));
|
||||||
@@ -80,6 +80,8 @@ button {
|
|||||||
|
|
||||||
.icon-btn[data-on="true"] {
|
.icon-btn[data-on="true"] {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
background: var(--accent-wash);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dirty-dot {
|
.dirty-dot {
|
||||||
@@ -355,12 +357,18 @@ body.resizing {
|
|||||||
transition: background 120ms var(--ease), color 120ms var(--ease);
|
transition: background 120ms var(--ease), color 120ms var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row-menu-item:hover {
|
||||||
|
background: var(--accent-wash);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
.row-menu-item.danger {
|
.row-menu-item.danger {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-menu-item.danger:hover {
|
.row-menu-item.danger:hover {
|
||||||
background: var(--danger-wash);
|
background: var(--danger-wash);
|
||||||
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-chapter {
|
.add-chapter {
|
||||||
@@ -628,6 +636,30 @@ body.resizing {
|
|||||||
color: var(--ink-faint);
|
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,
|
.editor-host,
|
||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -1621,3 +1653,342 @@ body.resizing {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
background: var(--accent-wash);
|
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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user