Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4975e76358 | ||
|
|
efeb64d289 | ||
|
|
cd41a02737 | ||
|
|
5a2ada9e9e | ||
|
|
782a4c3dee | ||
|
|
f8742c440f | ||
|
|
c090fb595f | ||
|
|
11df8697b1 | ||
|
|
f47e822497 | ||
|
|
9e714c8997 | ||
|
|
cc26e1d51c | ||
|
|
530e260903 | ||
|
|
74a684498a | ||
|
|
3fccb17060 | ||
|
|
9acb3aae3c |
No files matched your search
@@ -85,7 +85,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
- os: macos-26
|
||||
args: "--target universal-apple-darwin --config src-tauri/tauri.release.conf.json"
|
||||
rust-targets: "aarch64-apple-darwin,x86_64-apple-darwin"
|
||||
- os: ubuntu-latest
|
||||
@@ -137,6 +137,19 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Provision Google credentials
|
||||
shell: bash
|
||||
env:
|
||||
GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_CREDENTIALS }}
|
||||
run: |
|
||||
if [ -n "$GOOGLE_CREDENTIALS" ]; then
|
||||
printf '%s' "$GOOGLE_CREDENTIALS" > google-credentials.json
|
||||
echo "Wrote google-credentials.json from GOOGLE_CREDENTIALS secret."
|
||||
else
|
||||
cp google-credentials.example.json google-credentials.json
|
||||
echo "::warning::GOOGLE_CREDENTIALS secret not set — embedding placeholder credentials; Google Drive backup will be disabled in this release."
|
||||
fi
|
||||
|
||||
- name: Build and upload
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "margin-app",
|
||||
"private": true,
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.11",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -4713,7 +4713,7 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||
|
||||
[[package]]
|
||||
name = "margin-app"
|
||||
version = "0.1.4"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"fontdb",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "margin-app"
|
||||
version = "0.1.5"
|
||||
version = "0.1.11"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-destroy",
|
||||
"core:window:allow-start-dragging",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"updater:default",
|
||||
"process:allow-restart"
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -6,9 +6,12 @@ mod pdf;
|
||||
mod project;
|
||||
mod proofing;
|
||||
|
||||
#[cfg(desktop)]
|
||||
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder};
|
||||
#[cfg(desktop)]
|
||||
use tauri::{Emitter, Runtime};
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
|
||||
let menu = Menu::default(handle)?;
|
||||
let new_book = MenuItemBuilder::with_id("new-book", "New Book")
|
||||
@@ -62,22 +65,30 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
|
||||
pub fn run() {
|
||||
let context = tauri::generate_context!();
|
||||
|
||||
#[cfg_attr(mobile, allow(unused_mut))]
|
||||
let mut builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_process::init());
|
||||
.plugin(tauri_plugin_dialog::init());
|
||||
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
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(gdrive::GDriveState::default())
|
||||
.setup(|app| {
|
||||
gdrive::init_session(app.handle());
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
builder = builder
|
||||
.menu(|handle| build_menu(handle))
|
||||
.on_menu_event(|app, event| {
|
||||
if matches!(
|
||||
@@ -86,7 +97,10 @@ pub fn run() {
|
||||
) {
|
||||
app.emit("menu-action", event.id().0.as_str()).ok();
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
builder
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
epub::package_epub,
|
||||
epub::unzip_epub,
|
||||
|
||||
@@ -59,11 +59,17 @@ fn read_words(contents: &str, set: &mut HashSet<String>) {
|
||||
for line in contents.lines() {
|
||||
let word = line.trim();
|
||||
if !word.is_empty() && !word.starts_with('#') {
|
||||
set.insert(word.to_string());
|
||||
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> {
|
||||
let mut set = HashSet::new();
|
||||
if let Ok(path) = custom_dict_path(app) {
|
||||
@@ -98,6 +104,10 @@ fn is_word_char(c: char) -> bool {
|
||||
c.is_alphabetic()
|
||||
}
|
||||
|
||||
fn has_irregular_case(word: &str) -> bool {
|
||||
word.chars().skip(1).any(|c| c.is_uppercase())
|
||||
}
|
||||
|
||||
fn is_apostrophe(c: char) -> bool {
|
||||
c == '\'' || c == '\u{2019}'
|
||||
}
|
||||
@@ -128,7 +138,7 @@ fn collect_spelling(
|
||||
}
|
||||
let end = i;
|
||||
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;
|
||||
}
|
||||
let mut suggestions = Vec::new();
|
||||
@@ -184,10 +194,12 @@ pub async fn proof_text(
|
||||
let state = app.state::<ProofState>();
|
||||
let mut guard = state.lock().map_err(|e| e.to_string())?;
|
||||
if guard.is_none() {
|
||||
let mut custom = load_custom(&app);
|
||||
load_tech(&app, &mut custom);
|
||||
*guard = Some(Engine {
|
||||
speller: None,
|
||||
harper: None,
|
||||
custom: load_custom(&app),
|
||||
custom,
|
||||
});
|
||||
}
|
||||
let engine = guard.as_mut().unwrap();
|
||||
@@ -232,7 +244,7 @@ pub fn remember_word(
|
||||
|
||||
let mut guard = state.inner().lock().map_err(|e| e.to_string())?;
|
||||
if let Some(engine) = guard.as_mut() {
|
||||
engine.custom.insert(word);
|
||||
engine.custom.insert(word.to_lowercase());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "margin",
|
||||
"version": "0.1.5",
|
||||
"productName": "Margin",
|
||||
"version": "0.1.11",
|
||||
"identifier": "studio.margin.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
@@ -4,8 +4,10 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { Library } from "./components/Library";
|
||||
import { EditorView } from "./components/EditorView";
|
||||
import { BackupSettings } from "./components/BackupSettings";
|
||||
import { ExportPreview } from "./components/ExportPreview";
|
||||
import { useBook } from "./store/useBook";
|
||||
import { useBackup } from "./store/useBackup";
|
||||
import { useExportPreview } from "./store/useExportPreview";
|
||||
import { isDesktop } from "./ipc";
|
||||
import { createAndOpenBook, saveBook } from "./library";
|
||||
import { runExport } from "./export/run";
|
||||
@@ -21,7 +23,9 @@ function App() {
|
||||
const unlisten = listen<string>("menu-action", (event) => {
|
||||
const state = useBook.getState();
|
||||
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 === "check-updates") checkForUpdates(false);
|
||||
});
|
||||
@@ -82,6 +86,7 @@ function App() {
|
||||
<>
|
||||
{book ? <EditorView /> : <Library onOpen={openBook} />}
|
||||
{isDesktop && <BackupSettings />}
|
||||
{isDesktop && <ExportPreview />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Icon } from "./Icon";
|
||||
|
||||
interface AddPageMenuProps {
|
||||
onAdd: (group: "front" | "back", title: string) => void;
|
||||
onAddPart: () => void;
|
||||
}
|
||||
|
||||
const GROUPS: { key: "front" | "back"; label: string }[] = [
|
||||
@@ -11,7 +12,7 @@ const GROUPS: { key: "front" | "back"; label: string }[] = [
|
||||
{ key: "back", label: "Back matter" },
|
||||
];
|
||||
|
||||
export function AddPageMenu({ onAdd }: AddPageMenuProps) {
|
||||
export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -42,6 +43,11 @@ export function AddPageMenu({ onAdd }: AddPageMenuProps) {
|
||||
onAdd(group, label);
|
||||
};
|
||||
|
||||
const pickPart = () => {
|
||||
setOpen(false);
|
||||
onAddPart();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={btnRef} className="add-page" data-open={open} onClick={toggle}>
|
||||
@@ -54,6 +60,12 @@ export function AddPageMenu({ onAdd }: AddPageMenuProps) {
|
||||
style={{ top: coords.top, left: coords.left }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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) => (
|
||||
<div key={g.key} className="add-page-group">
|
||||
<div className="add-page-label">{g.label}</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { generateHTML } from "@tiptap/core";
|
||||
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 { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
|
||||
import { compilePdf, isDesktop } from "../ipc";
|
||||
@@ -71,7 +71,15 @@ function DockHead({ meta }: { meta?: ReactNode }) {
|
||||
|
||||
function chapterEyebrow(chapter: Chapter, chapters: Chapter[], idx: number): string {
|
||||
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() {
|
||||
@@ -163,7 +171,7 @@ function HtmlDock() {
|
||||
<div className="page">
|
||||
<div className="p-opener">
|
||||
<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 className="page-body" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
<div className="folio">{idx * 8 + 7}</div>
|
||||
@@ -207,7 +215,7 @@ function DeviceDock({ device }: { device: Device }) {
|
||||
<DeviceFrame
|
||||
device={device}
|
||||
eyebrow={eyebrow}
|
||||
title={chapter.noTitle ? undefined : chapter.title || "Untitled"}
|
||||
title={previewTitle(chapter)}
|
||||
html={html}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Dock } from "./Dock";
|
||||
@@ -14,13 +14,15 @@ import { FloatingToolbar } from "../editor/FloatingToolbar";
|
||||
import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing";
|
||||
import { COVER_ID, useBook } from "../store/useBook";
|
||||
import { useBackup } from "../store/useBackup";
|
||||
import { useExportPreview } from "../store/useExportPreview";
|
||||
import { useProofing } from "../store/useProofing";
|
||||
import { useTheme } from "../store/useTheme";
|
||||
import { useWidth } from "../store/useWidth";
|
||||
import { WIDTH_OPTIONS } from "../width";
|
||||
import { bodyNumber, chapterKind } from "../model/book";
|
||||
import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
|
||||
import { saveBook } from "../library";
|
||||
import { isDesktop } from "../ipc";
|
||||
import { useCompact } from "../useMedia";
|
||||
import { issueSignature, rememberWord, runProof } from "../proofing";
|
||||
import { runExport } from "../export/run";
|
||||
|
||||
@@ -40,10 +42,13 @@ export function EditorView() {
|
||||
const width = useWidth((s) => s.width);
|
||||
const setWidth = useWidth((s) => s.setWidth);
|
||||
|
||||
const isCompact = useCompact();
|
||||
const [editor, setEditor] = useState<TiptapEditor | null>(null);
|
||||
const [dock, setDock] = useState(true);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(!isCompact);
|
||||
const [dock, setDock] = useState(!isCompact);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [widthOpen, setWidthOpen] = useState(false);
|
||||
const [findOpen, setFindOpen] = useState(false);
|
||||
const [findExpanded, setFindExpanded] = useState(false);
|
||||
@@ -58,6 +63,21 @@ export function EditorView() {
|
||||
const coverActive = activeChapterId === COVER_ID;
|
||||
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);
|
||||
};
|
||||
|
||||
const openFind = useCallback((expanded: boolean) => {
|
||||
setFindExpanded(expanded);
|
||||
setFindOpen(true);
|
||||
@@ -112,6 +132,11 @@ export function EditorView() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [notice]);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarOpen(!isCompact);
|
||||
setDock(!isCompact);
|
||||
}, [isCompact]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const storage = (editor.storage as unknown as Record<string, ProofingStorage>).proofing;
|
||||
@@ -147,7 +172,8 @@ export function EditorView() {
|
||||
|
||||
const handleExport = (format: "pdf" | "epub") => {
|
||||
setExportOpen(false);
|
||||
runExport(format);
|
||||
if (format === "pdf" && isDesktop) useExportPreview.getState().openPreview();
|
||||
else runExport(format);
|
||||
};
|
||||
|
||||
if (!book) return null;
|
||||
@@ -156,21 +182,32 @@ export function EditorView() {
|
||||
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
|
||||
const kind = chapter ? chapterKind(chapter) : "body";
|
||||
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 (
|
||||
<div className="app">
|
||||
<div className="app" data-compact={isCompact} data-sidebar={sidebarOpen} data-dock={dock}>
|
||||
<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">
|
||||
{book.metadata.title || "Untitled"}
|
||||
{dirty && <span className="dirty-dot" />}
|
||||
</button>
|
||||
<div className="actions">
|
||||
{isDesktop && <BackupButton />}
|
||||
{isDesktop && !isCompact && <BackupButton />}
|
||||
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)">
|
||||
<Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" />
|
||||
</button>
|
||||
{proofingAvailable && !coverActive && (
|
||||
{!isCompact && proofingAvailable && !coverActive && (
|
||||
<>
|
||||
<button className="icon-btn" data-on={spelling} onClick={toggleSpelling} title="Check spelling">
|
||||
<Icon d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" />
|
||||
@@ -180,6 +217,7 @@ export function EditorView() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!isCompact && (
|
||||
<div className="menu-wrap">
|
||||
<button className="icon-btn" data-on={widthOpen} onClick={() => setWidthOpen((v) => !v)} title="Editor width">
|
||||
<Icon d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" />
|
||||
@@ -204,6 +242,8 @@ export function EditorView() {
|
||||
</>
|
||||
)}
|
||||
</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" />
|
||||
@@ -218,6 +258,8 @@ export function EditorView() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isCompact && (
|
||||
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? "Light mode" : "Dark mode"}>
|
||||
{theme === "dark" ? (
|
||||
<Icon>
|
||||
@@ -228,9 +270,67 @@ export function EditorView() {
|
||||
<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">
|
||||
)}
|
||||
<button className="icon-btn" data-on={dock} onClick={toggleDock} title="Toggle preview">
|
||||
<Icon d="M3 4.5h18v15H3zM14 4.5v15" />
|
||||
</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>
|
||||
{moreOpen && (
|
||||
<>
|
||||
<div className="menu-backdrop" onClick={() => setMoreOpen(false)} />
|
||||
<div className="menu">
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -242,8 +342,8 @@ export function EditorView() {
|
||||
/>
|
||||
|
||||
<div className="body">
|
||||
<Sidebar />
|
||||
<ResizeHandle pane="sidebar" />
|
||||
<Sidebar onNavigate={() => isCompact && setSidebarOpen(false)} />
|
||||
{sidebarOpen && !isCompact && <ResizeHandle pane="sidebar" />}
|
||||
<main className="editor-pane">
|
||||
{coverActive ? (
|
||||
<CoverView />
|
||||
@@ -252,13 +352,13 @@ export function EditorView() {
|
||||
<article className="sheet">
|
||||
<header className="chapter-opener">
|
||||
<div className="chapter-num">{eyebrow}</div>
|
||||
<input
|
||||
className="chapter-title-input"
|
||||
value={chapter.noTitle ? "" : chapter.title}
|
||||
placeholder={chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : "Page title"}
|
||||
spellCheck={false}
|
||||
disabled={chapter.noTitle}
|
||||
onChange={(e) => setChapterTitle(chapter.id, e.target.value)}
|
||||
<ChapterTitleInput
|
||||
value={chapter.title}
|
||||
placeholder={
|
||||
chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : kind === "part" ? "Part title (optional)" : "Page title"
|
||||
}
|
||||
disabled={!!chapter.noTitle}
|
||||
onChange={(value) => setChapterTitle(chapter.id, value)}
|
||||
/>
|
||||
{(chapter.noTitle || !chapter.title.trim()) && (
|
||||
<label className="no-title-check">
|
||||
@@ -287,8 +387,11 @@ export function EditorView() {
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
{dock && <ResizeHandle pane="dock" />}
|
||||
{dock && !isCompact && <ResizeHandle pane="dock" />}
|
||||
{dock && <Dock />}
|
||||
{isCompact && (sidebarOpen || dock) && (
|
||||
<button className="drawer-scrim" aria-label="Close panel" onClick={closeDrawers} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{proofPopover && editor && (
|
||||
@@ -328,3 +431,38 @@ export function EditorView() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterTitleInput({
|
||||
value,
|
||||
placeholder,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
disabled: boolean;
|
||||
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]);
|
||||
|
||||
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,339 @@
|
||||
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 { 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]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [close]);
|
||||
|
||||
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>{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 [stageWidth, setStageWidth] = useState(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 stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
const update = () => setStageWidth(stage.clientWidth);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(stage);
|
||||
return () => observer.disconnect();
|
||||
}, [doc]);
|
||||
|
||||
const fit = Math.max(280, stageWidth - 56);
|
||||
const displayWidth = Math.round(fit * zoom);
|
||||
|
||||
return (
|
||||
<div className="preview-stage" ref={stageRef}>
|
||||
{doc && baseRatio !== null && stageWidth > 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) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,20 @@ interface RowMenuProps {
|
||||
label: string;
|
||||
onDuplicate?: () => void;
|
||||
onDelete: () => void;
|
||||
onToggleTitle?: () => void;
|
||||
titleHidden?: boolean;
|
||||
onToggleMargin?: () => void;
|
||||
marginHidden?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RowMenu({ label, onDuplicate, onDelete, className = "" }: RowMenuProps) {
|
||||
export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidden, onToggleMargin, marginHidden, onOpenChange, className = "" }: RowMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onOpenChange?.(open);
|
||||
}, [open]);
|
||||
const [coords, setCoords] = useState({ top: 0, right: 0 });
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
@@ -41,6 +50,18 @@ export function RowMenu({ label, onDuplicate, onDelete, className = "" }: RowMen
|
||||
};
|
||||
}, [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) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
@@ -68,6 +89,26 @@ export function RowMenu({ label, onDuplicate, onDelete, className = "" }: RowMen
|
||||
{open &&
|
||||
createPortal(
|
||||
<div ref={popRef} className="row-menu-pop" style={{ top: coords.top, right: coords.right }}>
|
||||
{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 && (
|
||||
<button className="row-menu-item" onClick={duplicate}>
|
||||
<Icon d="M9 9h11v11h-11z M6 15V5h9" size={14} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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 { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { Icon } from "./Icon";
|
||||
@@ -10,6 +10,8 @@ interface Row {
|
||||
chapter: Chapter;
|
||||
index: number;
|
||||
num: number | null;
|
||||
part: number | null;
|
||||
indent: boolean;
|
||||
}
|
||||
|
||||
interface DropTarget {
|
||||
@@ -37,13 +39,16 @@ function formatEdited(ts: number, now: number): string {
|
||||
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const chapters = useBook((s) => s.book?.chapters ?? []);
|
||||
const activeChapterId = useBook((s) => s.activeChapterId);
|
||||
const setActiveChapter = useBook((s) => s.setActiveChapter);
|
||||
const addChapter = useBook((s) => s.addChapter);
|
||||
const addPage = useBook((s) => s.addPage);
|
||||
const addPart = useBook((s) => s.addPart);
|
||||
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 moveChapter = useBook((s) => s.moveChapter);
|
||||
const closeBook = useBook((s) => s.closeBook);
|
||||
@@ -51,6 +56,7 @@ export function Sidebar() {
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null);
|
||||
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
const dropRef = useRef<DropTarget | null>(null);
|
||||
@@ -62,15 +68,23 @@ export function Sidebar() {
|
||||
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[] }[] = [
|
||||
{ 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") },
|
||||
];
|
||||
const groupEnd: Record<ChapterKind, number> = {
|
||||
front: groups[0].rows.length,
|
||||
body: groups[0].rows.length + groups[1].rows.length,
|
||||
part: groups[0].rows.length + groups[1].rows.length,
|
||||
back: rows.length,
|
||||
};
|
||||
|
||||
@@ -125,7 +139,7 @@ export function Sidebar() {
|
||||
};
|
||||
|
||||
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;
|
||||
gesture.current = { from: index, x: e.clientX, y: e.clientY, active: false };
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
@@ -138,6 +152,7 @@ export function Sidebar() {
|
||||
return;
|
||||
}
|
||||
setActiveChapter(id);
|
||||
onNavigate?.();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -149,12 +164,15 @@ export function Sidebar() {
|
||||
<button
|
||||
className="cover-item"
|
||||
data-active={activeChapterId === COVER_ID}
|
||||
onClick={() => setActiveChapter(COVER_ID)}
|
||||
onClick={() => {
|
||||
setActiveChapter(COVER_ID);
|
||||
onNavigate?.();
|
||||
}}
|
||||
>
|
||||
<Icon d="M5 4h11l3 3v13H5zM16 4v4h3" size={15} />
|
||||
<span className="title">Cover</span>
|
||||
</button>
|
||||
<AddPageMenu onAdd={addPage} />
|
||||
<AddPageMenu onAdd={addPage} onAddPart={addPart} />
|
||||
|
||||
<div className="nav-scroll">
|
||||
{groups.map((group) =>
|
||||
@@ -162,12 +180,22 @@ export function Sidebar() {
|
||||
<div key={group.kind} className="nav-section" data-kind={group.kind}>
|
||||
<div className="nav-label">{group.label}</div>
|
||||
<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
|
||||
key={row.chapter.id}
|
||||
className="chapter"
|
||||
data-idx={row.index}
|
||||
data-kind={group.kind}
|
||||
data-part={isPart}
|
||||
data-indent={row.indent}
|
||||
data-active={row.chapter.id === activeChapterId}
|
||||
data-dragging={dragIndex === row.index}
|
||||
data-drop-before={dropTarget?.kind === group.kind && dropTarget.index === row.index}
|
||||
@@ -178,8 +206,10 @@ export function Sidebar() {
|
||||
<span className="grip" title="Drag to reorder">
|
||||
<Icon d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" size={14} />
|
||||
</span>
|
||||
<span className="num">{row.num ?? ""}</span>
|
||||
{!isPart && <span className="num">{row.num ?? ""}</span>}
|
||||
<span className="text">
|
||||
{isPart && <span className="part-eyebrow">Part {partRoman(row.part ?? 0)}</span>}
|
||||
{(!isPart || row.chapter.title) && (
|
||||
<span
|
||||
className="title"
|
||||
onMouseEnter={(e) => {
|
||||
@@ -190,17 +220,26 @@ export function Sidebar() {
|
||||
>
|
||||
{row.chapter.title || "Untitled"}
|
||||
</span>
|
||||
)}
|
||||
{row.chapter.id === activeChapterId && (
|
||||
<span className="meta">Edited {formatEdited(row.chapter.updatedAt, now)}</span>
|
||||
)}
|
||||
</span>
|
||||
<RowMenu
|
||||
label="Page options"
|
||||
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)}
|
||||
onDelete={() => setPendingDelete({ id: row.chapter.id, title: row.chapter.title || "Untitled" })}
|
||||
onDelete={() => setPendingDelete({ id: row.chapter.id, title: rowLabel })}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{!group.rows.length && (
|
||||
<li className="chapter-drop" data-on={dropTarget?.kind === group.kind}>
|
||||
Drop here
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useEffect, useReducer, useRef, useState, type ReactNode } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { Icon } from "../components/Icon";
|
||||
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> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -23,6 +30,7 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
||||
const linkInputRef = useRef<HTMLInputElement>(null);
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
const [linkValue, setLinkValue] = useState("");
|
||||
const [alignOpen, setAlignOpen] = useState(false);
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -67,6 +75,13 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
||||
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) => (
|
||||
<button
|
||||
className="tool"
|
||||
@@ -87,6 +102,28 @@ 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("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" />)}
|
||||
<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 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-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" />)}
|
||||
|
||||
@@ -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,10 @@ import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Figure } from "./figure";
|
||||
import { ParagraphIndent } from "./indent";
|
||||
import { TextAlign } from "./align";
|
||||
import { SearchHighlight } from "./search";
|
||||
import { Proofing } from "./proofing";
|
||||
import { Paste } from "./paste";
|
||||
|
||||
export const editorExtensions: Extensions = [
|
||||
StarterKit.configure({
|
||||
@@ -17,6 +19,8 @@ export const editorExtensions: Extensions = [
|
||||
}),
|
||||
Figure,
|
||||
ParagraphIndent,
|
||||
TextAlign,
|
||||
SearchHighlight,
|
||||
Proofing,
|
||||
Paste,
|
||||
];
|
||||
@@ -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");
|
||||
|
||||
function buildDecorations(doc: PMNode, issues: ProofIssue[]): DecorationSet {
|
||||
function buildDecorations(doc: PMNode, issues: ProofIssue[], cursor: number | null): DecorationSet {
|
||||
const size = doc.content.size;
|
||||
const decos = issues
|
||||
.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)}` }));
|
||||
return DecorationSet.create(doc, decos);
|
||||
}
|
||||
@@ -73,9 +74,11 @@ export const Proofing = Extension.create<unknown, ProofingStorage>({
|
||||
state: {
|
||||
init: () => ({ issues: [], decorations: DecorationSet.empty }),
|
||||
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;
|
||||
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) {
|
||||
return { issues: [], decorations: DecorationSet.empty };
|
||||
@@ -84,7 +87,10 @@ export const Proofing = Extension.create<unknown, ProofingStorage>({
|
||||
const issues = value.issues
|
||||
.map((i) => ({ ...i, from: tr.mapping.map(i.from), to: tr.mapping.map(i.to) }))
|
||||
.filter((i) => i.to > i.from);
|
||||
return { issues, decorations: value.decorations.map(tr.mapping, tr.doc) };
|
||||
return { 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;
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { JSONContent } from "@tiptap/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";
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
function alignStyle(align: unknown): string {
|
||||
return align === "center" || align === "right" ? ` style="text-align:${align}"` : "";
|
||||
}
|
||||
|
||||
function block(node: JSONContent, paths: Map<string, string>): string {
|
||||
switch (node.type) {
|
||||
case "paragraph": {
|
||||
const body = inlines(node.content);
|
||||
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>`;
|
||||
}
|
||||
case "heading":
|
||||
return node.attrs?.level === 3
|
||||
? `<h3>${inlines(node.content)}</h3>`
|
||||
: `<h2>${inlines(node.content)}</h2>`;
|
||||
case "heading": {
|
||||
const tag = node.attrs?.level === 3 ? "h3" : "h2";
|
||||
return `<${tag}${alignStyle(node.attrs?.align)}>${inlines(node.content)}</${tag}>`;
|
||||
}
|
||||
case "blockquote":
|
||||
return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`;
|
||||
case "bulletList":
|
||||
@@ -338,9 +344,12 @@ function contentOpf(
|
||||
function navXhtml(book: Book): string {
|
||||
const items = book.chapters
|
||||
.map((chapter, i) => {
|
||||
if (chapter.noTitle && chapterKind(chapter) !== "body") return "";
|
||||
const fallback = chapterKind(chapter) === "body" ? `Chapter ${bodyNumber(book.chapters, i)}` : "Untitled";
|
||||
const label = chapter.noTitle ? fallback : chapter.title || fallback;
|
||||
const kind = chapterKind(chapter);
|
||||
if (chapter.noTitle && kind !== "body" && kind !== "part") return "";
|
||||
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>`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
@@ -366,16 +375,20 @@ function chapterXhtml(book: Book, index: number, paths: Map<string, string>): st
|
||||
const chapter = book.chapters[index];
|
||||
const kind = chapterKind(chapter);
|
||||
const num = bodyNumber(book.chapters, index);
|
||||
const title = chapter.title || (kind === "body" ? `Chapter ${num}` : "Untitled");
|
||||
const eyebrow = kind === "body" ? `<p class="eyebrow">Chapter ${num}</p>` : "";
|
||||
const heading = chapter.noTitle ? "" : `<h1>${esc(title)}</h1>`;
|
||||
const partLabel = `Part ${partRoman(partNumber(book.chapters, index) ?? 0)}`;
|
||||
const eyebrow =
|
||||
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"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
|
||||
<head>
|
||||
<title>${esc(title)}</title>
|
||||
<title>${esc(docTitle)}</title>
|
||||
<link rel="stylesheet" type="text/css" href="style.css"/>
|
||||
</head>
|
||||
<body epub:type="${kind === "body" ? "chapter" : kind === "front" ? "frontmatter" : "backmatter"}">
|
||||
<body epub:type="${epubType}">
|
||||
<header class="chapter-opener">
|
||||
${eyebrow}
|
||||
${heading}
|
||||
@@ -435,6 +448,18 @@ h2, h3 {
|
||||
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 {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.28em;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { ImageInput } from "../ipc";
|
||||
|
||||
@@ -37,6 +37,15 @@ function str(value: string): string {
|
||||
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 {
|
||||
if (node.type === "text") {
|
||||
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})`;
|
||||
}
|
||||
|
||||
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 {
|
||||
switch (node.type) {
|
||||
case "paragraph": {
|
||||
const body = inlines(node.content, true);
|
||||
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;
|
||||
}
|
||||
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":
|
||||
return `#blockquote[${(node.content ?? []).map((n) => block(n, paths)).join("\n\n")}]`;
|
||||
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}),
|
||||
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)
|
||||
#show heading: set text(font: ${str(heading)}, weight: "medium")
|
||||
#show heading.where(level: 1): set text(size: 22pt)
|
||||
@@ -153,7 +169,7 @@ function preamble(book: Book): string {
|
||||
#let openchapter(info) = {
|
||||
pagebreak(weak: true)
|
||||
[#metadata(info) <chap>]
|
||||
v(2.1in)
|
||||
if not info.nomargin { v(2.1in) }
|
||||
align(center)[
|
||||
#if info.kind == "body" {
|
||||
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)
|
||||
}
|
||||
|
||||
#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 pat = loc.page-numbering()
|
||||
if pat == none { pat = "1" }
|
||||
@@ -186,6 +216,7 @@ function preamble(book: Book): string {
|
||||
let info = it.value
|
||||
let pg = pageof(it.location())
|
||||
let body = info.kind == "body"
|
||||
let part = info.kind == "part"
|
||||
let gap = if prev != none and prev != info.kind { 1.7em } else { 0.95em }
|
||||
prev = info.kind
|
||||
block(width: 100%, above: gap, below: 0.95em, {
|
||||
@@ -195,7 +226,9 @@ function preamble(book: Book): string {
|
||||
column-gutter: (0.65em, 1em),
|
||||
align: (right + top, left + top, right + top),
|
||||
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,
|
||||
)]
|
||||
})
|
||||
@@ -312,18 +345,26 @@ function normTitle(title: string): string {
|
||||
|
||||
function inToc(book: Book, index: number): boolean {
|
||||
const chapter = book.chapters[index];
|
||||
if (chapter.noTitle && chapterKind(chapter) !== "body") return false;
|
||||
const kind = chapterKind(chapter);
|
||||
if (kind === "part") return true;
|
||||
if (chapter.noTitle && kind !== "body") return false;
|
||||
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 {
|
||||
const chapter = book.chapters[index];
|
||||
const kind = chapterKind(chapter);
|
||||
const num = kind === "body" ? String(bodyNumber(book.chapters, index)) : "";
|
||||
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";
|
||||
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 {
|
||||
@@ -335,13 +376,13 @@ export function bookToTypst(book: Book, paths: Map<string, string> = new Map(),
|
||||
#set page(numbering: "i")
|
||||
#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 body = book.chapters
|
||||
.map((chapter, i) => {
|
||||
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)}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
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";
|
||||
|
||||
export interface RawFile {
|
||||
@@ -508,7 +508,7 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
|
||||
subtitle: "",
|
||||
author: localText(opf, "creator"),
|
||||
isbn: findIsbn(opf),
|
||||
language: localText(opf, "language") || "en",
|
||||
language: clampLanguage(localText(opf, "language") || "en"),
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -64,7 +64,7 @@ export function trimRatio(trim: TrimSize): number {
|
||||
return w / h;
|
||||
}
|
||||
|
||||
export type ChapterKind = "front" | "body" | "back";
|
||||
export type ChapterKind = "front" | "body" | "part" | "back";
|
||||
|
||||
export interface Chapter {
|
||||
id: string;
|
||||
@@ -73,6 +73,7 @@ export interface Chapter {
|
||||
updatedAt: number;
|
||||
kind?: ChapterKind;
|
||||
noTitle?: boolean;
|
||||
noMargin?: boolean;
|
||||
}
|
||||
|
||||
export interface PageType {
|
||||
@@ -107,6 +108,31 @@ export function bodyNumber(chapters: Chapter[], index: number): number | null {
|
||||
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 {
|
||||
schema: "margin/1";
|
||||
id: string;
|
||||
@@ -141,6 +167,10 @@ export function createPage(group: "front" | "back", title: string): Chapter {
|
||||
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 cloneChapter(chapter: Chapter): Chapter {
|
||||
const title = chapter.noTitle || !chapter.title ? chapter.title : `${chapter.title} (copy)`;
|
||||
return { ...chapter, id: crypto.randomUUID(), title, content: structuredClone(chapter.content), updatedAt: Date.now() };
|
||||
@@ -153,11 +183,19 @@ export function createCover(): Cover {
|
||||
const DEFAULT_METADATA: BookMetadata = { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" };
|
||||
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 {
|
||||
const chapters = (Array.isArray(book.chapters) ? book.chapters : []).filter(Boolean);
|
||||
const metadata = { ...DEFAULT_METADATA, ...book.metadata };
|
||||
return {
|
||||
...book,
|
||||
metadata: { ...DEFAULT_METADATA, ...book.metadata },
|
||||
metadata: { ...metadata, language: clampLanguage(metadata.language) },
|
||||
settings: { ...DEFAULT_SETTINGS, ...book.settings, fonts: { ...DEFAULT_FONTS, ...book.settings?.fonts } },
|
||||
cover: book.cover ? { ...createCover(), ...book.cover } : createCover(),
|
||||
chapters: (chapters.length ? chapters : [createChapter()]).map((c) =>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
cloneChapter,
|
||||
createChapter,
|
||||
createPage,
|
||||
createPart,
|
||||
normalizeBook,
|
||||
} from "../model/book";
|
||||
import { loadActiveChapter, saveActiveChapter } from "../editor/positions";
|
||||
@@ -32,8 +33,10 @@ interface BookState {
|
||||
setChapterContent: (id: string, content: JSONContent) => void;
|
||||
setChapterTitle: (id: string, title: string) => void;
|
||||
setChapterNoTitle: (id: string, noTitle: boolean) => void;
|
||||
setChapterNoMargin: (id: string, noMargin: boolean) => void;
|
||||
addChapter: () => void;
|
||||
addPage: (group: "front" | "back", title: string) => void;
|
||||
addPart: () => void;
|
||||
duplicateChapter: (id: string) => void;
|
||||
deleteChapter: (id: string) => void;
|
||||
moveChapter: (from: number, to: number, toKind?: ChapterKind) => void;
|
||||
@@ -85,7 +88,13 @@ export const useBook = create<BookState>((set, get) => ({
|
||||
setChapterNoTitle: (id, noTitle) =>
|
||||
set((s) =>
|
||||
s.book
|
||||
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, noTitle, title: noTitle ? "" : c.title, updatedAt: Date.now() } : c)) } }
|
||||
? { 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: () =>
|
||||
@@ -107,6 +116,18 @@ export const useBook = create<BookState>((set, get) => ({
|
||||
chapters.splice(insertAt, 0, page);
|
||||
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) =>
|
||||
set((s) => {
|
||||
if (!s.book) return {};
|
||||
@@ -133,7 +154,7 @@ export const useBook = create<BookState>((set, get) => ({
|
||||
const chapters = [...s.book.chapters];
|
||||
if (from < 0 || from >= chapters.length) return {};
|
||||
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));
|
||||
if (from === dest && chapterKind(target) === nextKind) return {};
|
||||
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 }),
|
||||
}));
|
||||
@@ -8,8 +8,17 @@ body,
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
background: var(--shell);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-ui);
|
||||
@@ -35,12 +44,15 @@ button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
flex: none;
|
||||
height: 46px;
|
||||
position: relative;
|
||||
z-index: 45;
|
||||
height: var(--titlebar-h);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
@@ -49,6 +61,13 @@ button {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.titlebar .lead {
|
||||
grid-column: 1;
|
||||
justify-self: start;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.titlebar .doc-title {
|
||||
grid-column: 2;
|
||||
font-size: var(--t-2);
|
||||
@@ -150,6 +169,17 @@ body.resizing {
|
||||
padding: 6px 0 10px;
|
||||
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;
|
||||
}
|
||||
|
||||
body.resizing .sidebar {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -233,6 +263,37 @@ body.resizing {
|
||||
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 {
|
||||
color: var(--accent);
|
||||
}
|
||||
@@ -623,18 +684,22 @@ body.resizing {
|
||||
}
|
||||
|
||||
.chapter-title-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 15px 0 0;
|
||||
padding: 0;
|
||||
padding: 0 0 0.3em;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: 31px;
|
||||
line-height: 1.16;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.chapter-title-input:focus {
|
||||
@@ -783,7 +848,7 @@ body.resizing {
|
||||
|
||||
.editor-toolbar {
|
||||
position: sticky;
|
||||
bottom: 22px;
|
||||
bottom: max(22px, env(safe-area-inset-bottom));
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -856,6 +921,24 @@ body.resizing {
|
||||
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 {
|
||||
width: 220px;
|
||||
height: 28px;
|
||||
@@ -1041,6 +1124,122 @@ body.resizing {
|
||||
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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1054,8 +1253,7 @@ body.resizing {
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: 480px;
|
||||
max-width: 100%;
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1196,6 +1394,21 @@ body.resizing {
|
||||
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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1251,6 +1464,7 @@ body.resizing {
|
||||
|
||||
.library {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow-y: auto;
|
||||
background: var(--shell);
|
||||
}
|
||||
@@ -2454,3 +2668,95 @@ body.resizing {
|
||||
font-size: var(--t-2);
|
||||
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%);
|
||||
transition: transform 260ms var(--ease);
|
||||
}
|
||||
|
||||
.app[data-compact="true"][data-sidebar="true"] .sidebar {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.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-dock: 384px;
|
||||
--measure: 46em;
|
||||
--titlebar-h: 46px;
|
||||
|
||||
--t-1: 11px;
|
||||
--t-2: 12px;
|
||||
|
||||
@@ -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)");
|
||||
@@ -17,16 +17,16 @@ const cols = [
|
||||
{
|
||||
title: "Product",
|
||||
links: [
|
||||
{ label: "Features", href: "#features" },
|
||||
{ label: "Publishing", href: "#publish" },
|
||||
{ label: "Compare", href: "#compare" },
|
||||
{ label: "Pricing", href: "#pricing" },
|
||||
{ label: "Features", href: "/#features" },
|
||||
{ label: "Publishing", href: "/#publish" },
|
||||
{ label: "Compare", href: "/#compare" },
|
||||
{ label: "Pricing", href: "/#pricing" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Help",
|
||||
links: [
|
||||
{ label: "Questions", href: "#faq" },
|
||||
{ label: "Questions", href: "/#faq" },
|
||||
{ label: "Email us", href: `mailto:${site.supportEmail}` },
|
||||
],
|
||||
},
|
||||
@@ -50,14 +50,14 @@ const cols = [
|
||||
<p class="footer__title">{col.title}</p>
|
||||
<ul>
|
||||
{col.links.map((l) => {
|
||||
const isAnchor = l.href.startsWith("#") || l.href.startsWith("mailto:");
|
||||
const isExternal = l.href.startsWith("http");
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={l.href}
|
||||
data-download-os={(l as { os?: string }).os}
|
||||
target={isAnchor ? undefined : "_blank"}
|
||||
rel={isAnchor ? undefined : "noopener"}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={isExternal ? "noopener" : undefined}
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
@@ -73,6 +73,10 @@ const cols = [
|
||||
|
||||
<div class="container footer__base">
|
||||
<span>© {site.name}. Built with <span class="heart">♥</span> in NYC</span>
|
||||
<nav class="footer__legal" aria-label="Legal">
|
||||
<a href="/privacy">Privacy</a>
|
||||
<a href="/terms">Terms</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -136,10 +140,23 @@ const cols = [
|
||||
.footer__base {
|
||||
padding-top: 22px;
|
||||
border-top: 1px solid var(--line);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.footer__legal {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
}
|
||||
.footer__legal a {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.footer__legal a:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
.heart {
|
||||
color: var(--danger);
|
||||
}
|
||||
@@ -153,5 +170,10 @@ const cols = [
|
||||
.footer__cols {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.footer__base {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,15 +11,15 @@ import Logo from "./Logo.astro";
|
||||
</a>
|
||||
|
||||
<nav class="nav__links" aria-label="Primary">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#publish">Publishing</a>
|
||||
<a href="#compare">Compare</a>
|
||||
<a href="#pricing">Pricing</a>
|
||||
<a href="#faq">Questions</a>
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/#publish">Publishing</a>
|
||||
<a href="/#compare">Compare</a>
|
||||
<a href="/#pricing">Pricing</a>
|
||||
<a href="/#faq">Questions</a>
|
||||
</nav>
|
||||
|
||||
<div class="nav__actions">
|
||||
<a class="btn nav__cta" href="#download">
|
||||
<a class="btn nav__cta" href="/#download">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||
<path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
import Base from "./Base.astro";
|
||||
import Nav from "../components/Nav.astro";
|
||||
import Footer from "../components/Footer.astro";
|
||||
import { site } from "../data/site";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
updated: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { title, updated, description } = Astro.props;
|
||||
---
|
||||
|
||||
<Base title={`${title} · ${site.name}`} description={description}>
|
||||
<Nav />
|
||||
<main class="legal">
|
||||
<div class="container legal__inner">
|
||||
<header class="legal__head">
|
||||
<p class="eyebrow">Legal</p>
|
||||
<h1>{title}</h1>
|
||||
<p class="legal__updated">Last updated {updated}</p>
|
||||
</header>
|
||||
<div class="legal__prose">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.legal {
|
||||
padding-block: clamp(48px, 7vw, 96px);
|
||||
}
|
||||
.legal__inner {
|
||||
max-width: 760px;
|
||||
}
|
||||
.legal__head {
|
||||
padding-bottom: clamp(28px, 4vw, 44px);
|
||||
margin-bottom: clamp(28px, 4vw, 44px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.legal__head h1 {
|
||||
font-family: var(--font-book);
|
||||
font-weight: 560;
|
||||
letter-spacing: -0.012em;
|
||||
line-height: 1.08;
|
||||
font-size: clamp(2.1rem, 1.4rem + 2.8vw, 3.2rem);
|
||||
}
|
||||
.legal__updated {
|
||||
margin-top: 16px;
|
||||
color: var(--ink-faint);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.legal__prose {
|
||||
color: var(--ink-soft);
|
||||
font-size: 1.04rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.legal__prose :global(h2) {
|
||||
font-size: clamp(1.3rem, 1.05rem + 0.9vw, 1.6rem);
|
||||
color: var(--ink);
|
||||
margin: 44px 0 14px;
|
||||
}
|
||||
.legal__prose :global(h2:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
.legal__prose :global(p) {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.legal__prose :global(ul) {
|
||||
margin: 0 0 16px;
|
||||
padding-left: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.legal__prose :global(li) {
|
||||
padding-left: 4px;
|
||||
}
|
||||
.legal__prose :global(li::marker) {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.legal__prose :global(strong) {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.legal__prose :global(a) {
|
||||
color: var(--ink);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: var(--line-strong);
|
||||
}
|
||||
.legal__prose :global(a:hover) {
|
||||
text-decoration-color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
import Legal from "../layouts/Legal.astro";
|
||||
import { site } from "../data/site";
|
||||
---
|
||||
|
||||
<Legal
|
||||
title="Privacy Policy"
|
||||
updated="June 22, 2026"
|
||||
description={`How ${site.name} handles your data. The short version: your book never leaves your machine, and we keep no copy.`}
|
||||
>
|
||||
<h2>The short version</h2>
|
||||
<p>
|
||||
Margin is an offline writing studio that runs on your own computer. Your book and everything
|
||||
you write stay on your device. We never receive a copy, and nothing you write is sent away to
|
||||
be read or stored by us. This page explains the few places where data is involved, so you know
|
||||
exactly what happens and what does not.
|
||||
</p>
|
||||
|
||||
<h2>What stays on your device</h2>
|
||||
<p>
|
||||
Your manuscript, your spelling and grammar checks, your fonts and book setup, and every print
|
||||
and ebook export all happen locally on your machine. None of this requires an account, a login,
|
||||
or an internet connection, and none of it passes through our servers.
|
||||
</p>
|
||||
|
||||
<h2>No account, no tracking</h2>
|
||||
<p>
|
||||
You do not need to sign up, log in, or give us an email address to write with Margin. This
|
||||
website does not use tracking cookies, advertising pixels, or third-party analytics. The only
|
||||
thing the site stores in your browser is your light or dark theme preference, kept locally so
|
||||
the page looks the way you left it.
|
||||
</p>
|
||||
|
||||
<h2>Downloads and updates</h2>
|
||||
<p>
|
||||
Margin is distributed through GitHub Releases. When you download the app, or when the app checks
|
||||
for a new version to keep itself up to date, a request is made to GitHub to fetch the latest
|
||||
release. As with any web request, GitHub can see your IP address for that request, handled under
|
||||
GitHub's own privacy policy. We do not add any tracking of our own to these requests.
|
||||
</p>
|
||||
|
||||
<h2>Optional backup to your Google Drive</h2>
|
||||
<p>
|
||||
Margin can save a backup of your book to your own Google Drive, but only if you turn it on. When
|
||||
you do, you connect your own Google account, and the backup is written to your Drive, in your
|
||||
account, under your control. We never receive a copy of that backup and cannot read it. You can
|
||||
disconnect Margin from your Google account at any time from your Google account settings, and the
|
||||
app works exactly the same with the feature switched off.
|
||||
</p>
|
||||
|
||||
<h2>Payments</h2>
|
||||
<p>
|
||||
If you buy Margin, the payment is handled by a third-party payment processor. We do not see or
|
||||
store your full card details. The processor handles the transaction and shares with us only what
|
||||
we need to confirm your purchase and provide support.
|
||||
</p>
|
||||
|
||||
<h2>Children</h2>
|
||||
<p>
|
||||
Margin is a tool for writers and is not directed at children under 13. We do not knowingly
|
||||
collect personal information from children.
|
||||
</p>
|
||||
|
||||
<h2>Changes to this policy</h2>
|
||||
<p>
|
||||
If we update this policy, we will change the date at the top of this page. Because the app keeps
|
||||
your book on your own device, the spirit of this policy is unlikely to change: your words belong
|
||||
to you.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
Questions about your privacy? Write to <a href={`mailto:${site.supportEmail}`}>{site.supportEmail}</a>
|
||||
and a real person will answer.
|
||||
</p>
|
||||
</Legal>
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
import Legal from "../layouts/Legal.astro";
|
||||
import { site } from "../data/site";
|
||||
---
|
||||
|
||||
<Legal
|
||||
title="Terms of Use"
|
||||
updated="June 22, 2026"
|
||||
description={`The terms for using ${site.name}: a one-time purchase, your content stays yours, used as is.`}
|
||||
>
|
||||
<h2>Acceptance</h2>
|
||||
<p>
|
||||
These terms cover your use of Margin, the offline writing app, and this website. By downloading,
|
||||
buying, or using Margin, you agree to them. If you do not agree, please do not use the app.
|
||||
</p>
|
||||
|
||||
<h2>Your license to use Margin</h2>
|
||||
<p>
|
||||
Margin is a one-time purchase. When you buy it, you get a personal, non-exclusive license to
|
||||
install and use the app on the devices you own. You keep using the version you bought, and
|
||||
updates are included at no extra cost. You may not resell, rent, or redistribute the app, or
|
||||
attempt to defeat its purchase, and you may not reverse engineer it except where the law
|
||||
expressly allows.
|
||||
</p>
|
||||
|
||||
<h2>Your content is yours</h2>
|
||||
<p>
|
||||
Everything you write in Margin belongs to you. Your book is a file on your own computer. We claim
|
||||
no ownership of it, take no license to it, and never receive a copy. You are responsible for your
|
||||
own work, including keeping your own backups and making sure you have the right to publish what
|
||||
you write.
|
||||
</p>
|
||||
|
||||
<h2>Acceptable use</h2>
|
||||
<p>
|
||||
Use Margin lawfully. Do not use it to create or distribute material that is illegal, or that
|
||||
infringes someone else's rights. What you choose to write and publish is your responsibility, not
|
||||
ours.
|
||||
</p>
|
||||
|
||||
<h2>Third-party services</h2>
|
||||
<p>
|
||||
Margin can connect to services you choose, such as your own Google Drive for backups, and you
|
||||
download and publish through platforms like GitHub and the ebook and print stores. Your use of
|
||||
those services is governed by their own terms and policies, which we do not control.
|
||||
</p>
|
||||
|
||||
<h2>Updates and availability</h2>
|
||||
<p>
|
||||
We work to keep Margin reliable and to improve it over time, but we may change, pause, or
|
||||
discontinue parts of the app or this website. Because Margin runs on your own machine, the copy
|
||||
you have installed keeps working even if a feature that depends on an outside service changes.
|
||||
</p>
|
||||
|
||||
<h2>The app is provided as is</h2>
|
||||
<p>
|
||||
Margin is provided "as is" and "as available," without warranties of any kind, whether express or
|
||||
implied, including fitness for a particular purpose and non-infringement. We do not warrant that
|
||||
the app will be error-free or uninterrupted, or that it will meet every need. You are responsible
|
||||
for backing up your own work.
|
||||
</p>
|
||||
|
||||
<h2>Limitation of liability</h2>
|
||||
<p>
|
||||
To the fullest extent permitted by law, Margin and its maker will not be liable for any indirect,
|
||||
incidental, or consequential damages, or for any loss of data or content, arising from your use
|
||||
of the app. Our total liability for any claim is limited to the amount you paid for Margin.
|
||||
</p>
|
||||
|
||||
<h2>Refunds</h2>
|
||||
<p>
|
||||
If Margin is not working for you, write to us. We would rather make it right or refund your
|
||||
purchase than have you stuck with a tool you cannot use.
|
||||
</p>
|
||||
|
||||
<h2>Changes to these terms</h2>
|
||||
<p>
|
||||
We may update these terms from time to time. When we do, we will change the date at the top of
|
||||
this page. Continuing to use Margin after a change means you accept the updated terms.
|
||||
</p>
|
||||
|
||||
<h2>Governing law</h2>
|
||||
<p>
|
||||
These terms are governed by the laws of the State of New York, United States, without regard to
|
||||
its conflict of laws rules.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
Questions about these terms? Write to <a href={`mailto:${site.supportEmail}`}>{site.supportEmail}</a>.
|
||||
</p>
|
||||
</Legal>
|
||||