31 Commits
Author SHA1 Message Date
github-actions[bot] df1a447a79 chore(release): v0.1.17 2026-08-24 04:11:47 +00:00
pj bcd5eb9ebc group of bug fixes 2026-08-24 09:17:26 +05:30
github-actions[bot] cfc93fc97d chore(release): v0.1.16 2026-08-10 15:00:26 +00:00
pj b954a3135d Straight through shortcut 2026-08-10 18:44:46 +05:30
pj 9e310a34bb feat(macos): trigger Apple Intelligence writing tools from the editor 2026-08-10 18:37:23 +05:30
pj fe39274ed7 feat(nav): peel overlays with esc, close the book on a second esc 2026-08-10 18:37:23 +05:30
pj 2e15dc2dc9 feat(editor): open the link popover with cmd+k 2026-08-10 18:36:52 +05:30
pj dad0ac86d0 feat(chapters): focus the title field when adding a chapter 2026-08-10 18:36:39 +05:30
pj efd45cf61e feat(library): show last edited time on book cards 2026-08-10 18:36:30 +05:30
pj 742e42d65b feat(editor): keep the caret clear of the floating toolbar 2026-08-10 18:36:18 +05:30
pj abcd133b19 chore: sync Cargo.lock with package version 2026-08-10 18:36:18 +05:30
github-actions[bot] 4fa14306d1 chore(release): v0.1.15 2026-07-15 04:02:28 +00:00
pj acf78dfe29 feat(menu): reorganize native menu bar with discoverable actions
Move Check for Updates into the macOS app menu and add Settings (Cmd+,),
Save (Cmd+S), Find (Cmd+F), chapter navigation, and a Help > Report an
Issue link. Route new menu actions through menu-action events and trim
duplicate editor keybindings.
2026-07-15 09:31:57 +05:30
pj 866a971729 feat(updater): show in-app progress during app update
Replace native update dialogs with an in-app modal that shows a live
download progress bar and installing state, so updates no longer feel
abrupt. Drives the plugin's downloadAndInstall progress events through a
zustand store.
2026-07-15 09:31:57 +05:30
github-actions[bot] 7ea33b5bfb chore(release): v0.1.14 2026-07-15 04:00:48 +00:00
github-actions[bot] 0abeb3c5d4 chore(release): v0.1.13 2026-07-14 10:37:34 +00:00
pj a31f99cb76 fix keyboard fixed 2026-07-14 16:07:05 +05:30
pj 20365dd02d fix(proofing): stop flagging bare URLs as misspelled on macOS
NSSpellChecker's implicit URL handling is unreliable for URLs alone on a
line (a bookmark list), flagging the whole URL as one misspelling. Request
the link checking type alongside spelling so the data detector claims URL
ranges, then keep only spelling results. Real misspellings are unaffected.
2026-07-14 16:07:05 +05:30
github-actions[bot] af389c7097 chore(release): v0.1.12 2026-07-13 15:00:54 +00:00
pj f11ddd290f fix(proofing): use macOS NSSpellChecker for spelling on macOS
The bundled Hunspell dictionary (~49k SCOWL roots) lacked common words like
"cybersecurity", "scalable", and "assistantship", and checked words inside
URLs. On macOS, spelling now uses the system NSSpellChecker (same engine as
TextEdit/Safari): far better vocabulary, skips URLs, and respects words the
user has taught macOS. The custom/tech dictionaries and Harper grammar are
unchanged; non-macOS builds keep the Hunspell fallback.
2026-07-13 20:27:58 +05:30
github-actions[bot] 4975e76358 chore(release): v0.1.11 2026-07-13 14:51:25 +00:00
github-actions[bot] efeb64d289 chore(release): v0.1.10 2026-06-27 13:49:24 +00:00
pj cd41a02737 changes 2026-06-27 09:48:42 -04:00
pj 5a2ada9e9e ci(release): pin macOS runner to macos-26 2026-06-27 09:48:42 -04:00
github-actions[bot] 782a4c3dee chore(release): v0.1.9 2026-06-27 13:48:19 +00:00
github-actions[bot] f8742c440f chore(release): v0.1.8 2026-06-27 13:32:54 +00:00
pj c090fb595f changes 2026-06-27 09:32:22 -04:00
pj 11df8697b1 changes 2026-06-23 11:29:54 -04:00
pj f47e822497 changes 2026-06-22 21:26:03 -04:00
github-actions[bot] 9e714c8997 chore(release): v0.1.7 2026-06-22 21:14:30 +00:00
pj cc26e1d51c changes 2026-06-22 17:12:44 -04:00
95 changed files with 54693 additions and 308 deletions

No files matched your search

+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- os: macos-latest - os: macos-26
args: "--target universal-apple-darwin --config src-tauri/tauri.release.conf.json" args: "--target universal-apple-darwin --config src-tauri/tauri.release.conf.json"
rust-targets: "aarch64-apple-darwin,x86_64-apple-darwin" rust-targets: "aarch64-apple-darwin,x86_64-apple-darwin"
- os: ubuntu-latest - os: ubuntu-latest
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<link rel="icon" type="image/png" href="/margin-mark.png" /> <link rel="icon" type="image/png" href="/margin-mark.png" />
<script> <script>
(function () { (function () {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "margin-app", "name": "margin-app",
"private": true, "private": true,
"version": "0.1.6", "version": "0.1.17",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+26 -1
View File
@@ -4713,11 +4713,14 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]] [[package]]
name = "margin-app" name = "margin-app"
version = "0.1.5" version = "0.1.15"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"fontdb", "fontdb",
"harper-core", "harper-core",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"rand 0.8.6", "rand 0.8.6",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
@@ -5138,9 +5141,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation", "objc2-foundation",
"objc2-quartz-core",
] ]
[[package]] [[package]]
@@ -5160,6 +5171,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [ dependencies = [
"bitflags 2.13.0",
"objc2", "objc2",
"objc2-foundation", "objc2-foundation",
] ]
@@ -5220,6 +5232,19 @@ dependencies = [
"objc2-core-graphics", "objc2-core-graphics",
] ]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.13.0",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]] [[package]]
name = "objc2-encode" name = "objc2-encode"
version = "4.1.0" version = "4.1.0"
+21 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "margin-app" name = "margin-app"
version = "0.1.6" version = "0.1.17"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -31,7 +31,6 @@ fontdb = "0.23"
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 # 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, # 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]) # so bump deliberately and re-audit the stubs. (see [patch.crates-io])
@@ -41,6 +40,26 @@ sha2 = "0.10"
rand = "0.8" rand = "0.8"
url = "2" url = "2"
# Spelling on macOS uses the system NSSpellChecker (same engine as TextEdit/Safari): far better
# vocabulary than the bundled Hunspell dictionary and it skips URLs. These objc2 crates are already
# in the graph via Tauri, so this adds nothing new to the build.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = [
"NSSpellChecker",
"NSApplication",
"NSEvent",
"NSMenu",
"NSMenuItem",
"NSResponder",
"NSWritingToolsCoordinator",
] }
objc2-foundation = { version = "0.3", features = ["NSString", "NSArray", "NSRange", "NSTextCheckingResult"] }
# Other platforms fall back to the bundled Hunspell dictionary via spellbook.
[target.'cfg(not(target_os = "macos"))'.dependencies]
spellbook = "0.4"
# harper-core transitively declares an optional, disabled `burn-cuda` (CUDA backend). # harper-core transitively declares an optional, disabled `burn-cuda` (CUDA backend).
# Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm # 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 # prereleases) and links-collides with `zip`'s lzma. Replace it with an empty stub so the
+2 -3
View File
@@ -6,9 +6,8 @@
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-destroy", "core:window:allow-destroy",
"core:window:allow-start-dragging",
"opener:default", "opener:default",
"dialog:default", "dialog:default"
"updater:default",
"process:allow-restart"
] ]
} }
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop",
"description": "Desktop-only permissions (updater, process restart)",
"platforms": ["macOS", "windows", "linux"],
"windows": ["main"],
"permissions": ["updater:default", "process:allow-restart"]
}
+3
View File
@@ -0,0 +1,3 @@
xcuserdata/
build/
Externals/
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,116 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "[email protected]",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>debugging</string>
</dict>
</plist>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17150" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="Y6W-OH-hqX">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17122"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="s0d-6b-0kx">
<objects>
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
<resources>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
+21
View File
@@ -0,0 +1,21 @@
# Uncomment the next line to define a global platform for your project
target 'margin-app_iOS' do
platform :ios, '14.0'
# Pods for margin-app_iOS
end
target 'margin-app_macOS' do
platform :osx, '11.0'
# Pods for margin-app_macOS
end
# Delete the deployment target for iOS and macOS, causing it to be inherited from the Podfile
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET'
config.build_settings.delete 'MACOSX_DEPLOYMENT_TARGET'
end
end
end
@@ -0,0 +1,8 @@
#pragma once
namespace ffi {
extern "C" {
void start_app();
}
}
@@ -0,0 +1,6 @@
#include "bindings/bindings.h"
int main(int argc, char * argv[]) {
ffi::start_app();
return 0;
}
@@ -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
@@ -0,0 +1,474 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
0EC3DA4FFDC5C3B2B4E0B13B /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DD7D0823B077104D6C806D11 /* MetalKit.framework */; };
380D17F67E636CBECF7E4E6D /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F2318AB597D2BA37D0450A64 /* WebKit.framework */; };
3E829B66804DD93D0CE82436 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C46540657F8D4E893AC66C7 /* QuartzCore.framework */; };
8261C76B0C8AAFFF0976BB94 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9B857C0B494A9C83F9F9115 /* Metal.framework */; };
89611C54325E79BBFCAF5A6F /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 623FD9F4898AE5FDE23B0A73 /* libapp.a */; };
911563A32C960AC8241ED351 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8CC5D253EF80093EC54CD51D /* Assets.xcassets */; };
D6FCF60E9A225E886B4201B4 /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = D7C0C2B726053A491DE0A492 /* main.mm */; };
E9F56517F64D408654A47123 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 877554CC1AAF0ED8612C473F /* Security.framework */; };
EA15997B9A4564CD88AAF265 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AC3E0782AC1DE69EC1393F4F /* LaunchScreen.storyboard */; };
F1736708F9507F3358D2FB10 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 360E99CA5BCBFEBC9E48DB30 /* CoreGraphics.framework */; };
F6530048C395DF030B4AB269 /* assets in Resources */ = {isa = PBXBuildFile; fileRef = E4E3950AAF9CFB9F61092532 /* assets */; };
F75C7FAF5CEEF9BE332DC814 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AEE5851B77FD26F3F57404A0 /* UIKit.framework */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
14817B3F82D600114D013B7D /* pdf.rs */ = {isa = PBXFileReference; path = pdf.rs; sourceTree = "<group>"; };
277A822D18EFBF6E79B2EFC6 /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = "<group>"; };
2C46540657F8D4E893AC66C7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
360E99CA5BCBFEBC9E48DB30 /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
448CDA24E192A9EDE84EE2F6 /* library.rs */ = {isa = PBXFileReference; path = library.rs; sourceTree = "<group>"; };
623FD9F4898AE5FDE23B0A73 /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
6A0CF179B6206602B684ACC5 /* margin-app_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "margin-app_iOS.entitlements"; sourceTree = "<group>"; };
769526E10F6C7273A9421972 /* epub.rs */ = {isa = PBXFileReference; path = epub.rs; sourceTree = "<group>"; };
832AEF636C465719EE30629D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
877554CC1AAF0ED8612C473F /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
8C696B0612077DC32CFD7281 /* proofing.rs */ = {isa = PBXFileReference; path = proofing.rs; sourceTree = "<group>"; };
8CC5D253EF80093EC54CD51D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
AC3E0782AC1DE69EC1393F4F /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
AEE5851B77FD26F3F57404A0 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
B94688F7EA7A2C4A8C0DFD01 /* margin-app_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "margin-app_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
C018CB1E589580D894AB7585 /* gdrive.rs */ = {isa = PBXFileReference; path = gdrive.rs; sourceTree = "<group>"; };
D33920AF504282488CBE64EF /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
D74A9059FCE11D1A08EC6511 /* project.rs */ = {isa = PBXFileReference; path = project.rs; sourceTree = "<group>"; };
D7C0C2B726053A491DE0A492 /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = "<group>"; };
DD7D0823B077104D6C806D11 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; };
E4E3950AAF9CFB9F61092532 /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = assets; sourceTree = SOURCE_ROOT; };
F2318AB597D2BA37D0450A64 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
F8B65F0038A8DDF9CE7C2310 /* fonts.rs */ = {isa = PBXFileReference; path = fonts.rs; sourceTree = "<group>"; };
F95681711AA6F4A228A97009 /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
F9B857C0B494A9C83F9F9115 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
AACF50BDF8AA677AF9F50383 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
89611C54325E79BBFCAF5A6F /* libapp.a in Frameworks */,
F1736708F9507F3358D2FB10 /* CoreGraphics.framework in Frameworks */,
8261C76B0C8AAFFF0976BB94 /* Metal.framework in Frameworks */,
0EC3DA4FFDC5C3B2B4E0B13B /* MetalKit.framework in Frameworks */,
3E829B66804DD93D0CE82436 /* QuartzCore.framework in Frameworks */,
E9F56517F64D408654A47123 /* Security.framework in Frameworks */,
F75C7FAF5CEEF9BE332DC814 /* UIKit.framework in Frameworks */,
380D17F67E636CBECF7E4E6D /* WebKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1F4A93A133625BBBC5DD173D /* margin-app */ = {
isa = PBXGroup;
children = (
D7C0C2B726053A491DE0A492 /* main.mm */,
E1DE2A12D42E2CA3B48BF39C /* bindings */,
);
path = "margin-app";
sourceTree = "<group>";
};
603E19204F4A400DBF3BDC47 /* Frameworks */ = {
isa = PBXGroup;
children = (
360E99CA5BCBFEBC9E48DB30 /* CoreGraphics.framework */,
623FD9F4898AE5FDE23B0A73 /* libapp.a */,
F9B857C0B494A9C83F9F9115 /* Metal.framework */,
DD7D0823B077104D6C806D11 /* MetalKit.framework */,
2C46540657F8D4E893AC66C7 /* QuartzCore.framework */,
877554CC1AAF0ED8612C473F /* Security.framework */,
AEE5851B77FD26F3F57404A0 /* UIKit.framework */,
F2318AB597D2BA37D0450A64 /* WebKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
AF74A9A4E2E249230D0E350E /* Products */ = {
isa = PBXGroup;
children = (
B94688F7EA7A2C4A8C0DFD01 /* margin-app_iOS.app */,
);
name = Products;
sourceTree = "<group>";
};
B3F300FBBFBCCA3CA7B12D3D /* src */ = {
isa = PBXGroup;
children = (
769526E10F6C7273A9421972 /* epub.rs */,
F8B65F0038A8DDF9CE7C2310 /* fonts.rs */,
C018CB1E589580D894AB7585 /* gdrive.rs */,
F95681711AA6F4A228A97009 /* lib.rs */,
448CDA24E192A9EDE84EE2F6 /* library.rs */,
D33920AF504282488CBE64EF /* main.rs */,
14817B3F82D600114D013B7D /* pdf.rs */,
D74A9059FCE11D1A08EC6511 /* project.rs */,
8C696B0612077DC32CFD7281 /* proofing.rs */,
);
name = src;
path = ../../src;
sourceTree = "<group>";
};
C49F8FAE68067D0E047F17C1 /* margin-app_iOS */ = {
isa = PBXGroup;
children = (
832AEF636C465719EE30629D /* Info.plist */,
6A0CF179B6206602B684ACC5 /* margin-app_iOS.entitlements */,
);
path = "margin-app_iOS";
sourceTree = "<group>";
};
D99826F6BEBF021E86B50785 = {
isa = PBXGroup;
children = (
E4E3950AAF9CFB9F61092532 /* assets */,
8CC5D253EF80093EC54CD51D /* Assets.xcassets */,
AC3E0782AC1DE69EC1393F4F /* LaunchScreen.storyboard */,
FF76185D65C96C10997F4EC9 /* Externals */,
C49F8FAE68067D0E047F17C1 /* margin-app_iOS */,
F62D42C7D88CAD0BB5707700 /* Sources */,
B3F300FBBFBCCA3CA7B12D3D /* src */,
603E19204F4A400DBF3BDC47 /* Frameworks */,
AF74A9A4E2E249230D0E350E /* Products */,
);
sourceTree = "<group>";
};
E1DE2A12D42E2CA3B48BF39C /* bindings */ = {
isa = PBXGroup;
children = (
277A822D18EFBF6E79B2EFC6 /* bindings.h */,
);
path = bindings;
sourceTree = "<group>";
};
F62D42C7D88CAD0BB5707700 /* Sources */ = {
isa = PBXGroup;
children = (
1F4A93A133625BBBC5DD173D /* margin-app */,
);
path = Sources;
sourceTree = "<group>";
};
FF76185D65C96C10997F4EC9 /* Externals */ = {
isa = PBXGroup;
children = (
);
path = Externals;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
D914A04EE7625E0BFF302CBB /* margin-app_iOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = 2AFB5AED9BBDA36D01ECA7AE /* Build configuration list for PBXNativeTarget "margin-app_iOS" */;
buildPhases = (
AB760B90696AB2964C7D49A9 /* Build Rust Code */,
DE9CC46A3C745D3EE2659FC1 /* Sources */,
303B1D89A72F4ED6D2E12CFD /* Resources */,
AACF50BDF8AA677AF9F50383 /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = "margin-app_iOS";
packageProductDependencies = (
);
productName = "margin-app_iOS";
productReference = B94688F7EA7A2C4A8C0DFD01 /* margin-app_iOS.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
5FC750CFBA18ED6763EEF3A3 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
};
};
buildConfigurationList = C4AD3401C458C5C48B1EF849 /* Build configuration list for PBXProject "margin-app" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = D99826F6BEBF021E86B50785;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = AF74A9A4E2E249230D0E350E /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
D914A04EE7625E0BFF302CBB /* margin-app_iOS */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
303B1D89A72F4ED6D2E12CFD /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
911563A32C960AC8241ED351 /* Assets.xcassets in Resources */,
EA15997B9A4564CD88AAF265 /* LaunchScreen.storyboard in Resources */,
F6530048C395DF030B4AB269 /* assets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
AB760B90696AB2964C7D49A9 /* Build Rust Code */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Build Rust Code";
outputFileListPaths = (
);
outputPaths = (
"$(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a",
"$(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "npm run -- tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
DE9CC46A3C745D3EE2659FC1 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D6FCF60E9A225E886B4201B4 /* main.mm in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
2115AE8BB6330806DC18A6DE /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ARCHS = (
arm64,
);
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "margin-app_iOS/margin-app_iOS.entitlements";
CODE_SIGN_IDENTITY = "iPhone Developer";
ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\".\"",
);
INFOPLIST_FILE = "margin-app_iOS/Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = studio.margin.app;
PRODUCT_NAME = "Margin";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALID_ARCHS = arm64;
};
name = release;
};
44B57C3232BEF013CE90639D /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = debug;
};
845C6F72D84890D3B5F7D222 /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
};
name = release;
};
E3805913695CB6F5D2BE9BCB /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ARCHS = (
arm64,
);
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "margin-app_iOS/margin-app_iOS.entitlements";
CODE_SIGN_IDENTITY = "iPhone Developer";
ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\".\"",
);
INFOPLIST_FILE = "margin-app_iOS/Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = studio.margin.app;
PRODUCT_NAME = "Margin";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALID_ARCHS = arm64;
};
name = debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
2AFB5AED9BBDA36D01ECA7AE /* Build configuration list for PBXNativeTarget "margin-app_iOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E3805913695CB6F5D2BE9BCB /* debug */,
2115AE8BB6330806DC18A6DE /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
C4AD3401C458C5C48B1EF849 /* Build configuration list for PBXProject "margin-app" */ = {
isa = XCConfigurationList;
buildConfigurations = (
44B57C3232BEF013CE90639D /* debug */,
845C6F72D84890D3B5F7D222 /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
/* End XCConfigurationList section */
};
rootObject = 5FC750CFBA18ED6763EEF3A3 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuildSystemType</key>
<string>Original</string>
<key>DisableBuildSystemDeprecationDiagnostic</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D914A04EE7625E0BFF302CBB"
BuildableName = "margin-app_iOS.app"
BlueprintName = "margin-app_iOS"
ReferencedContainer = "container:margin-app.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "NO"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D914A04EE7625E0BFF302CBB"
BuildableName = "margin-app_iOS.app"
BlueprintName = "margin-app_iOS"
ReferencedContainer = "container:margin-app.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</TestAction>
<LaunchAction
buildConfiguration = "debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D914A04EE7625E0BFF302CBB"
BuildableName = "margin-app_iOS.app"
BlueprintName = "margin-app_iOS"
ReferencedContainer = "container:margin-app.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "release"
shouldUseLaunchSchemeArgsEnv = "NO"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D914A04EE7625E0BFF302CBB"
BuildableName = "margin-app_iOS.app"
BlueprintName = "margin-app_iOS"
ReferencedContainer = "container:margin-app.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.7</string>
<key>CFBundleVersion</key>
<string>0.1.7</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
<string>metal</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
+88
View File
@@ -0,0 +1,88 @@
name: margin-app
options:
bundleIdPrefix: studio.margin.app
deploymentTarget:
iOS: 14.0
fileGroups: [../../src]
configs:
debug: debug
release: release
settingGroups:
app:
base:
PRODUCT_NAME: Margin
PRODUCT_BUNDLE_IDENTIFIER: studio.margin.app
targetTemplates:
app:
type: application
sources:
- path: Sources
scheme:
environmentVariables:
RUST_BACKTRACE: full
RUST_LOG: info
settings:
groups: [app]
targets:
margin-app_iOS:
type: application
platform: iOS
sources:
- path: Sources
- path: Assets.xcassets
- path: Externals
- path: margin-app_iOS
- path: assets
buildPhase: resources
type: folder
- path: LaunchScreen.storyboard
info:
path: margin-app_iOS/Info.plist
properties:
LSRequiresIPhoneOS: true
UILaunchStoryboardName: LaunchScreen
UIRequiredDeviceCapabilities: [arm64, metal]
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
CFBundleShortVersionString: 0.1.7
CFBundleVersion: "0.1.7"
entitlements:
path: margin-app_iOS/margin-app_iOS.entitlements
scheme:
environmentVariables:
RUST_BACKTRACE: full
RUST_LOG: info
settings:
base:
ENABLE_BITCODE: false
ARCHS: [arm64]
VALID_ARCHS: arm64
LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true
EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64
groups: [app]
dependencies:
- framework: libapp.a
embed: false
- sdk: CoreGraphics.framework
- sdk: Metal.framework
- sdk: MetalKit.framework
- sdk: QuartzCore.framework
- sdk: Security.framework
- sdk: UIKit.framework
- sdk: WebKit.framework
preBuildScripts:
- script: npm run -- tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}
name: Build Rust Code
basedOnDependencyAnalysis: false
outputFiles:
- $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a
@@ -0,0 +1,304 @@
# Bundled technical and product terms accepted by the spell checker.
# Matched case-insensitively. Words with internal capitals (macOS, iOS,
# iPhone, GitHub) are already accepted by a heuristic and need not be listed.
# Platforms and operating systems
ios
ipados
macos
watchos
tvos
android
linux
unix
ubuntu
debian
fedora
centos
distro
kernel
chromeos
# Apple ecosystem
iphone
ipad
ipod
imac
macbook
airpods
airpod
airtag
homepod
icloud
airdrop
facetime
imessage
siri
retina
# Devices and hardware
smartphone
smartphones
smartwatch
touchscreen
webcam
microcontroller
cpu
gpu
ssd
hdd
ram
usb
hdmi
ethernet
bluetooth
wifi
nfc
# Languages and runtimes
javascript
typescript
nodejs
node
deno
bun
golang
kotlin
swift
rust
ruby
python
php
perl
scala
clojure
elixir
haskell
dart
lua
# Tooling and package managers
npm
npx
nvm
yarn
pnpm
webpack
vite
rollup
esbuild
babel
eslint
prettier
tsconfig
linter
linters
transpiler
bundler
runtime
runtimes
toolchain
boilerplate
# Web and frontend
frontend
backend
fullstack
middleware
serverless
api
apis
endpoint
endpoints
url
urls
uri
uris
cors
oauth
jwt
csrf
xss
favicon
viewport
responsive
dropdown
checkbox
tooltip
modal
navbar
sidebar
toolbar
wireframe
mockup
placeholder
# Data formats and protocols
json
yaml
toml
xml
svg
csv
markdown
http
https
ssh
ftp
sftp
tcp
udp
dns
vpn
ssl
tls
localhost
hostname
subdomain
namespace
# Databases
sql
nosql
postgres
postgresql
mysql
mariadb
sqlite
mongodb
redis
elasticsearch
schema
schemas
schemaless
indexing
# DevOps and cloud
docker
dockerfile
kubernetes
nginx
serverless
devops
ci
cd
pipeline
pipelines
deploy
deployment
rollback
autoscaling
balancer
microservice
microservices
container
containers
orchestration
# Cloud providers and services
aws
gcp
azure
cloudflare
vercel
netlify
heroku
firebase
supabase
digitalocean
# Version control and collaboration
git
github
gitlab
bitbucket
repo
repos
monorepo
changelog
commit
commits
rebase
merge
codebase
hotfix
changeset
# Programming concepts
async
await
callback
callbacks
boolean
enum
struct
regex
stdin
stdout
stderr
config
configs
metadata
dataset
datasets
workflow
workflows
plugin
plugins
framework
frameworks
sdk
cli
gui
ide
debugger
breakpoint
recursion
memoization
multithreading
serialization
deserialization
preprocessor
middleware
# AI and data
chatgpt
openai
anthropic
llm
llms
chatbot
chatbots
embeddings
tokenization
finetuning
inference
# Web3 and crypto
blockchain
bitcoin
ethereum
crypto
cryptocurrency
nft
nfts
# Product and UX
ux
ui
onboarding
login
logout
signup
username
usernames
changelog
screenshot
screenshots
emoji
emojis
hashtag
hashtags
clickable
scrollable
swipeable
+162 -15
View File
@@ -15,6 +15,7 @@ use tauri::{Emitter, Manager};
const FOLDER_NAME: &str = "margin"; const FOLDER_NAME: &str = "margin";
const DICTIONARY_NAME: &str = "custom-dictionary.txt"; const DICTIONARY_NAME: &str = "custom-dictionary.txt";
const DRIVE_SCOPE: &str = "https://www.googleapis.com/auth/drive.file";
const SCOPES: &str = "openid email https://www.googleapis.com/auth/drive.file"; const SCOPES: &str = "openid email https://www.googleapis.com/auth/drive.file";
const AUTH_TIMEOUT_SECS: u64 = 120; const AUTH_TIMEOUT_SECS: u64 = 120;
@@ -58,6 +59,7 @@ pub struct Session {
access_expiry: u64, access_expiry: u64,
email: Option<String>, email: Option<String>,
folder_id: Option<String>, folder_id: Option<String>,
scopes: Option<String>,
} }
#[derive(Serialize, Deserialize, Default)] #[derive(Serialize, Deserialize, Default)]
@@ -68,6 +70,8 @@ struct BackupState {
folder_id: Option<String>, folder_id: Option<String>,
last_backup: Option<u64>, last_backup: Option<u64>,
#[serde(default)] #[serde(default)]
scopes: Option<String>,
#[serde(default)]
files: HashMap<String, FileRecord>, files: HashMap<String, FileRecord>,
} }
@@ -84,6 +88,7 @@ pub struct Status {
email: Option<String>, email: Option<String>,
last_backup: Option<u64>, last_backup: Option<u64>,
pending: bool, pending: bool,
needs_reauth: bool,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -92,6 +97,15 @@ pub struct RestoreResult {
restored: usize, restored: usize,
} }
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncOutcome {
uploaded: usize,
downloaded: usize,
#[serde(flatten)]
status: Status,
}
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct BackupOutcome { pub struct BackupOutcome {
@@ -164,6 +178,13 @@ fn urlencode(s: &str) -> String {
url::form_urlencoded::byte_serialize(s.as_bytes()).collect() url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
} }
fn has_drive_scope(scope: &str) -> bool {
scope.split_whitespace().any(|s| s == DRIVE_SCOPE)
}
const REAUTH_MESSAGE: &str =
"margin needs the \"See, edit, create and delete only the specific Google Drive files you use with this app\" permission. Connect again and tick that box.";
fn drive_escape(s: &str) -> String { fn drive_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('\'', "\\'") s.replace('\\', "\\\\").replace('\'', "\\'")
} }
@@ -260,6 +281,8 @@ struct TokenResponse {
refresh_token: Option<String>, refresh_token: Option<String>,
#[serde(default)] #[serde(default)]
expires_in: u64, expires_in: u64,
#[serde(default)]
scope: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -484,7 +507,29 @@ async fn valid_access_token(app: &tauri::AppHandle, state: &GDriveState) -> Resu
} }
let refresh = refresh.ok_or("Not connected to Google Drive.")?; let refresh = refresh.ok_or("Not connected to Google Drive.")?;
let creds = load_credentials()?; let creds = load_credentials()?;
let tokens = refresh_access_token(&creds, &refresh).await?; let tokens = match refresh_access_token(&creds, &refresh).await {
Ok(tokens) => tokens,
Err(e) => {
if e.contains("invalid_grant") {
forget_credentials(app, state);
return Err(
"Google Drive access has expired. Connect again to keep backing up.".to_string()
);
}
return Err(e);
}
};
if let Some(granted) = &tokens.scope {
if !has_drive_scope(granted) {
let mut session = state.0.lock().unwrap();
session.scopes = Some(granted.clone());
drop(session);
let mut stored = load_state(app);
stored.scopes = Some(granted.clone());
let _ = save_state(app, &stored);
return Err(REAUTH_MESSAGE.to_string());
}
}
let access_token = tokens.access_token.clone(); let access_token = tokens.access_token.clone();
{ {
let mut session = state.0.lock().unwrap(); let mut session = state.0.lock().unwrap();
@@ -493,10 +538,18 @@ async fn valid_access_token(app: &tauri::AppHandle, state: &GDriveState) -> Resu
if let Some(rotated) = &tokens.refresh_token { if let Some(rotated) = &tokens.refresh_token {
session.refresh_token = Some(rotated.clone()); session.refresh_token = Some(rotated.clone());
} }
if let Some(granted) = &tokens.scope {
session.scopes = Some(granted.clone());
} }
if let Some(rotated) = &tokens.refresh_token { }
if tokens.refresh_token.is_some() || tokens.scope.is_some() {
let mut stored = load_state(app); let mut stored = load_state(app);
if let Some(rotated) = &tokens.refresh_token {
stored.refresh_token = Some(rotated.clone()); stored.refresh_token = Some(rotated.clone());
}
if let Some(granted) = &tokens.scope {
stored.scopes = Some(granted.clone());
}
let _ = save_state(app, &stored); let _ = save_state(app, &stored);
} }
Ok(access_token) Ok(access_token)
@@ -592,17 +645,19 @@ fn compute_pending(app: &tauri::AppHandle, stored: &BackupState) -> bool {
} }
fn status_inner(app: &tauri::AppHandle, state: &GDriveState) -> Status { fn status_inner(app: &tauri::AppHandle, state: &GDriveState) -> Status {
let (connected, email) = { let (connected, email, scopes) = {
let session = state.0.lock().unwrap(); let session = state.0.lock().unwrap();
(session.refresh_token.is_some(), session.email.clone()) (session.refresh_token.is_some(), session.email.clone(), session.scopes.clone())
}; };
let stored = load_state(app); let stored = load_state(app);
let pending = connected && compute_pending(app, &stored); let pending = connected && compute_pending(app, &stored);
let granted = scopes.or_else(|| stored.scopes.clone());
Status { Status {
connected, connected,
email: email.or(stored.email), email: email.or(stored.email),
last_backup: stored.last_backup, last_backup: stored.last_backup,
pending, pending,
needs_reauth: connected && granted.map(|s| !has_drive_scope(&s)).unwrap_or(false),
} }
} }
@@ -613,6 +668,21 @@ pub fn init_session(app: &tauri::AppHandle) {
session.refresh_token = stored.refresh_token; session.refresh_token = stored.refresh_token;
session.email = stored.email; session.email = stored.email;
session.folder_id = stored.folder_id; session.folder_id = stored.folder_id;
session.scopes = stored.scopes;
}
fn forget_credentials(app: &tauri::AppHandle, state: &GDriveState) {
{
let mut session = state.0.lock().unwrap();
session.refresh_token = None;
session.access_token = None;
session.access_expiry = 0;
session.scopes = None;
}
let mut stored = load_state(app);
stored.refresh_token = None;
stored.scopes = None;
let _ = save_state(app, &stored);
} }
async fn complete_auth( async fn complete_auth(
@@ -631,6 +701,15 @@ async fn complete_auth(
.map_err(|e| e.to_string())??; .map_err(|e| e.to_string())??;
let tokens = exchange_code(&creds, &code, &redirect, &verifier).await?; let tokens = exchange_code(&creds, &code, &redirect, &verifier).await?;
let granted = tokens.scope.clone().unwrap_or_default();
if !has_drive_scope(&granted) {
let _ = HTTP
.post("https://oauth2.googleapis.com/revoke")
.form(&[("token", tokens.access_token.as_str())])
.send()
.await;
return Err(REAUTH_MESSAGE.to_string());
}
let email = fetch_email(&tokens.access_token).await?; let email = fetch_email(&tokens.access_token).await?;
let folder_id = ensure_folder(&tokens.access_token).await?; let folder_id = ensure_folder(&tokens.access_token).await?;
@@ -644,6 +723,7 @@ async fn complete_auth(
session.access_expiry = now() + tokens.expires_in.saturating_sub(60); session.access_expiry = now() + tokens.expires_in.saturating_sub(60);
session.email = Some(email.clone()); session.email = Some(email.clone());
session.folder_id = Some(folder_id.clone()); session.folder_id = Some(folder_id.clone());
session.scopes = Some(granted.clone());
} }
let mut stored = load_state(app); let mut stored = load_state(app);
@@ -652,6 +732,7 @@ async fn complete_auth(
} }
stored.email = Some(email); stored.email = Some(email);
stored.folder_id = Some(folder_id); stored.folder_id = Some(folder_id);
stored.scopes = Some(granted);
save_state(app, &stored)?; save_state(app, &stored)?;
Ok(()) Ok(())
} }
@@ -711,10 +792,12 @@ pub async fn gdrive_disconnect(app: tauri::AppHandle, state: tauri::State<'_, GD
session.access_token = None; session.access_token = None;
session.access_expiry = 0; session.access_expiry = 0;
session.email = None; session.email = None;
session.scopes = None;
} }
let mut stored = load_state(&app); let mut stored = load_state(&app);
stored.refresh_token = None; stored.refresh_token = None;
stored.email = None; stored.email = None;
stored.scopes = None;
save_state(&app, &stored)?; save_state(&app, &stored)?;
Ok(status_inner(&app, &state)) Ok(status_inner(&app, &state))
} }
@@ -724,12 +807,13 @@ pub async fn gdrive_status(app: tauri::AppHandle, state: tauri::State<'_, GDrive
Ok(status_inner(&app, &state)) Ok(status_inner(&app, &state))
} }
#[tauri::command] async fn push_local_files(
pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<BackupOutcome, String> { app: &tauri::AppHandle,
let access_token = valid_access_token(&app, &state).await?; access_token: &str,
let folder_id = ensure_folder_id(&app, &state, &access_token).await?; folder_id: &str,
let files = collect_local_files(&app)?; stored: &mut BackupState,
let mut stored = load_state(&app); ) -> Result<usize, String> {
let files = collect_local_files(app)?;
let mut uploaded = 0; let mut uploaded = 0;
for (name, bytes) in &files { for (name, bytes) in &files {
let hash = hash_bytes(bytes); let hash = hash_bytes(bytes);
@@ -739,18 +823,27 @@ pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDrive
continue; continue;
} }
} }
let drive_id = match &existing { let drive_id = match existing.filter(|record| !record.drive_id.is_empty()) {
Some(record) => Some(record.drive_id.clone()), Some(record) => Some(record.drive_id),
None => find_file(&access_token, &folder_id, name).await?.map(|file| file.id), None => find_file(access_token, folder_id, name).await?.map(|file| file.id),
}; };
let result = upload_file(&access_token, &folder_id, name, bytes, drive_id).await?; let result = upload_file(access_token, folder_id, name, bytes, drive_id).await?;
stored.files.insert(name.clone(), FileRecord { hash, drive_id: result.id }); stored.files.insert(name.clone(), FileRecord { hash, drive_id: result.id });
uploaded += 1; uploaded += 1;
} }
Ok(uploaded)
}
#[tauri::command]
pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<BackupOutcome, String> {
let access_token = valid_access_token(&app, &state).await?;
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
let mut stored = load_state(&app);
let uploaded = push_local_files(&app, &access_token, &folder_id, &mut stored).await?;
if uploaded > 0 { if uploaded > 0 {
stored.last_backup = Some(now()); stored.last_backup = Some(now());
save_state(&app, &stored)?;
} }
save_state(&app, &stored)?;
Ok(BackupOutcome { Ok(BackupOutcome {
uploaded, uploaded,
status: Status { status: Status {
@@ -758,6 +851,60 @@ pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDrive
email: stored.email.clone(), email: stored.email.clone(),
last_backup: stored.last_backup, last_backup: stored.last_backup,
pending: false, pending: false,
needs_reauth: false,
},
})
}
#[tauri::command]
pub async fn gdrive_sync(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<SyncOutcome, String> {
let access_token = valid_access_token(&app, &state).await?;
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
let remote = list_in_folder(&access_token, &folder_id).await?;
let mut stored = load_state(&app);
let mut downloaded = 0;
for file in &remote {
let is_dictionary = file.name == DICTIONARY_NAME;
if !is_dictionary && !safe_book_name(&file.name) {
continue;
}
let destination = if is_dictionary {
dictionary_path(&app)?
} else {
crate::library::library_dir(&app)?.join(&file.name)
};
if destination.exists() {
let record = stored
.files
.entry(file.name.clone())
.or_insert_with(|| FileRecord { hash: String::new(), drive_id: file.id.clone() });
record.drive_id = file.id.clone();
continue;
}
let bytes = download_file(&access_token, &file.id).await?;
crate::project::atomic_write(&destination, &bytes, false)?;
stored.files.insert(
file.name.clone(),
FileRecord { hash: hash_bytes(&bytes), drive_id: file.id.clone() },
);
downloaded += 1;
}
let uploaded = push_local_files(&app, &access_token, &folder_id, &mut stored).await?;
if uploaded > 0 || downloaded > 0 {
stored.last_backup = Some(now());
}
save_state(&app, &stored)?;
Ok(SyncOutcome {
uploaded,
downloaded,
status: Status {
connected: true,
email: stored.email.clone(),
last_backup: stored.last_backup,
pending: false,
needs_reauth: false,
}, },
}) })
} }
+155 -23
View File
@@ -2,59 +2,137 @@ mod epub;
mod fonts; mod fonts;
mod gdrive; mod gdrive;
mod library; mod library;
#[cfg(target_os = "macos")]
mod macspell;
mod pdf; mod pdf;
mod project; mod project;
mod proofing; mod proofing;
mod writingtools;
#[cfg(desktop)]
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder}; use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder};
#[cfg(desktop)]
use tauri::{Emitter, Runtime}; use tauri::{Emitter, Runtime};
#[cfg(desktop)]
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>> {
let menu = Menu::default(handle)?; let menu = Menu::default(handle)?;
let new_book = MenuItemBuilder::with_id("new-book", "New Book") let new_book = MenuItemBuilder::with_id("new-book", "New Book")
.accelerator("CmdOrCtrl+N") .accelerator("CmdOrCtrl+N")
.build(handle)?; .build(handle)?;
let export_pdf = MenuItemBuilder::with_id("export-pdf", "Export as PDF…").build(handle)?; let save = MenuItemBuilder::with_id("save", "Save")
let export_epub = MenuItemBuilder::with_id("export-epub", "Export as EPUB…").build(handle)?; .accelerator("CmdOrCtrl+S")
.build(handle)?;
let export_pdf = MenuItemBuilder::with_id("export-pdf", "Export as PDF…")
.accelerator("CmdOrCtrl+Shift+P")
.build(handle)?;
let export_epub = MenuItemBuilder::with_id("export-epub", "Export as EPUB…")
.accelerator("CmdOrCtrl+Shift+E")
.build(handle)?;
let check_updates = let check_updates =
MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?; MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?;
let settings = MenuItemBuilder::with_id("settings", "Settings…")
.accelerator("CmdOrCtrl+,")
.build(handle)?;
let find = MenuItemBuilder::with_id("find", "Find…")
.accelerator("CmdOrCtrl+F")
.build(handle)?;
let report_issue =
MenuItemBuilder::with_id("report-issue", "Report an Issue…").build(handle)?;
let file_submenu = menu let submenus: Vec<_> = menu
.items()? .items()?
.into_iter() .into_iter()
.find_map(|item| match item { .filter_map(|item| match item {
MenuItemKind::Submenu(submenu) MenuItemKind::Submenu(submenu) => Some(submenu),
if submenu.text().map(|t| t == "File").unwrap_or(false) =>
{
Some(submenu)
}
_ => None, _ => None,
}); })
.collect();
match file_submenu { let find_submenu = |name: &str| {
submenus
.iter()
.find(|submenu| submenu.text().map(|t| t == name).unwrap_or(false))
.cloned()
};
match find_submenu("File") {
Some(submenu) => { Some(submenu) => {
submenu.prepend_items(&[ submenu.prepend_items(&[
&new_book, &new_book,
&PredefinedMenuItem::separator(handle)?, &PredefinedMenuItem::separator(handle)?,
&save,
&PredefinedMenuItem::separator(handle)?,
&export_pdf, &export_pdf,
&export_epub, &export_epub,
&PredefinedMenuItem::separator(handle)?, &PredefinedMenuItem::separator(handle)?,
&check_updates,
&PredefinedMenuItem::separator(handle)?,
])?; ])?;
} }
None => { None => {
let submenu = SubmenuBuilder::new(handle, "File") let submenu = SubmenuBuilder::new(handle, "File")
.item(&new_book) .item(&new_book)
.item(&PredefinedMenuItem::separator(handle)?) .item(&PredefinedMenuItem::separator(handle)?)
.item(&save)
.item(&PredefinedMenuItem::separator(handle)?)
.item(&export_pdf) .item(&export_pdf)
.item(&export_epub) .item(&export_epub)
.item(&PredefinedMenuItem::separator(handle)?)
.item(&check_updates)
.build()?; .build()?;
menu.insert(&submenu, 1)?; menu.insert(&submenu, 1)?;
} }
} }
if let Some(edit) = find_submenu("Edit") {
edit.append_items(&[&PredefinedMenuItem::separator(handle)?, &find])?;
}
if let Some(help) = find_submenu("Help") {
help.append_items(&[&report_issue])?;
}
#[cfg(target_os = "macos")]
{
if let Some(app_submenu) = submenus.first() {
app_submenu.insert(&check_updates, 1)?;
app_submenu.insert(&settings, 3)?;
app_submenu.insert(&PredefinedMenuItem::separator(handle)?, 4)?;
}
if let Some(window) = find_submenu("Window") {
let show_window = MenuItemBuilder::with_id("show-window", "Open Window")
.accelerator("CmdOrCtrl+Shift+M")
.build(handle)?;
window.append_items(&[&PredefinedMenuItem::separator(handle)?, &show_window])?;
}
if let Some(view) = find_submenu("View") {
let toggle_chapters = MenuItemBuilder::with_id("toggle-chapters", "Toggle Chapters")
.accelerator("CmdOrCtrl+\\")
.build(handle)?;
let next_chapter = MenuItemBuilder::with_id("next-chapter", "Next Chapter")
.accelerator("CmdOrCtrl+Alt+Down")
.build(handle)?;
let prev_chapter = MenuItemBuilder::with_id("prev-chapter", "Previous Chapter")
.accelerator("CmdOrCtrl+Alt+Up")
.build(handle)?;
view.prepend_items(&[
&toggle_chapters,
&PredefinedMenuItem::separator(handle)?,
&next_chapter,
&prev_chapter,
&PredefinedMenuItem::separator(handle)?,
])?;
}
}
#[cfg(not(target_os = "macos"))]
{
if let Some(file) = find_submenu("File") {
file.append_items(&[&PredefinedMenuItem::separator(handle)?, &check_updates])?;
}
if let Some(edit) = find_submenu("Edit") {
edit.append_items(&[&settings])?;
}
}
Ok(menu) Ok(menu)
} }
@@ -62,31 +140,57 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
pub fn run() { pub fn run() {
let context = tauri::generate_context!(); let context = tauri::generate_context!();
#[cfg_attr(mobile, allow(unused_mut))]
let mut builder = tauri::Builder::default() let mut builder = 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_process::init());
#[cfg(desktop)]
{
builder = builder.plugin(tauri_plugin_process::init());
if context.config().plugins.0.contains_key("updater") { if context.config().plugins.0.contains_key("updater") {
builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
} }
}
builder builder = builder
.manage(proofing::new_state()) .manage(proofing::new_state())
.manage(gdrive::GDriveState::default()) .manage(gdrive::GDriveState::default())
.setup(|app| { .setup(|app| {
gdrive::init_session(app.handle()); gdrive::init_session(app.handle());
writingtools::label_writing_tool_shortcuts();
Ok(()) Ok(())
}) });
#[cfg(desktop)]
{
builder = builder
.menu(|handle| build_menu(handle)) .menu(|handle| build_menu(handle))
.on_menu_event(|app, event| { .on_menu_event(|app, event| {
if matches!( if matches!(
event.id().0.as_str(), event.id().0.as_str(),
"new-book" | "export-pdf" | "export-epub" | "check-updates" "new-book"
| "save"
| "export-pdf"
| "export-epub"
| "check-updates"
| "settings"
| "find"
| "toggle-chapters"
| "next-chapter"
| "prev-chapter"
| "report-issue"
) { ) {
app.emit("menu-action", event.id().0.as_str()).ok(); app.emit("menu-action", event.id().0.as_str()).ok();
} }
}) #[cfg(target_os = "macos")]
if event.id().0.as_str() == "show-window" {
open_main_window(app);
}
});
}
let app = builder
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
epub::package_epub, epub::package_epub,
epub::unzip_epub, epub::unzip_epub,
@@ -101,13 +205,41 @@ pub fn run() {
library::delete_book, library::delete_book,
proofing::proof_text, proofing::proof_text,
proofing::remember_word, proofing::remember_word,
writingtools::run_writing_tool,
gdrive::gdrive_connect, gdrive::gdrive_connect,
gdrive::gdrive_disconnect, gdrive::gdrive_disconnect,
gdrive::gdrive_status, gdrive::gdrive_status,
gdrive::gdrive_backup, gdrive::gdrive_backup,
gdrive::gdrive_sync,
gdrive::gdrive_restore, gdrive::gdrive_restore,
gdrive::gdrive_list_backups gdrive::gdrive_list_backups
]) ])
.run(context) .build(context)
.expect("error while running margin"); .expect("error while building margin");
app.run(|_app, _event| {
#[cfg(target_os = "macos")]
match &_event {
tauri::RunEvent::ExitRequested { code: None, api, .. } => api.prevent_exit(),
tauri::RunEvent::Reopen { has_visible_windows: false, .. } => open_main_window(_app),
_ => {}
}
});
}
#[cfg(target_os = "macos")]
fn open_main_window(app: &tauri::AppHandle) {
use tauri::Manager;
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
return;
}
let Some(config) = app.config().app.windows.first().cloned() else {
return;
};
if let Ok(builder) = tauri::WebviewWindowBuilder::from_config(app, &config) {
let _ = builder.build();
}
} }
+34 -1
View File
@@ -1,13 +1,16 @@
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use tauri::Manager; use tauri::Manager;
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BookSummary { pub struct BookSummary {
id: String, id: String,
title: String, title: String,
author: String, author: String,
corrupt: bool, corrupt: bool,
updated_at: u64,
} }
fn corrupt_summary(stem: &str) -> BookSummary { fn corrupt_summary(stem: &str) -> BookSummary {
@@ -16,9 +19,33 @@ fn corrupt_summary(stem: &str) -> BookSummary {
title: "Unreadable book".to_string(), title: "Unreadable book".to_string(),
author: String::new(), author: String::new(),
corrupt: true, corrupt: true,
updated_at: 0,
} }
} }
fn file_modified_ms(path: &Path) -> u64 {
fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn latest_chapter_ms(value: &serde_json::Value) -> u64 {
value
.get("chapters")
.and_then(|c| c.as_array())
.map(|chapters| {
chapters
.iter()
.filter_map(|c| c.get("updatedAt").and_then(|v| v.as_u64()))
.max()
.unwrap_or(0)
})
.unwrap_or(0)
}
pub(crate) fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> { pub(crate) fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_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())?; fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
@@ -81,13 +108,19 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
let updated_at = match latest_chapter_ms(&value) {
0 => file_modified_ms(&path),
ms => ms,
};
books.push(BookSummary { books.push(BookSummary {
id: id.to_string(), id: id.to_string(),
title, title,
author, author,
corrupt: false, corrupt: false,
updated_at,
}); });
} }
books.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
Ok(books) Ok(books)
} }
+73
View File
@@ -0,0 +1,73 @@
use std::collections::HashSet;
use objc2::rc::autoreleasepool;
use objc2_app_kit::NSSpellChecker;
use objc2_foundation::{NSRange, NSString, NSTextCheckingType};
pub struct MacIssue {
pub start: usize,
pub end: usize,
pub word: String,
pub suggestions: Vec<String>,
}
fn utf16_to_codepoint(text: &str, utf16_len: usize) -> Vec<usize> {
let mut map = Vec::with_capacity(utf16_len + 1);
let mut cp = 0;
for ch in text.chars() {
for _ in 0..ch.len_utf16() {
map.push(cp);
}
cp += 1;
}
map.push(cp);
map
}
pub fn check(text: &str, custom: &HashSet<String>) -> Vec<MacIssue> {
autoreleasepool(|_| {
let checker = NSSpellChecker::sharedSpellChecker();
let ns = NSString::from_str(text);
let len = ns.length();
let results = unsafe {
checker.checkString_range_types_options_inSpellDocumentWithTag_orthography_wordCount(
&ns,
NSRange { location: 0, length: len },
(NSTextCheckingType::Spelling | NSTextCheckingType::Link).bits(),
None,
0,
None,
std::ptr::null_mut(),
)
};
let map = utf16_to_codepoint(text, len);
let chars: Vec<char> = text.chars().collect();
let mut issues = Vec::new();
for result in results.iter() {
if result.resultType() != NSTextCheckingType::Spelling {
continue;
}
let range = result.range();
let start = map[range.location.min(len)];
let end = map[(range.location + range.length).min(len)];
let word: String = chars[start..end].iter().collect();
if custom.contains(&word.to_lowercase()) {
continue;
}
let mut suggestions = Vec::new();
if let Some(guesses) =
checker.guessesForWordRange_inString_language_inSpellDocumentWithTag(range, &ns, None, 0)
{
for guess in guesses.iter() {
suggestions.push(guess.to_string());
if suggestions.len() >= 5 {
break;
}
}
}
issues.push(MacIssue { start, end, word, suggestions });
}
issues
})
}
+41 -4
View File
@@ -27,6 +27,7 @@ struct Harper {
} }
pub struct Engine { pub struct Engine {
#[cfg(not(target_os = "macos"))]
speller: Option<spellbook::Dictionary>, speller: Option<spellbook::Dictionary>,
harper: Option<Harper>, harper: Option<Harper>,
custom: HashSet<String>, custom: HashSet<String>,
@@ -59,11 +60,17 @@ fn read_words(contents: &str, set: &mut HashSet<String>) {
for line in contents.lines() { for line in contents.lines() {
let word = line.trim(); let word = line.trim();
if !word.is_empty() && !word.starts_with('#') { if !word.is_empty() && !word.starts_with('#') {
set.insert(word.to_string()); set.insert(word.to_lowercase());
} }
} }
} }
fn load_tech(app: &tauri::AppHandle, set: &mut HashSet<String>) {
if let Ok(contents) = dict_file(app, "tech.txt") {
read_words(&contents, set);
}
}
fn load_custom(app: &tauri::AppHandle) -> HashSet<String> { fn load_custom(app: &tauri::AppHandle) -> HashSet<String> {
let mut set = HashSet::new(); let mut set = HashSet::new();
if let Ok(path) = custom_dict_path(app) { if let Ok(path) = custom_dict_path(app) {
@@ -81,6 +88,7 @@ fn load_custom(app: &tauri::AppHandle) -> HashSet<String> {
set set
} }
#[cfg(not(target_os = "macos"))]
fn build_speller(app: &tauri::AppHandle) -> Result<spellbook::Dictionary, String> { fn build_speller(app: &tauri::AppHandle) -> Result<spellbook::Dictionary, String> {
let aff = dict_file(app, "index.aff")?; let aff = dict_file(app, "index.aff")?;
let dic = dict_file(app, "index.dic")?; let dic = dict_file(app, "index.dic")?;
@@ -94,14 +102,22 @@ fn build_harper() -> Harper {
Harper { linter, dict } Harper { linter, dict }
} }
#[cfg(not(target_os = "macos"))]
fn is_word_char(c: char) -> bool { fn is_word_char(c: char) -> bool {
c.is_alphabetic() c.is_alphabetic()
} }
#[cfg(not(target_os = "macos"))]
fn has_irregular_case(word: &str) -> bool {
word.chars().skip(1).any(|c| c.is_uppercase())
}
#[cfg(not(target_os = "macos"))]
fn is_apostrophe(c: char) -> bool { fn is_apostrophe(c: char) -> bool {
c == '\'' || c == '\u{2019}' c == '\'' || c == '\u{2019}'
} }
#[cfg(not(target_os = "macos"))]
fn collect_spelling( fn collect_spelling(
speller: &spellbook::Dictionary, speller: &spellbook::Dictionary,
custom: &HashSet<String>, custom: &HashSet<String>,
@@ -128,11 +144,15 @@ fn collect_spelling(
} }
let end = i; let end = i;
let word: String = chars[start..end].iter().collect(); let word: String = chars[start..end].iter().collect();
if speller.check(&word) || custom.contains(&word) { if has_irregular_case(&word) || speller.check(&word) || custom.contains(&word.to_lowercase()) {
continue; continue;
} }
let mut suggestions = Vec::new(); let mut suggestions = Vec::new();
speller.suggest(&word, &mut suggestions); speller.suggest(&word, &mut suggestions);
let lower = word.to_lowercase();
if suggestions.iter().any(|s| s.to_lowercase() == lower) {
continue;
}
suggestions.truncate(5); suggestions.truncate(5);
issues.push(Issue { issues.push(Issue {
start, start,
@@ -184,10 +204,13 @@ pub async fn proof_text(
let state = app.state::<ProofState>(); let state = app.state::<ProofState>();
let mut guard = state.lock().map_err(|e| e.to_string())?; let mut guard = state.lock().map_err(|e| e.to_string())?;
if guard.is_none() { if guard.is_none() {
let mut custom = load_custom(&app);
load_tech(&app, &mut custom);
*guard = Some(Engine { *guard = Some(Engine {
#[cfg(not(target_os = "macos"))]
speller: None, speller: None,
harper: None, harper: None,
custom: load_custom(&app), custom,
}); });
} }
let engine = guard.as_mut().unwrap(); let engine = guard.as_mut().unwrap();
@@ -195,11 +218,25 @@ pub async fn proof_text(
let chars: Vec<char> = text.chars().collect(); let chars: Vec<char> = text.chars().collect();
let mut issues = Vec::new(); let mut issues = Vec::new();
if spelling { if spelling {
#[cfg(target_os = "macos")]
for mi in crate::macspell::check(&text, &engine.custom) {
issues.push(Issue {
start: mi.start,
end: mi.end,
kind: "spelling".into(),
category: "Spelling".into(),
message: format!("“{}” may be misspelled", mi.word),
suggestions: mi.suggestions,
});
}
#[cfg(not(target_os = "macos"))]
{
if engine.speller.is_none() { if engine.speller.is_none() {
engine.speller = Some(build_speller(&app)?); engine.speller = Some(build_speller(&app)?);
} }
collect_spelling(engine.speller.as_ref().unwrap(), &engine.custom, &chars, &mut issues); collect_spelling(engine.speller.as_ref().unwrap(), &engine.custom, &chars, &mut issues);
} }
}
if grammar { if grammar {
if engine.harper.is_none() { if engine.harper.is_none() {
engine.harper = Some(build_harper()); engine.harper = Some(build_harper());
@@ -232,7 +269,7 @@ pub fn remember_word(
let mut guard = state.inner().lock().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() { if let Some(engine) = guard.as_mut() {
engine.custom.insert(word); engine.custom.insert(word.to_lowercase());
} }
Ok(()) Ok(())
} }
+81
View File
@@ -0,0 +1,81 @@
#[cfg(target_os = "macos")]
mod mac {
use objc2::rc::Retained;
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSMenu, NSMenuItem};
use objc2_foundation::{NSArray, NSString};
const SHORTCUTS: [(&str, &str); 2] = [("Proofread", "f"), ("Rewrite", "r")];
fn submenu_named(items: &NSArray<NSMenuItem>, title: &str) -> Option<Retained<NSMenu>> {
for i in 0..items.count() {
let item = items.objectAtIndex(i);
if item.title().to_string() == title {
return item.submenu();
}
}
None
}
fn edit_menu(mtm: MainThreadMarker) -> Option<Retained<NSMenu>> {
let main = NSApplication::sharedApplication(mtm).mainMenu()?;
for i in 0..main.numberOfItems() {
let Some(item) = main.itemAtIndex(i) else { continue };
let Some(submenu) = item.submenu() else { continue };
if submenu.title().to_string() == "Edit" || item.title().to_string() == "Edit" {
return Some(submenu);
}
}
None
}
fn writing_tools_menu(mtm: MainThreadMarker) -> Option<Retained<NSMenu>> {
submenu_named(&edit_menu(mtm)?.itemArray(), "Writing Tools")
}
pub fn label_shortcuts() {
let Some(mtm) = MainThreadMarker::new() else { return };
let Some(menu) = writing_tools_menu(mtm) else { return };
let items = menu.itemArray();
for i in 0..items.count() {
let item = items.objectAtIndex(i);
let title = item.title().to_string();
let Some((_, key)) = SHORTCUTS.iter().find(|(name, _)| *name == title) else { continue };
item.setKeyEquivalent(&NSString::from_str(key));
item.setKeyEquivalentModifierMask(
NSEventModifierFlags::Shift | NSEventModifierFlags::Option,
);
}
}
pub fn perform(tool: &str) {
let Some(mtm) = MainThreadMarker::new() else { return };
let Some(menu) = writing_tools_menu(mtm) else { return };
let items = menu.itemArray();
for i in 0..items.count() {
if items.objectAtIndex(i).title().to_string() == tool {
menu.performActionForItemAtIndex(i as isize);
return;
}
}
}
}
pub fn label_writing_tool_shortcuts() {
#[cfg(target_os = "macos")]
mac::label_shortcuts();
}
#[tauri::command]
pub fn run_writing_tool(app: tauri::AppHandle, tool: String) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
app.run_on_main_thread(move || mac::perform(&tool))
.map_err(|e| e.to_string())
}
#[cfg(not(target_os = "macos"))]
{
let _ = (app, tool);
Ok(())
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "margin", "productName": "Margin",
"version": "0.1.6", "version": "0.1.17",
"identifier": "studio.margin.app", "identifier": "studio.margin.app",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
+32 -9
View File
@@ -1,13 +1,17 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { openUrl } from "@tauri-apps/plugin-opener";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { Library } from "./components/Library"; import { Library } from "./components/Library";
import { EditorView } from "./components/EditorView"; import { EditorView } from "./components/EditorView";
import { BackupSettings } from "./components/BackupSettings"; import { BackupSettings } from "./components/BackupSettings";
import { ExportPreview } from "./components/ExportPreview";
import { UpdateDialog } from "./components/UpdateDialog";
import { useBook } from "./store/useBook"; import { useBook } from "./store/useBook";
import { useBackup } from "./store/useBackup"; import { useBackup } from "./store/useBackup";
import { useExportPreview } from "./store/useExportPreview";
import { isDesktop } from "./ipc"; import { isDesktop } from "./ipc";
import { createAndOpenBook, saveBook } from "./library"; import { createAndOpenBook, lastBookId, loadBook, saveBook } from "./library";
import { runExport } from "./export/run"; import { runExport } from "./export/run";
import { checkForUpdates } from "./updater"; import { checkForUpdates } from "./updater";
@@ -21,9 +25,13 @@ function App() {
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") createAndOpenBook(state.openBook, state.setNotice); if (event.payload === "new-book") createAndOpenBook(state.openBook, state.setNotice);
else if (event.payload === "export-pdf") runExport("pdf"); else if (event.payload === "export-pdf") {
if (useBook.getState().book) useExportPreview.getState().openPreview();
}
else if (event.payload === "export-epub") runExport("epub"); else if (event.payload === "export-epub") runExport("epub");
else if (event.payload === "check-updates") checkForUpdates(false); else if (event.payload === "check-updates") checkForUpdates(false);
else if (event.payload === "report-issue")
openUrl("https://github.com/priyanshujain/margin/issues").catch(() => {});
}); });
const unlistenWarn = listen<string>("pdf-warnings", (event) => { const unlistenWarn = listen<string>("pdf-warnings", (event) => {
useBook.getState().setNotice(`PDF exported with warnings:\n${event.payload}`); useBook.getState().setNotice(`PDF exported with warnings:\n${event.payload}`);
@@ -43,12 +51,7 @@ function App() {
if ((!book || !dirty) && !connected) return; if ((!book || !dirty) && !connected) return;
event.preventDefault(); event.preventDefault();
if (book && dirty) await saveBook(book).catch(() => {}); if (book && dirty) await saveBook(book).catch(() => {});
if (connected) { if (connected) useBackup.getState().backup(true);
await Promise.race([
useBackup.getState().backup(true),
new Promise((resolve) => setTimeout(resolve, 8000)),
]);
}
win.destroy(); win.destroy();
}); });
return () => { return () => {
@@ -68,20 +71,40 @@ function App() {
useEffect(() => { useEffect(() => {
if (!isDesktop) return; if (!isDesktop) return;
let first = true;
const tick = async () => { const tick = async () => {
await useBackup.getState().refresh(); await useBackup.getState().refresh();
const state = useBackup.getState(); const state = useBackup.getState();
if (state.connected && state.pending) state.backup(true); if (!state.connected || state.needsReauth) return;
if (first) {
first = false;
state.sync(true);
} else if (state.pending) {
state.backup(true);
}
}; };
tick(); tick();
const id = setInterval(tick, 15 * 60 * 1000); const id = setInterval(tick, 15 * 60 * 1000);
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, []);
useEffect(() => {
if (!isDesktop) return;
const id = lastBookId();
if (!id || useBook.getState().book) return;
loadBook(id)
.then((last) => {
if (!useBook.getState().book) useBook.getState().openBook(last);
})
.catch(() => {});
}, []);
return ( return (
<> <>
{book ? <EditorView /> : <Library onOpen={openBook} />} {book ? <EditorView /> : <Library onOpen={openBook} />}
{isDesktop && <BackupSettings />} {isDesktop && <BackupSettings />}
{isDesktop && <ExportPreview />}
{isDesktop && <UpdateDialog />}
</> </>
); );
} }
+11 -1
View File
@@ -6,6 +6,7 @@ export interface BackupStatus {
email: string | null; email: string | null;
lastBackup: number | null; lastBackup: number | null;
pending: boolean; pending: boolean;
needsReauth: boolean;
} }
export interface RestoreResult { export interface RestoreResult {
@@ -16,11 +17,16 @@ export interface BackupOutcome extends BackupStatus {
uploaded: number; uploaded: number;
} }
export interface SyncOutcome extends BackupStatus {
uploaded: number;
downloaded: number;
}
export interface RemoteBackup { export interface RemoteBackup {
name: string; name: string;
} }
const OFFLINE: BackupStatus = { connected: false, email: null, lastBackup: null, pending: false }; const OFFLINE: BackupStatus = { connected: false, email: null, lastBackup: null, pending: false, needsReauth: false };
export async function gdriveStatus(): Promise<BackupStatus> { export async function gdriveStatus(): Promise<BackupStatus> {
if (!isDesktop) return OFFLINE; if (!isDesktop) return OFFLINE;
@@ -39,6 +45,10 @@ export async function gdriveBackup(): Promise<BackupOutcome> {
return invoke<BackupOutcome>("gdrive_backup"); return invoke<BackupOutcome>("gdrive_backup");
} }
export async function gdriveSync(): Promise<SyncOutcome> {
return invoke<SyncOutcome>("gdrive_sync");
}
export async function gdriveRestore(): Promise<RestoreResult> { export async function gdriveRestore(): Promise<RestoreResult> {
return invoke<RestoreResult>("gdrive_restore"); return invoke<RestoreResult>("gdrive_restore");
} }
+35 -1
View File
@@ -1,9 +1,12 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { PAGE_TYPES } from "../model/book"; import { PAGE_TYPES } from "../model/book";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
interface AddPageMenuProps { interface AddPageMenuProps {
onAdd: (group: "front" | "back", title: string) => void; onAdd: (group: "front" | "back", title: string) => void;
onAddPart: () => void;
} }
const GROUPS: { key: "front" | "back"; label: string }[] = [ const GROUPS: { key: "front" | "back"; label: string }[] = [
@@ -11,10 +14,11 @@ const GROUPS: { key: "front" | "back"; label: string }[] = [
{ key: "back", label: "Back matter" }, { key: "back", label: "Back matter" },
]; ];
export function AddPageMenu({ onAdd }: AddPageMenuProps) { export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 }); const [coords, setCoords] = useState({ top: 0, left: 0 });
const btnRef = useRef<HTMLButtonElement>(null); const btnRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const toggle = () => { const toggle = () => {
if (!open && btnRef.current) { if (!open && btnRef.current) {
@@ -37,11 +41,33 @@ export function AddPageMenu({ onAdd }: AddPageMenuProps) {
}; };
}, [open]); }, [open]);
useFocusTrap(popRef, open);
useEscapeLayer(open, () => {
setOpen(false);
btnRef.current?.focus();
});
const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const items = Array.from(popRef.current?.querySelectorAll<HTMLElement>(".add-page-item") ?? []);
const idx = items.indexOf(document.activeElement as HTMLElement);
const nextIdx = e.key === "ArrowDown" ? (idx + 1) % items.length : (idx - 1 + items.length) % items.length;
items[nextIdx]?.focus();
}
};
const pick = (group: "front" | "back", label: string) => { const pick = (group: "front" | "back", label: string) => {
setOpen(false); setOpen(false);
onAdd(group, label); onAdd(group, label);
}; };
const pickPart = () => {
setOpen(false);
onAddPart();
};
return ( return (
<> <>
<button ref={btnRef} className="add-page" data-open={open} onClick={toggle}> <button ref={btnRef} className="add-page" data-open={open} onClick={toggle}>
@@ -50,10 +76,18 @@ export function AddPageMenu({ onAdd }: AddPageMenuProps) {
</button> </button>
{open && ( {open && (
<div <div
ref={popRef}
className="add-page-pop" className="add-page-pop"
style={{ top: coords.top, left: coords.left }} style={{ top: coords.top, left: coords.left }}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onKeyDown={onMenuKeyDown}
> >
<div className="add-page-group">
<div className="add-page-label">Body</div>
<button className="add-page-item" onClick={pickPart}>
Part divider
</button>
</div>
{GROUPS.map((g) => ( {GROUPS.map((g) => (
<div key={g.key} className="add-page-group"> <div key={g.key} className="add-page-group">
<div className="add-page-label">{g.label}</div> <div className="add-page-label">{g.label}</div>
+40 -7
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { ConfirmDialog } from "./ConfirmDialog"; import { ConfirmDialog } from "./ConfirmDialog";
import { useBackup } from "../store/useBackup"; import { useBackup } from "../store/useBackup";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { formatBackupTime, gdriveListBackups } from "../backup"; import { formatBackupTime, gdriveListBackups } from "../backup";
export function BackupSettings() { export function BackupSettings() {
@@ -11,10 +13,12 @@ export function BackupSettings() {
const email = useBackup((s) => s.email); const email = useBackup((s) => s.email);
const lastBackup = useBackup((s) => s.lastBackup); const lastBackup = useBackup((s) => s.lastBackup);
const pending = useBackup((s) => s.pending); const pending = useBackup((s) => s.pending);
const needsReauth = useBackup((s) => s.needsReauth);
const phase = useBackup((s) => s.phase); const phase = useBackup((s) => s.phase);
const connect = useBackup((s) => s.connect); const connect = useBackup((s) => s.connect);
const disconnect = useBackup((s) => s.disconnect); const disconnect = useBackup((s) => s.disconnect);
const backup = useBackup((s) => s.backup); const backup = useBackup((s) => s.backup);
const sync = useBackup((s) => s.sync);
const restore = useBackup((s) => s.restore); const restore = useBackup((s) => s.restore);
const cancelConnect = useBackup((s) => s.cancelConnect); const cancelConnect = useBackup((s) => s.cancelConnect);
const openAuthUrl = useBackup((s) => s.openAuthUrl); const openAuthUrl = useBackup((s) => s.openAuthUrl);
@@ -22,10 +26,13 @@ export function BackupSettings() {
const [remoteCount, setRemoteCount] = useState<number | null>(null); const [remoteCount, setRemoteCount] = useState<number | null>(null);
const [confirmRestore, setConfirmRestore] = useState(false); const [confirmRestore, setConfirmRestore] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const working = phase === "working"; const working = phase === "working";
useFocusTrap(panelRef, open && !confirmRestore);
useEffect(() => { useEffect(() => {
if (!open || !connected) { if (!open || !connected || needsReauth) {
setRemoteCount(null); setRemoteCount(null);
return; return;
} }
@@ -36,14 +43,16 @@ export function BackupSettings() {
return () => { return () => {
active = false; active = false;
}; };
}, [open, connected, lastBackup]); }, [open, connected, needsReauth, lastBackup]);
useEscapeLayer(open, close);
if (!open) return null; if (!open) return null;
return ( return (
<> <>
<div className="overlay" onClick={close}> <div className="overlay" onClick={close}>
<div className="panel" onClick={(e) => e.stopPropagation()}> <div ref={panelRef} className="panel" onClick={(e) => e.stopPropagation()}>
<div className="panel-head"> <div className="panel-head">
<h2>Backup &amp; Sync</h2> <h2>Backup &amp; Sync</h2>
<button className="icon-btn" onClick={close} title="Close"> <button className="icon-btn" onClick={close} title="Close">
@@ -97,14 +106,35 @@ export function BackupSettings() {
</button> </button>
</div> </div>
{needsReauth ? (
<>
<div className="backup-warn">
margin can't reach your Drive folder. The permission to create and edit its own files was
not granted, so nothing is being backed up.
</div>
<div className="backup-actions">
<button className="btn-primary" disabled={working} onClick={connect}>
{working ? "Working…" : "Reconnect Google Drive"}
</button>
</div>
<p className="backup-note">
On the Google screen, tick <strong>See, edit, create and delete only the specific Google Drive
files you use with this app</strong> before continuing.
</p>
</>
) : (
<>
<div className="backup-status-row"> <div className="backup-status-row">
<span className={`backup-dot ${pending ? "is-pending" : "is-synced"}`} /> <span className={`backup-dot ${pending ? "is-pending" : "is-synced"}`} />
<span>{pending ? "Changes not backed up" : formatBackupTime(lastBackup)}</span> <span>{pending ? "Changes not backed up" : formatBackupTime(lastBackup)}</span>
</div> </div>
<div className="backup-actions"> <div className="backup-actions">
<button className="btn-primary" disabled={working} onClick={() => backup()}> <button className="btn-primary" disabled={working} onClick={() => sync()}>
{working ? "Working…" : "Back up now"} {working ? "Working…" : "Sync now"}
</button>
<button className="btn-ghost" disabled={working} onClick={() => backup()}>
Back up only
</button> </button>
<button <button
className="btn-ghost" className="btn-ghost"
@@ -116,10 +146,13 @@ export function BackupSettings() {
</div> </div>
<p className="backup-note"> <p className="backup-note">
Backs up automatically when you close margin, and every 15 minutes while there are changes. Syncs on launch and right after connecting, backs up when you close margin, and every 15 minutes
while there are changes.
</p> </p>
</> </>
)} )}
</>
)}
</div> </div>
</div> </div>
</div> </div>
+15 -3
View File
@@ -1,4 +1,6 @@
import type { ReactNode } from "react"; import { useEffect, useRef, type ReactNode } from "react";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
interface ConfirmDialogProps { interface ConfirmDialogProps {
@@ -10,9 +12,19 @@ interface ConfirmDialogProps {
} }
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onClose }: ConfirmDialogProps) { export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onClose }: ConfirmDialogProps) {
const confirmRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
confirmRef.current?.focus();
}, []);
useEscapeLayer(true, onClose);
useFocusTrap(panelRef);
return ( return (
<div className="overlay" onClick={onClose}> <div className="overlay" onClick={onClose}>
<div className="panel panel-confirm" onClick={(e) => e.stopPropagation()}> <div ref={panelRef} className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
<div className="panel-head"> <div className="panel-head">
<h2>{title}</h2> <h2>{title}</h2>
<button className="icon-btn" onClick={onClose} title="Close"> <button className="icon-btn" onClick={onClose} title="Close">
@@ -26,7 +38,7 @@ export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfi
<button className="btn-ghost" onClick={onClose}> <button className="btn-ghost" onClick={onClose}>
Cancel Cancel
</button> </button>
<button className="btn-danger" onClick={onConfirm}> <button ref={confirmRef} className="btn-danger" onClick={onConfirm}>
{confirmLabel} {confirmLabel}
</button> </button>
</div> </div>
+12 -4
View File
@@ -1,7 +1,7 @@
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { generateHTML } from "@tiptap/core"; import { generateHTML } from "@tiptap/core";
import { COVER_ID, useBook } from "../store/useBook"; import { COVER_ID, useBook } from "../store/useBook";
import { type Chapter, type TrimSize, bodyNumber, chapterKind } from "../model/book"; import { type Chapter, type TrimSize, bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
import { editorExtensions } from "../editor/extensions"; import { editorExtensions } from "../editor/extensions";
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst"; import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
import { compilePdf, isDesktop } from "../ipc"; import { compilePdf, isDesktop } from "../ipc";
@@ -71,7 +71,15 @@ function DockHead({ meta }: { meta?: ReactNode }) {
function chapterEyebrow(chapter: Chapter, chapters: Chapter[], idx: number): string { function chapterEyebrow(chapter: Chapter, chapters: Chapter[], idx: number): string {
const kind = chapterKind(chapter); const kind = chapterKind(chapter);
return kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter"; if (kind === "body") return `Chapter ${bodyNumber(chapters, idx) ?? ""}`;
if (kind === "part") return `Part ${partRoman(partNumber(chapters, idx) ?? 0)}`;
return kind === "front" ? "Front matter" : "Back matter";
}
function previewTitle(chapter: Chapter): string | undefined {
if (chapter.noTitle) return undefined;
if (chapterKind(chapter) === "part") return chapter.title || undefined;
return chapter.title || "Untitled";
} }
function useActiveChapter() { function useActiveChapter() {
@@ -163,7 +171,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>
{!chapter.noTitle && <div className="p-title">{chapter.title || "Untitled"}</div>} {previewTitle(chapter) !== undefined && <div className="p-title">{previewTitle(chapter)}</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>
@@ -207,7 +215,7 @@ function DeviceDock({ device }: { device: Device }) {
<DeviceFrame <DeviceFrame
device={device} device={device}
eyebrow={eyebrow} eyebrow={eyebrow}
title={chapter.noTitle ? undefined : chapter.title || "Untitled"} title={previewTitle(chapter)}
html={html} html={html}
/> />
</section> </section>
+279 -50
View File
@@ -1,9 +1,11 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import type { Editor as TiptapEditor } from "@tiptap/react"; import type { Editor as TiptapEditor } from "@tiptap/react";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { Dock } from "./Dock"; import { Dock } from "./Dock";
import { ResizeHandle } from "./ResizeHandle"; import { ResizeHandle } from "./ResizeHandle";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { Menu } from "./Menu";
import { Settings } from "./Settings"; import { Settings } from "./Settings";
import { BackupButton } from "./BackupButton"; import { BackupButton } from "./BackupButton";
import { CoverView } from "./CoverView"; import { CoverView } from "./CoverView";
@@ -14,16 +16,25 @@ import { FloatingToolbar } from "../editor/FloatingToolbar";
import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing"; import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing";
import { COVER_ID, useBook } from "../store/useBook"; import { COVER_ID, useBook } from "../store/useBook";
import { useBackup } from "../store/useBackup"; import { useBackup } from "../store/useBackup";
import { useExportPreview } from "../store/useExportPreview";
import { useProofing } from "../store/useProofing"; 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, partNumber, partRoman } from "../model/book";
import { saveBook } from "../library"; import { saveBook } from "../library";
import { isDesktop } from "../ipc"; import { useEscapeLayer } from "../escape";
import { focusTrapped } from "../focus";
import { isDesktop, runWritingTool } from "../ipc";
import { useCompact } from "../useMedia";
import { issueSignature, rememberWord, runProof } from "../proofing"; import { issueSignature, rememberWord, runProof } from "../proofing";
import { runExport } from "../export/run"; import { runExport } from "../export/run";
const WRITING_TOOLS: Record<string, "Proofread" | "Rewrite"> = {
KeyF: "Proofread",
KeyR: "Rewrite",
};
export function EditorView() { export function EditorView() {
const book = useBook((s) => s.book); const book = useBook((s) => s.book);
const activeChapterId = useBook((s) => s.activeChapterId); const activeChapterId = useBook((s) => s.activeChapterId);
@@ -34,20 +45,32 @@ export function EditorView() {
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 setChapterNoTitle = useBook((s) => s.setChapterNoTitle);
const pendingTitleFocus = useBook((s) => s.pendingTitleFocus);
const clearTitleFocus = useBook((s) => s.clearTitleFocus);
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);
const width = useWidth((s) => s.width); const width = useWidth((s) => s.width);
const setWidth = useWidth((s) => s.setWidth); const setWidth = useWidth((s) => s.setWidth);
const isCompact = useCompact();
const [editor, setEditor] = useState<TiptapEditor | null>(null); const [editor, setEditor] = useState<TiptapEditor | null>(null);
const [dock, setDock] = useState(true); const editorRef = useRef<TiptapEditor | null>(null);
editorRef.current = editor;
const lastChapterRef = useRef<string>("");
const [sidebarOpen, setSidebarOpen] = useState(!isCompact);
const [dock, setDock] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const [widthOpen, setWidthOpen] = useState(false); const [widthOpen, setWidthOpen] = useState(false);
const [findOpen, setFindOpen] = useState(false); const [findOpen, setFindOpen] = useState(false);
const [findExpanded, setFindExpanded] = useState(false); const [findExpanded, setFindExpanded] = useState(false);
const [proofPopover, setProofPopover] = useState<{ issue: ProofIssue; coords: ProofCoords } | null>(null); const [proofPopover, setProofPopover] = useState<{ issue: ProofIssue; coords: ProofCoords } | null>(null);
const [escArmed, setEscArmed] = useState(false);
const escArmedRef = useRef(false);
escArmedRef.current = escArmed;
const escTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const spelling = useProofing((s) => s.spelling); const spelling = useProofing((s) => s.spelling);
const grammar = useProofing((s) => s.grammar); const grammar = useProofing((s) => s.grammar);
@@ -58,11 +81,58 @@ export function EditorView() {
const coverActive = activeChapterId === COVER_ID; const coverActive = activeChapterId === COVER_ID;
const proofingAvailable = isDesktop && book?.metadata.language === "en"; const proofingAvailable = isDesktop && book?.metadata.language === "en";
const toggleSidebar = () =>
setSidebarOpen((open) => {
if (!open && isCompact) setDock(false);
return !open;
});
const toggleDock = () =>
setDock((open) => {
if (!open && isCompact) setSidebarOpen(false);
return !open;
});
const closeDrawers = () => {
setSidebarOpen(false);
setDock(false);
};
useEscapeLayer(true, () => {
clearTimeout(escTimer.current);
if (escArmedRef.current) {
setEscArmed(false);
useBook.getState().closeBook();
return;
}
(document.activeElement as HTMLElement | null)?.blur();
setEscArmed(true);
escTimer.current = setTimeout(() => setEscArmed(false), 1500);
});
useEscapeLayer(isCompact && (sidebarOpen || dock), closeDrawers);
useEscapeLayer(widthOpen, () => setWidthOpen(false));
useEscapeLayer(exportOpen, () => setExportOpen(false));
useEscapeLayer(moreOpen, () => setMoreOpen(false));
useEscapeLayer(!!proofPopover, () => setProofPopover(null));
useEffect(() => () => clearTimeout(escTimer.current), []);
const openFind = useCallback((expanded: boolean) => { const openFind = useCallback((expanded: boolean) => {
setFindExpanded(expanded); setFindExpanded(expanded);
setFindOpen(true); setFindOpen(true);
}, []); }, []);
const focusEditorSoon = useCallback(() => {
if (useBook.getState().pendingTitleFocus || focusTrapped()) return;
requestAnimationFrame(() => editorRef.current?.commands.focus(undefined, { scrollIntoView: false }));
}, []);
const switchChapter = useCallback(
(dir: 1 | -1) => {
useBook.getState().goToAdjacentChapter(dir);
focusEditorSoon();
},
[focusEditorSoon]
);
const reproof = useCallback(() => { const reproof = useCallback(() => {
if (!editor) return; if (!editor) return;
const state = useProofing.getState(); const state = useProofing.getState();
@@ -94,17 +164,50 @@ export function EditorView() {
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") { if (focusTrapped()) return;
if (e.metaKey && e.altKey && e.key.toLowerCase() === "f") {
e.preventDefault(); e.preventDefault();
saveNow(); openFind(true);
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") { } else if (e.metaKey && e.altKey && e.key === "ArrowRight") {
e.preventDefault(); e.preventDefault();
openFind(e.altKey); e.stopPropagation();
switchChapter(1);
} else if (e.metaKey && e.altKey && e.key === "ArrowLeft") {
e.preventDefault();
e.stopPropagation();
switchChapter(-1);
} else if (e.ctrlKey && !e.metaKey && e.key === "Tab") {
e.preventDefault();
e.stopPropagation();
switchChapter(e.shiftKey ? -1 : 1);
} else if (e.shiftKey && e.altKey && !e.metaKey && !e.ctrlKey && WRITING_TOOLS[e.code]) {
e.preventDefault();
e.stopPropagation();
runWritingTool(WRITING_TOOLS[e.code]).catch(() => {});
} }
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey, true);
}, [saveNow, openFind]); }, [openFind, switchChapter]);
const menuCommands = useRef({ saveNow, openFind, setSettingsOpen, toggleSidebar, switchChapter });
menuCommands.current = { saveNow, openFind, setSettingsOpen, toggleSidebar, switchChapter };
useEffect(() => {
if (!isDesktop) return;
const unlisten = listen<string>("menu-action", (e) => {
const cmd = menuCommands.current;
if (e.payload === "save") cmd.saveNow();
else if (e.payload === "find") cmd.openFind(false);
else if (e.payload === "settings") cmd.setSettingsOpen(true);
else if (e.payload === "toggle-chapters") cmd.toggleSidebar();
else if (e.payload === "next-chapter") cmd.switchChapter(1);
else if (e.payload === "prev-chapter") cmd.switchChapter(-1);
});
return () => {
unlisten.then((stop) => stop());
};
}, []);
useEffect(() => { useEffect(() => {
if (!notice) return; if (!notice) return;
@@ -112,6 +215,11 @@ export function EditorView() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [notice]); }, [notice]);
useEffect(() => {
setSidebarOpen(!isCompact);
if (isCompact) setDock(false);
}, [isCompact]);
useEffect(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
const storage = (editor.storage as unknown as Record<string, ProofingStorage>).proofing; const storage = (editor.storage as unknown as Record<string, ProofingStorage>).proofing;
@@ -147,30 +255,45 @@ export function EditorView() {
const handleExport = (format: "pdf" | "epub") => { const handleExport = (format: "pdf" | "epub") => {
setExportOpen(false); setExportOpen(false);
runExport(format); if (format === "pdf" && isDesktop) useExportPreview.getState().openPreview();
else runExport(format);
}; };
if (!book) return null; if (!book) return null;
const idx = book.chapters.findIndex((c) => c.id === activeChapterId); const activeIdx = book.chapters.findIndex((c) => c.id === activeChapterId);
const chapter = book.chapters[idx] ?? book.chapters[0]; const activeChapter = book.chapters[activeIdx];
if (activeChapter) lastChapterRef.current = activeChapter.id;
const editorIdx = activeChapter ? activeIdx : book.chapters.findIndex((c) => c.id === lastChapterRef.current);
const chapter = book.chapters[editorIdx] ?? book.chapters[0];
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id); const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
const kind = chapter ? chapterKind(chapter) : "body"; const kind = chapter ? chapterKind(chapter) : "body";
const eyebrow = const eyebrow =
kind === "body" ? `Chapter ${bodyNumber(book.chapters, realIdx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter"; kind === "body"
? `Chapter ${bodyNumber(book.chapters, realIdx) ?? ""}`
: kind === "part"
? `Part ${partRoman(partNumber(book.chapters, realIdx) ?? 0)}`
: kind === "front"
? "Front matter"
: "Back matter";
return ( return (
<div className="app"> <div className="app" data-compact={isCompact} data-sidebar={sidebarOpen} data-dock={dock}>
<header className="titlebar" data-tauri-drag-region> <header className="titlebar" data-tauri-drag-region>
<div className="lead">
<button className="icon-btn" data-on={sidebarOpen} onClick={toggleSidebar} title="Toggle chapters" aria-label="Toggle chapters">
<Icon d="M3 4.5h18v15H3zM9 4.5v15" />
</button>
</div>
<button className="doc-title" onClick={() => setSettingsOpen(true)} title="Book setup"> <button className="doc-title" onClick={() => setSettingsOpen(true)} title="Book setup">
{book.metadata.title || "Untitled"} {book.metadata.title || "Untitled"}
{dirty && <span className="dirty-dot" />} {dirty && <span className="dirty-dot" />}
</button> </button>
<div className="actions"> <div className="actions">
{isDesktop && <BackupButton />} {isDesktop && !isCompact && <BackupButton />}
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)"> <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" /> <Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" />
</button> </button>
{proofingAvailable && !coverActive && ( {!isCompact && proofingAvailable && !coverActive && (
<> <>
<button className="icon-btn" data-on={spelling} onClick={toggleSpelling} title="Check spelling"> <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" /> <Icon d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" />
@@ -180,14 +303,12 @@ export function EditorView() {
</button> </button>
</> </>
)} )}
{!isCompact && (
<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" />
</button> </button>
{widthOpen && ( <Menu open={widthOpen} onClose={() => setWidthOpen(false)}>
<>
<div className="menu-backdrop" onClick={() => setWidthOpen(false)} />
<div className="menu">
{WIDTH_OPTIONS.map((w) => ( {WIDTH_OPTIONS.map((w) => (
<button <button
key={w.id} key={w.id}
@@ -200,24 +321,21 @@ export function EditorView() {
{w.label} {w.label}
</button> </button>
))} ))}
</Menu>
</div> </div>
</>
)} )}
</div> {!isCompact && (
<div className="menu-wrap"> <div className="menu-wrap">
<button className="icon-btn" data-on={exportOpen} onClick={() => setExportOpen((v) => !v)} title="Export"> <button className="icon-btn" data-on={exportOpen} onClick={() => setExportOpen((v) => !v)} title="Export">
<Icon d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" /> <Icon d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" />
</button> </button>
{exportOpen && ( <Menu open={exportOpen} onClose={() => setExportOpen(false)}>
<>
<div className="menu-backdrop" onClick={() => setExportOpen(false)} />
<div className="menu">
<button onClick={() => handleExport("pdf")}>Export PDF…</button> <button onClick={() => handleExport("pdf")}>Export PDF…</button>
<button onClick={() => handleExport("epub")}>Export EPUB…</button> <button onClick={() => handleExport("epub")}>Export EPUB…</button>
</Menu>
</div> </div>
</>
)} )}
</div> {!isCompact && (
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? "Light mode" : "Dark mode"}> <button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? "Light mode" : "Dark mode"}>
{theme === "dark" ? ( {theme === "dark" ? (
<Icon> <Icon>
@@ -228,9 +346,62 @@ export function EditorView() {
<Icon d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /> <Icon d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
)} )}
</button> </button>
<button className="icon-btn" data-on={dock} onClick={() => setDock(!dock)} title="Toggle preview"> )}
<button className="icon-btn" data-on={dock} onClick={toggleDock} title="Toggle preview">
<Icon d="M3 4.5h18v15H3zM14 4.5v15" /> <Icon d="M3 4.5h18v15H3zM14 4.5v15" />
</button> </button>
{isCompact && (
<div className="menu-wrap">
<button className="icon-btn" data-on={moreOpen} onClick={() => setMoreOpen((v) => !v)} title="More" aria-label="More options">
<Icon d="M5 12h.01M12 12h.01M19 12h.01" />
</button>
<Menu open={moreOpen} onClose={() => setMoreOpen(false)}>
<div className="menu-label">Editor width</div>
{WIDTH_OPTIONS.map((w) => (
<button
key={w.id}
data-on={width === w.id}
onClick={() => {
setWidth(w.id);
setMoreOpen(false);
}}
>
{w.label}
</button>
))}
<div className="menu-sep" />
<button
onClick={() => {
setMoreOpen(false);
handleExport("pdf");
}}
>
Export PDF…
</button>
<button
onClick={() => {
setMoreOpen(false);
handleExport("epub");
}}
>
Export EPUB…
</button>
{proofingAvailable && !coverActive && (
<>
<div className="menu-sep" />
<button data-on={spelling} onClick={toggleSpelling}>
Check spelling
</button>
<button data-on={grammar} onClick={toggleGrammar}>
Check grammar
</button>
</>
)}
<div className="menu-sep" />
<button onClick={toggleTheme}>{theme === "dark" ? "Light mode" : "Dark mode"}</button>
</Menu>
</div>
)}
</div> </div>
</header> </header>
@@ -242,23 +413,27 @@ export function EditorView() {
/> />
<div className="body"> <div className="body">
<Sidebar /> <Sidebar
<ResizeHandle pane="sidebar" /> onNavigate={() => {
if (isCompact) setSidebarOpen(false);
focusEditorSoon();
}}
/>
{sidebarOpen && !isCompact && <ResizeHandle pane="sidebar" />}
<main className="editor-pane"> <main className="editor-pane">
{coverActive ? ( {coverActive && <CoverView />}
<CoverView /> <article className="sheet" hidden={coverActive}>
) : (
<>
<article className="sheet">
<header className="chapter-opener"> <header className="chapter-opener">
<div className="chapter-num">{eyebrow}</div> <div className="chapter-num">{eyebrow}</div>
<input <ChapterTitleInput
className="chapter-title-input" value={chapter.title}
value={chapter.noTitle ? "" : chapter.title} placeholder={
placeholder={chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : "Page title"} chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : kind === "part" ? "Part title (optional)" : "Page title"
spellCheck={false} }
disabled={chapter.noTitle} disabled={!!chapter.noTitle}
onChange={(e) => setChapterTitle(chapter.id, e.target.value)} focusRequest={pendingTitleFocus === chapter.id}
onFocused={clearTitleFocus}
onChange={(value) => setChapterTitle(chapter.id, value)}
/> />
{(chapter.noTitle || !chapter.title.trim()) && ( {(chapter.noTitle || !chapter.title.trim()) && (
<label className="no-title-check"> <label className="no-title-check">
@@ -272,7 +447,6 @@ export function EditorView() {
)} )}
</header> </header>
<Editor <Editor
key={chapter.id}
bookId={book.id} bookId={book.id}
chapterId={chapter.id} chapterId={chapter.id}
content={chapter.content} content={chapter.content}
@@ -283,12 +457,13 @@ export function EditorView() {
} }
/> />
</article> </article>
<FloatingToolbar editor={editor} /> {!coverActive && <FloatingToolbar editor={editor} />}
</>
)}
</main> </main>
{dock && <ResizeHandle pane="dock" />} {dock && !isCompact && <ResizeHandle pane="dock" />}
{dock && <Dock />} {dock && <Dock />}
{isCompact && (sidebarOpen || dock) && (
<button className="drawer-scrim" aria-label="Close panel" onClick={closeDrawers} />
)}
</div> </div>
{proofPopover && editor && ( {proofPopover && editor && (
@@ -313,6 +488,12 @@ export function EditorView() {
/> />
)} )}
{escArmed && (
<div className="esc-hint">
Press <kbd>Esc</kbd> again for All books
</div>
)}
{settingsOpen && <Settings onClose={() => setSettingsOpen(false)} onSave={saveNow} />} {settingsOpen && <Settings onClose={() => setSettingsOpen(false)} onSave={saveNow} />}
{exporting && ( {exporting && (
<div className="export-overlay"> <div className="export-overlay">
@@ -328,3 +509,51 @@ export function EditorView() {
</div> </div>
); );
} }
function ChapterTitleInput({
value,
placeholder,
disabled,
focusRequest,
onFocused,
onChange,
}: {
value: string;
placeholder: string;
disabled: boolean;
focusRequest: boolean;
onFocused: () => void;
onChange: (value: string) => void;
}) {
const ref = useRef<HTMLTextAreaElement>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [value, disabled]);
useEffect(() => {
if (!focusRequest || disabled) return;
const el = ref.current;
if (!el) return;
el.focus();
el.select();
onFocused();
}, [focusRequest, disabled, onFocused]);
return (
<textarea
ref={ref}
className="chapter-title-input"
rows={1}
value={value}
placeholder={placeholder}
spellCheck={false}
disabled={disabled}
onKeyDown={(e) => e.key === "Enter" && e.preventDefault()}
onChange={(e) => onChange(e.target.value)}
/>
);
}
+336
View File
@@ -0,0 +1,336 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import * as pdfjs from "pdfjs-dist";
import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist";
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import { useBook } from "../store/useBook";
import { useExportPreview } from "../store/useExportPreview";
import { bookToPdfInputs, unsupportedScripts } from "../export/typst";
import { compilePdf } from "../ipc";
import { saveBytes } from "../project";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon";
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
const PDF_FILTER = [{ name: "PDF", extensions: ["pdf"] }];
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 2.5;
const ZOOM_STEP = 0.25;
interface Frame {
left: number;
top: number;
width: number;
height: number;
}
function measureEditorPane(): Frame | null {
const pane = document.querySelector(".editor-pane");
if (!pane) return null;
const r = pane.getBoundingClientRect();
return {
left: Math.round(r.left),
top: Math.round(r.top),
width: Math.round(r.width),
height: Math.round(r.height),
};
}
export function ExportPreview() {
const open = useExportPreview((s) => s.open);
const book = useBook((s) => s.book);
if (!open || !book) return null;
return <ExportPreviewModal />;
}
function ExportPreviewModal() {
const book = useBook((s) => s.book)!;
const close = useExportPreview((s) => s.close);
const [pdf, setPdf] = useState<Uint8Array | null>(null);
const [error, setError] = useState<string | null>(null);
const [pages, setPages] = useState(0);
const [zoom, setZoom] = useState(1);
const [saving, setSaving] = useState(false);
const [frame, setFrame] = useState<Frame | null>(measureEditorPane);
const panelRef = useRef<HTMLDivElement>(null);
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
useLayoutEffect(() => {
const update = () => setFrame(measureEditorPane());
update();
const pane = document.querySelector(".editor-pane");
const observer = new ResizeObserver(update);
if (pane) observer.observe(pane);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("resize", update);
};
}, []);
useEffect(() => {
let cancelled = false;
setPdf(null);
setError(null);
const { source, images, fonts } = bookToPdfInputs(book);
compilePdf(source, images, false, fonts)
.then((bytes) => !cancelled && setPdf(bytes))
.catch((e) => !cancelled && setError(String(e)));
return () => {
cancelled = true;
};
}, [book]);
useEscapeLayer(true, close);
useFocusTrap(panelRef);
useEffect(() => {
const el = panelRef.current;
if (!el) return;
const clamp = (z: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
const snap = (z: number) => clamp(Math.round(z / ZOOM_STEP) * ZOOM_STEP);
let start = 1;
let accum = 0;
const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey) return;
e.preventDefault();
accum += e.deltaY;
if (accum <= -40) {
accum = 0;
setZoom((z) => clamp(z + ZOOM_STEP));
} else if (accum >= 40) {
accum = 0;
setZoom((z) => clamp(z - ZOOM_STEP));
}
};
const onGestureStart = (e: Event) => {
e.preventDefault();
start = zoomRef.current;
};
const onGestureChange = (e: Event) => {
e.preventDefault();
const next = snap(start * (e as unknown as { scale: number }).scale);
setZoom((z) => (z === next ? z : next));
};
const onGestureEnd = (e: Event) => e.preventDefault();
el.addEventListener("wheel", onWheel, { passive: false });
el.addEventListener("gesturestart", onGestureStart);
el.addEventListener("gesturechange", onGestureChange);
el.addEventListener("gestureend", onGestureEnd);
return () => {
el.removeEventListener("wheel", onWheel);
el.removeEventListener("gesturestart", onGestureStart);
el.removeEventListener("gesturechange", onGestureChange);
el.removeEventListener("gestureend", onGestureEnd);
};
}, []);
const scripts = unsupportedScripts(book);
const save = async () => {
if (!pdf) return;
setSaving(true);
try {
await saveBytes(pdf, `${book.metadata.title || "Untitled"}.pdf`, PDF_FILTER);
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
};
const adjustZoom = (delta: number) =>
setZoom((z) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round((z + delta) * 100) / 100)));
const compact = (frame?.width ?? 1000) < 460;
return (
<div className="overlay preview-overlay" onClick={close}>
<div
ref={panelRef}
className="panel preview-panel"
style={frame ? { position: "fixed", left: frame.left, top: frame.top, width: frame.width, height: frame.height } : undefined}
onClick={(e) => e.stopPropagation()}
>
<header className="preview-bar">
<button className="icon-btn" onClick={close} title="Close (Esc)">
<Icon d="M6 6l12 12M18 6L6 18" />
</button>
<div className="preview-title">{book.metadata.title || "Untitled"}</div>
{pdf && !compact && (
<span className="preview-count">
{pages} {pages === 1 ? "page" : "pages"}
</span>
)}
<div className="preview-zoom">
<button className="icon-btn" disabled={!pdf || zoom <= ZOOM_MIN} onClick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out">
<Icon d="M5 12h14" />
</button>
{!compact && <span>{zoom === 1 ? "Fit" : `${Math.round(zoom * 100)}%`}</span>}
<button className="icon-btn" disabled={!pdf || zoom >= ZOOM_MAX} onClick={() => adjustZoom(ZOOM_STEP)} title="Zoom in">
<Icon d="M12 5v14M5 12h14" />
</button>
</div>
<button className="btn-primary" disabled={!pdf || saving} onClick={save}>
{saving ? "Saving…" : compact ? "Save" : "Save PDF…"}
</button>
</header>
{pdf && scripts.length > 0 && (
<div className="preview-warn">
Some {scripts.join(", ")} characters may not render — the PDF fonts cover Latin scripts only.
</div>
)}
{error ? (
<div className="preview-stage">
<pre className="dock-error">{error}</pre>
</div>
) : pdf ? (
<BookPages data={pdf} zoom={zoom} onPages={setPages} />
) : (
<div className="preview-stage">
<div className="preview-loading">
<div className="spinner spinner-dark" />
<p>Typesetting your book…</p>
</div>
</div>
)}
</div>
</div>
);
}
function BookPages({ data, zoom, onPages }: { data: Uint8Array; zoom: number; onPages: (n: number) => void }) {
const stageRef = useRef<HTMLDivElement>(null);
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
const [baseRatio, setBaseRatio] = useState<number | null>(null);
const [stage, setStage] = useState({ width: 0, height: 0 });
useEffect(() => {
let cancelled = false;
const task = pdfjs.getDocument({ data: data.slice() });
task.promise
.then(async (d) => {
if (cancelled) return;
const first = (await d.getPage(1)).getViewport({ scale: 1 });
if (cancelled) return;
setDoc(d);
setBaseRatio(first.height / first.width);
onPages(d.numPages);
})
.catch(() => {});
return () => {
cancelled = true;
task.destroy();
};
}, [data, onPages]);
useEffect(() => {
const el = stageRef.current;
if (!el) return;
const update = () => setStage({ width: el.clientWidth, height: el.clientHeight });
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, [doc]);
const pageFit = Math.max(240, Math.min(stage.width - 56, (stage.height - 56) / (baseRatio ?? 1)));
const displayWidth = Math.round(pageFit * zoom);
return (
<div className="preview-stage" ref={stageRef}>
{doc && baseRatio !== null && stage.width > 0 && (
<div className="preview-col">
{Array.from({ length: doc.numPages }, (_, i) => (
<PdfPage
key={i + 1}
doc={doc}
pageNumber={i + 1}
root={stageRef.current}
displayWidth={displayWidth}
baseRatio={baseRatio}
/>
))}
</div>
)}
</div>
);
}
function PdfPage({
doc,
pageNumber,
root,
displayWidth,
baseRatio,
}: {
doc: PDFDocumentProxy;
pageNumber: number;
root: HTMLElement | null;
displayWidth: number;
baseRatio: number;
}) {
const holder = useRef<HTMLDivElement>(null);
const [show, setShow] = useState(false);
const [ratio, setRatio] = useState(baseRatio);
useEffect(() => {
const el = holder.current;
if (!el) return;
const observer = new IntersectionObserver(([entry]) => setShow(entry.isIntersecting), {
root,
rootMargin: "1400px 0px",
});
observer.observe(el);
return () => observer.disconnect();
}, [root]);
useEffect(() => {
const el = holder.current;
if (!el) return;
if (!show) {
el.replaceChildren();
return;
}
let cancelled = false;
let task: RenderTask | undefined;
(async () => {
try {
const page = await doc.getPage(pageNumber);
if (cancelled) return;
const natural = page.getViewport({ scale: 1 });
setRatio(natural.height / natural.width);
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const viewport = page.getViewport({ scale: (displayWidth / natural.width) * dpr });
const canvas = document.createElement("canvas");
canvas.className = "preview-canvas";
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
task = page.render({ canvas, viewport });
await task.promise;
if (cancelled) return;
el.replaceChildren(canvas);
} catch {
// render cancelled or page failed; keep the previous canvas
}
})();
return () => {
cancelled = true;
task?.cancel();
};
}, [show, doc, pageNumber, displayWidth]);
return (
<div
ref={holder}
className="preview-page"
style={{ width: displayWidth, height: Math.round(displayWidth * ratio) }}
/>
);
}
+11 -9
View File
@@ -3,6 +3,7 @@ import type { Editor as TiptapEditor } from "@tiptap/react";
import { buildRegex, getSearchState } from "../editor/search"; import { buildRegex, getSearchState } from "../editor/search";
import { countMatches, replaceInContent } from "../search-book"; import { countMatches, replaceInContent } from "../search-book";
import { useBook } from "../store/useBook"; import { useBook } from "../store/useBook";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
interface FindBarProps { interface FindBarProps {
@@ -58,7 +59,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
return () => { return () => {
editor.off("transaction", update); editor.off("transaction", update);
}; };
}, [editor]); }, [editor, activeChapterId]);
useEffect(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
@@ -67,7 +68,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
} else { } else {
editor.commands.clearSearch(); editor.commands.clearSearch();
} }
}, [editor, open, query, caseSensitive, wholeWord]); }, [editor, open, query, caseSensitive, wholeWord, activeChapterId]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -95,6 +96,13 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
}; };
}, [editor, pendingNav, activeChapterId]); }, [editor, pendingNav, activeChapterId]);
const close = () => {
editor?.commands.focus();
onClose();
};
useEscapeLayer(open, close);
if (!open || !editor) return null; if (!open || !editor) return null;
const before = jsonCounts.slice(0, Math.max(0, activeIdx)).reduce((a, b) => a + b, 0); const before = jsonCounts.slice(0, Math.max(0, activeIdx)).reduce((a, b) => a + b, 0);
@@ -163,9 +171,6 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
e.shiftKey ? prev() : next(); e.shiftKey ? prev() : next();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
} }
}; };
@@ -239,7 +244,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
<button className="find-toggle" data-on={wholeWord} title="Whole word" onClick={() => setWholeWord((v) => !v)}> <button className="find-toggle" data-on={wholeWord} title="Whole word" onClick={() => setWholeWord((v) => !v)}>
<span className="find-ww">ab</span> <span className="find-ww">ab</span>
</button> </button>
<button className="find-btn" title="Close (Esc)" onClick={onClose}> <button className="find-btn" title="Close (Esc)" onClick={close}>
<Icon d="M18 6L6 18M6 6l12 12" size={14} /> <Icon d="M18 6L6 18M6 6l12 12" size={14} />
</button> </button>
</div> </div>
@@ -256,9 +261,6 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
replaceOne(); replaceOne();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
} }
}} }}
/> />
+35 -2
View File
@@ -1,8 +1,19 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { type BookSummary, createAndOpenBook, deleteBook, exampleBook, listBooks, loadBook, saveBook } from "../library"; import {
type BookSummary,
createAndOpenBook,
deleteBook,
exampleBook,
lastBookId,
listBooks,
loadBook,
rememberLastBook,
saveBook,
} from "../library";
import type { Book } from "../model/book"; import type { Book } from "../model/book";
import { importEpub } from "../import/epub"; import { importEpub } from "../import/epub";
import { clearPositions } from "../editor/positions"; import { clearPositions } from "../editor/positions";
import { clearChapterStates } from "../editor/session";
import { isDesktop } from "../ipc"; import { isDesktop } from "../ipc";
import { ConfirmDialog } from "./ConfirmDialog"; import { ConfirmDialog } from "./ConfirmDialog";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
@@ -10,10 +21,12 @@ import { RowMenu } from "./RowMenu";
import { BackupButton } from "./BackupButton"; import { BackupButton } from "./BackupButton";
import { useBackup } from "../store/useBackup"; import { useBackup } from "../store/useBackup";
import { useBook } from "../store/useBook"; import { useBook } from "../store/useBook";
import { relativeTime } from "../time";
export function Library({ onOpen }: { onOpen: (book: Book) => void }) { export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
const [books, setBooks] = useState<BookSummary[]>([]); const [books, setBooks] = useState<BookSummary[]>([]);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [now, setNow] = useState(() => Date.now());
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null); const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null);
const notice = useBook((s) => s.notice); const notice = useBook((s) => s.notice);
@@ -30,6 +43,10 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
useEffect(() => { useEffect(() => {
refresh(); refresh();
}, []); }, []);
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(timer);
}, []);
useEffect(() => { useEffect(() => {
if (restoreNonce) refresh(); if (restoreNonce) refresh();
}, [restoreNonce]); }, [restoreNonce]);
@@ -51,10 +68,17 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
onOpen(copy); onOpen(copy);
}; };
const openById = (id: string) =>
loadBook(id)
.then(onOpen)
.catch((e) => setNotice(`Could not open book: ${e}`));
const removeBook = async () => { const removeBook = async () => {
if (!pendingDelete) return; if (!pendingDelete) return;
await deleteBook(pendingDelete.id); await deleteBook(pendingDelete.id);
clearPositions(pendingDelete.id); clearPositions(pendingDelete.id);
clearChapterStates(pendingDelete.id);
if (lastBookId() === pendingDelete.id) rememberLastBook(null);
setPendingDelete(null); setPendingDelete(null);
refresh(); refresh();
}; };
@@ -108,10 +132,19 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
<div <div
key={b.id} key={b.id}
className="card card-book" className="card card-book"
onClick={() => loadBook(b.id).then(onOpen).catch((e) => setNotice(`Could not open book: ${e}`))} role="button"
tabIndex={0}
onClick={() => openById(b.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openById(b.id);
}
}}
> >
<span className="card-title">{b.title || "Untitled"}</span> <span className="card-title">{b.title || "Untitled"}</span>
{b.author && <span className="card-author">{b.author}</span>} {b.author && <span className="card-author">{b.author}</span>}
{b.updatedAt > 0 && <span className="card-meta">Edited {relativeTime(b.updatedAt, now)}</span>}
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} /> <RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
</div> </div>
), ),
+42
View File
@@ -0,0 +1,42 @@
import { useRef, type ReactNode } from "react";
import { useFocusTrap } from "../focus";
interface MenuProps {
open: boolean;
onClose: () => void;
children: ReactNode;
}
export function Menu({ open, onClose, children }: MenuProps) {
const ref = useRef<HTMLDivElement>(null);
useFocusTrap(ref, open);
if (!open) return null;
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") return;
e.preventDefault();
const items = Array.from(ref.current?.querySelectorAll<HTMLElement>("button") ?? []);
if (!items.length) return;
const at = items.indexOf(document.activeElement as HTMLElement);
const next =
e.key === "Home"
? 0
: e.key === "End"
? items.length - 1
: e.key === "ArrowDown"
? (at + 1) % items.length
: (at - 1 + items.length) % items.length;
items[next]?.focus();
};
return (
<>
<div className="menu-backdrop" onClick={onClose} />
<div ref={ref} className="menu" onKeyDown={onKeyDown}>
{children}
</div>
</>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from "react";
import type { Chapter } from "../model/book";
import { type BookSummary, listBooks, moveChapterToBook, saveBook } from "../library";
import { useBook } from "../store/useBook";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon";
interface MoveChapterDialogProps {
chapter: Chapter;
label: string;
onClose: () => void;
}
export function MoveChapterDialog({ chapter, label, onClose }: MoveChapterDialogProps) {
const bookId = useBook((s) => s.book?.id ?? "");
const deleteChapter = useBook((s) => s.deleteChapter);
const setNotice = useBook((s) => s.setNotice);
const [books, setBooks] = useState<BookSummary[] | null>(null);
const [moving, setMoving] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
useEscapeLayer(!moving, onClose);
useFocusTrap(panelRef, books !== null);
useEffect(() => {
listBooks()
.then((list) => setBooks(list.filter((b) => b.id !== bookId && !b.corrupt)))
.catch(() => setBooks([]));
}, [bookId]);
const move = async (target: BookSummary) => {
setMoving(true);
try {
const source = useBook.getState().book;
if (source) await saveBook(source);
await moveChapterToBook(chapter, target.id);
deleteChapter(chapter.id);
const trimmed = useBook.getState().book;
if (trimmed) await saveBook(trimmed);
setNotice(`Moved to ${target.title || "Untitled"}`);
onClose();
} catch (e) {
setNotice(`Could not move: ${e}`);
setMoving(false);
}
};
return (
<div className="overlay" onClick={moving ? undefined : onClose}>
<div ref={panelRef} className="panel panel-move" onClick={(e) => e.stopPropagation()}>
<div className="panel-head">
<h2>Move to book</h2>
<button className="icon-btn" onClick={onClose} title="Close">
<Icon d="M6 6l12 12M18 6L6 18" />
</button>
</div>
<div className="panel-body">
<p className="confirm-text">
Move <strong>{label}</strong> out of this book and into:
</p>
{books === null ? (
<p className="move-empty">Loading books…</p>
) : books.length === 0 ? (
<p className="move-empty">There is no other book to move this into yet.</p>
) : (
<ul className="move-list">
{books.map((b) => (
<li key={b.id}>
<button className="move-target" disabled={moving} onClick={() => move(b)}>
<span className="move-title">{b.title || "Untitled"}</span>
{b.author && <span className="move-author">{b.author}</span>}
</button>
</li>
))}
</ul>
)}
</div>
<div className="panel-foot">
<button className="btn-ghost" onClick={onClose} disabled={moving}>
{moving ? "Moving…" : "Cancel"}
</button>
</div>
</div>
</div>
);
}
+3
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useFocusTrap } from "../focus";
import { severityFor, type ProofCoords, type ProofIssue } from "../editor/proofing"; import { severityFor, type ProofCoords, type ProofIssue } from "../editor/proofing";
function humanize(category: string): string { function humanize(category: string): string {
@@ -20,6 +21,8 @@ const POP_WIDTH = 264;
export function ProofPopover({ issue, coords, onReplace, onIgnore, onRemember, onClose }: ProofPopoverProps) { export function ProofPopover({ issue, coords, onReplace, onIgnore, onRemember, onClose }: ProofPopoverProps) {
const popRef = useRef<HTMLDivElement>(null); const popRef = useRef<HTMLDivElement>(null);
useFocusTrap(popRef);
useEffect(() => { useEffect(() => {
const onDown = (e: MouseEvent) => { const onDown = (e: MouseEvent) => {
if (popRef.current?.contains(e.target as Node)) return; if (popRef.current?.contains(e.target as Node)) return;
+17 -1
View File
@@ -1,6 +1,8 @@
import type { PointerEvent } from "react"; import type { KeyboardEvent, PointerEvent } from "react";
import { applyPaneWidth, currentPaneWidth, resetPaneWidth, type Pane } from "../panes"; import { applyPaneWidth, currentPaneWidth, resetPaneWidth, type Pane } from "../panes";
const STEP = 16;
export function ResizeHandle({ pane }: { pane: Pane }) { export function ResizeHandle({ pane }: { pane: Pane }) {
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => { const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
@@ -23,12 +25,26 @@ export function ResizeHandle({ pane }: { pane: Pane }) {
handle.addEventListener("pointerup", onUp); handle.addEventListener("pointerup", onUp);
}; };
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const towards = pane === "sidebar" ? 1 : -1;
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
applyPaneWidth(pane, currentPaneWidth(pane) + (e.key === "ArrowRight" ? STEP : -STEP) * towards);
} else if (e.key === "Home" || e.key === "Enter") {
e.preventDefault();
resetPaneWidth(pane);
}
};
return ( return (
<div <div
className="pane-resizer" className="pane-resizer"
role="separator" role="separator"
aria-orientation="vertical" aria-orientation="vertical"
aria-label={pane === "sidebar" ? "Resize chapters panel" : "Resize preview panel"}
tabIndex={0}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
onDoubleClick={() => resetPaneWidth(pane)} onDoubleClick={() => resetPaneWidth(pane)}
title="Drag to resize · double-click to reset" title="Drag to resize · double-click to reset"
/> />
+77 -2
View File
@@ -1,16 +1,29 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
interface RowMenuProps { interface RowMenuProps {
label: string; label: string;
onDuplicate?: () => void; onDuplicate?: () => void;
onMove?: () => void;
onDelete: () => void; onDelete: () => void;
onToggleTitle?: () => void;
titleHidden?: boolean;
onToggleMargin?: () => void;
marginHidden?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string; className?: string;
tabIndex?: number;
} }
export function RowMenu({ label, onDuplicate, onDelete, className = "" }: RowMenuProps) { export function RowMenu({ label, onDuplicate, onMove, onDelete, onToggleTitle, titleHidden, onToggleMargin, marginHidden, onOpenChange, className = "", tabIndex }: RowMenuProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
useEffect(() => {
onOpenChange?.(open);
}, [open]);
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);
const popRef = useRef<HTMLDivElement>(null); const popRef = useRef<HTMLDivElement>(null);
@@ -24,6 +37,23 @@ export function RowMenu({ label, onDuplicate, onDelete, className = "" }: RowMen
setOpen((v) => !v); setOpen((v) => !v);
}; };
useFocusTrap(popRef, open);
useEscapeLayer(open, () => {
setOpen(false);
btnRef.current?.focus();
});
const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const items = Array.from(popRef.current?.querySelectorAll<HTMLElement>(".row-menu-item") ?? []);
const idx = items.indexOf(document.activeElement as HTMLElement);
const nextIdx = e.key === "ArrowDown" ? (idx + 1) % items.length : (idx - 1 + items.length) % items.length;
items[nextIdx]?.focus();
}
};
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onDown = (e: MouseEvent) => { const onDown = (e: MouseEvent) => {
@@ -41,12 +71,30 @@ export function RowMenu({ label, onDuplicate, onDelete, className = "" }: RowMen
}; };
}, [open]); }, [open]);
const toggleTitle = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(false);
onToggleTitle?.();
};
const toggleMargin = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(false);
onToggleMargin?.();
};
const duplicate = (e: React.MouseEvent) => { const duplicate = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
setOpen(false); setOpen(false);
onDuplicate?.(); onDuplicate?.();
}; };
const moveOut = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(false);
onMove?.();
};
const remove = (e: React.MouseEvent) => { const remove = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
setOpen(false); setOpen(false);
@@ -59,6 +107,7 @@ export function RowMenu({ label, onDuplicate, onDelete, className = "" }: RowMen
ref={btnRef} ref={btnRef}
className={`row-menu-btn ${className}`} className={`row-menu-btn ${className}`}
data-open={open} data-open={open}
tabIndex={tabIndex}
title={label} title={label}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onClick={toggle} onClick={toggle}
@@ -67,13 +116,39 @@ export function RowMenu({ label, onDuplicate, onDelete, className = "" }: RowMen
</button> </button>
{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 }} onKeyDown={onMenuKeyDown}>
{onToggleTitle && (
<button className="row-menu-item" onClick={toggleTitle}>
{titleHidden ? (
<Icon d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z" size={14} />
) : (
<Icon d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24M1 1l22 22" size={14} />
)}
{titleHidden ? "Show title" : "Hide title"}
</button>
)}
{onToggleMargin && (
<button className="row-menu-item" onClick={toggleMargin}>
{marginHidden ? (
<Icon d="M4 5h16 M12 10v10 M8 16l4 4 4-4" size={14} />
) : (
<Icon d="M4 5h16 M12 20V10 M8 14l4-4 4 4" size={14} />
)}
{marginHidden ? "Show top margin" : "Hide top margin"}
</button>
)}
{onDuplicate && ( {onDuplicate && (
<button className="row-menu-item" onClick={duplicate}> <button className="row-menu-item" onClick={duplicate}>
<Icon d="M9 9h11v11h-11z M6 15V5h9" size={14} /> <Icon d="M9 9h11v11h-11z M6 15V5h9" size={14} />
Duplicate Duplicate
</button> </button>
)} )}
{onMove && (
<button className="row-menu-item" onClick={moveOut}>
<Icon d="M4 12h13M13 8l4 4-4 4M20 4v16" size={14} />
Move to book…
</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
+15 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useBook } from "../store/useBook"; import { useBook } from "../store/useBook";
import { listSystemFonts } from "../ipc"; import { listSystemFonts } from "../ipc";
@@ -13,6 +13,8 @@ import {
fontStack, fontStack,
pairingFor, pairingFor,
} from "../model/fonts"; } from "../model/fonts";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
const LANGUAGES = [ const LANGUAGES = [
@@ -85,6 +87,13 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
})); }));
const [system, setSystem] = useState<string[]>(() => systemFontCache ?? []); const [system, setSystem] = useState<string[]>(() => systemFontCache ?? []);
const [advanced, setAdvanced] = useState(() => pairingFor(draft.fonts) === null); const [advanced, setAdvanced] = useState(() => pairingFor(draft.fonts) === null);
const panelRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLInputElement>(null);
useEffect(() => {
titleRef.current?.focus();
titleRef.current?.select();
}, []);
useEffect(() => { useEffect(() => {
if (systemFontCache) return; if (systemFontCache) return;
@@ -94,6 +103,9 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
}); });
}, []); }, []);
useEscapeLayer(true, onClose);
useFocusTrap(panelRef);
if (!book) return null; if (!book) return null;
const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch })); const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
@@ -112,7 +124,7 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
return ( return (
<div className="overlay" onClick={onClose}> <div className="overlay" onClick={onClose}>
<div className="panel" onClick={(e) => e.stopPropagation()}> <div ref={panelRef} className="panel" onClick={(e) => e.stopPropagation()}>
<div className="panel-head"> <div className="panel-head">
<h2>Book setup</h2> <h2>Book setup</h2>
<button className="icon-btn" onClick={onClose} title="Close"> <button className="icon-btn" onClick={onClose} title="Close">
@@ -121,7 +133,7 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
</div> </div>
<div className="panel-body"> <div className="panel-body">
<Field label="Title"> <Field label="Title">
<input value={draft.title} placeholder="Untitled" onChange={(e) => set({ title: e.target.value })} /> <input ref={titleRef} value={draft.title} placeholder="Untitled" onChange={(e) => set({ title: e.target.value })} />
</Field> </Field>
<Field label="Subtitle"> <Field label="Subtitle">
<input value={draft.subtitle} onChange={(e) => set({ subtitle: e.target.value })} /> <input value={draft.subtitle} onChange={(e) => set({ subtitle: e.target.value })} />
+105 -33
View File
@@ -1,15 +1,20 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { COVER_ID, useBook } from "../store/useBook"; import { COVER_ID, useBook } from "../store/useBook";
import { type Chapter, type ChapterKind, bodyNumber, chapterKind } from "../model/book"; import { type Chapter, type ChapterKind, bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
import { AddPageMenu } from "./AddPageMenu"; import { AddPageMenu } from "./AddPageMenu";
import { ConfirmDialog } from "./ConfirmDialog"; import { ConfirmDialog } from "./ConfirmDialog";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { RowMenu } from "./RowMenu"; import { RowMenu } from "./RowMenu";
import { MoveChapterDialog } from "./MoveChapterDialog";
import { relativeTime } from "../time";
import { isDesktop } from "../ipc";
interface Row { interface Row {
chapter: Chapter; chapter: Chapter;
index: number; index: number;
num: number | null; num: number | null;
part: number | null;
indent: boolean;
} }
interface DropTarget { interface DropTarget {
@@ -17,33 +22,16 @@ interface DropTarget {
index: number; index: number;
} }
function formatEdited(ts: number, now: number): string { export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const diff = Math.max(0, now - ts);
const min = 60_000;
const hour = 60 * min;
const day = 24 * hour;
if (diff < min) return "just now";
if (diff < hour) {
const m = Math.floor(diff / min);
return `${m} minute${m === 1 ? "" : "s"} ago`;
}
if (diff < day) {
const h = Math.floor(diff / hour);
return `${h} hour${h === 1 ? "" : "s"} ago`;
}
if (diff < 2 * day) return "yesterday";
const d = Math.floor(diff / day);
if (d < 7) return `${d} days ago`;
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
export function Sidebar() {
const chapters = useBook((s) => s.book?.chapters ?? []); const chapters = useBook((s) => s.book?.chapters ?? []);
const activeChapterId = useBook((s) => s.activeChapterId); const activeChapterId = useBook((s) => s.activeChapterId);
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 addPart = useBook((s) => s.addPart);
const duplicateChapter = useBook((s) => s.duplicateChapter); const duplicateChapter = useBook((s) => s.duplicateChapter);
const setChapterNoTitle = useBook((s) => s.setChapterNoTitle);
const setChapterNoMargin = useBook((s) => s.setChapterNoMargin);
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);
@@ -51,6 +39,8 @@ export function Sidebar() {
const [dragIndex, setDragIndex] = useState<number | null>(null); const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null); const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null); const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null);
const [pendingMove, setPendingMove] = useState<{ chapter: Chapter; label: string } | null>(null);
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
const dropRef = useRef<DropTarget | null>(null); const dropRef = useRef<DropTarget | null>(null);
@@ -62,15 +52,23 @@ export function Sidebar() {
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
const rows: Row[] = chapters.map((chapter, index) => ({ chapter, index, num: bodyNumber(chapters, index) })); const rows: Row[] = chapters.map((chapter, index) => ({
chapter,
index,
num: bodyNumber(chapters, index),
part: partNumber(chapters, index),
indent: chapterKind(chapter) === "body" && chapters.slice(0, index).some((c) => chapterKind(c) === "part"),
}));
const inBody = (r: Row) => chapterKind(r.chapter) === "body" || chapterKind(r.chapter) === "part";
const groups: { kind: ChapterKind; label: string; rows: Row[] }[] = [ const groups: { kind: ChapterKind; label: string; rows: Row[] }[] = [
{ kind: "front", label: "Front matter", rows: rows.filter((r) => chapterKind(r.chapter) === "front") }, { kind: "front", label: "Front matter", rows: rows.filter((r) => chapterKind(r.chapter) === "front") },
{ kind: "body", label: "Chapters", rows: rows.filter((r) => chapterKind(r.chapter) === "body") }, { kind: "body", label: "Chapters", rows: rows.filter(inBody) },
{ kind: "back", label: "Back matter", rows: rows.filter((r) => chapterKind(r.chapter) === "back") }, { kind: "back", label: "Back matter", rows: rows.filter((r) => chapterKind(r.chapter) === "back") },
]; ];
const groupEnd: Record<ChapterKind, number> = { const groupEnd: Record<ChapterKind, number> = {
front: groups[0].rows.length, front: groups[0].rows.length,
body: groups[0].rows.length + groups[1].rows.length, body: groups[0].rows.length + groups[1].rows.length,
part: groups[0].rows.length + groups[1].rows.length,
back: rows.length, back: rows.length,
}; };
@@ -125,7 +123,7 @@ export function Sidebar() {
}; };
const onRowPointerDown = (e: React.PointerEvent, index: number) => { const onRowPointerDown = (e: React.PointerEvent, index: number) => {
if (e.button !== 0 || (e.target as HTMLElement).closest(".row-menu-btn")) return; if (e.button !== 0 || chapters[index]?.id === menuOpenId || (e.target as HTMLElement).closest(".row-menu-btn")) return;
suppressClick.current = false; suppressClick.current = false;
gesture.current = { from: index, x: e.clientX, y: e.clientY, active: false }; gesture.current = { from: index, x: e.clientX, y: e.clientY, active: false };
window.addEventListener("pointermove", onPointerMove); window.addEventListener("pointermove", onPointerMove);
@@ -138,8 +136,37 @@ export function Sidebar() {
return; return;
} }
setActiveChapter(id); setActiveChapter(id);
onNavigate?.();
}; };
const focusRow = (id: string) =>
requestAnimationFrame(() => {
document.querySelector<HTMLElement>(`.chapter[data-id="${CSS.escape(id)}"]`)?.focus();
});
const onRowKeyDown = (e: React.KeyboardEvent, index: number, id: string) => {
if ((e.target as HTMLElement).closest(".row-menu-btn")) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveChapter(id);
onNavigate?.();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Home" || e.key === "End") {
e.preventDefault();
const target =
e.key === "Home"
? chapters[0]
: e.key === "End"
? chapters[chapters.length - 1]
: chapters[index + (e.key === "ArrowDown" ? 1 : -1)];
if (target) {
setActiveChapter(target.id);
focusRow(target.id);
}
}
};
const tabStopId = chapters.some((c) => c.id === activeChapterId) ? activeChapterId : chapters[0]?.id;
return ( return (
<aside className="sidebar"> <aside className="sidebar">
<button className="brand" onClick={closeBook} title="All books"> <button className="brand" onClick={closeBook} title="All books">
@@ -149,12 +176,15 @@ export function Sidebar() {
<button <button
className="cover-item" className="cover-item"
data-active={activeChapterId === COVER_ID} data-active={activeChapterId === COVER_ID}
onClick={() => setActiveChapter(COVER_ID)} onClick={() => {
setActiveChapter(COVER_ID);
onNavigate?.();
}}
> >
<Icon d="M5 4h11l3 3v13H5zM16 4v4h3" size={15} /> <Icon d="M5 4h11l3 3v13H5zM16 4v4h3" size={15} />
<span className="title">Cover</span> <span className="title">Cover</span>
</button> </button>
<AddPageMenu onAdd={addPage} /> <AddPageMenu onAdd={addPage} onAddPart={addPart} />
<div className="nav-scroll"> <div className="nav-scroll">
{groups.map((group) => {groups.map((group) =>
@@ -162,24 +192,41 @@ export function Sidebar() {
<div key={group.kind} className="nav-section" data-kind={group.kind}> <div key={group.kind} className="nav-section" data-kind={group.kind}>
<div className="nav-label">{group.label}</div> <div className="nav-label">{group.label}</div>
<ul className="chapters"> <ul className="chapters">
{group.rows.map((row, i) => ( {group.rows.map((row, i) => {
const isPart = chapterKind(row.chapter) === "part";
const partLabel = `Part ${partRoman(row.part ?? 0)}`;
const rowLabel = isPart
? row.chapter.title
? `${partLabel}: ${row.chapter.title}`
: partLabel
: row.chapter.title || "Untitled";
return (
<li <li
key={row.chapter.id} key={row.chapter.id}
className="chapter" className="chapter"
data-idx={row.index} data-idx={row.index}
data-id={row.chapter.id}
data-kind={group.kind} data-kind={group.kind}
data-part={isPart}
data-indent={row.indent}
data-active={row.chapter.id === activeChapterId} data-active={row.chapter.id === activeChapterId}
data-dragging={dragIndex === row.index} data-dragging={dragIndex === row.index}
data-drop-before={dropTarget?.kind === group.kind && dropTarget.index === row.index} data-drop-before={dropTarget?.kind === group.kind && dropTarget.index === row.index}
data-drop-after={dropTarget?.kind === group.kind && i === group.rows.length - 1 && dropTarget.index === row.index + 1} data-drop-after={dropTarget?.kind === group.kind && i === group.rows.length - 1 && dropTarget.index === row.index + 1}
role="button"
tabIndex={row.chapter.id === tabStopId ? 0 : -1}
aria-current={row.chapter.id === activeChapterId}
onClick={() => onRowClick(row.chapter.id)} onClick={() => onRowClick(row.chapter.id)}
onKeyDown={(e) => onRowKeyDown(e, row.index, row.chapter.id)}
onPointerDown={(e) => onRowPointerDown(e, row.index)} onPointerDown={(e) => onRowPointerDown(e, row.index)}
> >
<span className="grip" title="Drag to reorder"> <span className="grip" title="Drag to reorder">
<Icon d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" size={14} /> <Icon d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" size={14} />
</span> </span>
<span className="num">{row.num ?? ""}</span> {!isPart && <span className="num">{row.num ?? ""}</span>}
<span className="text"> <span className="text">
{isPart && <span className="part-eyebrow">Part {partRoman(row.part ?? 0)}</span>}
{(!isPart || row.chapter.title) && (
<span <span
className="title" className="title"
onMouseEnter={(e) => { onMouseEnter={(e) => {
@@ -190,17 +237,28 @@ export function Sidebar() {
> >
{row.chapter.title || "Untitled"} {row.chapter.title || "Untitled"}
</span> </span>
)}
{row.chapter.id === activeChapterId && ( {row.chapter.id === activeChapterId && (
<span className="meta">Edited {formatEdited(row.chapter.updatedAt, now)}</span> <span className="meta">Edited {relativeTime(row.chapter.updatedAt, now)}</span>
)} )}
</span> </span>
<RowMenu <RowMenu
label="Page options" label="Page options"
tabIndex={row.chapter.id === tabStopId ? 0 : -1}
onOpenChange={(open) =>
setMenuOpenId((cur) => (open ? row.chapter.id : cur === row.chapter.id ? null : cur))
}
titleHidden={!!row.chapter.noTitle}
onToggleTitle={() => setChapterNoTitle(row.chapter.id, !row.chapter.noTitle)}
marginHidden={!!row.chapter.noMargin}
onToggleMargin={() => setChapterNoMargin(row.chapter.id, !row.chapter.noMargin)}
onDuplicate={() => duplicateChapter(row.chapter.id)} onDuplicate={() => duplicateChapter(row.chapter.id)}
onDelete={() => setPendingDelete({ id: row.chapter.id, title: row.chapter.title || "Untitled" })} onMove={isDesktop ? () => setPendingMove({ chapter: row.chapter, label: rowLabel }) : undefined}
onDelete={() => setPendingDelete({ id: row.chapter.id, title: rowLabel })}
/> />
</li> </li>
))} );
})}
{!group.rows.length && ( {!group.rows.length && (
<li className="chapter-drop" data-on={dropTarget?.kind === group.kind}> <li className="chapter-drop" data-on={dropTarget?.kind === group.kind}>
Drop here Drop here
@@ -208,7 +266,13 @@ export function Sidebar() {
)} )}
</ul> </ul>
{group.kind === "body" && ( {group.kind === "body" && (
<button className="add-chapter" onClick={addChapter}> <button
className="add-chapter"
onClick={() => {
addChapter();
onNavigate?.();
}}
>
<Icon d="M12 5v14M5 12h14" /> <Icon d="M12 5v14M5 12h14" />
New chapter New chapter
</button> </button>
@@ -218,6 +282,14 @@ export function Sidebar() {
)} )}
</div> </div>
{pendingMove && (
<MoveChapterDialog
chapter={pendingMove.chapter}
label={pendingMove.label}
onClose={() => setPendingMove(null)}
/>
)}
{pendingDelete && ( {pendingDelete && (
<ConfirmDialog <ConfirmDialog
title="Delete" title="Delete"
+111
View File
@@ -0,0 +1,111 @@
import { useRef } from "react";
import { useUpdater } from "../store/useUpdater";
import { installUpdate, dismissUpdate } from "../updater";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon";
const TITLES: Record<string, string> = {
checking: "Check for Updates",
available: "Update Available",
downloading: "Updating margin",
installing: "Updating margin",
uptodate: "Check for Updates",
error: "Update Failed",
};
export function UpdateDialog() {
const phase = useUpdater((s) => s.phase);
const version = useUpdater((s) => s.version);
const notes = useUpdater((s) => s.notes);
const downloaded = useUpdater((s) => s.downloaded);
const total = useUpdater((s) => s.total);
const error = useUpdater((s) => s.error);
const busy = phase === "downloading" || phase === "installing";
const panelRef = useRef<HTMLDivElement>(null);
useEscapeLayer(phase !== "idle" && !busy, dismissUpdate);
useFocusTrap(panelRef, phase !== "idle");
if (phase === "idle") return null;
const pct = total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : 0;
const close = busy ? undefined : dismissUpdate;
return (
<div className="overlay" onClick={close}>
<div ref={panelRef} className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
<div className="panel-head">
<h2>{TITLES[phase]}</h2>
{!busy && (
<button className="icon-btn" onClick={dismissUpdate} title="Close">
<Icon d="M6 6l12 12M18 6L6 18" />
</button>
)}
</div>
<div className="panel-body">
{phase === "checking" && (
<div className="update-status-row">
<span className="update-spinner" />
<span>Checking for updates…</span>
</div>
)}
{phase === "uptodate" && <p className="confirm-text">margin is up to date.</p>}
{phase === "available" && (
<>
<p className="confirm-text">
<strong>margin {version}</strong> is available.
</p>
{notes && <div className="update-notes">{notes}</div>}
</>
)}
{phase === "downloading" && (
<>
<div className={`update-progress${total > 0 ? "" : " indeterminate"}`}>
<div className="update-progress-fill" style={total > 0 ? { width: `${pct}%` } : undefined} />
</div>
<p className="update-status">{total > 0 ? `Downloading update… ${pct}%` : "Downloading update…"}</p>
</>
)}
{phase === "installing" && (
<div className="update-status-row">
<span className="update-spinner" />
<span>Installing update. margin will restart…</span>
</div>
)}
{phase === "error" && (
<>
<p className="confirm-text">Could not update margin.</p>
{error && <div className="update-notes">{error}</div>}
</>
)}
</div>
{phase === "available" && (
<div className="panel-foot">
<button className="btn-ghost" onClick={dismissUpdate}>
Later
</button>
<button className="btn-primary" onClick={installUpdate}>
Install &amp; Restart
</button>
</div>
)}
{(phase === "uptodate" || phase === "error") && (
<div className="panel-foot">
<button className="btn-primary" onClick={dismissUpdate}>
OK
</button>
</div>
)}
</div>
</div>
);
}
+105 -43
View File
@@ -1,8 +1,10 @@
import { useEffect, useRef } from "react"; import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react"; import { EditorContent, type Editor as TiptapEditor } from "@tiptap/react";
import { EditorState } from "@tiptap/pm/state";
import type { JSONContent } from "@tiptap/core"; import type { JSONContent } from "@tiptap/core";
import { editorExtensions } from "./extensions";
import { loadPosition, savePosition, type ChapterPosition } from "./positions"; import { loadPosition, savePosition, type ChapterPosition } from "./positions";
import { loadChapterState, saveChapterState, sharedEditor, type ChapterState } from "./session";
import { useBook } from "../store/useBook";
interface EditorProps { interface EditorProps {
bookId: string; bookId: string;
@@ -13,67 +15,127 @@ interface EditorProps {
onContentError: (error: Error) => void; onContentError: (error: Error) => void;
} }
function buildState(editor: TiptapEditor, content: JSONContent, onError: (error: Error) => void): EditorState {
const base = editor.view.state;
try {
const doc = editor.schema.nodeFromJSON(content);
doc.check();
return EditorState.create({ doc, plugins: base.plugins });
} catch (error) {
onError(error as Error);
return EditorState.create({ schema: base.schema, plugins: base.plugins });
}
}
function stillMatches(editor: TiptapEditor, entry: ChapterState, content: JSONContent): boolean {
if (entry.state.schema !== editor.schema) return false;
if (entry.content === content) return true;
try {
return entry.state.doc.eq(editor.schema.nodeFromJSON(content));
} catch {
return false;
}
}
export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: EditorProps) { export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: EditorProps) {
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
onChangeRef.current = onChange; onChangeRef.current = onChange;
const onContentErrorRef = useRef(onContentError); const onContentErrorRef = useRef(onContentError);
onContentErrorRef.current = onContentError; onContentErrorRef.current = onContentError;
const latest = useRef<ChapterPosition | null>(null); const [editor] = useState(() =>
sharedEditor({
onUpdate: (next) => onChangeRef.current(next),
onContentError: (error) => onContentErrorRef.current(error),
})
);
const editor = useEditor({ const active = useRef({ bookId: "", chapterId: "" });
extensions: editorExtensions, const latest = useRef<ChapterPosition | null>(null);
content, const restoreToken = useRef(0);
immediatelyRender: false,
enableContentCheck: true, const scrollerOf = (ed: TiptapEditor) => ed.view.dom.closest(".editor-pane") as HTMLElement | null;
editorProps: { attributes: { class: "prose" } },
onContentError: ({ error }) => onContentErrorRef.current(error), const restorePosition = (ed: TiptapEditor, id: string, focus: boolean) => {
onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()), const scroller = scrollerOf(ed);
}); const saved = loadPosition(bookId, id);
const size = ed.state.doc.content.size;
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
const chain = ed.chain().setTextSelection(selection);
if (focus) chain.focus(undefined, { scrollIntoView: false });
chain.run();
if (scroller) {
const token = ++restoreToken.current;
const top = saved?.scroll ?? 0;
const apply = () => {
if (restoreToken.current === token) scroller.scrollTop = top;
};
apply();
requestAnimationFrame(apply);
document.fonts?.ready.then(apply).catch(() => {});
}
};
const stash = (ed: TiptapEditor) => {
const { bookId: prevBook, chapterId: prevChapter } = active.current;
if (!prevChapter) return;
const scroller = scrollerOf(ed);
const { from, to } = ed.state.selection;
const scroll = scroller?.scrollTop ?? loadChapterState(prevBook, prevChapter)?.scroll ?? 0;
const stored = useBook.getState().book?.chapters.find((c) => c.id === prevChapter)?.content ?? ed.getJSON();
saveChapterState(prevBook, prevChapter, { state: ed.view.state, content: stored, scroll });
savePosition(prevBook, prevChapter, { from, to, scroll });
};
const applyChapter = (ed: TiptapEditor, id: string, next: JSONContent, focus: boolean) => {
const entry = loadChapterState(bookId, id);
if (entry && stillMatches(ed, entry, next)) {
entry.content = next;
ed.view.updateState(entry.state);
const scroller = scrollerOf(ed);
if (scroller) scroller.scrollTop = entry.scroll;
++restoreToken.current;
if (focus) ed.commands.focus(undefined, { scrollIntoView: false });
return;
}
ed.view.updateState(buildState(ed, next, onContentErrorRef.current));
saveChapterState(bookId, id, { state: ed.view.state, content: next, scroll: 0 });
restorePosition(ed, id, focus);
};
useLayoutEffect(() => {
const prev = active.current;
if (prev.bookId === bookId && prev.chapterId === chapterId) return;
stash(editor);
applyChapter(editor, chapterId, content, !prev.chapterId);
active.current = { bookId, chapterId };
latest.current = null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookId, chapterId, content, editor]);
useEffect(() => { useEffect(() => {
onReady(editor); onReady(editor);
return () => onReady(null);
}, [editor, onReady]);
useEffect(() => {
if (!editor) return;
let active = true;
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
const saved = loadPosition(bookId, chapterId);
const size = editor.state.doc.content.size;
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
editor.chain().setTextSelection(selection).focus(undefined, { scrollIntoView: false }).run();
if (scroller) {
const top = saved?.scroll ?? 0;
const restore = () => {
if (active) scroller.scrollTop = top;
};
restore();
requestAnimationFrame(restore);
document.fonts?.ready.then(restore).catch(() => {});
}
return () => { return () => {
active = false; stash(editor);
onReady(null);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor]); }, [editor]);
useEffect(() => { useEffect(() => {
if (!editor) return; const scroller = scrollerOf(editor);
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
let timer: ReturnType<typeof setTimeout>; let timer: ReturnType<typeof setTimeout>;
const capture = () => { const capture = () => {
const { from, to } = editor.state.selection; const { from, to } = editor.state.selection;
latest.current = { from, to, scroll: scroller?.scrollTop ?? 0 }; const scroll = scroller?.scrollTop ?? 0;
latest.current = { from, to, scroll };
const entry = loadChapterState(active.current.bookId, active.current.chapterId);
if (entry) entry.scroll = scroll;
}; };
const persist = () => { const persist = () => {
capture(); capture();
clearTimeout(timer); clearTimeout(timer);
timer = setTimeout(() => savePosition(bookId, chapterId, latest.current!), 400); timer = setTimeout(() => savePosition(active.current.bookId, active.current.chapterId, latest.current!), 400);
}; };
editor.on("selectionUpdate", persist); editor.on("selectionUpdate", persist);
scroller?.addEventListener("scroll", persist, { passive: true }); scroller?.addEventListener("scroll", persist, { passive: true });
@@ -81,9 +143,9 @@ export function Editor({ bookId, chapterId, content, onChange, onReady, onConten
clearTimeout(timer); clearTimeout(timer);
editor.off("selectionUpdate", persist); editor.off("selectionUpdate", persist);
scroller?.removeEventListener("scroll", persist); scroller?.removeEventListener("scroll", persist);
if (latest.current) savePosition(bookId, chapterId, latest.current); if (latest.current) savePosition(active.current.bookId, active.current.chapterId, latest.current);
}; };
}, [editor, bookId, chapterId]); }, [editor]);
return <EditorContent editor={editor} className="editor-host" />; return <EditorContent editor={editor} className="editor-host" />;
} }
+15 -1
View File
@@ -20,6 +20,14 @@ export function FigureView({ node, updateAttributes, selected }: NodeViewProps)
const baseWidth = node.attrs.width ?? FIGURE_WIDTH[placement as FigurePlacement] ?? 100; const baseWidth = node.attrs.width ?? FIGURE_WIDTH[placement as FigurePlacement] ?? 100;
const width = resizable ? (dragWidth ?? baseWidth) : null; const width = resizable ? (dragWidth ?? baseWidth) : null;
const nudge = (delta: number) => updateAttributes({ width: clampWidth((width ?? baseWidth) + delta) });
const onHandleKey = (e: React.KeyboardEvent) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
nudge(e.key === "ArrowRight" ? 5 : -5);
};
const startResize = (e: React.PointerEvent) => { const startResize = (e: React.PointerEvent) => {
e.preventDefault(); e.preventDefault();
const wrapper = frameRef.current?.closest(".figure") as HTMLElement | null; const wrapper = frameRef.current?.closest(".figure") as HTMLElement | null;
@@ -52,7 +60,13 @@ export function FigureView({ node, updateAttributes, selected }: NodeViewProps)
<div className="figure-frame" ref={frameRef}> <div className="figure-frame" ref={frameRef}>
{src ? <img src={src} alt={alt} /> : <div className="figure-empty">No image</div>} {src ? <img src={src} alt={alt} /> : <div className="figure-empty">No image</div>}
{selected && src && resizable && ( {selected && src && resizable && (
<div className="figure-handle" onPointerDown={startResize} title="Drag to resize" /> <button
className="figure-handle"
aria-label="Resize image"
title="Drag or press arrow keys to resize"
onPointerDown={startResize}
onKeyDown={onHandleKey}
/>
)} )}
</div> </div>
<input <input
+72 -15
View File
@@ -1,6 +1,15 @@
import { useEffect, useReducer, 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";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import type { Alignment } from "./align";
const ALIGN_ICONS: Record<Alignment, string> = {
left: "M4 7h16M4 12h10M4 17h13",
center: "M4 7h16M7 12h10M5 17h14",
right: "M4 7h16M10 12h10M7 17h13",
};
function readImage(file: File): Promise<string> { function readImage(file: File): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -20,14 +29,21 @@ function normalizeUrl(url: string): string {
export function FloatingToolbar({ editor }: { editor: Editor | null }) { export function FloatingToolbar({ editor }: { editor: Editor | null }) {
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const linkInputRef = useRef<HTMLInputElement>(null); const linkPopRef = useRef<HTMLDivElement>(null);
const alignPopRef = useRef<HTMLDivElement>(null);
const [linkOpen, setLinkOpen] = useState(false); const [linkOpen, setLinkOpen] = useState(false);
const [linkValue, setLinkValue] = useState(""); const [linkValue, setLinkValue] = useState("");
const [alignOpen, setAlignOpen] = useState(false);
const [, forceUpdate] = useReducer((x) => x + 1, 0); const [, forceUpdate] = useReducer((x) => x + 1, 0);
useEffect(() => { const openLink = () => {
if (linkOpen) linkInputRef.current?.focus(); if (!editor) return;
}, [linkOpen]); setLinkValue((editor.getAttributes("link").href as string) ?? "");
setLinkOpen(true);
};
useFocusTrap(linkPopRef, linkOpen);
useFocusTrap(alignPopRef, alignOpen);
useEffect(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
@@ -38,6 +54,27 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
}; };
}, [editor]); }, [editor]);
useEffect(() => {
if (!editor) return;
const onKey = (e: KeyboardEvent) => {
if (e.key.toLowerCase() !== "k" || !(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return;
if (!editor.isFocused && !linkOpen) return;
e.preventDefault();
e.stopPropagation();
if (linkOpen) setLinkOpen(false);
else openLink();
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, linkOpen]);
useEscapeLayer(linkOpen, () => {
setLinkOpen(false);
editor?.commands.focus();
});
useEscapeLayer(alignOpen, () => setAlignOpen(false));
if (!editor) return null; if (!editor) return null;
const insertImage = async (file: File) => { const insertImage = async (file: File) => {
@@ -45,11 +82,6 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
editor.chain().focus().insertContent({ type: "figure", attrs: { src, placement: "full-width" } }).run(); editor.chain().focus().insertContent({ type: "figure", attrs: { src, placement: "full-width" } }).run();
}; };
const openLink = () => {
setLinkValue((editor.getAttributes("link").href as string) ?? "");
setLinkOpen(true);
};
const applyLink = () => { const applyLink = () => {
const href = normalizeUrl(linkValue); const href = normalizeUrl(linkValue);
if (!href) { if (!href) {
@@ -67,6 +99,13 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
setLinkOpen(false); setLinkOpen(false);
}; };
const align: Alignment = (editor.getAttributes("heading").align ?? editor.getAttributes("paragraph").align) || "left";
const setAlign = (value: Alignment) => {
editor.chain().focus().setTextAlign(value).run();
setAlignOpen(false);
};
const tool = (active: boolean, onClick: () => void, title: string, content: ReactNode) => ( const tool = (active: boolean, onClick: () => void, title: string, content: ReactNode) => (
<button <button
className="tool" className="tool"
@@ -87,15 +126,36 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
{tool(editor.isActive("heading", { level: 2 }), () => editor.chain().focus().toggleHeading({ level: 2 }).run(), "Heading", <Icon d="M5 5v14M5 12h8M13 5v14" />)} {tool(editor.isActive("heading", { level: 2 }), () => editor.chain().focus().toggleHeading({ level: 2 }).run(), "Heading", <Icon d="M5 5v14M5 12h8M13 5v14" />)}
{tool(editor.isActive("blockquote"), () => editor.chain().focus().toggleBlockquote().run(), "Quote", <Icon d="M7 8h4v4a4 4 0 0 1-4 4M14 8h4v4a4 4 0 0 1-4 4" />)} {tool(editor.isActive("blockquote"), () => editor.chain().focus().toggleBlockquote().run(), "Quote", <Icon d="M7 8h4v4a4 4 0 0 1-4 4M14 8h4v4a4 4 0 0 1-4 4" />)}
{tool(editor.isActive("bulletList"), () => editor.chain().focus().toggleBulletList().run(), "Bulleted list", <Icon d="M8 6h12M8 12h12M8 18h12M3.5 6h.01M3.5 12h.01M3.5 18h.01" />)} {tool(editor.isActive("bulletList"), () => editor.chain().focus().toggleBulletList().run(), "Bulleted list", <Icon d="M8 6h12M8 12h12M8 18h12M3.5 6h.01M3.5 12h.01M3.5 18h.01" />)}
<span className="tool-wrap">
{tool(alignOpen || align !== "left", () => setAlignOpen((v) => !v), "Align", <Icon d={ALIGN_ICONS[align]} />)}
{alignOpen && (
<>
<div className="link-pop-backdrop" onMouseDown={() => setAlignOpen(false)} />
<div ref={alignPopRef} className="align-pop" onMouseDown={(e) => e.stopPropagation()}>
{(["left", "center", "right"] as Alignment[]).map((value) => (
<button
key={value}
className="tool"
data-on={align === value}
title={`Align ${value}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setAlign(value)}
>
<Icon d={ALIGN_ICONS[value]} />
</button>
))}
</div>
</>
)}
</span>
<span className="tool-sep" /> <span className="tool-sep" />
<span className="tool-wrap"> <span className="tool-wrap">
{tool(editor.isActive("link") || linkOpen, () => (linkOpen ? setLinkOpen(false) : openLink()), "Link", <Icon d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" />)} {tool(editor.isActive("link") || linkOpen, () => (linkOpen ? setLinkOpen(false) : openLink()), "Link (⌘K)", <Icon d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" />)}
{linkOpen && ( {linkOpen && (
<> <>
<div className="link-pop-backdrop" onMouseDown={() => setLinkOpen(false)} /> <div className="link-pop-backdrop" onMouseDown={() => setLinkOpen(false)} />
<div className="link-pop" onMouseDown={(e) => e.stopPropagation()}> <div ref={linkPopRef} className="link-pop" onMouseDown={(e) => e.stopPropagation()}>
<input <input
ref={linkInputRef}
className="link-input" className="link-input"
value={linkValue} value={linkValue}
placeholder="https://…" placeholder="https://…"
@@ -105,9 +165,6 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
applyLink(); applyLink();
} else if (e.key === "Escape") {
e.preventDefault();
setLinkOpen(false);
} }
}} }}
/> />
+64
View File
@@ -0,0 +1,64 @@
import { Extension } from "@tiptap/core";
export type Alignment = "left" | "center" | "right";
const ALIGNED_TYPES = ["paragraph", "heading"];
declare module "@tiptap/core" {
interface Commands<ReturnType> {
textAlign: {
setTextAlign: (align: Alignment) => ReturnType;
};
}
}
export const TextAlign = Extension.create({
name: "textAlign",
addGlobalAttributes() {
return [
{
types: ALIGNED_TYPES,
attributes: {
align: {
default: null,
keepOnSplit: true,
parseHTML: (el) => {
const value = el.style.textAlign || el.getAttribute("data-align");
return value === "center" || value === "right" ? value : null;
},
renderHTML: (attrs) =>
attrs.align ? { "data-align": attrs.align, style: `text-align: ${attrs.align}` } : {},
},
},
},
];
},
addCommands() {
return {
setTextAlign:
(align) =>
({ tr, dispatch }) => {
const value = align === "left" ? null : align;
const { from, to } = tr.selection;
let changed = false;
tr.doc.nodesBetween(from, to, (node, pos) => {
if (ALIGNED_TYPES.includes(node.type.name) && node.attrs.align !== value) {
if (dispatch) tr.setNodeAttribute(pos, "align", value);
changed = true;
}
});
return changed;
},
};
},
addKeyboardShortcuts() {
return {
"Mod-Shift-l": () => this.editor.commands.setTextAlign("left"),
"Mod-Shift-e": () => this.editor.commands.setTextAlign("center"),
"Mod-Shift-r": () => this.editor.commands.setTextAlign("right"),
};
},
});
+6
View File
@@ -3,8 +3,11 @@ 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 { TextAlign } from "./align";
import { SearchHighlight } from "./search"; import { SearchHighlight } from "./search";
import { Proofing } from "./proofing"; import { Proofing } from "./proofing";
import { Paste } from "./paste";
import { Shortcuts } from "./shortcuts";
export const editorExtensions: Extensions = [ export const editorExtensions: Extensions = [
StarterKit.configure({ StarterKit.configure({
@@ -17,6 +20,9 @@ export const editorExtensions: Extensions = [
}), }),
Figure, Figure,
ParagraphIndent, ParagraphIndent,
TextAlign,
SearchHighlight, SearchHighlight,
Proofing, Proofing,
Paste,
Shortcuts,
]; ];
+76
View File
@@ -0,0 +1,76 @@
import { Extension } from "@tiptap/core";
import type { JSONContent } from "@tiptap/core";
import { Plugin } from "@tiptap/pm/state";
function readImage(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function imageFiles(data: DataTransfer | null): File[] {
if (!data) return [];
const files = Array.from(data.files).filter((f) => f.type.startsWith("image/"));
if (files.length) return files;
return Array.from(data.items)
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((f): f is File => f !== null);
}
function plainContent(text: string): JSONContent[] {
return text
.replace(/\r\n?/g, "\n")
.split(/\n{2,}/)
.map((block) => {
const content: JSONContent[] = [];
block.split("\n").forEach((line, i) => {
if (i > 0) content.push({ type: "hardBreak" });
if (line) content.push({ type: "text", text: line });
});
return content.length ? { type: "paragraph", content } : { type: "paragraph" };
});
}
export const Paste = Extension.create({
name: "pasteHandler",
addKeyboardShortcuts() {
const editor = this.editor;
return {
"Mod-Shift-v": () => {
navigator.clipboard
.readText()
.then((text) => {
if (text) editor.chain().focus().insertContent(plainContent(text)).run();
})
.catch(() => {});
return true;
},
};
},
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
props: {
handlePaste(_view, event) {
const files = imageFiles(event.clipboardData);
if (!files.length) return false;
event.preventDefault();
files.forEach((file) =>
readImage(file).then((src) =>
editor.chain().focus().insertContent({ type: "figure", attrs: { src, placement: "full-width" } }).run(),
),
);
return true;
},
},
}),
];
},
});
+9 -3
View File
@@ -37,10 +37,11 @@ interface ProofingState {
export const proofingKey = new PluginKey<ProofingState>("proofing"); export const proofingKey = new PluginKey<ProofingState>("proofing");
function buildDecorations(doc: PMNode, issues: ProofIssue[]): DecorationSet { function buildDecorations(doc: PMNode, issues: ProofIssue[], cursor: number | null): DecorationSet {
const size = doc.content.size; const size = doc.content.size;
const decos = issues const decos = issues
.filter((i) => i.from < i.to && i.to <= size) .filter((i) => i.from < i.to && i.to <= size)
.filter((i) => cursor === null || cursor < i.from || cursor > i.to)
.map((i) => Decoration.inline(i.from, i.to, { class: `proof-mark sev-${severityFor(i.category)}` })); .map((i) => Decoration.inline(i.from, i.to, { class: `proof-mark sev-${severityFor(i.category)}` }));
return DecorationSet.create(doc, decos); return DecorationSet.create(doc, decos);
} }
@@ -73,9 +74,11 @@ export const Proofing = Extension.create<unknown, ProofingStorage>({
state: { state: {
init: () => ({ issues: [], decorations: DecorationSet.empty }), init: () => ({ issues: [], decorations: DecorationSet.empty }),
apply(tr, value, _old, newState) { apply(tr, value, _old, newState) {
const sel = newState.selection;
const cursor = sel.empty ? sel.head : null;
const meta = tr.getMeta(proofingKey) as { issues: ProofIssue[] } | { clear: true } | undefined; const meta = tr.getMeta(proofingKey) as { issues: ProofIssue[] } | { clear: true } | undefined;
if (meta && "issues" in meta) { if (meta && "issues" in meta) {
return { issues: meta.issues, decorations: buildDecorations(newState.doc, meta.issues) }; return { issues: meta.issues, decorations: buildDecorations(newState.doc, meta.issues, cursor) };
} }
if (meta && "clear" in meta) { if (meta && "clear" in meta) {
return { issues: [], decorations: DecorationSet.empty }; return { issues: [], decorations: DecorationSet.empty };
@@ -84,7 +87,10 @@ export const Proofing = Extension.create<unknown, ProofingStorage>({
const issues = value.issues const issues = value.issues
.map((i) => ({ ...i, from: tr.mapping.map(i.from), to: tr.mapping.map(i.to) })) .map((i) => ({ ...i, from: tr.mapping.map(i.from), to: tr.mapping.map(i.to) }))
.filter((i) => i.to > i.from); .filter((i) => i.to > i.from);
return { issues, decorations: value.decorations.map(tr.mapping, tr.doc) }; return { issues, decorations: buildDecorations(newState.doc, issues, cursor) };
}
if (tr.selectionSet && value.issues.length) {
return { issues: value.issues, decorations: buildDecorations(newState.doc, value.issues, cursor) };
} }
return value; return value;
}, },
+65
View File
@@ -0,0 +1,65 @@
import { Editor as TiptapEditor } from "@tiptap/react";
import type { JSONContent } from "@tiptap/core";
import type { EditorState } from "@tiptap/pm/state";
import { editorExtensions } from "./extensions";
export interface ChapterState {
state: EditorState;
content: JSONContent;
scroll: number;
}
export interface EditorHooks {
onUpdate: (content: JSONContent) => void;
onContentError: (error: Error) => void;
}
const SCROLL_KEEPOUT = { top: 32, right: 0, bottom: 96, left: 0 };
const MAX_STATES = 24;
const states = new Map<string, ChapterState>();
const stateKey = (bookId: string, chapterId: string) => `${bookId}:${chapterId}`;
let instance: TiptapEditor | null = null;
let hooks: EditorHooks | null = null;
export function sharedEditor(next: EditorHooks): TiptapEditor {
hooks = next;
if (!instance) {
instance = new TiptapEditor({
extensions: editorExtensions,
enableContentCheck: true,
editorProps: {
attributes: { class: "prose" },
scrollThreshold: SCROLL_KEEPOUT,
scrollMargin: SCROLL_KEEPOUT,
},
onContentError: ({ error }) => hooks?.onContentError(error),
onUpdate: ({ editor }) => hooks?.onUpdate(editor.getJSON()),
});
}
return instance;
}
export function loadChapterState(bookId: string, chapterId: string): ChapterState | undefined {
return states.get(stateKey(bookId, chapterId));
}
export function saveChapterState(bookId: string, chapterId: string, entry: ChapterState): void {
for (const id of states.keys()) {
if (!id.startsWith(`${bookId}:`)) states.delete(id);
}
const id = stateKey(bookId, chapterId);
states.delete(id);
states.set(id, entry);
for (const oldest of states.keys()) {
if (states.size <= MAX_STATES) break;
states.delete(oldest);
}
}
export function clearChapterStates(bookId: string): void {
for (const id of states.keys()) {
if (id.startsWith(`${bookId}:`)) states.delete(id);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Extension } from "@tiptap/core";
export const Shortcuts = Extension.create({
name: "shortcuts",
addKeyboardShortcuts() {
return {
"Mod-Shift-x": () => this.editor.commands.toggleStrike(),
};
},
});
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useRef } from "react";
type Handler = () => void;
const layers: Handler[] = [];
let bound = false;
function onKeyDown(e: KeyboardEvent) {
if (e.key !== "Escape" || e.isComposing || e.defaultPrevented) return;
const top = layers[layers.length - 1];
if (!top) return;
e.preventDefault();
e.stopPropagation();
top();
}
function pushLayer(handler: Handler): () => void {
if (!bound) {
window.addEventListener("keydown", onKeyDown, true);
bound = true;
}
layers.push(handler);
return () => {
const i = layers.lastIndexOf(handler);
if (i !== -1) layers.splice(i, 1);
};
}
export function useEscapeLayer(active: boolean, onEscape: () => void): void {
const latest = useRef(onEscape);
latest.current = onEscape;
useEffect(() => {
if (!active) return;
return pushLayer(() => latest.current());
}, [active]);
}
+38 -13
View File
@@ -1,6 +1,6 @@
import type { JSONContent } from "@tiptap/core"; import type { JSONContent } from "@tiptap/core";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { type Book, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book"; import { type Book, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement, partNumber, partRoman } from "../model/book";
import { fontStack, fontsUsed } from "../model/fonts"; import { fontStack, fontsUsed } from "../model/fonts";
export interface EpubFile { export interface EpubFile {
@@ -132,17 +132,23 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
return `<figure class="placement-${attr(placement)}"${style}><img src="${attr(path)}" alt="${alt}"/>${caption}</figure>`; return `<figure class="placement-${attr(placement)}"${style}><img src="${attr(path)}" alt="${alt}"/>${caption}</figure>`;
} }
function alignStyle(align: unknown): string {
return align === "center" || align === "right" ? ` style="text-align:${align}"` : "";
}
function block(node: JSONContent, paths: Map<string, string>): string { function block(node: JSONContent, paths: Map<string, string>): string {
switch (node.type) { switch (node.type) {
case "paragraph": { case "paragraph": {
const body = inlines(node.content); const body = inlines(node.content);
if (!body.trim()) return "<p>&#160;</p>"; if (!body.trim()) return "<p>&#160;</p>";
const align = node.attrs?.align;
if (align === "center" || align === "right") return `<p${alignStyle(align)}>${body}</p>`;
return `<p${node.attrs?.indent ? ' data-indent="true"' : ""}>${body}</p>`; return `<p${node.attrs?.indent ? ' data-indent="true"' : ""}>${body}</p>`;
} }
case "heading": case "heading": {
return node.attrs?.level === 3 const tag = node.attrs?.level === 3 ? "h3" : "h2";
? `<h3>${inlines(node.content)}</h3>` return `<${tag}${alignStyle(node.attrs?.align)}>${inlines(node.content)}</${tag}>`;
: `<h2>${inlines(node.content)}</h2>`; }
case "blockquote": case "blockquote":
return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`; return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`;
case "bulletList": case "bulletList":
@@ -338,9 +344,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 kind = chapterKind(chapter);
const fallback = chapterKind(chapter) === "body" ? `Chapter ${bodyNumber(book.chapters, i)}` : "Untitled"; if (chapter.noTitle && kind !== "body" && kind !== "part") return "";
const label = chapter.noTitle ? fallback : chapter.title || fallback; const partLabel = `Part ${partRoman(partNumber(book.chapters, i) ?? 0)}`;
const fallback = kind === "body" ? `Chapter ${bodyNumber(book.chapters, i)}` : kind === "part" ? partLabel : "Untitled";
const base = chapter.noTitle ? fallback : chapter.title || fallback;
const label = kind === "part" && !chapter.noTitle && chapter.title ? `${partLabel}: ${chapter.title}` : base;
return ` <li><a href="chapter-${i + 1}.xhtml">${esc(label)}</a></li>`; return ` <li><a href="chapter-${i + 1}.xhtml">${esc(label)}</a></li>`;
}) })
.filter(Boolean) .filter(Boolean)
@@ -366,16 +375,20 @@ function chapterXhtml(book: Book, index: number, paths: Map<string, string>): st
const chapter = book.chapters[index]; const chapter = book.chapters[index];
const kind = chapterKind(chapter); const kind = chapterKind(chapter);
const num = bodyNumber(book.chapters, index); const num = bodyNumber(book.chapters, index);
const title = chapter.title || (kind === "body" ? `Chapter ${num}` : "Untitled"); const partLabel = `Part ${partRoman(partNumber(book.chapters, index) ?? 0)}`;
const eyebrow = kind === "body" ? `<p class="eyebrow">Chapter ${num}</p>` : ""; const eyebrow =
const heading = chapter.noTitle ? "" : `<h1>${esc(title)}</h1>`; kind === "body" ? `<p class="eyebrow">Chapter ${num}</p>` : kind === "part" ? `<p class="eyebrow">${esc(partLabel)}</p>` : "";
const headingText = kind === "part" ? chapter.title : chapter.title || (kind === "body" ? `Chapter ${num}` : "Untitled");
const heading = chapter.noTitle || (kind === "part" && !chapter.title) ? "" : `<h1>${esc(headingText)}</h1>`;
const docTitle = chapter.title || (kind === "part" ? partLabel : kind === "body" ? `Chapter ${num}` : "Untitled");
const epubType = kind === "body" ? "chapter" : kind === "part" ? "part" : kind === "front" ? "frontmatter" : "backmatter";
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>
<title>${esc(title)}</title> <title>${esc(docTitle)}</title>
<link rel="stylesheet" type="text/css" href="style.css"/> <link rel="stylesheet" type="text/css" href="style.css"/>
</head> </head>
<body epub:type="${kind === "body" ? "chapter" : kind === "front" ? "frontmatter" : "backmatter"}"> <body epub:type="${epubType}">
<header class="chapter-opener"> <header class="chapter-opener">
${eyebrow} ${eyebrow}
${heading} ${heading}
@@ -435,6 +448,18 @@ h2, h3 {
margin: 3em 0 2.2em; margin: 3em 0 2.2em;
} }
body[epub:type="part"] {
page-break-before: always;
}
body[epub:type="part"] .chapter-opener {
margin-top: 32vh;
}
body[epub:type="part"] .eyebrow {
font-size: 0.82em;
}
.eyebrow { .eyebrow {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.28em; letter-spacing: 0.28em;
+53 -12
View File
@@ -1,5 +1,5 @@
import type { JSONContent } from "@tiptap/core"; import type { JSONContent } from "@tiptap/core";
import { type Book, type FigurePlacement, type TrimSize, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book"; import { type Book, type FigurePlacement, type TrimSize, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement, partNumber, partRoman } from "../model/book";
import { fontFamilyName, fontsUsed } from "../model/fonts"; import { fontFamilyName, fontsUsed } from "../model/fonts";
import type { ImageInput } from "../ipc"; import type { ImageInput } from "../ipc";
@@ -20,7 +20,7 @@ const TRIM: Record<TrimSize, { w: string; h: string }> = {
a5: { w: "148mm", h: "210mm" }, a5: { w: "148mm", h: "210mm" },
}; };
const INLINE_SPECIAL = /[\\#$*_`<>@~[\]]/g; const INLINE_SPECIAL = /[\\#$*_`<>@~()[\]]/g;
const LINE_SEPARATORS = /[\n\r\v\f\u2028\u2029]+/g; const LINE_SEPARATORS = /[\n\r\v\f\u2028\u2029]+/g;
function esc(text: string): string { function esc(text: string): string {
@@ -37,6 +37,15 @@ function str(value: string): string {
return JSON.stringify(value); return JSON.stringify(value);
} }
function localeParams(tag: string): string {
const parts = (tag || "en").trim().split(/[-_]/);
const primary = parts[0] ?? "";
const lang = /^[a-zA-Z]{2,3}$/.test(primary) ? primary.toLowerCase() : "en";
const region = parts.slice(1).find((p) => /^[a-zA-Z]{2}$/.test(p));
const params = `lang: ${str(lang)}`;
return region ? `${params}, region: ${str(region.toUpperCase())}` : params;
}
function inline(node: JSONContent, atLineStart: boolean): string { function inline(node: JSONContent, atLineStart: boolean): string {
if (node.type === "text") { if (node.type === "text") {
const marks = node.marks ?? []; const marks = node.marks ?? [];
@@ -80,15 +89,22 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`; return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`;
} }
function aligned(align: unknown, body: string): string {
if (align === "center" || align === "right") return `#align(${align})[#set par(justify: false)\n${body}]`;
return body;
}
function block(node: JSONContent, paths: Map<string, string>): string { function block(node: JSONContent, paths: Map<string, string>): string {
switch (node.type) { switch (node.type) {
case "paragraph": { case "paragraph": {
const body = inlines(node.content, true); const body = inlines(node.content, true);
if (!body.trim()) return "~"; if (!body.trim()) return "~";
const align = node.attrs?.align;
if (align === "center" || align === "right") return aligned(align, body);
return node.attrs?.indent ? `#h(1.3em)${body}` : body; return node.attrs?.indent ? `#h(1.3em)${body}` : body;
} }
case "heading": case "heading":
return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content, true)}]`; return aligned(node.attrs?.align, `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content, true)}]`);
case "blockquote": case "blockquote":
return `#blockquote[${(node.content ?? []).map((n) => block(n, paths)).join("\n\n")}]`; return `#blockquote[${(node.content ?? []).map((n) => block(n, paths)).join("\n\n")}]`;
case "bulletList": case "bulletList":
@@ -124,7 +140,7 @@ function preamble(book: Book): string {
margin: (inside: 0.875in, outside: 0.625in + ${bleed}, top: 0.8in + ${bleed}, bottom: 0.8in + ${bleed}), margin: (inside: 0.875in, outside: 0.625in + ${bleed}, top: 0.8in + ${bleed}, bottom: 0.8in + ${bleed}),
binding: left, binding: left,
) )
#set text(font: ${str(body)}, size: 11pt, lang: ${str(meta.language || "en")}, hyphenate: true) #set text(font: ${str(body)}, size: 11pt, ${localeParams(meta.language)}, hyphenate: true)
#set par(justify: true, leading: 0.72em, spacing: 0.72em) #set par(justify: true, leading: 0.72em, spacing: 0.72em)
#show heading: set text(font: ${str(heading)}, weight: "medium") #show heading: set text(font: ${str(heading)}, weight: "medium")
#show heading.where(level: 1): set text(size: 22pt) #show heading.where(level: 1): set text(size: 22pt)
@@ -153,7 +169,7 @@ function preamble(book: Book): string {
#let openchapter(info) = { #let openchapter(info) = {
pagebreak(weak: true) pagebreak(weak: true)
[#metadata(info) <chap>] [#metadata(info) <chap>]
v(2.1in) if not info.nomargin { v(2.1in) }
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)]
@@ -166,6 +182,20 @@ function preamble(book: Book): string {
v(1.5em) v(1.5em)
} }
#let openpart(info) = {
pagebreak(to: "odd", weak: true)
[#metadata(info) <chap>]
if not info.nomargin { v(3.1in) }
align(center)[
#text(font: "Hanken Grotesk", size: 10pt, weight: "semibold", tracking: 0.34em)[#upper("Part " + info.numeral)]
#if not info.notitle and info.title != "" {
v(0.85em)
heading(level: 1, numbering: none, outlined: info.toc)[#info.title]
}
]
v(1.5em)
}
#let pageof(loc) = { #let pageof(loc) = {
let pat = loc.page-numbering() let pat = loc.page-numbering()
if pat == none { pat = "1" } if pat == none { pat = "1" }
@@ -186,6 +216,7 @@ function preamble(book: Book): string {
let info = it.value let info = it.value
let pg = pageof(it.location()) let pg = pageof(it.location())
let body = info.kind == "body" let body = info.kind == "body"
let part = info.kind == "part"
let gap = if prev != none and prev != info.kind { 1.7em } else { 0.95em } let gap = if prev != none and prev != info.kind { 1.7em } else { 0.95em }
prev = info.kind prev = info.kind
block(width: 100%, above: gap, below: 0.95em, { block(width: 100%, above: gap, below: 0.95em, {
@@ -195,7 +226,9 @@ function preamble(book: Book): string {
column-gutter: (0.65em, 1em), column-gutter: (0.65em, 1em),
align: (right + top, left + top, right + top), align: (right + top, left + top, right + top),
if body { info.num } else { [] }, if body { info.num } else { [] },
if body { info.title } else { emph(info.title) }, if part {
strong(if info.title != "" { "Part " + info.numeral + ": " + info.title } else { "Part " + info.numeral })
} else if body { info.title } else { emph(info.title) },
pg, pg,
)] )]
}) })
@@ -312,18 +345,26 @@ 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 kind = chapterKind(chapter);
if (kind === "part") return true;
if (chapter.noTitle && kind !== "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 !(kind === "front" && normTitle(title) === normTitle(book.metadata.title));
} }
function openerCall(book: Book, index: number): string { 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 notitle = !!chapter.noTitle; const notitle = !!chapter.noTitle;
const nomargin = !!chapter.noMargin;
if (kind === "part") {
const numeral = partRoman(partNumber(book.chapters, index) ?? 0);
const title = notitle ? "" : cleanTitle(chapter.title);
return `#openpart((kind: "part", numeral: ${str(numeral)}, title: ${str(title)}, notitle: ${notitle}, nomargin: ${nomargin}, toc: ${inToc(book, index)}))`;
}
const num = kind === "body" ? String(bodyNumber(book.chapters, index)) : "";
const title = notitle ? "" : cleanTitle(chapter.title) || "Untitled"; const title = notitle ? "" : cleanTitle(chapter.title) || "Untitled";
return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, notitle: ${notitle}, toc: ${inToc(book, index)}))`; return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, notitle: ${notitle}, nomargin: ${nomargin}, 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 {
@@ -335,13 +376,13 @@ export function bookToTypst(book: Book, paths: Map<string, string> = new Map(),
#set page(numbering: "i") #set page(numbering: "i")
#counter(page).update(1)`; #counter(page).update(1)`;
const firstBody = book.chapters.findIndex((c) => chapterKind(c) === "body"); const firstMain = book.chapters.findIndex((c) => chapterKind(c) === "body" || chapterKind(c) === "part");
const firstToc = book.chapters.findIndex((_, i) => inToc(book, i)); const firstToc = book.chapters.findIndex((_, i) => inToc(book, i));
const body = book.chapters const body = book.chapters
.map((chapter, i) => { .map((chapter, i) => {
const toc = i === firstToc ? `#contents()\n\n` : ""; const toc = i === firstToc ? `#contents()\n\n` : "";
const reset = i === firstBody ? `#set page(numbering: "1")\n#counter(page).update(1)\n` : ""; const reset = i === firstMain ? `#set page(numbering: "1")\n#counter(page).update(1)\n` : "";
return `${toc}${reset}${openerCall(book, i)}\n\n${chapterBody(chapter.content, paths)}`; return `${toc}${reset}${openerCall(book, i)}\n\n${chapterBody(chapter.content, paths)}`;
}) })
.join("\n\n"); .join("\n\n");
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useRef, type RefObject } from "react";
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function focusable(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => !el.hidden && el.offsetParent !== null,
);
}
let openTraps = 0;
export function focusTrapped(): boolean {
return openTraps > 0;
}
export function useFocusTrap(ref: RefObject<HTMLElement | null>, active = true): void {
const opener = useRef<HTMLElement | null>(null);
const wasActive = useRef(false);
if (active && !wasActive.current) opener.current = document.activeElement as HTMLElement | null;
wasActive.current = active;
useEffect(() => {
const root = ref.current;
if (!active || !root) return;
openTraps++;
if (!root.contains(document.activeElement)) focusable(root)[0]?.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const items = focusable(root);
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
const current = document.activeElement as HTMLElement | null;
if (!current || !root.contains(current)) {
e.preventDefault();
(e.shiftKey ? last : first).focus();
} else if (e.shiftKey && current === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && current === last) {
e.preventDefault();
first.focus();
}
};
root.addEventListener("keydown", onKeyDown);
return () => {
openTraps--;
root.removeEventListener("keydown", onKeyDown);
if (opener.current?.isConnected) opener.current.focus();
};
}, [ref, active]);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import type { JSONContent } from "@tiptap/core"; import type { JSONContent } from "@tiptap/core";
import { type Book, type BookMetadata, type Chapter, type Cover, createCover } from "../model/book"; import { type Book, type BookMetadata, type Chapter, type Cover, clampLanguage, createCover } from "../model/book";
import { DEFAULT_FONTS } from "../model/fonts"; import { DEFAULT_FONTS } from "../model/fonts";
export interface RawFile { export interface RawFile {
@@ -508,7 +508,7 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
subtitle: "", subtitle: "",
author: localText(opf, "creator"), author: localText(opf, "creator"),
isbn: findIsbn(opf), isbn: findIsbn(opf),
language: localText(opf, "language") || "en", language: clampLanguage(localText(opf, "language") || "en"),
}; };
return { return {
+5
View File
@@ -2,6 +2,11 @@ import { invoke } from "@tauri-apps/api/core";
export const isDesktop = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; export const isDesktop = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
export async function runWritingTool(tool: "Proofread" | "Rewrite"): Promise<void> {
if (!isDesktop) return;
await invoke("run_writing_tool", { tool });
}
export interface ImageInput { export interface ImageInput {
path: string; path: string;
data: string; data: string;
+28 -1
View File
@@ -1,11 +1,21 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { isDesktop } from "./ipc"; import { isDesktop } from "./ipc";
import { type Book, SCHEMA_VERSION, createBook, schemaVersion, starterBook } from "./model/book"; import {
type Book,
type Chapter,
SCHEMA_VERSION,
createBook,
insertByKind,
normalizeBook,
schemaVersion,
starterBook,
} from "./model/book";
export interface BookSummary { export interface BookSummary {
id: string; id: string;
title: string; title: string;
author: string; author: string;
updatedAt: number;
corrupt?: boolean; corrupt?: boolean;
} }
@@ -38,6 +48,23 @@ export async function deleteBook(id: string): Promise<void> {
await invoke("delete_book", { id }); await invoke("delete_book", { id });
} }
export async function moveChapterToBook(chapter: Chapter, targetId: string): Promise<void> {
const target = normalizeBook(await loadBook(targetId));
const moved = { ...chapter, content: structuredClone(chapter.content), updatedAt: Date.now() };
await saveBook({ ...target, chapters: insertByKind(target.chapters, moved) });
}
const LAST_BOOK_KEY = "margin-last-book";
export function lastBookId(): string | null {
return localStorage.getItem(LAST_BOOK_KEY);
}
export function rememberLastBook(id: string | null): void {
if (id) localStorage.setItem(LAST_BOOK_KEY, id);
else localStorage.removeItem(LAST_BOOK_KEY);
}
export function newBook(): Book { export function newBook(): Book {
return createBook(); return createBook();
} }
+53 -2
View File
@@ -64,7 +64,7 @@ export function trimRatio(trim: TrimSize): number {
return w / h; return w / h;
} }
export type ChapterKind = "front" | "body" | "back"; export type ChapterKind = "front" | "body" | "part" | "back";
export interface Chapter { export interface Chapter {
id: string; id: string;
@@ -73,6 +73,7 @@ export interface Chapter {
updatedAt: number; updatedAt: number;
kind?: ChapterKind; kind?: ChapterKind;
noTitle?: boolean; noTitle?: boolean;
noMargin?: boolean;
} }
export interface PageType { export interface PageType {
@@ -107,6 +108,31 @@ export function bodyNumber(chapters: Chapter[], index: number): number | null {
return n; return n;
} }
export function partNumber(chapters: Chapter[], index: number): number | null {
if (index < 0 || index >= chapters.length || chapterKind(chapters[index]) !== "part") return null;
let n = 0;
for (let i = 0; i <= index; i++) if (chapterKind(chapters[i]) === "part") n++;
return n;
}
const ROMAN: [number, string][] = [
[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"],
[50, "L"], [40, "XL"], [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"],
];
export function partRoman(n: number): string {
if (n < 1) return String(n);
let num = n;
let out = "";
for (const [value, sym] of ROMAN) {
while (num >= value) {
out += sym;
num -= value;
}
}
return out;
}
export interface Book { export interface Book {
schema: "margin/1"; schema: "margin/1";
id: string; id: string;
@@ -141,6 +167,23 @@ 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 createPart(title = ""): Chapter {
return { id: crypto.randomUUID(), title, content: emptyDoc(), kind: "part", updatedAt: Date.now() };
}
export function insertByKind(chapters: Chapter[], chapter: Chapter): Chapter[] {
const kind = chapterKind(chapter);
const next = [...chapters];
const at =
kind === "front"
? next.filter((c) => chapterKind(c) === "front").length
: kind === "back"
? next.length
: next.length - next.filter((c) => chapterKind(c) === "back").length;
next.splice(at, 0, chapter);
return next;
}
export function cloneChapter(chapter: Chapter): Chapter { export function cloneChapter(chapter: Chapter): Chapter {
const title = chapter.noTitle || !chapter.title ? chapter.title : `${chapter.title} (copy)`; const title = chapter.noTitle || !chapter.title ? chapter.title : `${chapter.title} (copy)`;
return { ...chapter, id: crypto.randomUUID(), title, content: structuredClone(chapter.content), updatedAt: Date.now() }; return { ...chapter, id: crypto.randomUUID(), title, content: structuredClone(chapter.content), updatedAt: Date.now() };
@@ -153,11 +196,19 @@ export function createCover(): Cover {
const DEFAULT_METADATA: BookMetadata = { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" }; const DEFAULT_METADATA: BookMetadata = { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" };
const DEFAULT_SETTINGS: BookSettings = { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS }; const DEFAULT_SETTINGS: BookSettings = { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS };
export const SUPPORTED_LANGUAGES = ["en", "es", "fr", "de", "it", "pt"] as const;
export function clampLanguage(tag: string): string {
const primary = (tag || "").trim().split(/[-_]/)[0]?.toLowerCase() ?? "";
return (SUPPORTED_LANGUAGES as readonly string[]).includes(primary) ? primary : "en";
}
export function normalizeBook(book: Book): Book { export function normalizeBook(book: Book): Book {
const chapters = (Array.isArray(book.chapters) ? book.chapters : []).filter(Boolean); const chapters = (Array.isArray(book.chapters) ? book.chapters : []).filter(Boolean);
const metadata = { ...DEFAULT_METADATA, ...book.metadata };
return { return {
...book, ...book,
metadata: { ...DEFAULT_METADATA, ...book.metadata }, metadata: { ...metadata, language: clampLanguage(metadata.language) },
settings: { ...DEFAULT_SETTINGS, ...book.settings, fonts: { ...DEFAULT_FONTS, ...book.settings?.fonts } }, settings: { ...DEFAULT_SETTINGS, ...book.settings, fonts: { ...DEFAULT_FONTS, ...book.settings?.fonts } },
cover: book.cover ? { ...createCover(), ...book.cover } : createCover(), cover: book.cover ? { ...createCover(), ...book.cover } : createCover(),
chapters: (chapters.length ? chapters : [createChapter()]).map((c) => chapters: (chapters.length ? chapters : [createChapter()]).map((c) =>
+49 -7
View File
@@ -7,6 +7,7 @@ import {
gdriveDisconnect, gdriveDisconnect,
gdriveRestore, gdriveRestore,
gdriveStatus, gdriveStatus,
gdriveSync,
} from "../backup"; } from "../backup";
import { isDesktop } from "../ipc"; import { isDesktop } from "../ipc";
import { useBook } from "./useBook"; import { useBook } from "./useBook";
@@ -18,6 +19,7 @@ interface BackupState {
email: string | null; email: string | null;
lastBackup: number | null; lastBackup: number | null;
pending: boolean; pending: boolean;
needsReauth: boolean;
phase: Phase; phase: Phase;
error: string | null; error: string | null;
authUrl: string | null; authUrl: string | null;
@@ -33,6 +35,7 @@ interface BackupState {
copyAuthUrl: () => Promise<void>; copyAuthUrl: () => Promise<void>;
disconnect: () => Promise<void>; disconnect: () => Promise<void>;
backup: (silent?: boolean) => Promise<void>; backup: (silent?: boolean) => Promise<void>;
sync: (silent?: boolean) => Promise<void>;
restore: () => Promise<void>; restore: () => Promise<void>;
restoreFromDrive: () => Promise<void>; restoreFromDrive: () => Promise<void>;
openSettings: () => void; openSettings: () => void;
@@ -43,11 +46,31 @@ function notify(message: string) {
useBook.getState().setNotice(message); useBook.getState().setNotice(message);
} }
const RECONNECT = /Connect again/;
function describe(error: unknown, fallback: string): string {
const message = String(error).replace(/^Error:\s*/, "");
return RECONNECT.test(message) ? message : `${fallback}: ${message}`;
}
function plural(n: number, word: string): string {
return `${n} ${word}${n === 1 ? "" : "s"}`;
}
function syncMessage(uploaded: number, downloaded: number): string {
if (!uploaded && !downloaded) return "Everything is already in sync";
const parts = [];
if (downloaded) parts.push(`brought back ${plural(downloaded, "file")}`);
if (uploaded) parts.push(`backed up ${plural(uploaded, "file")}`);
return `Synced with Google Drive: ${parts.join(", ")}`;
}
export const useBackup = create<BackupState>((set, get) => ({ export const useBackup = create<BackupState>((set, get) => ({
connected: false, connected: false,
email: null, email: null,
lastBackup: null, lastBackup: null,
pending: false, pending: false,
needsReauth: false,
phase: "idle", phase: "idle",
error: null, error: null,
authUrl: null, authUrl: null,
@@ -60,6 +83,7 @@ export const useBackup = create<BackupState>((set, get) => ({
email: status.email, email: status.email,
lastBackup: status.lastBackup, lastBackup: status.lastBackup,
pending: status.pending, pending: status.pending,
needsReauth: status.needsReauth,
}), }),
refresh: async () => { refresh: async () => {
if (!isDesktop) return; if (!isDesktop) return;
@@ -75,7 +99,7 @@ export const useBackup = create<BackupState>((set, get) => ({
.then((url) => set({ authUrl: url })) .then((url) => set({ authUrl: url }))
.catch((e) => { .catch((e) => {
set({ phase: "error", error: String(e), resolveConnect: null }); set({ phase: "error", error: String(e), resolveConnect: null });
notify(`Could not connect: ${e}`); notify(describe(e, "Could not connect"));
resolve(false); resolve(false);
}); });
}), }),
@@ -91,11 +115,13 @@ export const useBackup = create<BackupState>((set, get) => ({
await get().refresh(); await get().refresh();
set({ phase: "idle", authUrl: null, error: null, resolveConnect: null }); set({ phase: "idle", authUrl: null, error: null, resolveConnect: null });
notify("Connected to Google Drive"); notify("Connected to Google Drive");
} else { await get().sync();
set({ phase: "error", authUrl: null, error: error ?? "authorization failed", resolveConnect: null }); resolve?.(true);
notify(`Could not connect: ${error ?? "authorization failed"}`); return;
} }
resolve?.(ok); set({ phase: "error", authUrl: null, error: error ?? "authorization failed", resolveConnect: null });
notify(describe(error ?? "authorization failed", "Could not connect"));
resolve?.(false);
}, },
openAuthUrl: () => { openAuthUrl: () => {
const url = get().authUrl; const url = get().authUrl;
@@ -132,7 +158,22 @@ export const useBackup = create<BackupState>((set, get) => ({
if (!silent) notify(outcome.uploaded > 0 ? "Backed up to Google Drive" : "Nothing new to back up"); if (!silent) notify(outcome.uploaded > 0 ? "Backed up to Google Drive" : "Nothing new to back up");
} catch (e) { } catch (e) {
set({ phase: "error", error: String(e) }); set({ phase: "error", error: String(e) });
if (!silent) notify(`Backup failed: ${e}`); await get().refresh();
if (!silent || RECONNECT.test(String(e))) notify(describe(e, "Backup failed"));
}
},
sync: async (silent = false) => {
if (!get().connected || get().phase === "working") return;
set({ phase: "working", error: null });
try {
const outcome = await gdriveSync();
get().apply(outcome);
set((s) => ({ phase: "idle", restoreNonce: outcome.downloaded > 0 ? s.restoreNonce + 1 : s.restoreNonce }));
if (!silent) notify(syncMessage(outcome.uploaded, outcome.downloaded));
} catch (e) {
set({ phase: "error", error: String(e) });
await get().refresh();
if (!silent || RECONNECT.test(String(e))) notify(describe(e, "Sync failed"));
} }
}, },
restore: async () => { restore: async () => {
@@ -144,7 +185,8 @@ export const useBackup = create<BackupState>((set, get) => ({
notify(result.restored > 0 ? `Restored ${result.restored} file${result.restored === 1 ? "" : "s"}` : "No backups found in Google Drive"); notify(result.restored > 0 ? `Restored ${result.restored} file${result.restored === 1 ? "" : "s"}` : "No backups found in Google Drive");
} catch (e) { } catch (e) {
set({ phase: "error", error: String(e) }); set({ phase: "error", error: String(e) });
notify(`Restore failed: ${e}`); await get().refresh();
notify(describe(e, "Restore failed"));
} }
}, },
restoreFromDrive: async () => { restoreFromDrive: async () => {
+51 -8
View File
@@ -10,10 +10,11 @@ import {
cloneChapter, cloneChapter,
createChapter, createChapter,
createPage, createPage,
createPart,
normalizeBook, normalizeBook,
} from "../model/book"; } from "../model/book";
import { loadActiveChapter, saveActiveChapter } from "../editor/positions"; import { loadActiveChapter, saveActiveChapter } from "../editor/positions";
import { saveBook } from "../library"; import { rememberLastBook, saveBook } from "../library";
import { applyBookFonts, resetBookFonts } from "../book-style"; import { applyBookFonts, resetBookFonts } from "../book-style";
export const COVER_ID = "__cover__"; export const COVER_ID = "__cover__";
@@ -24,16 +25,21 @@ interface BookState {
dirty: boolean; dirty: boolean;
exporting: string | null; exporting: string | null;
notice: string | null; notice: string | null;
pendingTitleFocus: string | null;
clearTitleFocus: () => void;
setExporting: (label: string | null) => void; setExporting: (label: string | null) => void;
setNotice: (message: string | null) => void; setNotice: (message: string | null) => void;
openBook: (book: Book) => void; openBook: (book: Book) => void;
closeBook: () => void; closeBook: () => void;
setActiveChapter: (id: string) => void; setActiveChapter: (id: string) => void;
goToAdjacentChapter: (dir: 1 | -1) => 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; setChapterNoTitle: (id: string, noTitle: boolean) => void;
setChapterNoMargin: (id: string, noMargin: boolean) => void;
addChapter: () => void; addChapter: () => void;
addPage: (group: "front" | "back", title: string) => void; addPage: (group: "front" | "back", title: string) => void;
addPart: () => void;
duplicateChapter: (id: 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;
@@ -53,6 +59,8 @@ export const useBook = create<BookState>((set, get) => ({
dirty: false, dirty: false,
exporting: null, exporting: null,
notice: null, notice: null,
pendingTitleFocus: null,
clearTitleFocus: () => set({ pendingTitleFocus: null }),
setExporting: (label) => set({ exporting: label }), setExporting: (label) => set({ exporting: label }),
setNotice: (message) => set({ notice: message }), setNotice: (message) => set({ notice: message }),
openBook: (book) => { openBook: (book) => {
@@ -62,14 +70,25 @@ export const useBook = create<BookState>((set, get) => ({
const valid = saved === COVER_ID || normalized.chapters.some((c) => c.id === saved); const valid = saved === COVER_ID || normalized.chapters.some((c) => c.id === saved);
const activeChapterId = valid ? (saved as string) : normalized.chapters[0]?.id ?? ""; const activeChapterId = valid ? (saved as string) : normalized.chapters[0]?.id ?? "";
applyBookFonts(normalized.settings.fonts); applyBookFonts(normalized.settings.fonts);
set({ book: normalized, activeChapterId, dirty: false }); rememberLastBook(normalized.id);
set({ book: normalized, activeChapterId, dirty: false, pendingTitleFocus: null });
}, },
closeBook: () => { closeBook: () => {
flushOutgoing(get().book, get().dirty); flushOutgoing(get().book, get().dirty);
rememberLastBook(null);
resetBookFonts(); resetBookFonts();
set({ book: null, activeChapterId: "", dirty: false }); set({ book: null, activeChapterId: "", dirty: false, pendingTitleFocus: null });
},
setActiveChapter: (id) => set({ activeChapterId: id, pendingTitleFocus: null }),
goToAdjacentChapter: (dir) => {
const { book, activeChapterId } = get();
if (!book) return;
const ids = [COVER_ID, ...book.chapters.map((c) => c.id)];
const i = ids.indexOf(activeChapterId);
if (i === -1) return;
const next = ids[(i + dir + ids.length) % ids.length];
if (next !== activeChapterId) set({ activeChapterId: next, pendingTitleFocus: null });
}, },
setActiveChapter: (id) => set({ activeChapterId: id }),
setChapterContent: (id, content) => setChapterContent: (id, content) =>
set((s) => set((s) =>
s.book s.book
@@ -85,17 +104,28 @@ export const useBook = create<BookState>((set, get) => ({
setChapterNoTitle: (id, noTitle) => setChapterNoTitle: (id, noTitle) =>
set((s) => set((s) =>
s.book 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)) } } ? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, noTitle, updatedAt: Date.now() } : c)) } }
: {}
),
setChapterNoMargin: (id, noMargin) =>
set((s) =>
s.book
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, noMargin, updatedAt: Date.now() } : c)) } }
: {} : {}
), ),
addChapter: () => addChapter: () =>
set((s) => { set((s) => {
if (!s.book) return {}; if (!s.book) return {};
const chapter = createChapter(); const chapter = createChapter("");
const chapters = [...s.book.chapters]; const chapters = [...s.book.chapters];
const backCount = chapters.filter((c) => chapterKind(c) === "back").length; const backCount = chapters.filter((c) => chapterKind(c) === "back").length;
chapters.splice(chapters.length - backCount, 0, chapter); chapters.splice(chapters.length - backCount, 0, chapter);
return { activeChapterId: chapter.id, dirty: true, book: { ...s.book, chapters } }; return {
activeChapterId: chapter.id,
pendingTitleFocus: chapter.id,
dirty: true,
book: { ...s.book, chapters },
};
}), }),
addPage: (group, title) => addPage: (group, title) =>
set((s) => { set((s) => {
@@ -107,6 +137,18 @@ export const useBook = create<BookState>((set, get) => ({
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 } };
}), }),
addPart: () =>
set((s) => {
if (!s.book) return {};
const part = createPart();
const chapters = [...s.book.chapters];
const bodyEnd = chapters.length - chapters.filter((c) => chapterKind(c) === "back").length;
const activeIdx = chapters.findIndex((c) => c.id === s.activeChapterId);
const activeKind = activeIdx >= 0 ? chapterKind(chapters[activeIdx]) : null;
const insertAt = activeKind === "body" || activeKind === "part" ? activeIdx + 1 : bodyEnd;
chapters.splice(insertAt, 0, part);
return { activeChapterId: part.id, dirty: true, book: { ...s.book, chapters } };
}),
duplicateChapter: (id) => duplicateChapter: (id) =>
set((s) => { set((s) => {
if (!s.book) return {}; if (!s.book) return {};
@@ -123,6 +165,7 @@ export const useBook = create<BookState>((set, get) => ({
const index = s.book.chapters.findIndex((c) => c.id === id); const index = s.book.chapters.findIndex((c) => c.id === id);
if (index === -1) return {}; if (index === -1) return {};
const chapters = s.book.chapters.filter((c) => c.id !== id); const chapters = s.book.chapters.filter((c) => c.id !== id);
if (!chapters.length) chapters.push(createChapter(""));
const activeChapterId = const activeChapterId =
s.activeChapterId === id ? (chapters[index] ?? chapters[index - 1])?.id ?? COVER_ID : s.activeChapterId; s.activeChapterId === id ? (chapters[index] ?? chapters[index - 1])?.id ?? COVER_ID : s.activeChapterId;
return { dirty: true, activeChapterId, book: { ...s.book, chapters } }; return { dirty: true, activeChapterId, book: { ...s.book, chapters } };
@@ -133,7 +176,7 @@ export const useBook = create<BookState>((set, get) => ({
const chapters = [...s.book.chapters]; const chapters = [...s.book.chapters];
if (from < 0 || from >= chapters.length) return {}; if (from < 0 || from >= chapters.length) return {};
const target = chapters[from]; const target = chapters[from];
const nextKind = toKind ?? chapterKind(target); const nextKind = chapterKind(target) === "part" ? "part" : toKind ?? chapterKind(target);
const dest = Math.max(0, Math.min(from < to ? to - 1 : to, chapters.length - 1)); const dest = Math.max(0, Math.min(from < to ? to - 1 : to, chapters.length - 1));
if (from === dest && chapterKind(target) === nextKind) return {}; if (from === dest && chapterKind(target) === nextKind) return {};
const [moved] = chapters.splice(from, 1); const [moved] = chapters.splice(from, 1);
+13
View File
@@ -0,0 +1,13 @@
import { create } from "zustand";
interface ExportPreviewState {
open: boolean;
openPreview: () => void;
close: () => void;
}
export const useExportPreview = create<ExportPreviewState>((set) => ({
open: false,
openPreview: () => set({ open: true }),
close: () => set({ open: false }),
}));
+39
View File
@@ -0,0 +1,39 @@
import { create } from "zustand";
import type { Update } from "@tauri-apps/plugin-updater";
export type UpdatePhase =
| "idle"
| "checking"
| "available"
| "downloading"
| "installing"
| "uptodate"
| "error";
interface UpdaterState {
phase: UpdatePhase;
version: string;
notes: string;
downloaded: number;
total: number;
error: string;
update: Update | null;
set: (partial: Partial<UpdaterState>) => void;
reset: () => void;
}
const initial = {
phase: "idle" as UpdatePhase,
version: "",
notes: "",
downloaded: 0,
total: 0,
error: "",
update: null as Update | null,
};
export const useUpdater = create<UpdaterState>((set) => ({
...initial,
set: (partial) => set(partial),
reset: () => set(initial),
}));
+516 -6
View File
@@ -8,8 +8,17 @@ body,
height: 100%; height: 100%;
} }
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body { body {
margin: 0; margin: 0;
position: fixed;
inset: 0;
overflow: hidden;
overscroll-behavior: none;
background: var(--shell); background: var(--shell);
color: var(--ink); color: var(--ink);
font-family: var(--font-ui); font-family: var(--font-ui);
@@ -31,16 +40,35 @@ button {
padding: 0; padding: 0;
} }
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.chapter:focus-visible,
.card:focus-visible {
outline-offset: -2px;
}
.prose:focus,
.prose:focus-visible,
.editor-host:focus-visible {
outline: none;
}
.app { .app {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100vh;
height: 100dvh;
overflow: hidden; overflow: hidden;
} }
.titlebar { .titlebar {
flex: none; flex: none;
height: 46px; position: relative;
z-index: 45;
height: var(--titlebar-h);
display: grid; display: grid;
grid-template-columns: 1fr auto 1fr; grid-template-columns: 1fr auto 1fr;
align-items: center; align-items: center;
@@ -49,6 +77,13 @@ button {
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.titlebar .lead {
grid-column: 1;
justify-self: start;
display: flex;
gap: 4px;
}
.titlebar .doc-title { .titlebar .doc-title {
grid-column: 2; grid-column: 2;
font-size: var(--t-2); font-size: var(--t-2);
@@ -134,6 +169,14 @@ body.resizing .pane-resizer::after {
opacity: 0.55; opacity: 0.55;
} }
.pane-resizer:focus-visible {
outline: none;
}
.pane-resizer:focus-visible::after {
opacity: 1;
}
body.resizing { body.resizing {
cursor: col-resize; cursor: col-resize;
user-select: none; user-select: none;
@@ -150,6 +193,18 @@ body.resizing {
padding: 6px 0 10px; padding: 6px 0 10px;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
transition: width 220ms var(--ease);
}
.app:not([data-compact="true"])[data-sidebar="false"] .sidebar {
width: 0;
border-right-width: 0;
overflow: hidden;
visibility: hidden;
}
body.resizing .sidebar {
transition: none;
} }
.brand { .brand {
@@ -233,6 +288,37 @@ body.resizing {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.chapter[data-part="true"] {
margin-top: 7px;
align-items: center;
}
.chapter[data-part="true"] .text {
gap: 1px;
}
.chapter .part-eyebrow {
font-family: var(--font-ui);
font-size: var(--t-1);
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-faint);
}
.chapter[data-part="true"][data-active="true"] .part-eyebrow {
color: var(--accent);
}
.chapter[data-part="true"] .title {
font-size: var(--t-2);
color: var(--ink-soft);
}
.chapter[data-indent="true"] {
padding-left: 26px;
}
.chapter[data-active="true"] .num { .chapter[data-active="true"] .num {
color: var(--accent); color: var(--accent);
} }
@@ -324,6 +410,7 @@ body.resizing {
} }
.chapter:hover .row-menu-btn, .chapter:hover .row-menu-btn,
.chapter .row-menu-btn:focus-visible,
.chapter .row-menu-btn[data-open="true"] { .chapter .row-menu-btn[data-open="true"] {
opacity: 0.7; opacity: 0.7;
} }
@@ -623,18 +710,22 @@ body.resizing {
} }
.chapter-title-input { .chapter-title-input {
display: block;
width: 100%; width: 100%;
margin: 15px 0 0; margin: 15px 0 0;
padding: 0; padding: 0 0 0.3em;
border: 0; border: 0;
background: transparent; background: transparent;
text-align: center; text-align: center;
font-family: var(--font-heading); font-family: var(--font-heading);
font-weight: 500; font-weight: 500;
font-size: 31px; font-size: 31px;
line-height: 1.16; line-height: 1.4;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: var(--ink); color: var(--ink);
resize: none;
overflow: hidden;
overflow-wrap: break-word;
} }
.chapter-title-input:focus { .chapter-title-input:focus {
@@ -783,7 +874,7 @@ body.resizing {
.editor-toolbar { .editor-toolbar {
position: sticky; position: sticky;
bottom: 22px; bottom: max(22px, env(safe-area-inset-bottom));
z-index: 5; z-index: 5;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -856,6 +947,24 @@ body.resizing {
box-shadow: var(--shadow-pop); box-shadow: var(--shadow-pop);
} }
.align-pop {
position: absolute;
bottom: calc(100% + 10px);
left: 50%;
transform: translateX(-50%);
z-index: 7;
display: flex;
align-items: center;
gap: 1px;
padding: 5px;
background: var(--glass);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
border: 1px solid var(--line);
border-radius: 999px;
box-shadow: var(--shadow-pop);
}
.link-input { .link-input {
width: 220px; width: 220px;
height: 28px; height: 28px;
@@ -1041,6 +1150,122 @@ body.resizing {
padding: 44px 0; padding: 44px 0;
} }
.preview-overlay {
z-index: 55;
}
.preview-panel {
display: flex;
flex-direction: column;
}
.preview-bar {
flex: none;
height: 52px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
background: var(--paper);
border-bottom: 1px solid var(--line);
}
.preview-title {
flex: 1;
min-width: 0;
font-family: var(--font-book);
font-size: 15px;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-count {
font-family: var(--font-ui);
font-size: var(--t-2);
color: var(--ink-faint);
font-variant-numeric: tabular-nums;
}
.preview-zoom {
display: flex;
align-items: center;
gap: 2px;
}
.preview-zoom span {
width: 44px;
text-align: center;
font-family: var(--font-ui);
font-size: var(--t-2);
color: var(--ink-soft);
font-variant-numeric: tabular-nums;
}
.preview-warn {
flex: none;
padding: 9px 18px;
background: var(--accent-wash);
color: var(--accent-ink);
font-family: var(--font-ui);
font-size: var(--t-2);
text-align: center;
border-bottom: 1px solid var(--line);
}
.preview-stage {
flex: 1;
overflow: auto;
padding: 28px;
background: var(--shell);
}
.preview-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: max-content;
min-width: 100%;
margin: 0 auto;
}
.preview-page {
flex: none;
background: #fff;
border-radius: 2px;
box-shadow: var(--shadow-page);
overflow: hidden;
}
.preview-canvas {
display: block;
width: 100%;
height: 100%;
}
.preview-loading {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.preview-loading p {
margin: 0;
font-family: var(--font-ui);
font-size: var(--t-3);
color: var(--ink-soft);
}
.spinner-dark {
border-color: var(--line-strong);
border-top-color: var(--accent);
}
.overlay { .overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -1054,8 +1279,7 @@ body.resizing {
} }
.panel { .panel {
width: 480px; width: min(480px, calc(100vw - 32px));
max-width: 100%;
max-height: 100%; max-height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1196,6 +1420,21 @@ body.resizing {
color: var(--accent); color: var(--accent);
} }
.menu-label {
padding: 7px 11px 3px;
font-size: var(--t-1);
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-faint);
}
.menu-sep {
height: 1px;
margin: 5px 7px;
background: var(--line);
}
.export-overlay { .export-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -1251,6 +1490,7 @@ body.resizing {
.library { .library {
height: 100vh; height: 100vh;
height: 100dvh;
overflow-y: auto; overflow-y: auto;
background: var(--shell); background: var(--shell);
} }
@@ -1336,6 +1576,19 @@ body.resizing {
color: var(--accent); color: var(--accent);
} }
.card-meta {
position: absolute;
left: 14px;
right: 14px;
bottom: 14px;
font-family: var(--font-ui);
font-size: var(--t-1);
color: var(--ink-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-action { .card-action {
background: transparent; background: transparent;
border: 1px dashed var(--line-strong); border: 1px dashed var(--line-strong);
@@ -1357,6 +1610,7 @@ body.resizing {
} }
.card-book:hover .card-menu, .card-book:hover .card-menu,
.card-menu:focus-visible,
.card-menu[data-open="true"] { .card-menu[data-open="true"] {
opacity: 1; opacity: 1;
} }
@@ -1385,6 +1639,129 @@ body.resizing {
color: var(--ink); color: var(--ink);
} }
.panel-move {
width: 440px;
}
.move-empty {
margin: 0;
font-size: var(--t-3);
color: var(--ink-soft);
}
.move-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 300px;
overflow-y: auto;
margin: 0;
padding: 0;
list-style: none;
}
.move-target {
width: 100%;
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: var(--r-sm);
background: none;
text-align: left;
cursor: pointer;
}
.move-target:hover:not(:disabled),
.move-target:focus-visible {
background: var(--raised);
border-color: var(--line);
}
.move-target:disabled {
opacity: 0.5;
cursor: default;
}
.move-title {
font-family: var(--font-book);
font-size: var(--t-4);
color: var(--ink);
}
.move-author {
font-family: var(--font-ui);
font-size: var(--t-1);
color: var(--ink-soft);
}
.update-notes {
max-height: 180px;
overflow-y: auto;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--raised);
font-size: var(--t-3);
line-height: 1.55;
color: var(--ink-soft);
white-space: pre-line;
}
.update-status-row {
display: flex;
align-items: center;
gap: 11px;
font-size: var(--t-4);
color: var(--ink-soft);
}
.update-spinner {
flex: none;
width: 18px;
height: 18px;
border: 2px solid var(--line-strong);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.update-progress {
width: 100%;
height: 7px;
border-radius: 999px;
background: var(--accent-wash);
overflow: hidden;
}
.update-progress-fill {
height: 100%;
border-radius: 999px;
background: var(--accent);
transition: width 160ms var(--ease);
}
.update-progress.indeterminate .update-progress-fill {
width: 40%;
animation: update-indeterminate 1.1s ease-in-out infinite;
}
@keyframes update-indeterminate {
0% {
transform: translateX(-120%);
}
100% {
transform: translateX(320%);
}
}
.update-status {
margin: 0;
font-size: var(--t-3);
color: var(--ink-soft);
}
.btn-primary, .btn-primary,
.btn-ghost, .btn-ghost,
.btn-danger { .btn-danger {
@@ -1787,6 +2164,32 @@ body.resizing {
} }
} }
.esc-hint {
position: fixed;
top: 54px;
left: 50%;
z-index: 30;
padding: 7px 14px;
font-family: var(--font-ui);
font-size: var(--t-2);
color: var(--ink-soft);
background: var(--glass);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
border: 1px solid var(--line);
border-radius: 999px;
box-shadow: var(--shadow-pop);
pointer-events: none;
transform: translateX(-50%);
animation: find-drop 140ms var(--ease);
}
.esc-hint kbd {
font-family: var(--font-ui);
font-weight: 600;
color: var(--ink);
}
.find-expand { .find-expand {
flex: none; flex: none;
width: 20px; width: 20px;
@@ -2444,9 +2847,22 @@ body.resizing {
.backup-actions { .backup-actions {
display: flex; display: flex;
flex-wrap: wrap;
gap: 10px; gap: 10px;
} }
.backup-warn {
margin: 18px 0;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--raised);
font-family: var(--font-ui);
font-size: var(--t-3);
line-height: 1.5;
color: var(--ink-soft);
}
.backup-note { .backup-note {
margin: 18px 0 0; margin: 18px 0 0;
color: var(--ink-faint); color: var(--ink-faint);
@@ -2454,3 +2870,97 @@ body.resizing {
font-size: var(--t-2); font-size: var(--t-2);
line-height: 1.5; line-height: 1.5;
} }
/* Compact / mobile layout: panes become slide-in drawers over a full-width editor */
.app[data-compact="true"] {
--titlebar-h: calc(46px + env(safe-area-inset-top));
}
.app[data-compact="true"] .titlebar {
padding: env(safe-area-inset-top) max(12px, env(safe-area-inset-right)) 0 max(12px, env(safe-area-inset-left));
}
.app[data-compact="true"] .sidebar,
.app[data-compact="true"] .dock {
position: fixed;
top: var(--titlebar-h);
bottom: 0;
z-index: 40;
box-shadow: var(--shadow-pop);
}
.app[data-compact="true"] .sidebar {
left: 0;
width: min(82vw, 320px);
padding-left: env(safe-area-inset-left);
border-right: 1px solid var(--line);
transform: translateX(-100%);
visibility: hidden;
transition: transform 260ms var(--ease), visibility 260ms var(--ease);
}
.app[data-compact="true"][data-sidebar="true"] .sidebar {
transform: translateX(0);
visibility: visible;
}
.app[data-compact="true"] .dock {
right: 0;
width: min(92vw, 440px);
padding-right: max(28px, env(safe-area-inset-right));
border-left: 1px solid var(--line);
animation: drawer-in-right 260ms var(--ease);
}
@keyframes drawer-in-right {
from {
transform: translateX(100%);
}
to {
transform: none;
}
}
.drawer-scrim {
position: fixed;
inset: var(--titlebar-h) 0 0 0;
z-index: 35;
background: rgba(0, 0, 0, 0.42);
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
animation: scrim-in 200ms var(--ease);
}
@keyframes scrim-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.app[data-compact="true"] .sheet {
padding: 56px 18px 160px;
}
.app[data-compact="true"] .chapter-title {
font-size: 26px;
}
@media (max-width: 899px) {
.overlay {
padding: 16px;
}
.library-head {
padding: max(8px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) 0 max(12px, env(safe-area-inset-left));
}
.shelf {
padding: 20px max(18px, env(safe-area-inset-right)) 60px max(18px, env(safe-area-inset-left));
gap: 18px;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
}
+1
View File
@@ -10,6 +10,7 @@
--pane-sidebar: 248px; --pane-sidebar: 248px;
--pane-dock: 384px; --pane-dock: 384px;
--measure: 46em; --measure: 46em;
--titlebar-h: 46px;
--t-1: 11px; --t-1: 11px;
--t-2: 12px; --t-2: 12px;
+19
View File
@@ -0,0 +1,19 @@
export function relativeTime(ts: number, now: number): string {
const diff = Math.max(0, now - ts);
const min = 60_000;
const hour = 60 * min;
const day = 24 * hour;
if (diff < min) return "just now";
if (diff < hour) {
const m = Math.floor(diff / min);
return `${m} minute${m === 1 ? "" : "s"} ago`;
}
if (diff < day) {
const h = Math.floor(diff / hour);
return `${h} hour${h === 1 ? "" : "s"} ago`;
}
if (diff < 2 * day) return "yesterday";
const d = Math.floor(diff / day);
if (d < 7) return `${d} days ago`;
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
+43 -16
View File
@@ -1,31 +1,58 @@
import { check } from "@tauri-apps/plugin-updater"; import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process"; import { relaunch } from "@tauri-apps/plugin-process";
import { ask, message } from "@tauri-apps/plugin-dialog"; import { useUpdater } from "./store/useUpdater";
let running = false; let checking = false;
export async function checkForUpdates(silent: boolean) { export async function checkForUpdates(silent: boolean) {
if (running) return; if (checking) return;
running = true; checking = true;
const store = useUpdater.getState();
if (!silent) store.set({ phase: "checking", error: "" });
try { try {
const update = await check(); const update = await check();
if (!update) { if (!update) {
if (!silent) await message("margin is up to date.", { title: "Check for Updates" }); store.set(silent ? { phase: "idle" } : { phase: "uptodate" });
return; return;
} }
const notes = update.body ? `\n\n${update.body}` : ""; store.set({
const install = await ask(`margin ${update.version} is available.${notes}\n\nInstall now and restart?`, { phase: "available",
title: "Update Available", update,
kind: "info", version: update.version,
notes: update.body ?? "",
downloaded: 0,
total: 0,
error: "",
}); });
if (!install) return; } catch (err) {
await update.downloadAndInstall(); store.set(silent ? { phase: "idle" } : { phase: "error", error: String(err) });
} finally {
checking = false;
}
}
export async function installUpdate() {
const { update, phase } = useUpdater.getState();
if (!update || phase === "downloading" || phase === "installing") return;
try {
useUpdater.getState().set({ phase: "downloading", downloaded: 0, total: 0 });
await update.downloadAndInstall((event) => {
const s = useUpdater.getState();
if (event.event === "Started") {
s.set({ total: event.data.contentLength ?? 0, downloaded: 0 });
} else if (event.event === "Progress") {
s.set({ downloaded: s.downloaded + event.data.chunkLength });
} else if (event.event === "Finished") {
s.set({ phase: "installing" });
}
});
useUpdater.getState().set({ phase: "installing" });
await relaunch(); await relaunch();
} catch (err) { } catch (err) {
if (!silent) { useUpdater.getState().set({ phase: "error", error: String(err) });
await message(`Could not check for updates.\n\n${err}`, { title: "Update Failed", kind: "error" });
}
} finally {
running = false;
} }
} }
export function dismissUpdate() {
useUpdater.getState().reset();
}
+15
View File
@@ -0,0 +1,15 @@
import { useEffect, useState } from "react";
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mq = window.matchMedia(query);
const onChange = () => setMatches(mq.matches);
onChange();
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, [query]);
return matches;
}
export const useCompact = () => useMediaQuery("(max-width: 899px)");