Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df1a447a79 | ||
|
|
bcd5eb9ebc | ||
|
|
cfc93fc97d | ||
|
|
b954a3135d | ||
|
|
9e310a34bb | ||
|
|
fe39274ed7 | ||
|
|
2e15dc2dc9 | ||
|
|
dad0ac86d0 | ||
|
|
efd45cf61e | ||
|
|
742e42d65b | ||
|
|
abcd133b19 | ||
|
|
4fa14306d1 | ||
|
|
acf78dfe29 | ||
|
|
866a971729 | ||
|
|
7ea33b5bfb | ||
|
|
0abeb3c5d4 | ||
|
|
a31f99cb76 | ||
|
|
20365dd02d | ||
|
|
af389c7097 | ||
|
|
f11ddd290f | ||
|
|
4975e76358 | ||
|
|
efeb64d289 | ||
|
|
cd41a02737 | ||
|
|
5a2ada9e9e | ||
|
|
782a4c3dee | ||
|
|
f8742c440f | ||
|
|
c090fb595f | ||
|
|
11df8697b1 | ||
|
|
f47e822497 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "margin-app",
|
"name": "margin-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.7",
|
"version": "0.1.17",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "margin-app"
|
name = "margin-app"
|
||||||
version = "0.1.7"
|
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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
xcuserdata/
|
||||||
|
build/
|
||||||
|
Externals/
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
stored.refresh_token = Some(rotated.clone());
|
if let Some(rotated) = &tokens.refresh_token {
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
|
||||||
|
|
||||||
if context.config().plugins.0.contains_key("updater") {
|
#[cfg(desktop)]
|
||||||
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
{
|
||||||
|
builder = builder.plugin(tauri_plugin_process::init());
|
||||||
|
if context.config().plugins.0.contains_key("updater") {
|
||||||
|
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
builder
|
builder = 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(())
|
||||||
})
|
});
|
||||||
.menu(|handle| build_menu(handle))
|
|
||||||
.on_menu_event(|app, event| {
|
#[cfg(desktop)]
|
||||||
if matches!(
|
{
|
||||||
event.id().0.as_str(),
|
builder = builder
|
||||||
"new-book" | "export-pdf" | "export-epub" | "check-updates"
|
.menu(|handle| build_menu(handle))
|
||||||
) {
|
.on_menu_event(|app, event| {
|
||||||
app.emit("menu-action", event.id().0.as_str()).ok();
|
if matches!(
|
||||||
}
|
event.id().0.as_str(),
|
||||||
})
|
"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();
|
||||||
|
}
|
||||||
|
#[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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,10 +218,24 @@ 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 {
|
||||||
if engine.speller.is_none() {
|
#[cfg(target_os = "macos")]
|
||||||
engine.speller = Some(build_speller(&app)?);
|
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() {
|
||||||
|
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() {
|
||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.7",
|
"version": "0.1.17",
|
||||||
"identifier": "studio.margin.app",
|
"identifier": "studio.margin.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
|||||||
@@ -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 />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 & Sync</h2>
|
<h2>Backup & Sync</h2>
|
||||||
<button className="icon-btn" onClick={close} title="Close">
|
<button className="icon-btn" onClick={close} title="Close">
|
||||||
@@ -97,27 +106,51 @@ export function BackupSettings() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="backup-status-row">
|
{needsReauth ? (
|
||||||
<span className={`backup-dot ${pending ? "is-pending" : "is-synced"}`} />
|
<>
|
||||||
<span>{pending ? "Changes not backed up" : formatBackupTime(lastBackup)}</span>
|
<div className="backup-warn">
|
||||||
</div>
|
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">
|
||||||
|
<span className={`backup-dot ${pending ? "is-pending" : "is-synced"}`} />
|
||||||
|
<span>{pending ? "Changes not backed up" : formatBackupTime(lastBackup)}</span>
|
||||||
|
</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>
|
||||||
<button
|
<button className="btn-ghost" disabled={working} onClick={() => backup()}>
|
||||||
className="btn-ghost"
|
Back up only
|
||||||
disabled={working || remoteCount === 0}
|
</button>
|
||||||
onClick={() => setConfirmRestore(true)}
|
<button
|
||||||
>
|
className="btn-ghost"
|
||||||
{remoteCount === null ? "Restore from Drive" : `Restore ${remoteCount} book${remoteCount === 1 ? "" : "s"}`}
|
disabled={working || remoteCount === 0}
|
||||||
</button>
|
onClick={() => setConfirmRestore(true)}
|
||||||
</div>
|
>
|
||||||
|
{remoteCount === null ? "Restore from Drive" : `Restore ${remoteCount} book${remoteCount === 1 ? "" : "s"}`}
|
||||||
|
</button>
|
||||||
|
</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
|
||||||
</p>
|
while there are changes.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,57 +303,105 @@ export function EditorView() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="menu-wrap">
|
{!isCompact && (
|
||||||
<button className="icon-btn" data-on={widthOpen} onClick={() => setWidthOpen((v) => !v)} title="Editor width">
|
<div className="menu-wrap">
|
||||||
<Icon d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" />
|
<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" />
|
||||||
|
</button>
|
||||||
|
<Menu open={widthOpen} onClose={() => setWidthOpen(false)}>
|
||||||
|
{WIDTH_OPTIONS.map((w) => (
|
||||||
|
<button
|
||||||
|
key={w.id}
|
||||||
|
data-on={width === w.id}
|
||||||
|
onClick={() => {
|
||||||
|
setWidth(w.id);
|
||||||
|
setWidthOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{w.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isCompact && (
|
||||||
|
<div className="menu-wrap">
|
||||||
|
<button className="icon-btn" data-on={exportOpen} onClick={() => setExportOpen((v) => !v)} title="Export">
|
||||||
|
<Icon d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" />
|
||||||
|
</button>
|
||||||
|
<Menu open={exportOpen} onClose={() => setExportOpen(false)}>
|
||||||
|
<button onClick={() => handleExport("pdf")}>Export PDF…</button>
|
||||||
|
<button onClick={() => handleExport("epub")}>Export EPUB…</button>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isCompact && (
|
||||||
|
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? "Light mode" : "Dark mode"}>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<Icon>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4" />
|
||||||
|
</Icon>
|
||||||
|
) : (
|
||||||
|
<Icon d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{widthOpen && (
|
)}
|
||||||
<>
|
<button className="icon-btn" data-on={dock} onClick={toggleDock} title="Toggle preview">
|
||||||
<div className="menu-backdrop" onClick={() => setWidthOpen(false)} />
|
|
||||||
<div className="menu">
|
|
||||||
{WIDTH_OPTIONS.map((w) => (
|
|
||||||
<button
|
|
||||||
key={w.id}
|
|
||||||
data-on={width === w.id}
|
|
||||||
onClick={() => {
|
|
||||||
setWidth(w.id);
|
|
||||||
setWidthOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{w.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="menu-wrap">
|
|
||||||
<button className="icon-btn" data-on={exportOpen} onClick={() => setExportOpen((v) => !v)} title="Export">
|
|
||||||
<Icon d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" />
|
|
||||||
</button>
|
|
||||||
{exportOpen && (
|
|
||||||
<>
|
|
||||||
<div className="menu-backdrop" onClick={() => setExportOpen(false)} />
|
|
||||||
<div className="menu">
|
|
||||||
<button onClick={() => handleExport("pdf")}>Export PDF…</button>
|
|
||||||
<button onClick={() => handleExport("epub")}>Export EPUB…</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? "Light mode" : "Dark mode"}>
|
|
||||||
{theme === "dark" ? (
|
|
||||||
<Icon>
|
|
||||||
<circle cx="12" cy="12" r="4" />
|
|
||||||
<path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4" />
|
|
||||||
</Icon>
|
|
||||||
) : (
|
|
||||||
<Icon d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button className="icon-btn" data-on={dock} onClick={() => setDock(!dock)} 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,53 +413,57 @@ 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}>
|
||||||
) : (
|
<header className="chapter-opener">
|
||||||
<>
|
<div className="chapter-num">{eyebrow}</div>
|
||||||
<article className="sheet">
|
<ChapterTitleInput
|
||||||
<header className="chapter-opener">
|
value={chapter.title}
|
||||||
<div className="chapter-num">{eyebrow}</div>
|
placeholder={
|
||||||
|
chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : kind === "part" ? "Part title (optional)" : "Page title"
|
||||||
|
}
|
||||||
|
disabled={!!chapter.noTitle}
|
||||||
|
focusRequest={pendingTitleFocus === chapter.id}
|
||||||
|
onFocused={clearTitleFocus}
|
||||||
|
onChange={(value) => setChapterTitle(chapter.id, value)}
|
||||||
|
/>
|
||||||
|
{(chapter.noTitle || !chapter.title.trim()) && (
|
||||||
|
<label className="no-title-check">
|
||||||
<input
|
<input
|
||||||
className="chapter-title-input"
|
type="checkbox"
|
||||||
value={chapter.noTitle ? "" : chapter.title}
|
checked={!!chapter.noTitle}
|
||||||
placeholder={chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : "Page title"}
|
onChange={(e) => setChapterNoTitle(chapter.id, e.target.checked)}
|
||||||
spellCheck={false}
|
|
||||||
disabled={chapter.noTitle}
|
|
||||||
onChange={(e) => setChapterTitle(chapter.id, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
{(chapter.noTitle || !chapter.title.trim()) && (
|
No title
|
||||||
<label className="no-title-check">
|
</label>
|
||||||
<input
|
)}
|
||||||
type="checkbox"
|
</header>
|
||||||
checked={!!chapter.noTitle}
|
<Editor
|
||||||
onChange={(e) => setChapterNoTitle(chapter.id, e.target.checked)}
|
bookId={book.id}
|
||||||
/>
|
chapterId={chapter.id}
|
||||||
No title
|
content={chapter.content}
|
||||||
</label>
|
onChange={(content) => setChapterContent(chapter.id, content)}
|
||||||
)}
|
onReady={setEditor}
|
||||||
</header>
|
onContentError={() =>
|
||||||
<Editor
|
setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.")
|
||||||
key={chapter.id}
|
}
|
||||||
bookId={book.id}
|
/>
|
||||||
chapterId={chapter.id}
|
</article>
|
||||||
content={chapter.content}
|
{!coverActive && <FloatingToolbar editor={editor} />}
|
||||||
onChange={(content) => setChapterContent(chapter.id, content)}
|
|
||||||
onReady={setEditor}
|
|
||||||
onContentError={() =>
|
|
||||||
setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</article>
|
|
||||||
<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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 })} />
|
||||||
|
|||||||
@@ -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,45 +192,73 @@ 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">
|
||||||
<span
|
{isPart && <span className="part-eyebrow">Part {partRoman(row.part ?? 0)}</span>}
|
||||||
className="title"
|
{(!isPart || row.chapter.title) && (
|
||||||
onMouseEnter={(e) => {
|
<span
|
||||||
const el = e.currentTarget;
|
className="title"
|
||||||
if (el.scrollWidth > el.clientWidth) el.title = row.chapter.title || "Untitled";
|
onMouseEnter={(e) => {
|
||||||
else el.removeAttribute("title");
|
const el = e.currentTarget;
|
||||||
}}
|
if (el.scrollWidth > el.clientWidth) el.title = row.chapter.title || "Untitled";
|
||||||
>
|
else el.removeAttribute("title");
|
||||||
{row.chapter.title || "Untitled"}
|
}}
|
||||||
</span>
|
>
|
||||||
|
{row.chapter.title || "Untitled"}
|
||||||
|
</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"
|
||||||
|
|||||||
@@ -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 & Restart
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(phase === "uptodate" || phase === "error") && (
|
||||||
|
<div className="panel-foot">
|
||||||
|
<button className="btn-primary" onClick={dismissUpdate}>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />;
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
];
|
];
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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> </p>";
|
if (!body.trim()) return "<p> </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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
@@ -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),
|
||||||
|
}));
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
await update.downloadAndInstall();
|
|
||||||
await relaunch();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!silent) {
|
store.set(silent ? { phase: "idle" } : { phase: "error", error: String(err) });
|
||||||
await message(`Could not check for updates.\n\n${err}`, { title: "Update Failed", kind: "error" });
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
running = false;
|
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();
|
||||||
|
} catch (err) {
|
||||||
|
useUpdater.getState().set({ phase: "error", error: String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissUpdate() {
|
||||||
|
useUpdater.getState().reset();
|
||||||
|
}
|
||||||
@@ -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)");
|
||||||