15 Commits
Author SHA1 Message Date
github-actions[bot] 4975e76358 chore(release): v0.1.11 2026-07-13 14:51:25 +00:00
github-actions[bot] efeb64d289 chore(release): v0.1.10 2026-06-27 13:49:24 +00:00
pj cd41a02737 changes 2026-06-27 09:48:42 -04:00
pj 5a2ada9e9e ci(release): pin macOS runner to macos-26 2026-06-27 09:48:42 -04:00
github-actions[bot] 782a4c3dee chore(release): v0.1.9 2026-06-27 13:48:19 +00:00
github-actions[bot] f8742c440f chore(release): v0.1.8 2026-06-27 13:32:54 +00:00
pj c090fb595f changes 2026-06-27 09:32:22 -04:00
pj 11df8697b1 changes 2026-06-23 11:29:54 -04:00
pj f47e822497 changes 2026-06-22 21:26:03 -04:00
github-actions[bot] 9e714c8997 chore(release): v0.1.7 2026-06-22 21:14:30 +00:00
pj cc26e1d51c changes 2026-06-22 17:12:44 -04:00
github-actions[bot] 530e260903 chore(release): v0.1.6 2026-06-22 20:52:01 +00:00
pj 74a684498a changes 2026-06-22 16:51:39 -04:00
pj 3fccb17060 fix(ci): provision google-credentials.json before release build
include_str! embeds google-credentials.json at compile time, but the
file is gitignored, so CI checkouts had no file and the Rust crate
failed to compile on all three platforms. Write it from the
GOOGLE_CREDENTIALS secret, falling back to the committed example
placeholders so the build always succeeds.
2026-06-22 16:50:28 -04:00
pj 9acb3aae3c changes 2026-06-22 16:42:03 -04:00
73 changed files with 53057 additions and 110 deletions

No files matched your search

+14 -1
View File
@@ -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:
+1 -1
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "margin-app",
"private": true,
"version": "0.1.5",
"version": "0.1.11",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "margin-app"
version = "0.1.5"
version = "0.1.11"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
+2 -3
View File
@@ -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"
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop",
"description": "Desktop-only permissions (updater, process restart)",
"platforms": ["macOS", "windows", "linux"],
"windows": ["main"],
"permissions": ["updater:default", "process:allow-restart"]
}
+3
View File
@@ -0,0 +1,3 @@
xcuserdata/
build/
Externals/
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,116 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "[email protected]",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>debugging</string>
</dict>
</plist>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17150" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="Y6W-OH-hqX">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17122"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="s0d-6b-0kx">
<objects>
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
<resources>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
+21
View File
@@ -0,0 +1,21 @@
# Uncomment the next line to define a global platform for your project
target 'margin-app_iOS' do
platform :ios, '14.0'
# Pods for margin-app_iOS
end
target 'margin-app_macOS' do
platform :osx, '11.0'
# Pods for margin-app_macOS
end
# Delete the deployment target for iOS and macOS, causing it to be inherited from the Podfile
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET'
config.build_settings.delete 'MACOSX_DEPLOYMENT_TARGET'
end
end
end
@@ -0,0 +1,8 @@
#pragma once
namespace ffi {
extern "C" {
void start_app();
}
}
@@ -0,0 +1,6 @@
#include "bindings/bindings.h"
int main(int argc, char * argv[]) {
ffi::start_app();
return 0;
}
@@ -0,0 +1,347 @@
en_US Hunspell Dictionary
Version 2020.12.07
Mon Dec 7 20:14:35 2020 -0500 [5ef55f9]
http://wordlist.sourceforge.net
README file for English Hunspell dictionaries derived from SCOWL.
These dictionaries are created using the speller/make-hunspell-dict
script in SCOWL.
The following dictionaries are available:
en_US (American)
en_CA (Canadian)
en_GB-ise (British with "ise" spelling)
en_GB-ize (British with "ize" spelling)
en_AU (Australian)
en_US-large
en_CA-large
en_GB-large (with both "ise" and "ize" spelling)
en_AU-large
The normal (non-large) dictionaries correspond to SCOWL size 60 and,
to encourage consistent spelling, generally only include one spelling
variant for a word. The large dictionaries correspond to SCOWL size
70 and may include multiple spelling for a word when both variants are
considered almost equal. The larger dictionaries however (1) have not
been as carefully checked for errors as the normal dictionaries and
thus may contain misspelled or invalid words; and (2) contain
uncommon, yet valid, words that might cause problems as they are
likely to be misspellings of more common words (for example, "ort" and
"calender").
To get an idea of the difference in size, here are 25 random words
only found in the large dictionary for American English:
Bermejo Freyr's Guenevere Hatshepsut Nottinghamshire arrestment
crassitudes crural dogwatches errorless fetial flaxseeds godroon
incretion jalapeño's kelpie kishkes neuroglias pietisms pullulation
stemwinder stenoses syce thalassic zees
The en_US, en_CA and en_AU are the official dictionaries for Hunspell.
The en_GB and large dictionaries are made available on an experimental
basis. If you find them useful please send me a quick email at
[email protected].
If none of these dictionaries suite you (for example, maybe you want
the normal dictionary that also includes common variants) additional
dictionaries can be generated at http://app.aspell.net/create or by
modifying speller/make-hunspell-dict in SCOWL. Please do let me know
if you end up publishing a customized dictionary.
If a word is not found in the dictionary or a word is there you think
shouldn't be, you can lookup the word up at http://app.aspell.net/lookup
to help determine why that is.
General comments on these list can be sent directly to me at
[email protected] or to the wordlist-devel mailing lists
(https://lists.sourceforge.net/lists/listinfo/wordlist-devel). If you
have specific issues with any of these dictionaries please file a bug
report at https://github.com/kevina/wordlist/issues.
IMPORTANT CHANGES INTRODUCED In 2016.11.20:
New Australian dictionaries thanks to the work of Benjamin Titze
([email protected]).
IMPORTANT CHANGES INTRODUCED IN 2016.04.24:
The dictionaries are now in UTF-8 format instead of ISO-8859-1. This
was required to handle smart quotes correctly.
IMPORTANT CHANGES INTRODUCED IN 2016.01.19:
"SET UTF8" was changes to "SET UTF-8" in the affix file as some
versions of Hunspell do not recognize "UTF8".
ADDITIONAL NOTES:
The NOSUGGEST flag was added to certain taboo words. While I made an
honest attempt to flag the strongest taboo words with the NOSUGGEST
flag, I MAKE NO GUARANTEE THAT I FLAGGED EVERY POSSIBLE TABOO WORD.
The list was originally derived from Németh László, however I removed
some words which, while being considered taboo by some dictionaries,
are not really considered swear words in today's society.
COPYRIGHT, SOURCES, and CREDITS:
The English dictionaries come directly from SCOWL
and is thus under the same copyright of SCOWL. The affix file is
a heavily modified version of the original english.aff file which was
released as part of Geoff Kuenning's Ispell and as such is covered by
his BSD license. Part of SCOWL is also based on Ispell thus the
Ispell copyright is included with the SCOWL copyright.
The collective work is Copyright 2000-2018 by Kevin Atkinson as well
as any of the copyrights mentioned below:
Copyright 2000-2018 by Kevin Atkinson
Permission to use, copy, modify, distribute and sell these word
lists, the associated scripts, the output created from the scripts,
and its documentation for any purpose is hereby granted without fee,
provided that the above copyright notice appears in all copies and
that both that copyright notice and this permission notice appear in
supporting documentation. Kevin Atkinson makes no representations
about the suitability of this array for any purpose. It is provided
"as is" without express or implied warranty.
Alan Beale <[email protected]> also deserves special credit as he has,
in addition to providing the 12Dicts package and being a major
contributor to the ENABLE word list, given me an incredible amount of
feedback and created a number of special lists (those found in the
Supplement) in order to help improve the overall quality of SCOWL.
The 10 level includes the 1000 most common English words (according to
the Moby (TM) Words II [MWords] package), a subset of the 1000 most
common words on the Internet (again, according to Moby Words II), and
frequently class 16 from Brian Kelk's "UK English Wordlist
with Frequency Classification".
The MWords package was explicitly placed in the public domain:
The Moby lexicon project is complete and has
been place into the public domain. Use, sell,
rework, excerpt and use in any way on any platform.
Placing this material on internal or public servers is
also encouraged. The compiler is not aware of any
export restrictions so freely distribute world-wide.
You can verify the public domain status by contacting
Grady Ward
3449 Martha Ct.
Arcata, CA 95521-4884
[email protected]
[email protected]
The "UK English Wordlist With Frequency Classification" is also in the
Public Domain:
Date: Sat, 08 Jul 2000 20:27:21 +0100
From: Brian Kelk <[email protected]>
> I was wondering what the copyright status of your "UK English
> Wordlist With Frequency Classification" word list as it seems to
> be lacking any copyright notice.
There were many many sources in total, but any text marked
"copyright" was avoided. Locally-written documentation was one
source. An earlier version of the list resided in a filespace called
PUBLIC on the University mainframe, because it was considered public
domain.
Date: Tue, 11 Jul 2000 19:31:34 +0100
> So are you saying your word list is also in the public domain?
That is the intention.
The 20 level includes frequency classes 7-15 from Brian's word list.
The 35 level includes frequency classes 2-6 and words appearing in at
least 11 of 12 dictionaries as indicated in the 12Dicts package. All
words from the 12Dicts package have had likely inflections added via
my inflection database.
The 12Dicts package and Supplement is in the Public Domain.
The WordNet database, which was used in the creation of the
Inflections database, is under the following copyright:
This software and database is being provided to you, the LICENSEE,
by Princeton University under the following license. By obtaining,
using and/or copying this software and database, you agree that you
have read, understood, and will comply with these terms and
conditions.:
Permission to use, copy, modify and distribute this software and
database and its documentation for any purpose and without fee or
royalty is hereby granted, provided that you agree to comply with
the following copyright notice and statements, including the
disclaimer, and that the same appear on ALL copies of the software,
database and documentation, including modifications that you make
for internal use or for distribution.
WordNet 1.6 Copyright 1997 by Princeton University. All rights
reserved.
THIS SOFTWARE AND DATABASE IS PROVIDED "AS IS" AND PRINCETON
UNIVERSITY MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PRINCETON
UNIVERSITY MAKES NO REPRESENTATIONS OR WARRANTIES OF MERCHANT-
ABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE
LICENSED SOFTWARE, DATABASE OR DOCUMENTATION WILL NOT INFRINGE ANY
THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS.
The name of Princeton University or Princeton may not be used in
advertising or publicity pertaining to distribution of the software
and/or database. Title to copyright in this software, database and
any associated documentation shall at all times remain with
Princeton University and LICENSEE agrees to preserve same.
The 40 level includes words from Alan's 3esl list found in version 4.0
of his 12dicts package. Like his other stuff the 3esl list is also in the
public domain.
The 50 level includes Brian's frequency class 1, words appearing
in at least 5 of 12 of the dictionaries as indicated in the 12Dicts
package, and uppercase words in at least 4 of the previous 12
dictionaries. A decent number of proper names is also included: The
top 1000 male, female, and Last names from the 1990 Census report; a
list of names sent to me by Alan Beale; and a few names that I added
myself. Finally a small list of abbreviations not commonly found in
other word lists is included.
The name files form the Census report is a government document which I
don't think can be copyrighted.
The file special-jargon.50 uses common.lst and word.lst from the
"Unofficial Jargon File Word Lists" which is derived from "The Jargon
File". All of which is in the Public Domain. This file also contain
a few extra UNIX terms which are found in the file "unix-terms" in the
special/ directory.
The 55 level includes words from Alan's 2of4brif list found in version
4.0 of his 12dicts package. Like his other stuff the 2of4brif is also
in the public domain.
The 60 level includes all words appearing in at least 2 of the 12
dictionaries as indicated by the 12Dicts package.
The 70 level includes Brian's frequency class 0 and the 74,550 common
dictionary words from the MWords package. The common dictionary words,
like those from the 12Dicts package, have had all likely inflections
added. The 70 level also included the 5desk list from version 4.0 of
the 12Dics package which is in the public domain.
The 80 level includes the ENABLE word list, all the lists in the
ENABLE supplement package (except for ABLE), the "UK Advanced Cryptics
Dictionary" (UKACD), the list of signature words from the YAWL package,
and the 10,196 places list from the MWords package.
The ENABLE package, mainted by M\Cooper <[email protected]>,
is in the Public Domain:
The ENABLE master word list, WORD.LST, is herewith formally released
into the Public Domain. Anyone is free to use it or distribute it in
any manner they see fit. No fee or registration is required for its
use nor are "contributions" solicited (if you feel you absolutely
must contribute something for your own peace of mind, the authors of
the ENABLE list ask that you make a donation on their behalf to your
favorite charity). This word list is our gift to the Scrabble
community, as an alternate to "official" word lists. Game designers
may feel free to incorporate the WORD.LST into their games. Please
mention the source and credit us as originators of the list. Note
that if you, as a game designer, use the WORD.LST in your product,
you may still copyright and protect your product, but you may *not*
legally copyright or in any way restrict redistribution of the
WORD.LST portion of your product. This *may* under law restrict your
rights to restrict your users' rights, but that is only fair.
UKACD, by J Ross Beresford <[email protected]>, is under the
following copyright:
Copyright (c) J Ross Beresford 1993-1999. All Rights Reserved.
The following restriction is placed on the use of this publication:
if The UK Advanced Cryptics Dictionary is used in a software package
or redistributed in any form, the copyright notice must be
prominently displayed and the text of this document must be included
verbatim.
There are no other restrictions: I would like to see the list
distributed as widely as possible.
The 95 level includes the 354,984 single words, 256,772 compound
words, 4,946 female names and the 3,897 male names, and 21,986 names
from the MWords package, ABLE.LST from the ENABLE Supplement, and some
additional words found in my part-of-speech database that were not
found anywhere else.
Accent information was taken from UKACD.
The VarCon package was used to create the American, British, Canadian,
and Australian word list. It is under the following copyright:
Copyright 2000-2016 by Kevin Atkinson
Permission to use, copy, modify, distribute and sell this array, the
associated software, and its documentation for any purpose is hereby
granted without fee, provided that the above copyright notice appears
in all copies and that both that copyright notice and this permission
notice appear in supporting documentation. Kevin Atkinson makes no
representations about the suitability of this array for any
purpose. It is provided "as is" without express or implied warranty.
Copyright 2016 by Benjamin Titze
Permission to use, copy, modify, distribute and sell this array, the
associated software, and its documentation for any purpose is hereby
granted without fee, provided that the above copyright notice appears
in all copies and that both that copyright notice and this permission
notice appear in supporting documentation. Benjamin Titze makes no
representations about the suitability of this array for any
purpose. It is provided "as is" without express or implied warranty.
Since the original words lists come from the Ispell distribution:
Copyright 1993, Geoff Kuenning, Granada Hills, CA
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. All modifications to the source code must be clearly marked as
such. Binary redistributions based on modified source code
must be clearly marked as modified versions in the documentation
and/or other materials provided with the distribution.
(clause 4 removed with permission from Geoff Kuenning)
5. The name of Geoff Kuenning may not be used to endorse or promote
products derived from this software without specific prior
written permission.
THIS SOFTWARE IS PROVIDED BY GEOFF KUENNING AND CONTRIBUTORS ``AS IS'' AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL GEOFF KUENNING OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
SUCH DAMAGE.
Build Date: Mon Dec 7 20:19:27 EST 2020
Wordlist Command: mk-list --accents=strip en_US 60
@@ -0,0 +1,205 @@
SET UTF-8
TRY esianrtolcdugmphbyfvkwzESIANRTOLCDUGMPHBYFVKWZ'
ICONV 1
ICONV ’ '
NOSUGGEST !
# ordinal numbers
COMPOUNDMIN 1
# only in compounds: 1th, 2th, 3th
ONLYINCOMPOUND c
# compound rules:
# 1. [0-9]*1[0-9]th (10th, 11th, 12th, 56714th, etc.)
# 2. [0-9]*[02-9](1st|2nd|3rd|[4-9]th) (21st, 22nd, 123rd, 1234th, etc.)
COMPOUNDRULE 2
COMPOUNDRULE n*1t
COMPOUNDRULE n*mp
WORDCHARS 0123456789
PFX A Y 1
PFX A 0 re .
PFX I Y 1
PFX I 0 in .
PFX U Y 1
PFX U 0 un .
PFX C Y 1
PFX C 0 de .
PFX E Y 1
PFX E 0 dis .
PFX F Y 1
PFX F 0 con .
PFX K Y 1
PFX K 0 pro .
SFX V N 2
SFX V e ive e
SFX V 0 ive [^e]
SFX N Y 3
SFX N e ion e
SFX N y ication y
SFX N 0 en [^ey]
SFX X Y 3
SFX X e ions e
SFX X y ications y
SFX X 0 ens [^ey]
SFX H N 2
SFX H y ieth y
SFX H 0 th [^y]
SFX Y Y 1
SFX Y 0 ly .
SFX G Y 2
SFX G e ing e
SFX G 0 ing [^e]
SFX J Y 2
SFX J e ings e
SFX J 0 ings [^e]
SFX D Y 4
SFX D 0 d e
SFX D y ied [^aeiou]y
SFX D 0 ed [^ey]
SFX D 0 ed [aeiou]y
SFX T N 4
SFX T 0 st e
SFX T y iest [^aeiou]y
SFX T 0 est [aeiou]y
SFX T 0 est [^ey]
SFX R Y 4
SFX R 0 r e
SFX R y ier [^aeiou]y
SFX R 0 er [aeiou]y
SFX R 0 er [^ey]
SFX Z Y 4
SFX Z 0 rs e
SFX Z y iers [^aeiou]y
SFX Z 0 ers [aeiou]y
SFX Z 0 ers [^ey]
SFX S Y 4
SFX S y ies [^aeiou]y
SFX S 0 s [aeiou]y
SFX S 0 es [sxzh]
SFX S 0 s [^sxzhy]
SFX P Y 3
SFX P y iness [^aeiou]y
SFX P 0 ness [aeiou]y
SFX P 0 ness [^y]
SFX M Y 1
SFX M 0 's .
SFX B Y 3
SFX B 0 able [^aeiou]
SFX B 0 able ee
SFX B e able [^aeiou]e
SFX L Y 1
SFX L 0 ment .
REP 90
REP a ei
REP ei a
REP a ey
REP ey a
REP ai ie
REP ie ai
REP alot a_lot
REP are air
REP are ear
REP are eir
REP air are
REP air ere
REP ere air
REP ere ear
REP ere eir
REP ear are
REP ear air
REP ear ere
REP eir are
REP eir ere
REP ch te
REP te ch
REP ch ti
REP ti ch
REP ch tu
REP tu ch
REP ch s
REP s ch
REP ch k
REP k ch
REP f ph
REP ph f
REP gh f
REP f gh
REP i igh
REP igh i
REP i uy
REP uy i
REP i ee
REP ee i
REP j di
REP di j
REP j gg
REP gg j
REP j ge
REP ge j
REP s ti
REP ti s
REP s ci
REP ci s
REP k cc
REP cc k
REP k qu
REP qu k
REP kw qu
REP o eau
REP eau o
REP o ew
REP ew o
REP oo ew
REP ew oo
REP ew ui
REP ui ew
REP oo ui
REP ui oo
REP ew u
REP u ew
REP oo u
REP u oo
REP u oe
REP oe u
REP u ieu
REP ieu u
REP ue ew
REP ew ue
REP uff ough
REP oo ieu
REP ieu oo
REP ier ear
REP ear ier
REP ear air
REP air ear
REP w qu
REP qu w
REP z ss
REP ss z
REP shun tion
REP shun sion
REP shun cion
REP size cise
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,474 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
0EC3DA4FFDC5C3B2B4E0B13B /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DD7D0823B077104D6C806D11 /* MetalKit.framework */; };
380D17F67E636CBECF7E4E6D /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F2318AB597D2BA37D0450A64 /* WebKit.framework */; };
3E829B66804DD93D0CE82436 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C46540657F8D4E893AC66C7 /* QuartzCore.framework */; };
8261C76B0C8AAFFF0976BB94 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9B857C0B494A9C83F9F9115 /* Metal.framework */; };
89611C54325E79BBFCAF5A6F /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 623FD9F4898AE5FDE23B0A73 /* libapp.a */; };
911563A32C960AC8241ED351 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8CC5D253EF80093EC54CD51D /* Assets.xcassets */; };
D6FCF60E9A225E886B4201B4 /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = D7C0C2B726053A491DE0A492 /* main.mm */; };
E9F56517F64D408654A47123 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 877554CC1AAF0ED8612C473F /* Security.framework */; };
EA15997B9A4564CD88AAF265 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AC3E0782AC1DE69EC1393F4F /* LaunchScreen.storyboard */; };
F1736708F9507F3358D2FB10 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 360E99CA5BCBFEBC9E48DB30 /* CoreGraphics.framework */; };
F6530048C395DF030B4AB269 /* assets in Resources */ = {isa = PBXBuildFile; fileRef = E4E3950AAF9CFB9F61092532 /* assets */; };
F75C7FAF5CEEF9BE332DC814 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AEE5851B77FD26F3F57404A0 /* UIKit.framework */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
14817B3F82D600114D013B7D /* pdf.rs */ = {isa = PBXFileReference; path = pdf.rs; sourceTree = "<group>"; };
277A822D18EFBF6E79B2EFC6 /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = "<group>"; };
2C46540657F8D4E893AC66C7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
360E99CA5BCBFEBC9E48DB30 /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
448CDA24E192A9EDE84EE2F6 /* library.rs */ = {isa = PBXFileReference; path = library.rs; sourceTree = "<group>"; };
623FD9F4898AE5FDE23B0A73 /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
6A0CF179B6206602B684ACC5 /* margin-app_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "margin-app_iOS.entitlements"; sourceTree = "<group>"; };
769526E10F6C7273A9421972 /* epub.rs */ = {isa = PBXFileReference; path = epub.rs; sourceTree = "<group>"; };
832AEF636C465719EE30629D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
877554CC1AAF0ED8612C473F /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
8C696B0612077DC32CFD7281 /* proofing.rs */ = {isa = PBXFileReference; path = proofing.rs; sourceTree = "<group>"; };
8CC5D253EF80093EC54CD51D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
AC3E0782AC1DE69EC1393F4F /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
AEE5851B77FD26F3F57404A0 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
B94688F7EA7A2C4A8C0DFD01 /* margin-app_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "margin-app_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
C018CB1E589580D894AB7585 /* gdrive.rs */ = {isa = PBXFileReference; path = gdrive.rs; sourceTree = "<group>"; };
D33920AF504282488CBE64EF /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
D74A9059FCE11D1A08EC6511 /* project.rs */ = {isa = PBXFileReference; path = project.rs; sourceTree = "<group>"; };
D7C0C2B726053A491DE0A492 /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = "<group>"; };
DD7D0823B077104D6C806D11 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; };
E4E3950AAF9CFB9F61092532 /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = assets; sourceTree = SOURCE_ROOT; };
F2318AB597D2BA37D0450A64 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
F8B65F0038A8DDF9CE7C2310 /* fonts.rs */ = {isa = PBXFileReference; path = fonts.rs; sourceTree = "<group>"; };
F95681711AA6F4A228A97009 /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
F9B857C0B494A9C83F9F9115 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
AACF50BDF8AA677AF9F50383 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
89611C54325E79BBFCAF5A6F /* libapp.a in Frameworks */,
F1736708F9507F3358D2FB10 /* CoreGraphics.framework in Frameworks */,
8261C76B0C8AAFFF0976BB94 /* Metal.framework in Frameworks */,
0EC3DA4FFDC5C3B2B4E0B13B /* MetalKit.framework in Frameworks */,
3E829B66804DD93D0CE82436 /* QuartzCore.framework in Frameworks */,
E9F56517F64D408654A47123 /* Security.framework in Frameworks */,
F75C7FAF5CEEF9BE332DC814 /* UIKit.framework in Frameworks */,
380D17F67E636CBECF7E4E6D /* WebKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1F4A93A133625BBBC5DD173D /* margin-app */ = {
isa = PBXGroup;
children = (
D7C0C2B726053A491DE0A492 /* main.mm */,
E1DE2A12D42E2CA3B48BF39C /* bindings */,
);
path = "margin-app";
sourceTree = "<group>";
};
603E19204F4A400DBF3BDC47 /* Frameworks */ = {
isa = PBXGroup;
children = (
360E99CA5BCBFEBC9E48DB30 /* CoreGraphics.framework */,
623FD9F4898AE5FDE23B0A73 /* libapp.a */,
F9B857C0B494A9C83F9F9115 /* Metal.framework */,
DD7D0823B077104D6C806D11 /* MetalKit.framework */,
2C46540657F8D4E893AC66C7 /* QuartzCore.framework */,
877554CC1AAF0ED8612C473F /* Security.framework */,
AEE5851B77FD26F3F57404A0 /* UIKit.framework */,
F2318AB597D2BA37D0450A64 /* WebKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
AF74A9A4E2E249230D0E350E /* Products */ = {
isa = PBXGroup;
children = (
B94688F7EA7A2C4A8C0DFD01 /* margin-app_iOS.app */,
);
name = Products;
sourceTree = "<group>";
};
B3F300FBBFBCCA3CA7B12D3D /* src */ = {
isa = PBXGroup;
children = (
769526E10F6C7273A9421972 /* epub.rs */,
F8B65F0038A8DDF9CE7C2310 /* fonts.rs */,
C018CB1E589580D894AB7585 /* gdrive.rs */,
F95681711AA6F4A228A97009 /* lib.rs */,
448CDA24E192A9EDE84EE2F6 /* library.rs */,
D33920AF504282488CBE64EF /* main.rs */,
14817B3F82D600114D013B7D /* pdf.rs */,
D74A9059FCE11D1A08EC6511 /* project.rs */,
8C696B0612077DC32CFD7281 /* proofing.rs */,
);
name = src;
path = ../../src;
sourceTree = "<group>";
};
C49F8FAE68067D0E047F17C1 /* margin-app_iOS */ = {
isa = PBXGroup;
children = (
832AEF636C465719EE30629D /* Info.plist */,
6A0CF179B6206602B684ACC5 /* margin-app_iOS.entitlements */,
);
path = "margin-app_iOS";
sourceTree = "<group>";
};
D99826F6BEBF021E86B50785 = {
isa = PBXGroup;
children = (
E4E3950AAF9CFB9F61092532 /* assets */,
8CC5D253EF80093EC54CD51D /* Assets.xcassets */,
AC3E0782AC1DE69EC1393F4F /* LaunchScreen.storyboard */,
FF76185D65C96C10997F4EC9 /* Externals */,
C49F8FAE68067D0E047F17C1 /* margin-app_iOS */,
F62D42C7D88CAD0BB5707700 /* Sources */,
B3F300FBBFBCCA3CA7B12D3D /* src */,
603E19204F4A400DBF3BDC47 /* Frameworks */,
AF74A9A4E2E249230D0E350E /* Products */,
);
sourceTree = "<group>";
};
E1DE2A12D42E2CA3B48BF39C /* bindings */ = {
isa = PBXGroup;
children = (
277A822D18EFBF6E79B2EFC6 /* bindings.h */,
);
path = bindings;
sourceTree = "<group>";
};
F62D42C7D88CAD0BB5707700 /* Sources */ = {
isa = PBXGroup;
children = (
1F4A93A133625BBBC5DD173D /* margin-app */,
);
path = Sources;
sourceTree = "<group>";
};
FF76185D65C96C10997F4EC9 /* Externals */ = {
isa = PBXGroup;
children = (
);
path = Externals;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
D914A04EE7625E0BFF302CBB /* margin-app_iOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = 2AFB5AED9BBDA36D01ECA7AE /* Build configuration list for PBXNativeTarget "margin-app_iOS" */;
buildPhases = (
AB760B90696AB2964C7D49A9 /* Build Rust Code */,
DE9CC46A3C745D3EE2659FC1 /* Sources */,
303B1D89A72F4ED6D2E12CFD /* Resources */,
AACF50BDF8AA677AF9F50383 /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = "margin-app_iOS";
packageProductDependencies = (
);
productName = "margin-app_iOS";
productReference = B94688F7EA7A2C4A8C0DFD01 /* margin-app_iOS.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
5FC750CFBA18ED6763EEF3A3 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
};
};
buildConfigurationList = C4AD3401C458C5C48B1EF849 /* Build configuration list for PBXProject "margin-app" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = D99826F6BEBF021E86B50785;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = AF74A9A4E2E249230D0E350E /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
D914A04EE7625E0BFF302CBB /* margin-app_iOS */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
303B1D89A72F4ED6D2E12CFD /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
911563A32C960AC8241ED351 /* Assets.xcassets in Resources */,
EA15997B9A4564CD88AAF265 /* LaunchScreen.storyboard in Resources */,
F6530048C395DF030B4AB269 /* assets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
AB760B90696AB2964C7D49A9 /* Build Rust Code */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Build Rust Code";
outputFileListPaths = (
);
outputPaths = (
"$(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a",
"$(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "npm run -- tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
DE9CC46A3C745D3EE2659FC1 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D6FCF60E9A225E886B4201B4 /* main.mm in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
2115AE8BB6330806DC18A6DE /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ARCHS = (
arm64,
);
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "margin-app_iOS/margin-app_iOS.entitlements";
CODE_SIGN_IDENTITY = "iPhone Developer";
ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\".\"",
);
INFOPLIST_FILE = "margin-app_iOS/Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = studio.margin.app;
PRODUCT_NAME = "Margin";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALID_ARCHS = arm64;
};
name = release;
};
44B57C3232BEF013CE90639D /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = debug;
};
845C6F72D84890D3B5F7D222 /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
};
name = release;
};
E3805913695CB6F5D2BE9BCB /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ARCHS = (
arm64,
);
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "margin-app_iOS/margin-app_iOS.entitlements";
CODE_SIGN_IDENTITY = "iPhone Developer";
ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\".\"",
);
INFOPLIST_FILE = "margin-app_iOS/Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = studio.margin.app;
PRODUCT_NAME = "Margin";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALID_ARCHS = arm64;
};
name = debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
2AFB5AED9BBDA36D01ECA7AE /* Build configuration list for PBXNativeTarget "margin-app_iOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E3805913695CB6F5D2BE9BCB /* debug */,
2115AE8BB6330806DC18A6DE /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
C4AD3401C458C5C48B1EF849 /* Build configuration list for PBXProject "margin-app" */ = {
isa = XCConfigurationList;
buildConfigurations = (
44B57C3232BEF013CE90639D /* debug */,
845C6F72D84890D3B5F7D222 /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
/* End XCConfigurationList section */
};
rootObject = 5FC750CFBA18ED6763EEF3A3 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuildSystemType</key>
<string>Original</string>
<key>DisableBuildSystemDeprecationDiagnostic</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D914A04EE7625E0BFF302CBB"
BuildableName = "margin-app_iOS.app"
BlueprintName = "margin-app_iOS"
ReferencedContainer = "container:margin-app.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "NO"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D914A04EE7625E0BFF302CBB"
BuildableName = "margin-app_iOS.app"
BlueprintName = "margin-app_iOS"
ReferencedContainer = "container:margin-app.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</TestAction>
<LaunchAction
buildConfiguration = "debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D914A04EE7625E0BFF302CBB"
BuildableName = "margin-app_iOS.app"
BlueprintName = "margin-app_iOS"
ReferencedContainer = "container:margin-app.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "release"
shouldUseLaunchSchemeArgsEnv = "NO"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D914A04EE7625E0BFF302CBB"
BuildableName = "margin-app_iOS.app"
BlueprintName = "margin-app_iOS"
ReferencedContainer = "container:margin-app.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.7</string>
<key>CFBundleVersion</key>
<string>0.1.7</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
<string>metal</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
+88
View File
@@ -0,0 +1,88 @@
name: margin-app
options:
bundleIdPrefix: studio.margin.app
deploymentTarget:
iOS: 14.0
fileGroups: [../../src]
configs:
debug: debug
release: release
settingGroups:
app:
base:
PRODUCT_NAME: Margin
PRODUCT_BUNDLE_IDENTIFIER: studio.margin.app
targetTemplates:
app:
type: application
sources:
- path: Sources
scheme:
environmentVariables:
RUST_BACKTRACE: full
RUST_LOG: info
settings:
groups: [app]
targets:
margin-app_iOS:
type: application
platform: iOS
sources:
- path: Sources
- path: Assets.xcassets
- path: Externals
- path: margin-app_iOS
- path: assets
buildPhase: resources
type: folder
- path: LaunchScreen.storyboard
info:
path: margin-app_iOS/Info.plist
properties:
LSRequiresIPhoneOS: true
UILaunchStoryboardName: LaunchScreen
UIRequiredDeviceCapabilities: [arm64, metal]
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
CFBundleShortVersionString: 0.1.7
CFBundleVersion: "0.1.7"
entitlements:
path: margin-app_iOS/margin-app_iOS.entitlements
scheme:
environmentVariables:
RUST_BACKTRACE: full
RUST_LOG: info
settings:
base:
ENABLE_BITCODE: false
ARCHS: [arm64]
VALID_ARCHS: arm64
LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true
EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64
groups: [app]
dependencies:
- framework: libapp.a
embed: false
- sdk: CoreGraphics.framework
- sdk: Metal.framework
- sdk: MetalKit.framework
- sdk: QuartzCore.framework
- sdk: Security.framework
- sdk: UIKit.framework
- sdk: WebKit.framework
preBuildScripts:
- script: npm run -- tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}
name: Build Rust Code
basedOnDependencyAnalysis: false
outputFiles:
- $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a
@@ -0,0 +1,304 @@
# Bundled technical and product terms accepted by the spell checker.
# Matched case-insensitively. Words with internal capitals (macOS, iOS,
# iPhone, GitHub) are already accepted by a heuristic and need not be listed.
# Platforms and operating systems
ios
ipados
macos
watchos
tvos
android
linux
unix
ubuntu
debian
fedora
centos
distro
kernel
chromeos
# Apple ecosystem
iphone
ipad
ipod
imac
macbook
airpods
airpod
airtag
homepod
icloud
airdrop
facetime
imessage
siri
retina
# Devices and hardware
smartphone
smartphones
smartwatch
touchscreen
webcam
microcontroller
cpu
gpu
ssd
hdd
ram
usb
hdmi
ethernet
bluetooth
wifi
nfc
# Languages and runtimes
javascript
typescript
nodejs
node
deno
bun
golang
kotlin
swift
rust
ruby
python
php
perl
scala
clojure
elixir
haskell
dart
lua
# Tooling and package managers
npm
npx
nvm
yarn
pnpm
webpack
vite
rollup
esbuild
babel
eslint
prettier
tsconfig
linter
linters
transpiler
bundler
runtime
runtimes
toolchain
boilerplate
# Web and frontend
frontend
backend
fullstack
middleware
serverless
api
apis
endpoint
endpoints
url
urls
uri
uris
cors
oauth
jwt
csrf
xss
favicon
viewport
responsive
dropdown
checkbox
tooltip
modal
navbar
sidebar
toolbar
wireframe
mockup
placeholder
# Data formats and protocols
json
yaml
toml
xml
svg
csv
markdown
http
https
ssh
ftp
sftp
tcp
udp
dns
vpn
ssl
tls
localhost
hostname
subdomain
namespace
# Databases
sql
nosql
postgres
postgresql
mysql
mariadb
sqlite
mongodb
redis
elasticsearch
schema
schemas
schemaless
indexing
# DevOps and cloud
docker
dockerfile
kubernetes
nginx
serverless
devops
ci
cd
pipeline
pipelines
deploy
deployment
rollback
autoscaling
balancer
microservice
microservices
container
containers
orchestration
# Cloud providers and services
aws
gcp
azure
cloudflare
vercel
netlify
heroku
firebase
supabase
digitalocean
# Version control and collaboration
git
github
gitlab
bitbucket
repo
repos
monorepo
changelog
commit
commits
rebase
merge
codebase
hotfix
changeset
# Programming concepts
async
await
callback
callbacks
boolean
enum
struct
regex
stdin
stdout
stderr
config
configs
metadata
dataset
datasets
workflow
workflows
plugin
plugins
framework
frameworks
sdk
cli
gui
ide
debugger
breakpoint
recursion
memoization
multithreading
serialization
deserialization
preprocessor
middleware
# AI and data
chatgpt
openai
anthropic
llm
llms
chatbot
chatbots
embeddings
tokenization
finetuning
inference
# Web3 and crypto
blockchain
bitcoin
ethereum
crypto
cryptocurrency
nft
nfts
# Product and UX
ux
ui
onboarding
login
logout
signup
username
usernames
changelog
screenshot
screenshots
emoji
emojis
hashtag
hashtags
clickable
scrollable
swipeable
+19 -5
View File
@@ -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,
+16 -4
View File
@@ -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(())
}
+2 -2
View File
@@ -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",
+6 -1
View File
@@ -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 />}
</>
);
}
+13 -1
View File
@@ -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>
+12 -4
View File
@@ -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>
+157 -19
View File
@@ -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)}
/>
);
}
+339
View File
@@ -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) }}
/>
);
}
+42 -1
View File
@@ -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} />
+50 -11
View File
@@ -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
+37
View File
@@ -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" />)}
+64
View File
@@ -0,0 +1,64 @@
import { Extension } from "@tiptap/core";
export type Alignment = "left" | "center" | "right";
const ALIGNED_TYPES = ["paragraph", "heading"];
declare module "@tiptap/core" {
interface Commands<ReturnType> {
textAlign: {
setTextAlign: (align: Alignment) => ReturnType;
};
}
}
export const TextAlign = Extension.create({
name: "textAlign",
addGlobalAttributes() {
return [
{
types: ALIGNED_TYPES,
attributes: {
align: {
default: null,
keepOnSplit: true,
parseHTML: (el) => {
const value = el.style.textAlign || el.getAttribute("data-align");
return value === "center" || value === "right" ? value : null;
},
renderHTML: (attrs) =>
attrs.align ? { "data-align": attrs.align, style: `text-align: ${attrs.align}` } : {},
},
},
},
];
},
addCommands() {
return {
setTextAlign:
(align) =>
({ tr, dispatch }) => {
const value = align === "left" ? null : align;
const { from, to } = tr.selection;
let changed = false;
tr.doc.nodesBetween(from, to, (node, pos) => {
if (ALIGNED_TYPES.includes(node.type.name) && node.attrs.align !== value) {
if (dispatch) tr.setNodeAttribute(pos, "align", value);
changed = true;
}
});
return changed;
},
};
},
addKeyboardShortcuts() {
return {
"Mod-Shift-l": () => this.editor.commands.setTextAlign("left"),
"Mod-Shift-e": () => this.editor.commands.setTextAlign("center"),
"Mod-Shift-r": () => this.editor.commands.setTextAlign("right"),
};
},
});
+4
View File
@@ -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,
];
+76
View File
@@ -0,0 +1,76 @@
import { Extension } from "@tiptap/core";
import type { JSONContent } from "@tiptap/core";
import { Plugin } from "@tiptap/pm/state";
function readImage(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function imageFiles(data: DataTransfer | null): File[] {
if (!data) return [];
const files = Array.from(data.files).filter((f) => f.type.startsWith("image/"));
if (files.length) return files;
return Array.from(data.items)
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((f): f is File => f !== null);
}
function plainContent(text: string): JSONContent[] {
return text
.replace(/\r\n?/g, "\n")
.split(/\n{2,}/)
.map((block) => {
const content: JSONContent[] = [];
block.split("\n").forEach((line, i) => {
if (i > 0) content.push({ type: "hardBreak" });
if (line) content.push({ type: "text", text: line });
});
return content.length ? { type: "paragraph", content } : { type: "paragraph" };
});
}
export const Paste = Extension.create({
name: "pasteHandler",
addKeyboardShortcuts() {
const editor = this.editor;
return {
"Mod-Shift-v": () => {
navigator.clipboard
.readText()
.then((text) => {
if (text) editor.chain().focus().insertContent(plainContent(text)).run();
})
.catch(() => {});
return true;
},
};
},
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
props: {
handlePaste(_view, event) {
const files = imageFiles(event.clipboardData);
if (!files.length) return false;
event.preventDefault();
files.forEach((file) =>
readImage(file).then((src) =>
editor.chain().focus().insertContent({ type: "figure", attrs: { src, placement: "full-width" } }).run(),
),
);
return true;
},
},
}),
];
},
});
+9 -3
View File
@@ -37,10 +37,11 @@ interface ProofingState {
export const proofingKey = new PluginKey<ProofingState>("proofing");
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;
},
+38 -13
View File
@@ -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>&#160;</p>";
const align = node.attrs?.align;
if (align === "center" || align === "right") return `<p${alignStyle(align)}>${body}</p>`;
return `<p${node.attrs?.indent ? ' data-indent="true"' : ""}>${body}</p>`;
}
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;
+52 -11
View File
@@ -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");
+2 -2
View File
@@ -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 {
+40 -2
View File
@@ -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) =>
+23 -2
View File
@@ -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);
+13
View File
@@ -0,0 +1,13 @@
import { create } from "zustand";
interface ExportPreviewState {
open: boolean;
openPreview: () => void;
close: () => void;
}
export const useExportPreview = create<ExportPreviewState>((set) => ({
open: false,
openPreview: () => set({ open: true }),
close: () => set({ open: false }),
}));
+312 -6
View File
@@ -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));
}
}
+1
View File
@@ -10,6 +10,7 @@
--pane-sidebar: 248px;
--pane-dock: 384px;
--measure: 46em;
--titlebar-h: 46px;
--t-1: 11px;
--t-2: 12px;
+15
View File
@@ -0,0 +1,15 @@
import { useEffect, useState } from "react";
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mq = window.matchMedia(query);
const onChange = () => setMatches(mq.matches);
onChange();
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, [query]);
return matches;
}
export const useCompact = () => useMediaQuery("(max-width: 899px)");
+31 -9
View File
@@ -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>
+6 -6
View File
@@ -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>
+100
View File
@@ -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>
+76
View File
@@ -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>
+92
View File
@@ -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>