Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04a0a49660 | ||
|
|
457209f934 | ||
|
|
b94b51140a | ||
|
|
5caf66a6fc | ||
|
|
983d8fd2c0 | ||
|
|
f4079a6f54 | ||
|
|
a54e7a42ef | ||
|
|
0485177282 | ||
|
|
3572c7a6b9 | ||
|
|
2f98f5ec1c | ||
|
|
608ba2f21b | ||
|
|
b63951f814 | ||
|
|
aa75b3ebda | ||
|
|
4e5ea6d855 | ||
|
|
9eabef9041 | ||
|
|
5c0040955e | ||
|
|
6e2ca9c4bc | ||
|
|
258d5e5209 | ||
|
|
14eb3c4427 | ||
|
|
0bc3e36ee6 | ||
|
|
c0da4c53db | ||
|
|
92941a2758 |
No files matched your search
@@ -85,7 +85,7 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: macos-26
|
- os: macos-latest
|
||||||
args: "--target universal-apple-darwin --config src-tauri/tauri.release.conf.json"
|
args: "--target universal-apple-darwin --config src-tauri/tauri.release.conf.json"
|
||||||
rust-targets: "aarch64-apple-darwin,x86_64-apple-darwin"
|
rust-targets: "aarch64-apple-darwin,x86_64-apple-darwin"
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
@@ -137,19 +137,6 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
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
|
- name: Build and upload
|
||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ dist-ssr
|
|||||||
# Tauri build output
|
# Tauri build output
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
|
|
||||||
# Google OAuth desktop client (real values, never commit)
|
|
||||||
/google-credentials.json
|
|
||||||
/client_secret_*.json
|
|
||||||
|
|
||||||
# Screenshots & Playwright MCP artifacts
|
# Screenshots & Playwright MCP artifacts
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
/*.png
|
/*.png
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
## Git Commit Rules
|
## Git Commit Rules
|
||||||
|
|
||||||
- Do not make branches, commit in main only
|
|
||||||
- Use conventional commit format: `feat|fix|refactor|docs|test|chore|ci(scope): message`
|
- Use conventional commit format: `feat|fix|refactor|docs|test|chore|ci(scope): message`
|
||||||
- Never use `git add .` or `git add -A`. Always stage specific files by name.
|
- Never use `git add .` or `git add -A`. Always stage specific files by name.
|
||||||
- Don't batch multiple unrelated changes into one commit.
|
- Don't batch multiple unrelated changes into one commit.
|
||||||
|
- Commit early and often. A working 5-line change is better than a pending 200-line change.
|
||||||
@@ -1,3 +1,37 @@
|
|||||||
# margin
|
# margin
|
||||||
|
|
||||||
Write your book. Own every word. A calm, offline studio to write, format, and publish your book to every store and to print.
|
An offline desktop app for writing books and exporting them to print-ready PDF and reflowable EPUB — a focused, free alternative to InDesign for text-first books (novels, non-fiction).
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Tauri 2** (Rust shell) — offline by construction; the entire export pipeline runs locally, nothing hits the network.
|
||||||
|
- **React + TipTap** — the semantic writing surface.
|
||||||
|
- **Typst** (embedded as a Rust crate) — print-ready PDF with real book typography.
|
||||||
|
- **Built-in EPUB3** generator + zip packager.
|
||||||
|
|
||||||
|
One semantic document feeds three renderers — the editor, the PDF, and the EPUB — from a single design-token theme (**Quiet Press**: Literata for the page, Hanken Grotesk for the chrome). Both fonts are bundled.
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm install
|
||||||
|
pnpm tauri dev # the real desktop app: live PDF dock, file dialogs, PDF/EPUB export
|
||||||
|
pnpm dev # browser-only UI preview (Tauri APIs are inert)
|
||||||
|
```
|
||||||
|
|
||||||
|
The first `tauri dev` build is slow because it compiles Typst.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
- A book saves as a single `.margin` file (JSON). Open / Save with ⌘O / ⌘S.
|
||||||
|
- Click the title in the top bar for **Book setup** (title, author, ISBN, trim size, language).
|
||||||
|
- Insert images from the toolbar and choose a placement (inline / full-width / full-page / float).
|
||||||
|
- Export to **PDF** or **EPUB** from the titlebar menu.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `src/editor/` — TipTap editor, extensions, figure block, floating toolbar.
|
||||||
|
- `src/components/` — app shell, preview dock, book-setup panel, pdf.js renderer.
|
||||||
|
- `src/export/` — `typst.ts` (book → Typst source), `epub.ts` (book → EPUB3 files), `exporters.ts`.
|
||||||
|
- `src/store/`, `src/model/` — document model and state.
|
||||||
|
- `src-tauri/src/` — `pdf.rs` (Typst compile), `epub.rs` (zip packager), `project.rs` (file IO).
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"installed": {
|
|
||||||
"client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com",
|
|
||||||
"project_id": "your-project-id",
|
|
||||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
||||||
"token_uri": "https://oauth2.googleapis.com/token",
|
|
||||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
|
||||||
"client_secret": "YOUR_CLIENT_SECRET",
|
|
||||||
"redirect_uris": ["http://127.0.0.1"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/png" href="/margin-mark.png" />
|
<link rel="icon" type="image/png" href="/margin-mark.png" />
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "margin-app",
|
"name": "margin-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.10",
|
"version": "0.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
|
|
||||||
|
|
||||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
||||||
This license is copied below, and is also available with a FAQ at:
|
|
||||||
https://openfontlicense.org
|
|
||||||
|
|
||||||
|
|
||||||
-----------------------------------------------------------
|
|
||||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
||||||
-----------------------------------------------------------
|
|
||||||
|
|
||||||
PREAMBLE
|
|
||||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
||||||
development of collaborative font projects, to support the font creation
|
|
||||||
efforts of academic and linguistic communities, and to provide a free and
|
|
||||||
open framework in which fonts may be shared and improved in partnership
|
|
||||||
with others.
|
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
|
||||||
redistributed and/or sold with any software provided that any reserved
|
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
|
||||||
however, cannot be released under any other type of license. The
|
|
||||||
requirement for fonts to remain under this license does not apply
|
|
||||||
to any document created using the fonts or their derivatives.
|
|
||||||
|
|
||||||
DEFINITIONS
|
|
||||||
"Font Software" refers to the set of files released by the Copyright
|
|
||||||
Holder(s) under this license and clearly marked as such. This may
|
|
||||||
include source files, build scripts and documentation.
|
|
||||||
|
|
||||||
"Reserved Font Name" refers to any names specified as such after the
|
|
||||||
copyright statement(s).
|
|
||||||
|
|
||||||
"Original Version" refers to the collection of Font Software components as
|
|
||||||
distributed by the Copyright Holder(s).
|
|
||||||
|
|
||||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
||||||
or substituting -- in part or in whole -- any of the components of the
|
|
||||||
Original Version, by changing formats or by porting the Font Software to a
|
|
||||||
new environment.
|
|
||||||
|
|
||||||
"Author" refers to any designer, engineer, programmer, technical
|
|
||||||
writer or other person who contributed to the Font Software.
|
|
||||||
|
|
||||||
PERMISSION & CONDITIONS
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
||||||
redistribute, and sell modified and unmodified copies of the Font
|
|
||||||
Software, subject to the following conditions:
|
|
||||||
|
|
||||||
1) Neither the Font Software nor any of its individual components,
|
|
||||||
in Original or Modified Versions, may be sold by itself.
|
|
||||||
|
|
||||||
2) Original or Modified Versions of the Font Software may be bundled,
|
|
||||||
redistributed and/or sold with any software, provided that each copy
|
|
||||||
contains the above copyright notice and this license. These can be
|
|
||||||
included either as stand-alone text files, human-readable headers or
|
|
||||||
in the appropriate machine-readable metadata fields within text or
|
|
||||||
binary files as long as those fields can be easily viewed by the user.
|
|
||||||
|
|
||||||
3) No Modified Version of the Font Software may use the Reserved Font
|
|
||||||
Name(s) unless explicit written permission is granted by the corresponding
|
|
||||||
Copyright Holder. This restriction only applies to the primary font name as
|
|
||||||
presented to the users.
|
|
||||||
|
|
||||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
||||||
Software shall not be used to promote, endorse or advertise any
|
|
||||||
Modified Version, except to acknowledge the contribution(s) of the
|
|
||||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
||||||
permission.
|
|
||||||
|
|
||||||
5) The Font Software, modified or unmodified, in part or in whole,
|
|
||||||
must be distributed entirely under this license, and must not be
|
|
||||||
distributed under any other license. The requirement for fonts to
|
|
||||||
remain under this license does not apply to any document created
|
|
||||||
using the Font Software.
|
|
||||||
|
|
||||||
TERMINATION
|
|
||||||
This license becomes null and void if any of the above conditions are
|
|
||||||
not met.
|
|
||||||
|
|
||||||
DISCLAIMER
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
||||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
||||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
||||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces)
|
|
||||||
|
|
||||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
||||||
This license is copied below, and is also available with a FAQ at:
|
|
||||||
http://scripts.sil.org/OFL
|
|
||||||
|
|
||||||
|
|
||||||
-----------------------------------------------------------
|
|
||||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
||||||
-----------------------------------------------------------
|
|
||||||
|
|
||||||
PREAMBLE
|
|
||||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
||||||
development of collaborative font projects, to support the font creation
|
|
||||||
efforts of academic and linguistic communities, and to provide a free and
|
|
||||||
open framework in which fonts may be shared and improved in partnership
|
|
||||||
with others.
|
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
|
||||||
redistributed and/or sold with any software provided that any reserved
|
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
|
||||||
however, cannot be released under any other type of license. The
|
|
||||||
requirement for fonts to remain under this license does not apply
|
|
||||||
to any document created using the fonts or their derivatives.
|
|
||||||
|
|
||||||
DEFINITIONS
|
|
||||||
"Font Software" refers to the set of files released by the Copyright
|
|
||||||
Holder(s) under this license and clearly marked as such. This may
|
|
||||||
include source files, build scripts and documentation.
|
|
||||||
|
|
||||||
"Reserved Font Name" refers to any names specified as such after the
|
|
||||||
copyright statement(s).
|
|
||||||
|
|
||||||
"Original Version" refers to the collection of Font Software components as
|
|
||||||
distributed by the Copyright Holder(s).
|
|
||||||
|
|
||||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
||||||
or substituting -- in part or in whole -- any of the components of the
|
|
||||||
Original Version, by changing formats or by porting the Font Software to a
|
|
||||||
new environment.
|
|
||||||
|
|
||||||
"Author" refers to any designer, engineer, programmer, technical
|
|
||||||
writer or other person who contributed to the Font Software.
|
|
||||||
|
|
||||||
PERMISSION & CONDITIONS
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
||||||
redistribute, and sell modified and unmodified copies of the Font
|
|
||||||
Software, subject to the following conditions:
|
|
||||||
|
|
||||||
1) Neither the Font Software nor any of its individual components,
|
|
||||||
in Original or Modified Versions, may be sold by itself.
|
|
||||||
|
|
||||||
2) Original or Modified Versions of the Font Software may be bundled,
|
|
||||||
redistributed and/or sold with any software, provided that each copy
|
|
||||||
contains the above copyright notice and this license. These can be
|
|
||||||
included either as stand-alone text files, human-readable headers or
|
|
||||||
in the appropriate machine-readable metadata fields within text or
|
|
||||||
binary files as long as those fields can be easily viewed by the user.
|
|
||||||
|
|
||||||
3) No Modified Version of the Font Software may use the Reserved Font
|
|
||||||
Name(s) unless explicit written permission is granted by the corresponding
|
|
||||||
Copyright Holder. This restriction only applies to the primary font name as
|
|
||||||
presented to the users.
|
|
||||||
|
|
||||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
||||||
Software shall not be used to promote, endorse or advertise any
|
|
||||||
Modified Version, except to acknowledge the contribution(s) of the
|
|
||||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
||||||
permission.
|
|
||||||
|
|
||||||
5) The Font Software, modified or unmodified, in part or in whole,
|
|
||||||
must be distributed entirely under this license, and must not be
|
|
||||||
distributed under any other license. The requirement for fonts to
|
|
||||||
remain under this license does not apply to any document created
|
|
||||||
using the Font Software.
|
|
||||||
|
|
||||||
TERMINATION
|
|
||||||
This license becomes null and void if any of the above conditions are
|
|
||||||
not met.
|
|
||||||
|
|
||||||
DISCLAIMER
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
||||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
||||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
||||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
|
|
||||||
|
|
||||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
||||||
This license is copied below, and is also available with a FAQ at:
|
|
||||||
https://scripts.sil.org/OFL
|
|
||||||
|
|
||||||
|
|
||||||
-----------------------------------------------------------
|
|
||||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
||||||
-----------------------------------------------------------
|
|
||||||
|
|
||||||
PREAMBLE
|
|
||||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
||||||
development of collaborative font projects, to support the font creation
|
|
||||||
efforts of academic and linguistic communities, and to provide a free and
|
|
||||||
open framework in which fonts may be shared and improved in partnership
|
|
||||||
with others.
|
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
|
||||||
redistributed and/or sold with any software provided that any reserved
|
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
|
||||||
however, cannot be released under any other type of license. The
|
|
||||||
requirement for fonts to remain under this license does not apply
|
|
||||||
to any document created using the fonts or their derivatives.
|
|
||||||
|
|
||||||
DEFINITIONS
|
|
||||||
"Font Software" refers to the set of files released by the Copyright
|
|
||||||
Holder(s) under this license and clearly marked as such. This may
|
|
||||||
include source files, build scripts and documentation.
|
|
||||||
|
|
||||||
"Reserved Font Name" refers to any names specified as such after the
|
|
||||||
copyright statement(s).
|
|
||||||
|
|
||||||
"Original Version" refers to the collection of Font Software components as
|
|
||||||
distributed by the Copyright Holder(s).
|
|
||||||
|
|
||||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
||||||
or substituting -- in part or in whole -- any of the components of the
|
|
||||||
Original Version, by changing formats or by porting the Font Software to a
|
|
||||||
new environment.
|
|
||||||
|
|
||||||
"Author" refers to any designer, engineer, programmer, technical
|
|
||||||
writer or other person who contributed to the Font Software.
|
|
||||||
|
|
||||||
PERMISSION & CONDITIONS
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
||||||
redistribute, and sell modified and unmodified copies of the Font
|
|
||||||
Software, subject to the following conditions:
|
|
||||||
|
|
||||||
1) Neither the Font Software nor any of its individual components,
|
|
||||||
in Original or Modified Versions, may be sold by itself.
|
|
||||||
|
|
||||||
2) Original or Modified Versions of the Font Software may be bundled,
|
|
||||||
redistributed and/or sold with any software, provided that each copy
|
|
||||||
contains the above copyright notice and this license. These can be
|
|
||||||
included either as stand-alone text files, human-readable headers or
|
|
||||||
in the appropriate machine-readable metadata fields within text or
|
|
||||||
binary files as long as those fields can be easily viewed by the user.
|
|
||||||
|
|
||||||
3) No Modified Version of the Font Software may use the Reserved Font
|
|
||||||
Name(s) unless explicit written permission is granted by the corresponding
|
|
||||||
Copyright Holder. This restriction only applies to the primary font name as
|
|
||||||
presented to the users.
|
|
||||||
|
|
||||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
||||||
Software shall not be used to promote, endorse or advertise any
|
|
||||||
Modified Version, except to acknowledge the contribution(s) of the
|
|
||||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
||||||
permission.
|
|
||||||
|
|
||||||
5) The Font Software, modified or unmodified, in part or in whole,
|
|
||||||
must be distributed entirely under this license, and must not be
|
|
||||||
distributed under any other license. The requirement for fonts to
|
|
||||||
remain under this license does not apply to any document created
|
|
||||||
using the Font Software.
|
|
||||||
|
|
||||||
TERMINATION
|
|
||||||
This license becomes null and void if any of the above conditions are
|
|
||||||
not met.
|
|
||||||
|
|
||||||
DISCLAIMER
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
||||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
||||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
||||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
Copyright 2014 The Source Serif 4 Project Authors (https://github.com/adobe-fonts/source-serif)
|
|
||||||
|
|
||||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
||||||
This license is copied below, and is also available with a FAQ at:
|
|
||||||
https://openfontlicense.org
|
|
||||||
|
|
||||||
|
|
||||||
-----------------------------------------------------------
|
|
||||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
||||||
-----------------------------------------------------------
|
|
||||||
|
|
||||||
PREAMBLE
|
|
||||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
||||||
development of collaborative font projects, to support the font creation
|
|
||||||
efforts of academic and linguistic communities, and to provide a free and
|
|
||||||
open framework in which fonts may be shared and improved in partnership
|
|
||||||
with others.
|
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
|
||||||
redistributed and/or sold with any software provided that any reserved
|
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
|
||||||
however, cannot be released under any other type of license. The
|
|
||||||
requirement for fonts to remain under this license does not apply
|
|
||||||
to any document created using the fonts or their derivatives.
|
|
||||||
|
|
||||||
DEFINITIONS
|
|
||||||
"Font Software" refers to the set of files released by the Copyright
|
|
||||||
Holder(s) under this license and clearly marked as such. This may
|
|
||||||
include source files, build scripts and documentation.
|
|
||||||
|
|
||||||
"Reserved Font Name" refers to any names specified as such after the
|
|
||||||
copyright statement(s).
|
|
||||||
|
|
||||||
"Original Version" refers to the collection of Font Software components as
|
|
||||||
distributed by the Copyright Holder(s).
|
|
||||||
|
|
||||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
||||||
or substituting -- in part or in whole -- any of the components of the
|
|
||||||
Original Version, by changing formats or by porting the Font Software to a
|
|
||||||
new environment.
|
|
||||||
|
|
||||||
"Author" refers to any designer, engineer, programmer, technical
|
|
||||||
writer or other person who contributed to the Font Software.
|
|
||||||
|
|
||||||
PERMISSION & CONDITIONS
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
||||||
redistribute, and sell modified and unmodified copies of the Font
|
|
||||||
Software, subject to the following conditions:
|
|
||||||
|
|
||||||
1) Neither the Font Software nor any of its individual components,
|
|
||||||
in Original or Modified Versions, may be sold by itself.
|
|
||||||
|
|
||||||
2) Original or Modified Versions of the Font Software may be bundled,
|
|
||||||
redistributed and/or sold with any software, provided that each copy
|
|
||||||
contains the above copyright notice and this license. These can be
|
|
||||||
included either as stand-alone text files, human-readable headers or
|
|
||||||
in the appropriate machine-readable metadata fields within text or
|
|
||||||
binary files as long as those fields can be easily viewed by the user.
|
|
||||||
|
|
||||||
3) No Modified Version of the Font Software may use the Reserved Font
|
|
||||||
Name(s) unless explicit written permission is granted by the corresponding
|
|
||||||
Copyright Holder. This restriction only applies to the primary font name as
|
|
||||||
presented to the users.
|
|
||||||
|
|
||||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
||||||
Software shall not be used to promote, endorse or advertise any
|
|
||||||
Modified Version, except to acknowledge the contribution(s) of the
|
|
||||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
||||||
permission.
|
|
||||||
|
|
||||||
5) The Font Software, modified or unmodified, in part or in whole,
|
|
||||||
must be distributed entirely under this license, and must not be
|
|
||||||
distributed under any other license. The requirement for fonts to
|
|
||||||
remain under this license does not apply to any document created
|
|
||||||
using the Font Software.
|
|
||||||
|
|
||||||
TERMINATION
|
|
||||||
This license becomes null and void if any of the above conditions are
|
|
||||||
not met.
|
|
||||||
|
|
||||||
DISCLAIMER
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
||||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
||||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
||||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
@@ -3089,10 +3089,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"js-sys",
|
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3756,7 +3754,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"webpki-roots",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4635,12 +4632,6 @@ dependencies = [
|
|||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lru-slab"
|
|
||||||
version = "0.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lzma-rs"
|
name = "lzma-rs"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -4713,16 +4704,12 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "margin-app"
|
name = "margin-app"
|
||||||
version = "0.1.7"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"fontdb",
|
|
||||||
"harper-core",
|
"harper-core",
|
||||||
"rand 0.8.6",
|
|
||||||
"reqwest 0.12.28",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
|
||||||
"spellbook",
|
"spellbook",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
@@ -4733,7 +4720,6 @@ dependencies = [
|
|||||||
"typst",
|
"typst",
|
||||||
"typst-as-lib",
|
"typst-as-lib",
|
||||||
"typst-pdf",
|
"typst-pdf",
|
||||||
"url",
|
|
||||||
"zip 2.4.2",
|
"zip 2.4.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5980,61 +5966,6 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quinn"
|
|
||||||
version = "0.11.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"cfg_aliases",
|
|
||||||
"pin-project-lite",
|
|
||||||
"quinn-proto",
|
|
||||||
"quinn-udp",
|
|
||||||
"rustc-hash 2.1.2",
|
|
||||||
"rustls",
|
|
||||||
"socket2",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"web-time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quinn-proto"
|
|
||||||
version = "0.11.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"getrandom 0.3.4",
|
|
||||||
"lru-slab",
|
|
||||||
"rand 0.9.4",
|
|
||||||
"ring",
|
|
||||||
"rustc-hash 2.1.2",
|
|
||||||
"rustls",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"slab",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tinyvec",
|
|
||||||
"tracing",
|
|
||||||
"web-time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quinn-udp"
|
|
||||||
version = "0.5.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
|
||||||
dependencies = [
|
|
||||||
"cfg_aliases",
|
|
||||||
"libc",
|
|
||||||
"once_cell",
|
|
||||||
"socket2",
|
|
||||||
"tracing",
|
|
||||||
"windows-sys 0.60.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -6062,8 +5993,6 @@ version = "0.8.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
|
||||||
"rand_chacha 0.3.1",
|
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
@@ -6104,7 +6033,6 @@ version = "0.6.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.2.17",
|
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6275,44 +6203,6 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "reqwest"
|
|
||||||
version = "0.12.28"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|
||||||
dependencies = [
|
|
||||||
"base64 0.22.1",
|
|
||||||
"bytes",
|
|
||||||
"futures-core",
|
|
||||||
"http",
|
|
||||||
"http-body",
|
|
||||||
"http-body-util",
|
|
||||||
"hyper",
|
|
||||||
"hyper-rustls",
|
|
||||||
"hyper-util",
|
|
||||||
"js-sys",
|
|
||||||
"log",
|
|
||||||
"percent-encoding",
|
|
||||||
"pin-project-lite",
|
|
||||||
"quinn",
|
|
||||||
"rustls",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"serde_urlencoded",
|
|
||||||
"sync_wrapper",
|
|
||||||
"tokio",
|
|
||||||
"tokio-rustls",
|
|
||||||
"tower",
|
|
||||||
"tower-http",
|
|
||||||
"tower-service",
|
|
||||||
"url",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"wasm-bindgen-futures",
|
|
||||||
"web-sys",
|
|
||||||
"webpki-roots",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.4"
|
version = "0.13.4"
|
||||||
@@ -6535,7 +6425,6 @@ version = "1.14.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"web-time",
|
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6884,18 +6773,6 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_urlencoded"
|
|
||||||
version = "0.7.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
|
||||||
dependencies = [
|
|
||||||
"form_urlencoded",
|
|
||||||
"itoa",
|
|
||||||
"ryu",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.21.0"
|
version = "3.21.0"
|
||||||
@@ -7544,7 +7421,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest 0.13.4",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -7733,7 +7610,7 @@ dependencies = [
|
|||||||
"minisign-verify",
|
"minisign-verify",
|
||||||
"osakit",
|
"osakit",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"reqwest 0.13.4",
|
"reqwest",
|
||||||
"rustls",
|
"rustls",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -9272,15 +9149,6 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "webpki-roots"
|
|
||||||
version = "1.0.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
|
||||||
dependencies = [
|
|
||||||
"rustls-pki-types",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "margin-app"
|
name = "margin-app"
|
||||||
version = "0.1.10"
|
version = "0.1.3"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -27,7 +27,6 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
zip = "2"
|
zip = "2"
|
||||||
fontdb = "0.23"
|
|
||||||
typst = "0.14.2"
|
typst = "0.14.2"
|
||||||
typst-pdf = "0.14.2"
|
typst-pdf = "0.14.2"
|
||||||
typst-as-lib = "0.15.5"
|
typst-as-lib = "0.15.5"
|
||||||
@@ -36,10 +35,6 @@ spellbook = "0.4"
|
|||||||
# bump could silently invalidate the patch ("unused") and pull the broken CUDA subtree back in,
|
# bump could silently invalidate the patch ("unused") and pull the broken CUDA subtree back in,
|
||||||
# so bump deliberately and re-audit the stubs. (see [patch.crates-io])
|
# so bump deliberately and re-audit the stubs. (see [patch.crates-io])
|
||||||
harper-core = { version = "=2.5.0", features = ["concurrent"] }
|
harper-core = { version = "=2.5.0", features = ["concurrent"] }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
|
||||||
sha2 = "0.10"
|
|
||||||
rand = "0.8"
|
|
||||||
url = "2"
|
|
||||||
|
|
||||||
# harper-core transitively declares an optional, disabled `burn-cuda` (CUDA backend).
|
# harper-core transitively declares an optional, disabled `burn-cuda` (CUDA backend).
|
||||||
# Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm
|
# Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-destroy",
|
"core:window:allow-destroy",
|
||||||
"core:window:allow-start-dragging",
|
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"dialog:default"
|
"dialog:default",
|
||||||
|
"updater:default",
|
||||||
|
"process:allow-restart"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"$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"]
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
xcuserdata/
|
|
||||||
build/
|
|
||||||
Externals/
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 10 KiB |
@@ -1,116 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
namespace ffi {
|
|
||||||
extern "C" {
|
|
||||||
void start_app();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#include "bindings/bindings.h"
|
|
||||||
|
|
||||||
int main(int argc, char * argv[]) {
|
|
||||||
ffi::start_app();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,474 +0,0 @@
|
|||||||
// !$*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 */;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Workspace
|
|
||||||
version = "1.0">
|
|
||||||
<FileRef
|
|
||||||
location = "self:">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -5,6 +5,9 @@ use std::io::{Read, Write};
|
|||||||
use zip::write::SimpleFileOptions;
|
use zip::write::SimpleFileOptions;
|
||||||
use zip::{CompressionMethod, ZipArchive, ZipWriter};
|
use zip::{CompressionMethod, ZipArchive, ZipWriter};
|
||||||
|
|
||||||
|
static LITERATA: &[u8] = include_bytes!("../../public/fonts/Literata-VF.ttf");
|
||||||
|
static LITERATA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Literata-Italic-VF.ttf");
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
pub struct EpubFile {
|
pub struct EpubFile {
|
||||||
path: String,
|
path: String,
|
||||||
@@ -45,6 +48,14 @@ fn build(files: &[EpubFile]) -> Result<Vec<u8>, String> {
|
|||||||
zip.write_all(&bytes).map_err(|e| e.to_string())?;
|
zip.write_all(&bytes).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (path, bytes) in [
|
||||||
|
("OEBPS/fonts/Literata-VF.ttf", LITERATA),
|
||||||
|
("OEBPS/fonts/Literata-Italic-VF.ttf", LITERATA_ITALIC),
|
||||||
|
] {
|
||||||
|
zip.start_file(path, deflated).map_err(|e| e.to_string())?;
|
||||||
|
zip.write_all(bytes).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(zip.finish().map_err(|e| e.to_string())?.into_inner())
|
Ok(zip.finish().map_err(|e| e.to_string())?.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
use fontdb::{Database, Family, Query};
|
|
||||||
|
|
||||||
fn system_db() -> Database {
|
|
||||||
let mut db = Database::new();
|
|
||||||
db.load_system_fonts();
|
|
||||||
db
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command(async)]
|
|
||||||
pub fn list_system_fonts() -> Vec<String> {
|
|
||||||
let db = system_db();
|
|
||||||
let mut names: Vec<String> = db
|
|
||||||
.faces()
|
|
||||||
.filter_map(|face| face.families.first().map(|(name, _)| name.clone()))
|
|
||||||
.collect();
|
|
||||||
names.sort();
|
|
||||||
names.dedup();
|
|
||||||
names
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn system_font_bytes(family: &str) -> Vec<Vec<u8>> {
|
|
||||||
let db = system_db();
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let styles = [
|
|
||||||
(fontdb::Weight::NORMAL, fontdb::Style::Normal),
|
|
||||||
(fontdb::Weight::NORMAL, fontdb::Style::Italic),
|
|
||||||
(fontdb::Weight::BOLD, fontdb::Style::Normal),
|
|
||||||
(fontdb::Weight::BOLD, fontdb::Style::Italic),
|
|
||||||
];
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
for (weight, style) in styles {
|
|
||||||
let query = Query {
|
|
||||||
families: &[Family::Name(family)],
|
|
||||||
weight,
|
|
||||||
style,
|
|
||||||
..Query::default()
|
|
||||||
};
|
|
||||||
if let Some(id) = db.query(&query) {
|
|
||||||
if seen.insert(id) {
|
|
||||||
if let Some(bytes) = db.with_face_data(id, |data, _index| data.to_vec()) {
|
|
||||||
out.push(bytes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
@@ -1,806 +0,0 @@
|
|||||||
use base64::Engine;
|
|
||||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|
||||||
use rand::RngCore;
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs;
|
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::net::{TcpListener, TcpStream};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::{LazyLock, Mutex};
|
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
||||||
use tauri::{Emitter, Manager};
|
|
||||||
|
|
||||||
const FOLDER_NAME: &str = "margin";
|
|
||||||
const DICTIONARY_NAME: &str = "custom-dictionary.txt";
|
|
||||||
const SCOPES: &str = "openid email https://www.googleapis.com/auth/drive.file";
|
|
||||||
const AUTH_TIMEOUT_SECS: u64 = 120;
|
|
||||||
|
|
||||||
const CREDENTIALS_JSON: &str =
|
|
||||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../google-credentials.json"));
|
|
||||||
|
|
||||||
static HTTP: LazyLock<reqwest::Client> = LazyLock::new(reqwest::Client::new);
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct CredentialsFile {
|
|
||||||
installed: Credentials,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Credentials {
|
|
||||||
client_id: String,
|
|
||||||
client_secret: String,
|
|
||||||
auth_uri: String,
|
|
||||||
token_uri: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_credentials() -> Result<Credentials, String> {
|
|
||||||
let runtime_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../google-credentials.json");
|
|
||||||
let raw = fs::read_to_string(runtime_path).unwrap_or_else(|_| CREDENTIALS_JSON.to_string());
|
|
||||||
let parsed: CredentialsFile = serde_json::from_str(&raw)
|
|
||||||
.map_err(|e| format!("invalid google-credentials.json: {e}"))?;
|
|
||||||
let creds = parsed.installed;
|
|
||||||
if creds.client_id.starts_with("YOUR_CLIENT_ID") || creds.client_secret.starts_with("YOUR_CLIENT_SECRET") {
|
|
||||||
return Err("Google Drive is not set up yet. Add a real OAuth desktop client to google-credentials.json.".to_string());
|
|
||||||
}
|
|
||||||
Ok(creds)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct GDriveState(pub Mutex<Session>);
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct Session {
|
|
||||||
refresh_token: Option<String>,
|
|
||||||
access_token: Option<String>,
|
|
||||||
access_expiry: u64,
|
|
||||||
email: Option<String>,
|
|
||||||
folder_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Default)]
|
|
||||||
struct BackupState {
|
|
||||||
#[serde(default)]
|
|
||||||
refresh_token: Option<String>,
|
|
||||||
email: Option<String>,
|
|
||||||
folder_id: Option<String>,
|
|
||||||
last_backup: Option<u64>,
|
|
||||||
#[serde(default)]
|
|
||||||
files: HashMap<String, FileRecord>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
|
||||||
struct FileRecord {
|
|
||||||
hash: String,
|
|
||||||
drive_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct Status {
|
|
||||||
connected: bool,
|
|
||||||
email: Option<String>,
|
|
||||||
last_backup: Option<u64>,
|
|
||||||
pending: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct RestoreResult {
|
|
||||||
restored: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct BackupOutcome {
|
|
||||||
uploaded: usize,
|
|
||||||
#[serde(flatten)]
|
|
||||||
status: Status,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct RemoteBackup {
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct AuthEvent {
|
|
||||||
ok: bool,
|
|
||||||
error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now() -> u64 {
|
|
||||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dictionary_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
|
||||||
Ok(crate::library::app_data_dir(app)?.join(DICTIONARY_NAME))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
|
||||||
Ok(crate::library::app_data_dir(app)?.join("backup.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_state(app: &tauri::AppHandle) -> BackupState {
|
|
||||||
let path = match state_path(app) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(_) => return BackupState::default(),
|
|
||||||
};
|
|
||||||
match fs::read_to_string(&path) {
|
|
||||||
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
|
|
||||||
Err(_) => BackupState::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_state(app: &tauri::AppHandle, state: &BackupState) -> Result<(), String> {
|
|
||||||
let path = state_path(app)?;
|
|
||||||
let contents = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?;
|
|
||||||
crate::project::atomic_write(&path, contents.as_bytes(), false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn random_b64(bytes: usize) -> String {
|
|
||||||
let mut buf = vec![0u8; bytes];
|
|
||||||
rand::thread_rng().fill_bytes(&mut buf);
|
|
||||||
URL_SAFE_NO_PAD.encode(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pkce_challenge(verifier: &str) -> String {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(verifier.as_bytes());
|
|
||||||
URL_SAFE_NO_PAD.encode(hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_bytes(bytes: &[u8]) -> String {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(bytes);
|
|
||||||
format!("{:x}", hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn urlencode(s: &str) -> String {
|
|
||||||
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drive_escape(s: &str) -> String {
|
|
||||||
s.replace('\\', "\\\\").replace('\'', "\\'")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn safe_book_name(name: &str) -> bool {
|
|
||||||
match name.strip_suffix(".margin") {
|
|
||||||
Some(stem) => !stem.is_empty() && stem.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
|
|
||||||
None => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_http_message(stream: &mut TcpStream, message: &str) {
|
|
||||||
let body = format!(
|
|
||||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>margin</title></head>\
|
|
||||||
<body style=\"font-family:system-ui,sans-serif;text-align:center;padding-top:80px;color:#222\">\
|
|
||||||
<h2>{message}</h2></body></html>"
|
|
||||||
);
|
|
||||||
let response = format!(
|
|
||||||
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
||||||
body.len(),
|
|
||||||
body
|
|
||||||
);
|
|
||||||
let _ = stream.write_all(response.as_bytes());
|
|
||||||
let _ = stream.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn await_code(listener: TcpListener, expected_state: &str, deadline: Instant) -> Result<String, String> {
|
|
||||||
listener.set_nonblocking(true).map_err(|e| e.to_string())?;
|
|
||||||
loop {
|
|
||||||
if Instant::now() > deadline {
|
|
||||||
return Err("Timed out waiting for Google authorization.".to_string());
|
|
||||||
}
|
|
||||||
match listener.accept() {
|
|
||||||
Ok((mut stream, _)) => {
|
|
||||||
stream.set_nonblocking(false).ok();
|
|
||||||
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
|
|
||||||
let mut buf = [0u8; 8192];
|
|
||||||
let n = stream.read(&mut buf).unwrap_or(0);
|
|
||||||
let request = String::from_utf8_lossy(&buf[..n]);
|
|
||||||
let path = request.lines().next().and_then(|line| line.split_whitespace().nth(1)).unwrap_or("");
|
|
||||||
if path == "/favicon.ico" {
|
|
||||||
write_http_message(&mut stream, "margin");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let full = format!("http://127.0.0.1{path}");
|
|
||||||
let parsed = match url::Url::parse(&full) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(_) => {
|
|
||||||
write_http_message(&mut stream, "Waiting for Google…");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut code = None;
|
|
||||||
let mut state = None;
|
|
||||||
let mut error = None;
|
|
||||||
for (key, value) in parsed.query_pairs() {
|
|
||||||
match key.as_ref() {
|
|
||||||
"code" => code = Some(value.into_owned()),
|
|
||||||
"state" => state = Some(value.into_owned()),
|
|
||||||
"error" => error = Some(value.into_owned()),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(error) = error {
|
|
||||||
write_http_message(&mut stream, "Authorization was cancelled. You can close this tab.");
|
|
||||||
return Err(format!("Google authorization failed: {error}"));
|
|
||||||
}
|
|
||||||
match (code, state) {
|
|
||||||
(Some(code), Some(state)) if state == expected_state => {
|
|
||||||
write_http_message(&mut stream, "Connected to margin. You can close this tab.");
|
|
||||||
return Ok(code);
|
|
||||||
}
|
|
||||||
(Some(_), _) => {
|
|
||||||
write_http_message(&mut stream, "Could not verify the request. You can close this tab.");
|
|
||||||
return Err("State mismatch during Google authorization.".to_string());
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
write_http_message(&mut stream, "Waiting for Google…");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
|
||||||
std::thread::sleep(Duration::from_millis(150));
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct TokenResponse {
|
|
||||||
access_token: String,
|
|
||||||
#[serde(default)]
|
|
||||||
refresh_token: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
expires_in: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct UserInfo {
|
|
||||||
#[serde(default)]
|
|
||||||
email: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct DriveFile {
|
|
||||||
id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct FileList {
|
|
||||||
#[serde(default)]
|
|
||||||
files: Vec<DriveFile>,
|
|
||||||
#[serde(default, rename = "nextPageToken")]
|
|
||||||
next_page_token: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_json<T: DeserializeOwned>(resp: reqwest::Response, context: &str) -> Result<T, String> {
|
|
||||||
let status = resp.status();
|
|
||||||
let text = resp.text().await.map_err(|e| e.to_string())?;
|
|
||||||
if !status.is_success() {
|
|
||||||
return Err(format!("{context} failed ({status}): {text}"));
|
|
||||||
}
|
|
||||||
serde_json::from_str(&text).map_err(|e| format!("{context}: could not parse response: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn exchange_code(creds: &Credentials, code: &str, redirect: &str, verifier: &str) -> Result<TokenResponse, String> {
|
|
||||||
let resp = HTTP
|
|
||||||
.post(&creds.token_uri)
|
|
||||||
.form(&[
|
|
||||||
("client_id", creds.client_id.as_str()),
|
|
||||||
("client_secret", creds.client_secret.as_str()),
|
|
||||||
("code", code),
|
|
||||||
("code_verifier", verifier),
|
|
||||||
("grant_type", "authorization_code"),
|
|
||||||
("redirect_uri", redirect),
|
|
||||||
])
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
read_json(resp, "Google token exchange").await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn refresh_access_token(creds: &Credentials, refresh_token: &str) -> Result<TokenResponse, String> {
|
|
||||||
let resp = HTTP
|
|
||||||
.post(&creds.token_uri)
|
|
||||||
.form(&[
|
|
||||||
("client_id", creds.client_id.as_str()),
|
|
||||||
("client_secret", creds.client_secret.as_str()),
|
|
||||||
("refresh_token", refresh_token),
|
|
||||||
("grant_type", "refresh_token"),
|
|
||||||
])
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
read_json(resp, "Google token refresh").await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_email(access_token: &str) -> Result<String, String> {
|
|
||||||
let resp = HTTP
|
|
||||||
.get("https://www.googleapis.com/oauth2/v2/userinfo")
|
|
||||||
.bearer_auth(access_token)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
let info: UserInfo = read_json(resp, "Google account lookup").await?;
|
|
||||||
Ok(info.email.unwrap_or_default())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_folder(access_token: &str) -> Result<String, String> {
|
|
||||||
let query = format!(
|
|
||||||
"name = '{}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false",
|
|
||||||
drive_escape(FOLDER_NAME)
|
|
||||||
);
|
|
||||||
let resp = HTTP
|
|
||||||
.get("https://www.googleapis.com/drive/v3/files")
|
|
||||||
.bearer_auth(access_token)
|
|
||||||
.query(&[
|
|
||||||
("q", query.as_str()),
|
|
||||||
("fields", "files(id,name)"),
|
|
||||||
("spaces", "drive"),
|
|
||||||
("pageSize", "1"),
|
|
||||||
])
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
let list: FileList = read_json(resp, "Drive folder lookup").await?;
|
|
||||||
if let Some(folder) = list.files.into_iter().next() {
|
|
||||||
return Ok(folder.id);
|
|
||||||
}
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"name": FOLDER_NAME,
|
|
||||||
"mimeType": "application/vnd.google-apps.folder",
|
|
||||||
});
|
|
||||||
let resp = HTTP
|
|
||||||
.post("https://www.googleapis.com/drive/v3/files")
|
|
||||||
.bearer_auth(access_token)
|
|
||||||
.query(&[("fields", "id")])
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
let created: DriveFile = read_json(resp, "Drive folder creation").await?;
|
|
||||||
Ok(created.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_file(access_token: &str, folder_id: &str, name: &str) -> Result<Option<DriveFile>, String> {
|
|
||||||
let query = format!(
|
|
||||||
"'{}' in parents and name = '{}' and trashed = false",
|
|
||||||
drive_escape(folder_id),
|
|
||||||
drive_escape(name)
|
|
||||||
);
|
|
||||||
let resp = HTTP
|
|
||||||
.get("https://www.googleapis.com/drive/v3/files")
|
|
||||||
.bearer_auth(access_token)
|
|
||||||
.query(&[
|
|
||||||
("q", query.as_str()),
|
|
||||||
("fields", "files(id,name)"),
|
|
||||||
("spaces", "drive"),
|
|
||||||
("pageSize", "1"),
|
|
||||||
])
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
let list: FileList = read_json(resp, "Drive file lookup").await?;
|
|
||||||
Ok(list.files.into_iter().next())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_in_folder(access_token: &str, folder_id: &str) -> Result<Vec<DriveFile>, String> {
|
|
||||||
let query = format!("'{}' in parents and trashed = false", drive_escape(folder_id));
|
|
||||||
let mut files = Vec::new();
|
|
||||||
let mut page_token: Option<String> = None;
|
|
||||||
loop {
|
|
||||||
let mut request = HTTP
|
|
||||||
.get("https://www.googleapis.com/drive/v3/files")
|
|
||||||
.bearer_auth(access_token)
|
|
||||||
.query(&[
|
|
||||||
("q", query.as_str()),
|
|
||||||
("fields", "nextPageToken,files(id,name)"),
|
|
||||||
("spaces", "drive"),
|
|
||||||
("pageSize", "100"),
|
|
||||||
]);
|
|
||||||
if let Some(token) = &page_token {
|
|
||||||
request = request.query(&[("pageToken", token.as_str())]);
|
|
||||||
}
|
|
||||||
let resp = request.send().await.map_err(|e| e.to_string())?;
|
|
||||||
let FileList { files: page, next_page_token } = read_json(resp, "Drive file list").await?;
|
|
||||||
files.extend(page);
|
|
||||||
match next_page_token {
|
|
||||||
Some(token) => page_token = Some(token),
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(files)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn upload_file(
|
|
||||||
access_token: &str,
|
|
||||||
folder_id: &str,
|
|
||||||
name: &str,
|
|
||||||
bytes: &[u8],
|
|
||||||
existing_id: Option<String>,
|
|
||||||
) -> Result<DriveFile, String> {
|
|
||||||
let boundary = "margin7f3e2a1b9c8d";
|
|
||||||
let metadata = match &existing_id {
|
|
||||||
Some(_) => serde_json::json!({ "name": name }),
|
|
||||||
None => serde_json::json!({ "name": name, "parents": [folder_id] }),
|
|
||||||
};
|
|
||||||
let mut body: Vec<u8> = Vec::new();
|
|
||||||
body.extend_from_slice(format!("--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n").as_bytes());
|
|
||||||
body.extend_from_slice(serde_json::to_string(&metadata).unwrap_or_default().as_bytes());
|
|
||||||
body.extend_from_slice(format!("\r\n--{boundary}\r\nContent-Type: application/octet-stream\r\n\r\n").as_bytes());
|
|
||||||
body.extend_from_slice(bytes);
|
|
||||||
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
|
|
||||||
|
|
||||||
let request = match &existing_id {
|
|
||||||
Some(id) => HTTP.patch(format!("https://www.googleapis.com/upload/drive/v3/files/{id}")),
|
|
||||||
None => HTTP.post("https://www.googleapis.com/upload/drive/v3/files"),
|
|
||||||
};
|
|
||||||
let resp = request
|
|
||||||
.bearer_auth(access_token)
|
|
||||||
.query(&[("uploadType", "multipart"), ("fields", "id")])
|
|
||||||
.header(reqwest::header::CONTENT_TYPE, format!("multipart/related; boundary={boundary}"))
|
|
||||||
.body(body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
read_json(resp, &format!("Drive upload of {name}")).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn download_file(access_token: &str, id: &str) -> Result<Vec<u8>, String> {
|
|
||||||
let resp = HTTP
|
|
||||||
.get(format!("https://www.googleapis.com/drive/v3/files/{id}"))
|
|
||||||
.bearer_auth(access_token)
|
|
||||||
.query(&[("alt", "media")])
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
let status = resp.status();
|
|
||||||
if !status.is_success() {
|
|
||||||
let text = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(format!("Drive download failed ({status}): {text}"));
|
|
||||||
}
|
|
||||||
Ok(resp.bytes().await.map_err(|e| e.to_string())?.to_vec())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn valid_access_token(app: &tauri::AppHandle, state: &GDriveState) -> Result<String, String> {
|
|
||||||
let (access, expiry, refresh) = {
|
|
||||||
let session = state.0.lock().unwrap();
|
|
||||||
(session.access_token.clone(), session.access_expiry, session.refresh_token.clone())
|
|
||||||
};
|
|
||||||
if let Some(token) = access {
|
|
||||||
if now() < expiry {
|
|
||||||
return Ok(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let refresh = refresh.ok_or("Not connected to Google Drive.")?;
|
|
||||||
let creds = load_credentials()?;
|
|
||||||
let tokens = refresh_access_token(&creds, &refresh).await?;
|
|
||||||
let access_token = tokens.access_token.clone();
|
|
||||||
{
|
|
||||||
let mut session = state.0.lock().unwrap();
|
|
||||||
session.access_token = Some(tokens.access_token);
|
|
||||||
session.access_expiry = now() + tokens.expires_in.saturating_sub(60);
|
|
||||||
if let Some(rotated) = &tokens.refresh_token {
|
|
||||||
session.refresh_token = Some(rotated.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(rotated) = &tokens.refresh_token {
|
|
||||||
let mut stored = load_state(app);
|
|
||||||
stored.refresh_token = Some(rotated.clone());
|
|
||||||
let _ = save_state(app, &stored);
|
|
||||||
}
|
|
||||||
Ok(access_token)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_folder_id(app: &tauri::AppHandle, state: &GDriveState, access_token: &str) -> Result<String, String> {
|
|
||||||
{
|
|
||||||
let session = state.0.lock().unwrap();
|
|
||||||
if let Some(folder) = &session.folder_id {
|
|
||||||
return Ok(folder.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let folder_id = ensure_folder(access_token).await?;
|
|
||||||
{
|
|
||||||
let mut session = state.0.lock().unwrap();
|
|
||||||
session.folder_id = Some(folder_id.clone());
|
|
||||||
}
|
|
||||||
let mut stored = load_state(app);
|
|
||||||
stored.folder_id = Some(folder_id.clone());
|
|
||||||
save_state(app, &stored)?;
|
|
||||||
Ok(folder_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_local_files(app: &tauri::AppHandle) -> Result<Vec<(String, Vec<u8>)>, String> {
|
|
||||||
let mut files = Vec::new();
|
|
||||||
let library = crate::library::library_dir(app)?;
|
|
||||||
for entry in fs::read_dir(&library).map_err(|e| e.to_string())? {
|
|
||||||
let path = match entry {
|
|
||||||
Ok(entry) => entry.path(),
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
if path.extension().and_then(|e| e.to_str()) != Some("margin") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let name = match path.file_name().and_then(|n| n.to_str()) {
|
|
||||||
Some(name) if safe_book_name(name) => name.to_string(),
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
let bytes = fs::read(&path).map_err(|e| e.to_string())?;
|
|
||||||
files.push((name, bytes));
|
|
||||||
}
|
|
||||||
let dictionary = dictionary_path(app)?;
|
|
||||||
if dictionary.exists() {
|
|
||||||
let bytes = fs::read(&dictionary).map_err(|e| e.to_string())?;
|
|
||||||
files.push((DICTIONARY_NAME.to_string(), bytes));
|
|
||||||
}
|
|
||||||
Ok(files)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn file_pending(path: &std::path::Path, name: &str, stored: &BackupState, last_backup: u64) -> bool {
|
|
||||||
let record = match stored.files.get(name) {
|
|
||||||
Some(record) => record,
|
|
||||||
None => return true,
|
|
||||||
};
|
|
||||||
let touched = fs::metadata(path)
|
|
||||||
.and_then(|meta| meta.modified())
|
|
||||||
.ok()
|
|
||||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
|
||||||
.map(|since| since.as_secs() > last_backup)
|
|
||||||
.unwrap_or(true);
|
|
||||||
if !touched {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
match fs::read(path) {
|
|
||||||
Ok(bytes) => record.hash != hash_bytes(&bytes),
|
|
||||||
Err(_) => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compute_pending(app: &tauri::AppHandle, stored: &BackupState) -> bool {
|
|
||||||
let last_backup = stored.last_backup.unwrap_or(0);
|
|
||||||
let library = match crate::library::library_dir(app) {
|
|
||||||
Ok(dir) => dir,
|
|
||||||
Err(_) => return false,
|
|
||||||
};
|
|
||||||
if let Ok(entries) = fs::read_dir(&library) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().and_then(|e| e.to_str()) != Some("margin") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
|
||||||
if safe_book_name(name) && file_pending(&path, name, stored, last_backup) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match dictionary_path(app) {
|
|
||||||
Ok(dict) if dict.exists() => file_pending(&dict, DICTIONARY_NAME, stored, last_backup),
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn status_inner(app: &tauri::AppHandle, state: &GDriveState) -> Status {
|
|
||||||
let (connected, email) = {
|
|
||||||
let session = state.0.lock().unwrap();
|
|
||||||
(session.refresh_token.is_some(), session.email.clone())
|
|
||||||
};
|
|
||||||
let stored = load_state(app);
|
|
||||||
let pending = connected && compute_pending(app, &stored);
|
|
||||||
Status {
|
|
||||||
connected,
|
|
||||||
email: email.or(stored.email),
|
|
||||||
last_backup: stored.last_backup,
|
|
||||||
pending,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init_session(app: &tauri::AppHandle) {
|
|
||||||
let state = app.state::<GDriveState>();
|
|
||||||
let stored = load_state(app);
|
|
||||||
let mut session = state.0.lock().unwrap();
|
|
||||||
session.refresh_token = stored.refresh_token;
|
|
||||||
session.email = stored.email;
|
|
||||||
session.folder_id = stored.folder_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn complete_auth(
|
|
||||||
app: &tauri::AppHandle,
|
|
||||||
listener: TcpListener,
|
|
||||||
csrf: String,
|
|
||||||
verifier: String,
|
|
||||||
redirect: String,
|
|
||||||
creds: Credentials,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let code = tauri::async_runtime::spawn_blocking(move || {
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(AUTH_TIMEOUT_SECS);
|
|
||||||
await_code(listener, &csrf, deadline)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())??;
|
|
||||||
|
|
||||||
let tokens = exchange_code(&creds, &code, &redirect, &verifier).await?;
|
|
||||||
let email = fetch_email(&tokens.access_token).await?;
|
|
||||||
let folder_id = ensure_folder(&tokens.access_token).await?;
|
|
||||||
|
|
||||||
{
|
|
||||||
let state = app.state::<GDriveState>();
|
|
||||||
let mut session = state.0.lock().unwrap();
|
|
||||||
if let Some(refresh) = &tokens.refresh_token {
|
|
||||||
session.refresh_token = Some(refresh.clone());
|
|
||||||
}
|
|
||||||
session.access_token = Some(tokens.access_token.clone());
|
|
||||||
session.access_expiry = now() + tokens.expires_in.saturating_sub(60);
|
|
||||||
session.email = Some(email.clone());
|
|
||||||
session.folder_id = Some(folder_id.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut stored = load_state(app);
|
|
||||||
if let Some(refresh) = &tokens.refresh_token {
|
|
||||||
stored.refresh_token = Some(refresh.clone());
|
|
||||||
}
|
|
||||||
stored.email = Some(email);
|
|
||||||
stored.folder_id = Some(folder_id);
|
|
||||||
save_state(app, &stored)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn gdrive_connect(app: tauri::AppHandle) -> Result<String, String> {
|
|
||||||
let creds = load_credentials()?;
|
|
||||||
let verifier = random_b64(64);
|
|
||||||
let challenge = pkce_challenge(&verifier);
|
|
||||||
let csrf = random_b64(24);
|
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
|
|
||||||
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
|
|
||||||
let redirect = format!("http://127.0.0.1:{port}");
|
|
||||||
|
|
||||||
let auth_url = format!(
|
|
||||||
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&code_challenge={}&code_challenge_method=S256&state={}&access_type=offline&prompt=consent",
|
|
||||||
creds.auth_uri,
|
|
||||||
urlencode(&creds.client_id),
|
|
||||||
urlencode(&redirect),
|
|
||||||
urlencode(SCOPES),
|
|
||||||
challenge,
|
|
||||||
urlencode(&csrf),
|
|
||||||
);
|
|
||||||
|
|
||||||
use tauri_plugin_opener::OpenerExt;
|
|
||||||
let _ = app.opener().open_url(auth_url.clone(), None::<&str>);
|
|
||||||
|
|
||||||
let app_bg = app.clone();
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
let event = match complete_auth(&app_bg, listener, csrf, verifier, redirect, creds).await {
|
|
||||||
Ok(()) => AuthEvent { ok: true, error: None },
|
|
||||||
Err(error) => AuthEvent { ok: false, error: Some(error) },
|
|
||||||
};
|
|
||||||
let _ = app_bg.emit("gdrive-auth", event);
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(auth_url)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn gdrive_disconnect(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<Status, String> {
|
|
||||||
let refresh = {
|
|
||||||
let session = state.0.lock().unwrap();
|
|
||||||
session.refresh_token.clone()
|
|
||||||
};
|
|
||||||
if let Some(refresh) = refresh {
|
|
||||||
let _ = HTTP
|
|
||||||
.post("https://oauth2.googleapis.com/revoke")
|
|
||||||
.form(&[("token", refresh.as_str())])
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let mut session = state.0.lock().unwrap();
|
|
||||||
session.refresh_token = None;
|
|
||||||
session.access_token = None;
|
|
||||||
session.access_expiry = 0;
|
|
||||||
session.email = None;
|
|
||||||
}
|
|
||||||
let mut stored = load_state(&app);
|
|
||||||
stored.refresh_token = None;
|
|
||||||
stored.email = None;
|
|
||||||
save_state(&app, &stored)?;
|
|
||||||
Ok(status_inner(&app, &state))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn gdrive_status(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<Status, String> {
|
|
||||||
Ok(status_inner(&app, &state))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<BackupOutcome, String> {
|
|
||||||
let access_token = valid_access_token(&app, &state).await?;
|
|
||||||
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
|
|
||||||
let files = collect_local_files(&app)?;
|
|
||||||
let mut stored = load_state(&app);
|
|
||||||
let mut uploaded = 0;
|
|
||||||
for (name, bytes) in &files {
|
|
||||||
let hash = hash_bytes(bytes);
|
|
||||||
let existing = stored.files.get(name).cloned();
|
|
||||||
if let Some(record) = &existing {
|
|
||||||
if record.hash == hash {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let drive_id = match &existing {
|
|
||||||
Some(record) => Some(record.drive_id.clone()),
|
|
||||||
None => find_file(&access_token, &folder_id, name).await?.map(|file| file.id),
|
|
||||||
};
|
|
||||||
let result = upload_file(&access_token, &folder_id, name, bytes, drive_id).await?;
|
|
||||||
stored.files.insert(name.clone(), FileRecord { hash, drive_id: result.id });
|
|
||||||
uploaded += 1;
|
|
||||||
}
|
|
||||||
if uploaded > 0 {
|
|
||||||
stored.last_backup = Some(now());
|
|
||||||
save_state(&app, &stored)?;
|
|
||||||
}
|
|
||||||
Ok(BackupOutcome {
|
|
||||||
uploaded,
|
|
||||||
status: Status {
|
|
||||||
connected: true,
|
|
||||||
email: stored.email.clone(),
|
|
||||||
last_backup: stored.last_backup,
|
|
||||||
pending: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn gdrive_restore(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<RestoreResult, String> {
|
|
||||||
let access_token = valid_access_token(&app, &state).await?;
|
|
||||||
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
|
|
||||||
let remote = list_in_folder(&access_token, &folder_id).await?;
|
|
||||||
let mut stored = load_state(&app);
|
|
||||||
let mut restored = 0;
|
|
||||||
for file in &remote {
|
|
||||||
let is_dictionary = file.name == DICTIONARY_NAME;
|
|
||||||
let is_book = safe_book_name(&file.name);
|
|
||||||
if !is_dictionary && !is_book {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let bytes = download_file(&access_token, &file.id).await?;
|
|
||||||
let destination = if is_dictionary {
|
|
||||||
dictionary_path(&app)?
|
|
||||||
} else {
|
|
||||||
crate::library::library_dir(&app)?.join(&file.name)
|
|
||||||
};
|
|
||||||
crate::project::atomic_write(&destination, &bytes, false)?;
|
|
||||||
stored.files.insert(
|
|
||||||
file.name.clone(),
|
|
||||||
FileRecord { hash: hash_bytes(&bytes), drive_id: file.id.clone() },
|
|
||||||
);
|
|
||||||
restored += 1;
|
|
||||||
}
|
|
||||||
stored.last_backup = Some(now());
|
|
||||||
save_state(&app, &stored)?;
|
|
||||||
Ok(RestoreResult { restored })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn gdrive_list_backups(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<Vec<RemoteBackup>, String> {
|
|
||||||
let access_token = valid_access_token(&app, &state).await?;
|
|
||||||
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
|
|
||||||
let remote = list_in_folder(&access_token, &folder_id).await?;
|
|
||||||
Ok(remote
|
|
||||||
.into_iter()
|
|
||||||
.filter(|file| safe_book_name(&file.name))
|
|
||||||
.map(|file| RemoteBackup { name: file.name })
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,12 @@
|
|||||||
mod epub;
|
mod epub;
|
||||||
mod fonts;
|
|
||||||
mod gdrive;
|
|
||||||
mod library;
|
mod library;
|
||||||
mod pdf;
|
mod pdf;
|
||||||
mod project;
|
mod project;
|
||||||
mod proofing;
|
mod proofing;
|
||||||
|
|
||||||
#[cfg(desktop)]
|
|
||||||
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder};
|
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder};
|
||||||
#[cfg(desktop)]
|
|
||||||
use tauri::{Emitter, Runtime};
|
use tauri::{Emitter, Runtime};
|
||||||
|
|
||||||
#[cfg(desktop)]
|
|
||||||
fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
|
fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
|
||||||
let menu = Menu::default(handle)?;
|
let menu = Menu::default(handle)?;
|
||||||
let new_book = MenuItemBuilder::with_id("new-book", "New Book")
|
let new_book = MenuItemBuilder::with_id("new-book", "New Book")
|
||||||
@@ -65,30 +60,17 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
let context = tauri::generate_context!();
|
let context = tauri::generate_context!();
|
||||||
|
|
||||||
#[cfg_attr(mobile, allow(unused_mut))]
|
|
||||||
let mut builder = tauri::Builder::default()
|
let mut builder = tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::init());
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_process::init());
|
||||||
|
|
||||||
#[cfg(desktop)]
|
|
||||||
{
|
|
||||||
builder = builder.plugin(tauri_plugin_process::init());
|
|
||||||
if context.config().plugins.0.contains_key("updater") {
|
if context.config().plugins.0.contains_key("updater") {
|
||||||
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
builder = builder
|
builder
|
||||||
.manage(proofing::new_state())
|
.manage(proofing::new_state())
|
||||||
.manage(gdrive::GDriveState::default())
|
|
||||||
.setup(|app| {
|
|
||||||
gdrive::init_session(app.handle());
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
#[cfg(desktop)]
|
|
||||||
{
|
|
||||||
builder = builder
|
|
||||||
.menu(|handle| build_menu(handle))
|
.menu(|handle| build_menu(handle))
|
||||||
.on_menu_event(|app, event| {
|
.on_menu_event(|app, event| {
|
||||||
if matches!(
|
if matches!(
|
||||||
@@ -97,14 +79,10 @@ pub fn run() {
|
|||||||
) {
|
) {
|
||||||
app.emit("menu-action", event.id().0.as_str()).ok();
|
app.emit("menu-action", event.id().0.as_str()).ok();
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
}
|
|
||||||
|
|
||||||
builder
|
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
epub::package_epub,
|
epub::package_epub,
|
||||||
epub::unzip_epub,
|
epub::unzip_epub,
|
||||||
fonts::list_system_fonts,
|
|
||||||
pdf::compile_pdf,
|
pdf::compile_pdf,
|
||||||
project::read_file,
|
project::read_file,
|
||||||
project::write_file,
|
project::write_file,
|
||||||
@@ -114,13 +92,7 @@ pub fn run() {
|
|||||||
library::save_book,
|
library::save_book,
|
||||||
library::delete_book,
|
library::delete_book,
|
||||||
proofing::proof_text,
|
proofing::proof_text,
|
||||||
proofing::remember_word,
|
proofing::remember_word
|
||||||
gdrive::gdrive_connect,
|
|
||||||
gdrive::gdrive_disconnect,
|
|
||||||
gdrive::gdrive_status,
|
|
||||||
gdrive::gdrive_backup,
|
|
||||||
gdrive::gdrive_restore,
|
|
||||||
gdrive::gdrive_list_backups
|
|
||||||
])
|
])
|
||||||
.run(context)
|
.run(context)
|
||||||
.expect("error while running margin");
|
.expect("error while running margin");
|
||||||
|
|||||||
@@ -19,14 +19,12 @@ fn corrupt_summary(stem: &str) -> BookSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
let dir = app
|
||||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
.path()
|
||||||
Ok(dir)
|
.app_data_dir()
|
||||||
}
|
.map_err(|e| e.to_string())?
|
||||||
|
.join("library");
|
||||||
pub(crate) fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
|
||||||
let dir = app_data_dir(app)?.join("library");
|
|
||||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,29 +10,6 @@ static LITERATA: &[u8] = include_bytes!("../../public/fonts/Literata-VF.ttf");
|
|||||||
static LITERATA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Literata-Italic-VF.ttf");
|
static LITERATA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Literata-Italic-VF.ttf");
|
||||||
static HANKEN: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-VF.ttf");
|
static HANKEN: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-VF.ttf");
|
||||||
static HANKEN_ITALIC: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-Italic-VF.ttf");
|
static HANKEN_ITALIC: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-Italic-VF.ttf");
|
||||||
static EB_GARAMOND: &[u8] = include_bytes!("../../public/fonts/EBGaramond-VF.ttf");
|
|
||||||
static EB_GARAMOND_ITALIC: &[u8] = include_bytes!("../../public/fonts/EBGaramond-Italic-VF.ttf");
|
|
||||||
static LORA: &[u8] = include_bytes!("../../public/fonts/Lora-VF.ttf");
|
|
||||||
static LORA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Lora-Italic-VF.ttf");
|
|
||||||
static SOURCE_SERIF: &[u8] = include_bytes!("../../public/fonts/SourceSerif4-VF.ttf");
|
|
||||||
static SOURCE_SERIF_ITALIC: &[u8] = include_bytes!("../../public/fonts/SourceSerif4-Italic-VF.ttf");
|
|
||||||
static FRAUNCES: &[u8] = include_bytes!("../../public/fonts/Fraunces-VF.ttf");
|
|
||||||
static FRAUNCES_ITALIC: &[u8] = include_bytes!("../../public/fonts/Fraunces-Italic-VF.ttf");
|
|
||||||
|
|
||||||
static EB_GARAMOND_FONTS: [&[u8]; 2] = [EB_GARAMOND, EB_GARAMOND_ITALIC];
|
|
||||||
static LORA_FONTS: [&[u8]; 2] = [LORA, LORA_ITALIC];
|
|
||||||
static SOURCE_SERIF_FONTS: [&[u8]; 2] = [SOURCE_SERIF, SOURCE_SERIF_ITALIC];
|
|
||||||
static FRAUNCES_FONTS: [&[u8]; 2] = [FRAUNCES, FRAUNCES_ITALIC];
|
|
||||||
|
|
||||||
fn bundled_font_bytes(id: &str) -> &'static [&'static [u8]] {
|
|
||||||
match id {
|
|
||||||
"eb-garamond" => &EB_GARAMOND_FONTS,
|
|
||||||
"lora" => &LORA_FONTS,
|
|
||||||
"source-serif" => &SOURCE_SERIF_FONTS,
|
|
||||||
"fraunces" => &FRAUNCES_FONTS,
|
|
||||||
_ => &[],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ImageInput {
|
pub struct ImageInput {
|
||||||
@@ -40,12 +17,7 @@ pub struct ImageInput {
|
|||||||
data: String,
|
data: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compile(
|
fn compile(source: String, images: &[ImageInput]) -> Result<(Vec<u8>, String), String> {
|
||||||
source: String,
|
|
||||||
images: &[ImageInput],
|
|
||||||
bundled_fonts: &[String],
|
|
||||||
system_fonts: &[String],
|
|
||||||
) -> Result<(Vec<u8>, String), String> {
|
|
||||||
let mut binaries: Vec<(&str, Vec<u8>)> = Vec::with_capacity(images.len());
|
let mut binaries: Vec<(&str, Vec<u8>)> = Vec::with_capacity(images.len());
|
||||||
for image in images {
|
for image in images {
|
||||||
let bytes = STANDARD
|
let bytes = STANDARD
|
||||||
@@ -54,24 +26,9 @@ fn compile(
|
|||||||
binaries.push((image.path.as_str(), bytes));
|
binaries.push((image.path.as_str(), bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut fonts: Vec<Vec<u8>> = vec![
|
|
||||||
LITERATA.to_vec(),
|
|
||||||
LITERATA_ITALIC.to_vec(),
|
|
||||||
HANKEN.to_vec(),
|
|
||||||
HANKEN_ITALIC.to_vec(),
|
|
||||||
];
|
|
||||||
for id in bundled_fonts {
|
|
||||||
for bytes in bundled_font_bytes(id) {
|
|
||||||
fonts.push(bytes.to_vec());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for family in system_fonts {
|
|
||||||
fonts.extend(crate::fonts::system_font_bytes(family));
|
|
||||||
}
|
|
||||||
|
|
||||||
let engine = TypstEngine::builder()
|
let engine = TypstEngine::builder()
|
||||||
.main_file(source)
|
.main_file(source)
|
||||||
.fonts(fonts)
|
.fonts([LITERATA, LITERATA_ITALIC, HANKEN, HANKEN_ITALIC])
|
||||||
.with_static_file_resolver(binaries)
|
.with_static_file_resolver(binaries)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -93,10 +50,8 @@ pub fn compile_pdf(
|
|||||||
source: String,
|
source: String,
|
||||||
images: Vec<ImageInput>,
|
images: Vec<ImageInput>,
|
||||||
emit_warnings: bool,
|
emit_warnings: bool,
|
||||||
bundled_fonts: Vec<String>,
|
|
||||||
system_fonts: Vec<String>,
|
|
||||||
) -> Result<tauri::ipc::Response, String> {
|
) -> Result<tauri::ipc::Response, String> {
|
||||||
let (bytes, warnings) = compile(source, &images, &bundled_fonts, &system_fonts)?;
|
let (bytes, warnings) = compile(source, &images)?;
|
||||||
if emit_warnings && !warnings.is_empty() {
|
if emit_warnings && !warnings.is_empty() {
|
||||||
app.emit("pdf-warnings", warnings).ok();
|
app.emit("pdf-warnings", warnings).ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,17 +59,11 @@ fn read_words(contents: &str, set: &mut HashSet<String>) {
|
|||||||
for line in contents.lines() {
|
for line in contents.lines() {
|
||||||
let word = line.trim();
|
let word = line.trim();
|
||||||
if !word.is_empty() && !word.starts_with('#') {
|
if !word.is_empty() && !word.starts_with('#') {
|
||||||
set.insert(word.to_lowercase());
|
set.insert(word.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_tech(app: &tauri::AppHandle, set: &mut HashSet<String>) {
|
|
||||||
if let Ok(contents) = dict_file(app, "tech.txt") {
|
|
||||||
read_words(&contents, set);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_custom(app: &tauri::AppHandle) -> HashSet<String> {
|
fn load_custom(app: &tauri::AppHandle) -> HashSet<String> {
|
||||||
let mut set = HashSet::new();
|
let mut set = HashSet::new();
|
||||||
if let Ok(path) = custom_dict_path(app) {
|
if let Ok(path) = custom_dict_path(app) {
|
||||||
@@ -104,10 +98,6 @@ fn is_word_char(c: char) -> bool {
|
|||||||
c.is_alphabetic()
|
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 {
|
fn is_apostrophe(c: char) -> bool {
|
||||||
c == '\'' || c == '\u{2019}'
|
c == '\'' || c == '\u{2019}'
|
||||||
}
|
}
|
||||||
@@ -138,7 +128,7 @@ fn collect_spelling(
|
|||||||
}
|
}
|
||||||
let end = i;
|
let end = i;
|
||||||
let word: String = chars[start..end].iter().collect();
|
let word: String = chars[start..end].iter().collect();
|
||||||
if has_irregular_case(&word) || speller.check(&word) || custom.contains(&word.to_lowercase()) {
|
if speller.check(&word) || custom.contains(&word) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut suggestions = Vec::new();
|
let mut suggestions = Vec::new();
|
||||||
@@ -194,12 +184,10 @@ pub async fn proof_text(
|
|||||||
let state = app.state::<ProofState>();
|
let state = app.state::<ProofState>();
|
||||||
let mut guard = state.lock().map_err(|e| e.to_string())?;
|
let mut guard = state.lock().map_err(|e| e.to_string())?;
|
||||||
if guard.is_none() {
|
if guard.is_none() {
|
||||||
let mut custom = load_custom(&app);
|
|
||||||
load_tech(&app, &mut custom);
|
|
||||||
*guard = Some(Engine {
|
*guard = Some(Engine {
|
||||||
speller: None,
|
speller: None,
|
||||||
harper: None,
|
harper: None,
|
||||||
custom,
|
custom: load_custom(&app),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let engine = guard.as_mut().unwrap();
|
let engine = guard.as_mut().unwrap();
|
||||||
@@ -244,7 +232,7 @@ pub fn remember_word(
|
|||||||
|
|
||||||
let mut guard = state.inner().lock().map_err(|e| e.to_string())?;
|
let mut guard = state.inner().lock().map_err(|e| e.to_string())?;
|
||||||
if let Some(engine) = guard.as_mut() {
|
if let Some(engine) = guard.as_mut() {
|
||||||
engine.custom.insert(word.to_lowercase());
|
engine.custom.insert(word);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Margin",
|
"productName": "margin",
|
||||||
"version": "0.1.10",
|
"version": "0.1.3",
|
||||||
"identifier": "studio.margin.app",
|
"identifier": "studio.margin.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ import { listen } from "@tauri-apps/api/event";
|
|||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { Library } from "./components/Library";
|
import { Library } from "./components/Library";
|
||||||
import { EditorView } from "./components/EditorView";
|
import { EditorView } from "./components/EditorView";
|
||||||
import { BackupSettings } from "./components/BackupSettings";
|
|
||||||
import { ExportPreview } from "./components/ExportPreview";
|
|
||||||
import { useBook } from "./store/useBook";
|
import { useBook } from "./store/useBook";
|
||||||
import { useBackup } from "./store/useBackup";
|
|
||||||
import { useExportPreview } from "./store/useExportPreview";
|
|
||||||
import { isDesktop } from "./ipc";
|
import { isDesktop } from "./ipc";
|
||||||
import { createAndOpenBook, saveBook } from "./library";
|
import { createAndOpenBook, saveBook } from "./library";
|
||||||
import { runExport } from "./export/run";
|
import { runExport } from "./export/run";
|
||||||
@@ -23,9 +19,7 @@ function App() {
|
|||||||
const unlisten = listen<string>("menu-action", (event) => {
|
const unlisten = listen<string>("menu-action", (event) => {
|
||||||
const state = useBook.getState();
|
const state = useBook.getState();
|
||||||
if (event.payload === "new-book") createAndOpenBook(state.openBook, state.setNotice);
|
if (event.payload === "new-book") createAndOpenBook(state.openBook, state.setNotice);
|
||||||
else if (event.payload === "export-pdf") {
|
else if (event.payload === "export-pdf") runExport("pdf");
|
||||||
if (useBook.getState().book) useExportPreview.getState().openPreview();
|
|
||||||
}
|
|
||||||
else if (event.payload === "export-epub") runExport("epub");
|
else if (event.payload === "export-epub") runExport("epub");
|
||||||
else if (event.payload === "check-updates") checkForUpdates(false);
|
else if (event.payload === "check-updates") checkForUpdates(false);
|
||||||
});
|
});
|
||||||
@@ -43,16 +37,9 @@ function App() {
|
|||||||
const win = getCurrentWindow();
|
const win = getCurrentWindow();
|
||||||
const unlisten = win.onCloseRequested(async (event) => {
|
const unlisten = win.onCloseRequested(async (event) => {
|
||||||
const { book, dirty } = useBook.getState();
|
const { book, dirty } = useBook.getState();
|
||||||
const { connected } = useBackup.getState();
|
if (!book || !dirty) return;
|
||||||
if ((!book || !dirty) && !connected) return;
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (book && dirty) await saveBook(book).catch(() => {});
|
await saveBook(book).catch(() => {});
|
||||||
if (connected) {
|
|
||||||
await Promise.race([
|
|
||||||
useBackup.getState().backup(true),
|
|
||||||
new Promise((resolve) => setTimeout(resolve, 8000)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
win.destroy();
|
win.destroy();
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
@@ -60,35 +47,7 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
return book ? <EditorView /> : <Library onOpen={openBook} />;
|
||||||
if (!isDesktop) return;
|
|
||||||
const unlisten = listen<{ ok: boolean; error: string | null }>("gdrive-auth", (event) => {
|
|
||||||
useBackup.getState().handleAuthEvent(event.payload.ok, event.payload.error);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
unlisten.then((stop) => stop());
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDesktop) return;
|
|
||||||
const tick = async () => {
|
|
||||||
await useBackup.getState().refresh();
|
|
||||||
const state = useBackup.getState();
|
|
||||||
if (state.connected && state.pending) state.backup(true);
|
|
||||||
};
|
|
||||||
tick();
|
|
||||||
const id = setInterval(tick, 15 * 60 * 1000);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{book ? <EditorView /> : <Library onOpen={openBook} />}
|
|
||||||
{isDesktop && <BackupSettings />}
|
|
||||||
{isDesktop && <ExportPreview />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { isDesktop } from "./ipc";
|
|
||||||
|
|
||||||
export interface BackupStatus {
|
|
||||||
connected: boolean;
|
|
||||||
email: string | null;
|
|
||||||
lastBackup: number | null;
|
|
||||||
pending: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RestoreResult {
|
|
||||||
restored: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BackupOutcome extends BackupStatus {
|
|
||||||
uploaded: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RemoteBackup {
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const OFFLINE: BackupStatus = { connected: false, email: null, lastBackup: null, pending: false };
|
|
||||||
|
|
||||||
export async function gdriveStatus(): Promise<BackupStatus> {
|
|
||||||
if (!isDesktop) return OFFLINE;
|
|
||||||
return invoke<BackupStatus>("gdrive_status");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function gdriveConnect(): Promise<string> {
|
|
||||||
return invoke<string>("gdrive_connect");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function gdriveDisconnect(): Promise<BackupStatus> {
|
|
||||||
return invoke<BackupStatus>("gdrive_disconnect");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function gdriveBackup(): Promise<BackupOutcome> {
|
|
||||||
return invoke<BackupOutcome>("gdrive_backup");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function gdriveRestore(): Promise<RestoreResult> {
|
|
||||||
return invoke<RestoreResult>("gdrive_restore");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function gdriveListBackups(): Promise<RemoteBackup[]> {
|
|
||||||
if (!isDesktop) return [];
|
|
||||||
return invoke<RemoteBackup[]>("gdrive_list_backups");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatBackupTime(ts: number | null): string {
|
|
||||||
if (!ts) return "Not backed up yet";
|
|
||||||
const diff = Date.now() - ts * 1000;
|
|
||||||
const min = Math.floor(diff / 60000);
|
|
||||||
if (min < 1) return "Backed up just now";
|
|
||||||
if (min < 60) return `Backed up ${min}m ago`;
|
|
||||||
const hr = Math.floor(min / 60);
|
|
||||||
if (hr < 24) return `Backed up ${hr}h ago`;
|
|
||||||
const day = Math.floor(hr / 24);
|
|
||||||
if (day === 1) return "Backed up yesterday";
|
|
||||||
if (day < 7) return `Backed up ${day}d ago`;
|
|
||||||
return `Backed up ${new Date(ts * 1000).toLocaleDateString()}`;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { type BookFonts, fontStack } from "./model/fonts";
|
|
||||||
|
|
||||||
export function applyBookFonts(fonts: BookFonts): void {
|
|
||||||
const root = document.documentElement;
|
|
||||||
root.style.setProperty("--font-book", fontStack(fonts.body));
|
|
||||||
root.style.setProperty("--font-heading", fontStack(fonts.heading));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetBookFonts(): void {
|
|
||||||
const root = document.documentElement;
|
|
||||||
root.style.removeProperty("--font-book");
|
|
||||||
root.style.removeProperty("--font-heading");
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import { Icon } from "./Icon";
|
|||||||
|
|
||||||
interface AddPageMenuProps {
|
interface AddPageMenuProps {
|
||||||
onAdd: (group: "front" | "back", title: string) => void;
|
onAdd: (group: "front" | "back", title: string) => void;
|
||||||
onAddPart: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUPS: { key: "front" | "back"; label: string }[] = [
|
const GROUPS: { key: "front" | "back"; label: string }[] = [
|
||||||
@@ -12,7 +11,7 @@ const GROUPS: { key: "front" | "back"; label: string }[] = [
|
|||||||
{ key: "back", label: "Back matter" },
|
{ key: "back", label: "Back matter" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
|
export function AddPageMenu({ onAdd }: AddPageMenuProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
||||||
const btnRef = useRef<HTMLButtonElement>(null);
|
const btnRef = useRef<HTMLButtonElement>(null);
|
||||||
@@ -43,11 +42,6 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
|
|||||||
onAdd(group, label);
|
onAdd(group, label);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickPart = () => {
|
|
||||||
setOpen(false);
|
|
||||||
onAddPart();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button ref={btnRef} className="add-page" data-open={open} onClick={toggle}>
|
<button ref={btnRef} className="add-page" data-open={open} onClick={toggle}>
|
||||||
@@ -60,12 +54,6 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
|
|||||||
style={{ top: coords.top, left: coords.left }}
|
style={{ top: coords.top, left: coords.left }}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
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) => (
|
{GROUPS.map((g) => (
|
||||||
<div key={g.key} className="add-page-group">
|
<div key={g.key} className="add-page-group">
|
||||||
<div className="add-page-label">{g.label}</div>
|
<div className="add-page-label">{g.label}</div>
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { Icon } from "./Icon";
|
|
||||||
import { useBackup } from "../store/useBackup";
|
|
||||||
import { formatBackupTime } from "../backup";
|
|
||||||
|
|
||||||
export function BackupButton() {
|
|
||||||
const connected = useBackup((s) => s.connected);
|
|
||||||
const pending = useBackup((s) => s.pending);
|
|
||||||
const phase = useBackup((s) => s.phase);
|
|
||||||
const lastBackup = useBackup((s) => s.lastBackup);
|
|
||||||
const openSettings = useBackup((s) => s.openSettings);
|
|
||||||
|
|
||||||
const busy = phase === "working" || phase === "connecting";
|
|
||||||
const error = phase === "error";
|
|
||||||
|
|
||||||
const state = busy ? "working" : error ? "error" : !connected ? "off" : pending ? "pending" : "synced";
|
|
||||||
|
|
||||||
const title =
|
|
||||||
phase === "connecting"
|
|
||||||
? "Waiting for Google authorization…"
|
|
||||||
: state === "working"
|
|
||||||
? "Backing up…"
|
|
||||||
: state === "error"
|
|
||||||
? "Backup needs attention"
|
|
||||||
: state === "off"
|
|
||||||
? "Connect Google Drive"
|
|
||||||
: state === "pending"
|
|
||||||
? "Changes not backed up"
|
|
||||||
: formatBackupTime(lastBackup);
|
|
||||||
|
|
||||||
const onClick = () => {
|
|
||||||
openSettings();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button className="icon-btn backup-btn" data-state={state} onClick={onClick} title={title}>
|
|
||||||
{busy ? (
|
|
||||||
<Icon size={18}>
|
|
||||||
<path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5" />
|
|
||||||
</Icon>
|
|
||||||
) : (
|
|
||||||
<Icon size={18}>
|
|
||||||
<path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.3A3.5 3.5 0 0 1 18 18H7z" />
|
|
||||||
{connected ? <path d="M12 16.5v-5M9.7 12.8l2.3-2.3 2.3 2.3" /> : null}
|
|
||||||
</Icon>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Icon } from "./Icon";
|
|
||||||
import { ConfirmDialog } from "./ConfirmDialog";
|
|
||||||
import { useBackup } from "../store/useBackup";
|
|
||||||
import { formatBackupTime, gdriveListBackups } from "../backup";
|
|
||||||
|
|
||||||
export function BackupSettings() {
|
|
||||||
const open = useBackup((s) => s.settingsOpen);
|
|
||||||
const close = useBackup((s) => s.closeSettings);
|
|
||||||
const connected = useBackup((s) => s.connected);
|
|
||||||
const email = useBackup((s) => s.email);
|
|
||||||
const lastBackup = useBackup((s) => s.lastBackup);
|
|
||||||
const pending = useBackup((s) => s.pending);
|
|
||||||
const phase = useBackup((s) => s.phase);
|
|
||||||
const connect = useBackup((s) => s.connect);
|
|
||||||
const disconnect = useBackup((s) => s.disconnect);
|
|
||||||
const backup = useBackup((s) => s.backup);
|
|
||||||
const restore = useBackup((s) => s.restore);
|
|
||||||
const cancelConnect = useBackup((s) => s.cancelConnect);
|
|
||||||
const openAuthUrl = useBackup((s) => s.openAuthUrl);
|
|
||||||
const copyAuthUrl = useBackup((s) => s.copyAuthUrl);
|
|
||||||
|
|
||||||
const [remoteCount, setRemoteCount] = useState<number | null>(null);
|
|
||||||
const [confirmRestore, setConfirmRestore] = useState(false);
|
|
||||||
const working = phase === "working";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !connected) {
|
|
||||||
setRemoteCount(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let active = true;
|
|
||||||
gdriveListBackups()
|
|
||||||
.then((list) => active && setRemoteCount(list.length))
|
|
||||||
.catch(() => active && setRemoteCount(null));
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [open, connected, lastBackup]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="overlay" onClick={close}>
|
|
||||||
<div className="panel" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="panel-head">
|
|
||||||
<h2>Backup & Sync</h2>
|
|
||||||
<button className="icon-btn" onClick={close} title="Close">
|
|
||||||
<Icon d="M6 6l12 12M18 6L6 18" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="panel-body">
|
|
||||||
{phase === "connecting" ? (
|
|
||||||
<div className="backup-connecting">
|
|
||||||
<div className="backup-waiting">
|
|
||||||
<Icon size={16}>
|
|
||||||
<path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5" />
|
|
||||||
</Icon>
|
|
||||||
<span>Waiting for Google authorization…</span>
|
|
||||||
</div>
|
|
||||||
<p>Finish in the browser tab that opened. If it didn't open, reopen the link or copy it into a browser.</p>
|
|
||||||
<div className="backup-link-row">
|
|
||||||
<button className="backup-text-btn" onClick={cancelConnect}>
|
|
||||||
Stop linking
|
|
||||||
</button>
|
|
||||||
<div className="backup-links">
|
|
||||||
<button className="backup-text-btn" onClick={openAuthUrl}>
|
|
||||||
Open link again
|
|
||||||
</button>
|
|
||||||
<span className="backup-link-sep">·</span>
|
|
||||||
<button className="backup-text-btn" onClick={copyAuthUrl}>
|
|
||||||
Copy link
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : !connected ? (
|
|
||||||
<div className="backup-intro">
|
|
||||||
<p>
|
|
||||||
Connect Google Drive to keep a private backup of every book. margin only ever sees the files it
|
|
||||||
creates in a <strong>margin</strong> folder, never the rest of your Drive.
|
|
||||||
</p>
|
|
||||||
<button className="btn-primary" disabled={working} onClick={connect}>
|
|
||||||
{working ? "Connecting…" : "Connect Google Drive"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="backup-account">
|
|
||||||
<div className="backup-account-info">
|
|
||||||
<span className="field-label">Connected account</span>
|
|
||||||
<span className="backup-email">{email || "Google Drive"}</span>
|
|
||||||
</div>
|
|
||||||
<button className="btn-ghost" disabled={working} onClick={disconnect}>
|
|
||||||
Disconnect
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="backup-status-row">
|
|
||||||
<span className={`backup-dot ${pending ? "is-pending" : "is-synced"}`} />
|
|
||||||
<span>{pending ? "Changes not backed up" : formatBackupTime(lastBackup)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="backup-actions">
|
|
||||||
<button className="btn-primary" disabled={working} onClick={() => backup()}>
|
|
||||||
{working ? "Working…" : "Back up now"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn-ghost"
|
|
||||||
disabled={working || remoteCount === 0}
|
|
||||||
onClick={() => setConfirmRestore(true)}
|
|
||||||
>
|
|
||||||
{remoteCount === null ? "Restore from Drive" : `Restore ${remoteCount} book${remoteCount === 1 ? "" : "s"}`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="backup-note">
|
|
||||||
Backs up automatically when you close margin, and every 15 minutes while there are changes.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{confirmRestore && (
|
|
||||||
<ConfirmDialog
|
|
||||||
title="Restore from Google Drive"
|
|
||||||
message={
|
|
||||||
<>
|
|
||||||
This replaces your local books with the copies in Google Drive. Any local changes that haven't been backed
|
|
||||||
up will be lost.
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
confirmLabel="Restore"
|
|
||||||
onConfirm={() => {
|
|
||||||
setConfirmRestore(false);
|
|
||||||
restore();
|
|
||||||
}}
|
|
||||||
onClose={() => setConfirmRestore(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { generateHTML } from "@tiptap/core";
|
import { generateHTML } from "@tiptap/core";
|
||||||
import { COVER_ID, useBook } from "../store/useBook";
|
import { COVER_ID, useBook } from "../store/useBook";
|
||||||
import { type Chapter, type TrimSize, bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
|
import { type Chapter, type TrimSize, bodyNumber, chapterKind } from "../model/book";
|
||||||
import { editorExtensions } from "../editor/extensions";
|
import { editorExtensions } from "../editor/extensions";
|
||||||
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
|
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
|
||||||
import { compilePdf, isDesktop } from "../ipc";
|
import { compilePdf, isDesktop } from "../ipc";
|
||||||
@@ -71,15 +71,7 @@ function DockHead({ meta }: { meta?: ReactNode }) {
|
|||||||
|
|
||||||
function chapterEyebrow(chapter: Chapter, chapters: Chapter[], idx: number): string {
|
function chapterEyebrow(chapter: Chapter, chapters: Chapter[], idx: number): string {
|
||||||
const kind = chapterKind(chapter);
|
const kind = chapterKind(chapter);
|
||||||
if (kind === "body") return `Chapter ${bodyNumber(chapters, idx) ?? ""}`;
|
return kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
|
||||||
if (kind === "part") return `Part ${partRoman(partNumber(chapters, idx) ?? 0)}`;
|
|
||||||
return kind === "front" ? "Front matter" : "Back matter";
|
|
||||||
}
|
|
||||||
|
|
||||||
function previewTitle(chapter: Chapter): string | undefined {
|
|
||||||
if (chapter.noTitle) return undefined;
|
|
||||||
if (chapterKind(chapter) === "part") return chapter.title || undefined;
|
|
||||||
return chapter.title || "Untitled";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useActiveChapter() {
|
function useActiveChapter() {
|
||||||
@@ -115,8 +107,8 @@ function PdfDock() {
|
|||||||
clearTimeout(timer.current);
|
clearTimeout(timer.current);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
timer.current = setTimeout(() => {
|
timer.current = setTimeout(() => {
|
||||||
const { source, images, fonts } = coverActive ? coverToPdfInputs(book) : chapterToPdfInputs(book, idx);
|
const { source, images } = coverActive ? coverToPdfInputs(book) : chapterToPdfInputs(book, idx);
|
||||||
compilePdf(source, images, false, fonts)
|
compilePdf(source, images)
|
||||||
.then((bytes) => {
|
.then((bytes) => {
|
||||||
setPdf(bytes);
|
setPdf(bytes);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -171,7 +163,7 @@ function HtmlDock() {
|
|||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="p-opener">
|
<div className="p-opener">
|
||||||
<div className="p-num">{eyebrow}</div>
|
<div className="p-num">{eyebrow}</div>
|
||||||
{previewTitle(chapter) !== undefined && <div className="p-title">{previewTitle(chapter)}</div>}
|
{!chapter.noTitle && <div className="p-title">{chapter.title || "Untitled"}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="page-body" dangerouslySetInnerHTML={{ __html: html }} />
|
<div className="page-body" dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
<div className="folio">{idx * 8 + 7}</div>
|
<div className="folio">{idx * 8 + 7}</div>
|
||||||
@@ -215,7 +207,7 @@ function DeviceDock({ device }: { device: Device }) {
|
|||||||
<DeviceFrame
|
<DeviceFrame
|
||||||
device={device}
|
device={device}
|
||||||
eyebrow={eyebrow}
|
eyebrow={eyebrow}
|
||||||
title={previewTitle(chapter)}
|
title={chapter.noTitle ? undefined : chapter.title || "Untitled"}
|
||||||
html={html}
|
html={html}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { Dock } from "./Dock";
|
import { Dock } from "./Dock";
|
||||||
import { ResizeHandle } from "./ResizeHandle";
|
import { ResizeHandle } from "./ResizeHandle";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { Settings } from "./Settings";
|
import { Settings } from "./Settings";
|
||||||
import { BackupButton } from "./BackupButton";
|
|
||||||
import { CoverView } from "./CoverView";
|
import { CoverView } from "./CoverView";
|
||||||
import { FindBar } from "./FindBar";
|
import { FindBar } from "./FindBar";
|
||||||
import { ProofPopover } from "./ProofPopover";
|
import { ProofPopover } from "./ProofPopover";
|
||||||
@@ -13,16 +12,13 @@ import { Editor } from "../editor/Editor";
|
|||||||
import { FloatingToolbar } from "../editor/FloatingToolbar";
|
import { FloatingToolbar } from "../editor/FloatingToolbar";
|
||||||
import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing";
|
import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing";
|
||||||
import { COVER_ID, useBook } from "../store/useBook";
|
import { COVER_ID, useBook } from "../store/useBook";
|
||||||
import { useBackup } from "../store/useBackup";
|
|
||||||
import { useExportPreview } from "../store/useExportPreview";
|
|
||||||
import { useProofing } from "../store/useProofing";
|
import { useProofing } from "../store/useProofing";
|
||||||
import { useTheme } from "../store/useTheme";
|
import { useTheme } from "../store/useTheme";
|
||||||
import { useWidth } from "../store/useWidth";
|
import { useWidth } from "../store/useWidth";
|
||||||
import { WIDTH_OPTIONS } from "../width";
|
import { WIDTH_OPTIONS } from "../width";
|
||||||
import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
|
import { bodyNumber, chapterKind } from "../model/book";
|
||||||
import { saveBook } from "../library";
|
import { saveBook } from "../library";
|
||||||
import { isDesktop } from "../ipc";
|
import { isDesktop } from "../ipc";
|
||||||
import { useCompact } from "../useMedia";
|
|
||||||
import { issueSignature, rememberWord, runProof } from "../proofing";
|
import { issueSignature, rememberWord, runProof } from "../proofing";
|
||||||
import { runExport } from "../export/run";
|
import { runExport } from "../export/run";
|
||||||
|
|
||||||
@@ -42,13 +38,10 @@ export function EditorView() {
|
|||||||
const width = useWidth((s) => s.width);
|
const width = useWidth((s) => s.width);
|
||||||
const setWidth = useWidth((s) => s.setWidth);
|
const setWidth = useWidth((s) => s.setWidth);
|
||||||
|
|
||||||
const isCompact = useCompact();
|
|
||||||
const [editor, setEditor] = useState<TiptapEditor | null>(null);
|
const [editor, setEditor] = useState<TiptapEditor | null>(null);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(!isCompact);
|
const [dock, setDock] = useState(true);
|
||||||
const [dock, setDock] = useState(!isCompact);
|
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [exportOpen, setExportOpen] = useState(false);
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
|
||||||
const [widthOpen, setWidthOpen] = useState(false);
|
const [widthOpen, setWidthOpen] = useState(false);
|
||||||
const [findOpen, setFindOpen] = useState(false);
|
const [findOpen, setFindOpen] = useState(false);
|
||||||
const [findExpanded, setFindExpanded] = useState(false);
|
const [findExpanded, setFindExpanded] = useState(false);
|
||||||
@@ -63,21 +56,6 @@ export function EditorView() {
|
|||||||
const coverActive = activeChapterId === COVER_ID;
|
const coverActive = activeChapterId === COVER_ID;
|
||||||
const proofingAvailable = isDesktop && book?.metadata.language === "en";
|
const proofingAvailable = isDesktop && book?.metadata.language === "en";
|
||||||
|
|
||||||
const toggleSidebar = () =>
|
|
||||||
setSidebarOpen((open) => {
|
|
||||||
if (!open && isCompact) setDock(false);
|
|
||||||
return !open;
|
|
||||||
});
|
|
||||||
const toggleDock = () =>
|
|
||||||
setDock((open) => {
|
|
||||||
if (!open && isCompact) setSidebarOpen(false);
|
|
||||||
return !open;
|
|
||||||
});
|
|
||||||
const closeDrawers = () => {
|
|
||||||
setSidebarOpen(false);
|
|
||||||
setDock(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openFind = useCallback((expanded: boolean) => {
|
const openFind = useCallback((expanded: boolean) => {
|
||||||
setFindExpanded(expanded);
|
setFindExpanded(expanded);
|
||||||
setFindOpen(true);
|
setFindOpen(true);
|
||||||
@@ -101,7 +79,6 @@ export function EditorView() {
|
|||||||
saveBook(current)
|
saveBook(current)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (useBook.getState().book === current) markSaved();
|
if (useBook.getState().book === current) markSaved();
|
||||||
useBackup.getState().refresh();
|
|
||||||
})
|
})
|
||||||
.catch((e) => setNotice(`Save failed: ${e}`));
|
.catch((e) => setNotice(`Save failed: ${e}`));
|
||||||
}, [markSaved, setNotice]);
|
}, [markSaved, setNotice]);
|
||||||
@@ -132,11 +109,6 @@ export function EditorView() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [notice]);
|
}, [notice]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSidebarOpen(!isCompact);
|
|
||||||
setDock(!isCompact);
|
|
||||||
}, [isCompact]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const storage = (editor.storage as unknown as Record<string, ProofingStorage>).proofing;
|
const storage = (editor.storage as unknown as Record<string, ProofingStorage>).proofing;
|
||||||
@@ -172,8 +144,7 @@ export function EditorView() {
|
|||||||
|
|
||||||
const handleExport = (format: "pdf" | "epub") => {
|
const handleExport = (format: "pdf" | "epub") => {
|
||||||
setExportOpen(false);
|
setExportOpen(false);
|
||||||
if (format === "pdf" && isDesktop) useExportPreview.getState().openPreview();
|
runExport(format);
|
||||||
else runExport(format);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!book) return null;
|
if (!book) return null;
|
||||||
@@ -182,32 +153,20 @@ export function EditorView() {
|
|||||||
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
|
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
|
||||||
const kind = chapter ? chapterKind(chapter) : "body";
|
const kind = chapter ? chapterKind(chapter) : "body";
|
||||||
const eyebrow =
|
const eyebrow =
|
||||||
kind === "body"
|
kind === "body" ? `Chapter ${bodyNumber(book.chapters, realIdx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
|
||||||
? `Chapter ${bodyNumber(book.chapters, realIdx) ?? ""}`
|
|
||||||
: kind === "part"
|
|
||||||
? `Part ${partRoman(partNumber(book.chapters, realIdx) ?? 0)}`
|
|
||||||
: kind === "front"
|
|
||||||
? "Front matter"
|
|
||||||
: "Back matter";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app" data-compact={isCompact} data-sidebar={sidebarOpen} data-dock={dock}>
|
<div className="app">
|
||||||
<header className="titlebar" data-tauri-drag-region>
|
<header className="titlebar" data-tauri-drag-region>
|
||||||
<div className="lead">
|
|
||||||
<button className="icon-btn" data-on={sidebarOpen} onClick={toggleSidebar} title="Toggle chapters" aria-label="Toggle chapters">
|
|
||||||
<Icon d="M3 4.5h18v15H3zM9 4.5v15" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button className="doc-title" onClick={() => setSettingsOpen(true)} title="Book setup">
|
<button className="doc-title" onClick={() => setSettingsOpen(true)} title="Book setup">
|
||||||
{book.metadata.title || "Untitled"}
|
{book.metadata.title || "Untitled"}
|
||||||
{dirty && <span className="dirty-dot" />}
|
{dirty && <span className="dirty-dot" />}
|
||||||
</button>
|
</button>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
{isDesktop && !isCompact && <BackupButton />}
|
|
||||||
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)">
|
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)">
|
||||||
<Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" />
|
<Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" />
|
||||||
</button>
|
</button>
|
||||||
{!isCompact && proofingAvailable && !coverActive && (
|
{proofingAvailable && !coverActive && (
|
||||||
<>
|
<>
|
||||||
<button className="icon-btn" data-on={spelling} onClick={toggleSpelling} title="Check spelling">
|
<button className="icon-btn" data-on={spelling} onClick={toggleSpelling} title="Check spelling">
|
||||||
<Icon d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" />
|
<Icon d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" />
|
||||||
@@ -217,7 +176,6 @@ export function EditorView() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isCompact && (
|
|
||||||
<div className="menu-wrap">
|
<div className="menu-wrap">
|
||||||
<button className="icon-btn" data-on={widthOpen} onClick={() => setWidthOpen((v) => !v)} title="Editor width">
|
<button className="icon-btn" data-on={widthOpen} onClick={() => setWidthOpen((v) => !v)} title="Editor width">
|
||||||
<Icon d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" />
|
<Icon d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" />
|
||||||
@@ -242,8 +200,6 @@ export function EditorView() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{!isCompact && (
|
|
||||||
<div className="menu-wrap">
|
<div className="menu-wrap">
|
||||||
<button className="icon-btn" data-on={exportOpen} onClick={() => setExportOpen((v) => !v)} title="Export">
|
<button className="icon-btn" data-on={exportOpen} onClick={() => setExportOpen((v) => !v)} title="Export">
|
||||||
<Icon d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" />
|
<Icon d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" />
|
||||||
@@ -258,8 +214,6 @@ export function EditorView() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{!isCompact && (
|
|
||||||
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? "Light mode" : "Dark mode"}>
|
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? "Light mode" : "Dark mode"}>
|
||||||
{theme === "dark" ? (
|
{theme === "dark" ? (
|
||||||
<Icon>
|
<Icon>
|
||||||
@@ -270,67 +224,9 @@ export function EditorView() {
|
|||||||
<Icon d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
<Icon d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
<button className="icon-btn" data-on={dock} onClick={() => setDock(!dock)} title="Toggle preview">
|
||||||
<button className="icon-btn" data-on={dock} onClick={toggleDock} title="Toggle preview">
|
|
||||||
<Icon d="M3 4.5h18v15H3zM14 4.5v15" />
|
<Icon d="M3 4.5h18v15H3zM14 4.5v15" />
|
||||||
</button>
|
</button>
|
||||||
{isCompact && (
|
|
||||||
<div className="menu-wrap">
|
|
||||||
<button className="icon-btn" data-on={moreOpen} onClick={() => setMoreOpen((v) => !v)} title="More" aria-label="More options">
|
|
||||||
<Icon d="M5 12h.01M12 12h.01M19 12h.01" />
|
|
||||||
</button>
|
|
||||||
{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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -342,8 +238,8 @@ export function EditorView() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="body">
|
<div className="body">
|
||||||
<Sidebar onNavigate={() => isCompact && setSidebarOpen(false)} />
|
<Sidebar />
|
||||||
{sidebarOpen && !isCompact && <ResizeHandle pane="sidebar" />}
|
<ResizeHandle pane="sidebar" />
|
||||||
<main className="editor-pane">
|
<main className="editor-pane">
|
||||||
{coverActive ? (
|
{coverActive ? (
|
||||||
<CoverView />
|
<CoverView />
|
||||||
@@ -352,13 +248,13 @@ export function EditorView() {
|
|||||||
<article className="sheet">
|
<article className="sheet">
|
||||||
<header className="chapter-opener">
|
<header className="chapter-opener">
|
||||||
<div className="chapter-num">{eyebrow}</div>
|
<div className="chapter-num">{eyebrow}</div>
|
||||||
<ChapterTitleInput
|
<input
|
||||||
value={chapter.title}
|
className="chapter-title-input"
|
||||||
placeholder={
|
value={chapter.noTitle ? "" : chapter.title}
|
||||||
chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : kind === "part" ? "Part title (optional)" : "Page title"
|
placeholder={chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : "Page title"}
|
||||||
}
|
spellCheck={false}
|
||||||
disabled={!!chapter.noTitle}
|
disabled={chapter.noTitle}
|
||||||
onChange={(value) => setChapterTitle(chapter.id, value)}
|
onChange={(e) => setChapterTitle(chapter.id, e.target.value)}
|
||||||
/>
|
/>
|
||||||
{(chapter.noTitle || !chapter.title.trim()) && (
|
{(chapter.noTitle || !chapter.title.trim()) && (
|
||||||
<label className="no-title-check">
|
<label className="no-title-check">
|
||||||
@@ -387,11 +283,8 @@ export function EditorView() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
{dock && !isCompact && <ResizeHandle pane="dock" />}
|
{dock && <ResizeHandle pane="dock" />}
|
||||||
{dock && <Dock />}
|
{dock && <Dock />}
|
||||||
{isCompact && (sidebarOpen || dock) && (
|
|
||||||
<button className="drawer-scrim" aria-label="Close panel" onClick={closeDrawers} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{proofPopover && editor && (
|
{proofPopover && editor && (
|
||||||
@@ -431,38 +324,3 @@ export function EditorView() {
|
|||||||
</div>
|
</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)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
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) }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,20 +7,13 @@ import { isDesktop } from "../ipc";
|
|||||||
import { ConfirmDialog } from "./ConfirmDialog";
|
import { ConfirmDialog } from "./ConfirmDialog";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { RowMenu } from "./RowMenu";
|
import { RowMenu } from "./RowMenu";
|
||||||
import { BackupButton } from "./BackupButton";
|
|
||||||
import { useBackup } from "../store/useBackup";
|
|
||||||
import { useBook } from "../store/useBook";
|
|
||||||
|
|
||||||
export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||||
const [books, setBooks] = useState<BookSummary[]>([]);
|
const [books, setBooks] = useState<BookSummary[]>([]);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null);
|
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null);
|
||||||
const notice = useBook((s) => s.notice);
|
|
||||||
const setNotice = useBook((s) => s.setNotice);
|
|
||||||
const restoreNonce = useBackup((s) => s.restoreNonce);
|
|
||||||
const restoreWorking = useBackup((s) => s.phase === "working");
|
|
||||||
const restoreFromDrive = useBackup((s) => s.restoreFromDrive);
|
|
||||||
|
|
||||||
const refresh = () =>
|
const refresh = () =>
|
||||||
listBooks()
|
listBooks()
|
||||||
@@ -30,14 +23,6 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
|
||||||
if (restoreNonce) refresh();
|
|
||||||
}, [restoreNonce]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!notice) return;
|
|
||||||
const timer = setTimeout(() => setNotice(null), 4000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [notice]);
|
|
||||||
|
|
||||||
const handleExample = async () => {
|
const handleExample = async () => {
|
||||||
const copy = exampleBook();
|
const copy = exampleBook();
|
||||||
@@ -79,9 +64,7 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="library">
|
<div className="library">
|
||||||
<header className="library-head" data-tauri-drag-region>
|
<header className="library-head" data-tauri-drag-region />
|
||||||
{isDesktop && <BackupButton />}
|
|
||||||
</header>
|
|
||||||
<div className="shelf">
|
<div className="shelf">
|
||||||
<button className="card card-action" onClick={() => createAndOpenBook(onOpen, setNotice)}>
|
<button className="card card-action" onClick={() => createAndOpenBook(onOpen, setNotice)}>
|
||||||
<Icon d="M12 5v14M5 12h14" size={20} />
|
<Icon d="M12 5v14M5 12h14" size={20} />
|
||||||
@@ -117,14 +100,6 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{loaded && books.length === 0 && isDesktop && (
|
|
||||||
<div className="library-restore">
|
|
||||||
<button className="restore-link" onClick={restoreFromDrive} disabled={restoreWorking}>
|
|
||||||
<Icon d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5" size={15} />
|
|
||||||
{restoreWorking ? "Restoring…" : "Restore from Google Drive"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{notice && (
|
{notice && (
|
||||||
<div className="toast" onClick={() => setNotice(null)}>
|
<div className="toast" onClick={() => setNotice(null)}>
|
||||||
{notice}
|
{notice}
|
||||||
|
|||||||
@@ -6,20 +6,11 @@ interface RowMenuProps {
|
|||||||
label: string;
|
label: string;
|
||||||
onDuplicate?: () => void;
|
onDuplicate?: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onToggleTitle?: () => void;
|
|
||||||
titleHidden?: boolean;
|
|
||||||
onToggleMargin?: () => void;
|
|
||||||
marginHidden?: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidden, onToggleMargin, marginHidden, onOpenChange, className = "" }: RowMenuProps) {
|
export function RowMenu({ label, onDuplicate, onDelete, className = "" }: RowMenuProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onOpenChange?.(open);
|
|
||||||
}, [open]);
|
|
||||||
const [coords, setCoords] = useState({ top: 0, right: 0 });
|
const [coords, setCoords] = useState({ top: 0, right: 0 });
|
||||||
const btnRef = useRef<HTMLButtonElement>(null);
|
const btnRef = useRef<HTMLButtonElement>(null);
|
||||||
const popRef = useRef<HTMLDivElement>(null);
|
const popRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -50,18 +41,6 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const toggleTitle = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setOpen(false);
|
|
||||||
onToggleTitle?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleMargin = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setOpen(false);
|
|
||||||
onToggleMargin?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const duplicate = (e: React.MouseEvent) => {
|
const duplicate = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -89,26 +68,6 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
|
|||||||
{open &&
|
{open &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div ref={popRef} className="row-menu-pop" style={{ top: coords.top, right: coords.right }}>
|
<div ref={popRef} className="row-menu-pop" style={{ top: coords.top, right: coords.right }}>
|
||||||
{onToggleTitle && (
|
|
||||||
<button className="row-menu-item" onClick={toggleTitle}>
|
|
||||||
{titleHidden ? (
|
|
||||||
<Icon d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z" size={14} />
|
|
||||||
) : (
|
|
||||||
<Icon d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24M1 1l22 22" size={14} />
|
|
||||||
)}
|
|
||||||
{titleHidden ? "Show title" : "Hide title"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onToggleMargin && (
|
|
||||||
<button className="row-menu-item" onClick={toggleMargin}>
|
|
||||||
{marginHidden ? (
|
|
||||||
<Icon d="M4 5h16 M12 10v10 M8 16l4 4 4-4" size={14} />
|
|
||||||
) : (
|
|
||||||
<Icon d="M4 5h16 M12 20V10 M8 14l4-4 4 4" size={14} />
|
|
||||||
)}
|
|
||||||
{marginHidden ? "Show top margin" : "Hide top margin"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onDuplicate && (
|
{onDuplicate && (
|
||||||
<button className="row-menu-item" onClick={duplicate}>
|
<button className="row-menu-item" onClick={duplicate}>
|
||||||
<Icon d="M9 9h11v11h-11z M6 15V5h9" size={14} />
|
<Icon d="M9 9h11v11h-11z M6 15V5h9" size={14} />
|
||||||
|
|||||||
@@ -1,18 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useBook } from "../store/useBook";
|
import { useBook } from "../store/useBook";
|
||||||
import { listSystemFonts } from "../ipc";
|
|
||||||
import {
|
|
||||||
type BookFonts,
|
|
||||||
type FontRef,
|
|
||||||
BUNDLED_FONTS,
|
|
||||||
DEFAULT_FONTS,
|
|
||||||
FONT_PAIRINGS,
|
|
||||||
decodeRef,
|
|
||||||
encodeRef,
|
|
||||||
fontStack,
|
|
||||||
pairingFor,
|
|
||||||
} from "../model/fonts";
|
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
const LANGUAGES = [
|
const LANGUAGES = [
|
||||||
@@ -24,8 +12,6 @@ const LANGUAGES = [
|
|||||||
{ value: "pt", label: "Portuguese" },
|
{ value: "pt", label: "Portuguese" },
|
||||||
];
|
];
|
||||||
|
|
||||||
let systemFontCache: string[] | null = null;
|
|
||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
@@ -33,7 +19,6 @@ interface Draft {
|
|||||||
isbn: string;
|
isbn: string;
|
||||||
language: string;
|
language: string;
|
||||||
bleed: boolean;
|
bleed: boolean;
|
||||||
fonts: BookFonts;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||||
@@ -45,30 +30,6 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FontSelect({ value, system, onChange }: { value: FontRef; system: string[]; onChange: (ref: FontRef) => void }) {
|
|
||||||
const extra = value.kind === "system" && !system.includes(value.family) ? [value.family] : [];
|
|
||||||
return (
|
|
||||||
<select value={encodeRef(value)} style={{ fontFamily: fontStack(value) }} onChange={(e) => onChange(decodeRef(e.target.value))}>
|
|
||||||
<optgroup label="Bundled">
|
|
||||||
{BUNDLED_FONTS.map((f) => (
|
|
||||||
<option key={f.id} value={`b:${f.id}`}>
|
|
||||||
{f.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
{(system.length > 0 || extra.length > 0) && (
|
|
||||||
<optgroup label="System">
|
|
||||||
{[...extra, ...system].map((name) => (
|
|
||||||
<option key={name} value={`s:${name}`}>
|
|
||||||
{name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Settings({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
|
export function Settings({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
|
||||||
const book = useBook((s) => s.book);
|
const book = useBook((s) => s.book);
|
||||||
const setMetadata = useBook((s) => s.setMetadata);
|
const setMetadata = useBook((s) => s.setMetadata);
|
||||||
@@ -81,31 +42,16 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
|||||||
isbn: book?.metadata.isbn ?? "",
|
isbn: book?.metadata.isbn ?? "",
|
||||||
language: book?.metadata.language ?? "en",
|
language: book?.metadata.language ?? "en",
|
||||||
bleed: book?.settings.bleed ?? true,
|
bleed: book?.settings.bleed ?? true,
|
||||||
fonts: book?.settings.fonts ?? DEFAULT_FONTS,
|
|
||||||
}));
|
}));
|
||||||
const [system, setSystem] = useState<string[]>(() => systemFontCache ?? []);
|
|
||||||
const [advanced, setAdvanced] = useState(() => pairingFor(draft.fonts) === null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (systemFontCache) return;
|
|
||||||
listSystemFonts().then((fonts) => {
|
|
||||||
systemFontCache = fonts;
|
|
||||||
setSystem(fonts);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!book) return null;
|
if (!book) return null;
|
||||||
|
|
||||||
const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
|
const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
|
||||||
const setFont = (slot: "body" | "heading", ref: FontRef) => set({ fonts: { ...draft.fonts, [slot]: ref } });
|
|
||||||
|
|
||||||
const activePreset = pairingFor(draft.fonts);
|
|
||||||
const usesSystem = draft.fonts.body.kind === "system" || draft.fonts.heading.kind === "system";
|
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
const { bleed, fonts, ...metadata } = draft;
|
const { bleed, ...metadata } = draft;
|
||||||
setMetadata(metadata);
|
setMetadata(metadata);
|
||||||
setSettings({ bleed, fonts });
|
setSettings({ bleed });
|
||||||
onSave();
|
onSave();
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -141,51 +87,6 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className="field">
|
|
||||||
<span className="field-label">Typography</span>
|
|
||||||
<div className="font-presets">
|
|
||||||
{FONT_PAIRINGS.map((p) => (
|
|
||||||
<button
|
|
||||||
key={p.id}
|
|
||||||
type="button"
|
|
||||||
className="font-preset"
|
|
||||||
data-on={activePreset === p.id}
|
|
||||||
onClick={() => set({ fonts: { body: p.body, heading: p.heading } })}
|
|
||||||
>
|
|
||||||
<span className="font-preset-demo" style={{ fontFamily: fontStack(p.heading) }}>
|
|
||||||
Ag
|
|
||||||
</span>
|
|
||||||
<span className="font-preset-name">{p.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button type="button" className="font-advanced" onClick={() => setAdvanced((v) => !v)}>
|
|
||||||
{advanced ? "Hide custom fonts" : "Customize fonts"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{advanced && (
|
|
||||||
<>
|
|
||||||
<Field label="Body font">
|
|
||||||
<FontSelect value={draft.fonts.body} system={system} onChange={(ref) => setFont("body", ref)} />
|
|
||||||
</Field>
|
|
||||||
<Field label="Heading font">
|
|
||||||
<FontSelect value={draft.fonts.heading} system={system} onChange={(ref) => setFont("heading", ref)} />
|
|
||||||
</Field>
|
|
||||||
{usesSystem && (
|
|
||||||
<p className="font-note">System fonts preview here but may not embed in exported PDF or EPUB, so they can look different on other devices.</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="font-sample" style={{ fontFamily: fontStack(draft.fonts.body) }}>
|
|
||||||
<span className="font-sample-title" style={{ fontFamily: fontStack(draft.fonts.heading) }}>
|
|
||||||
Chapter One
|
|
||||||
</span>
|
|
||||||
She read the first line twice, and the whole quiet house seemed to lean in to listen.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="check">
|
<label className="check">
|
||||||
<input type="checkbox" checked={draft.bleed} onChange={(e) => set({ bleed: e.target.checked })} />
|
<input type="checkbox" checked={draft.bleed} onChange={(e) => set({ bleed: e.target.checked })} />
|
||||||
Add bleed for full-page images (print)
|
Add bleed for full-page images (print)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { COVER_ID, useBook } from "../store/useBook";
|
import { COVER_ID, useBook } from "../store/useBook";
|
||||||
import { type Chapter, type ChapterKind, bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
|
import { type Chapter, type ChapterKind, bodyNumber, chapterKind } from "../model/book";
|
||||||
import { AddPageMenu } from "./AddPageMenu";
|
import { AddPageMenu } from "./AddPageMenu";
|
||||||
import { ConfirmDialog } from "./ConfirmDialog";
|
import { ConfirmDialog } from "./ConfirmDialog";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
@@ -10,8 +10,6 @@ interface Row {
|
|||||||
chapter: Chapter;
|
chapter: Chapter;
|
||||||
index: number;
|
index: number;
|
||||||
num: number | null;
|
num: number | null;
|
||||||
part: number | null;
|
|
||||||
indent: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DropTarget {
|
interface DropTarget {
|
||||||
@@ -39,16 +37,13 @@ function formatEdited(ts: number, now: number): string {
|
|||||||
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
export function Sidebar() {
|
||||||
const chapters = useBook((s) => s.book?.chapters ?? []);
|
const chapters = useBook((s) => s.book?.chapters ?? []);
|
||||||
const activeChapterId = useBook((s) => s.activeChapterId);
|
const activeChapterId = useBook((s) => s.activeChapterId);
|
||||||
const setActiveChapter = useBook((s) => s.setActiveChapter);
|
const setActiveChapter = useBook((s) => s.setActiveChapter);
|
||||||
const addChapter = useBook((s) => s.addChapter);
|
const addChapter = useBook((s) => s.addChapter);
|
||||||
const addPage = useBook((s) => s.addPage);
|
const addPage = useBook((s) => s.addPage);
|
||||||
const addPart = useBook((s) => s.addPart);
|
|
||||||
const duplicateChapter = useBook((s) => s.duplicateChapter);
|
const duplicateChapter = useBook((s) => s.duplicateChapter);
|
||||||
const setChapterNoTitle = useBook((s) => s.setChapterNoTitle);
|
|
||||||
const setChapterNoMargin = useBook((s) => s.setChapterNoMargin);
|
|
||||||
const deleteChapter = useBook((s) => s.deleteChapter);
|
const deleteChapter = useBook((s) => s.deleteChapter);
|
||||||
const moveChapter = useBook((s) => s.moveChapter);
|
const moveChapter = useBook((s) => s.moveChapter);
|
||||||
const closeBook = useBook((s) => s.closeBook);
|
const closeBook = useBook((s) => s.closeBook);
|
||||||
@@ -56,7 +51,6 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
||||||
const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null);
|
const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null);
|
||||||
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
|
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
const dropRef = useRef<DropTarget | null>(null);
|
const dropRef = useRef<DropTarget | null>(null);
|
||||||
@@ -68,23 +62,15 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const rows: Row[] = chapters.map((chapter, index) => ({
|
const rows: Row[] = chapters.map((chapter, index) => ({ chapter, index, num: bodyNumber(chapters, index) }));
|
||||||
chapter,
|
|
||||||
index,
|
|
||||||
num: bodyNumber(chapters, index),
|
|
||||||
part: partNumber(chapters, index),
|
|
||||||
indent: chapterKind(chapter) === "body" && chapters.slice(0, index).some((c) => chapterKind(c) === "part"),
|
|
||||||
}));
|
|
||||||
const inBody = (r: Row) => chapterKind(r.chapter) === "body" || chapterKind(r.chapter) === "part";
|
|
||||||
const groups: { kind: ChapterKind; label: string; rows: Row[] }[] = [
|
const groups: { kind: ChapterKind; label: string; rows: Row[] }[] = [
|
||||||
{ kind: "front", label: "Front matter", rows: rows.filter((r) => chapterKind(r.chapter) === "front") },
|
{ kind: "front", label: "Front matter", rows: rows.filter((r) => chapterKind(r.chapter) === "front") },
|
||||||
{ kind: "body", label: "Chapters", rows: rows.filter(inBody) },
|
{ kind: "body", label: "Chapters", rows: rows.filter((r) => chapterKind(r.chapter) === "body") },
|
||||||
{ kind: "back", label: "Back matter", rows: rows.filter((r) => chapterKind(r.chapter) === "back") },
|
{ kind: "back", label: "Back matter", rows: rows.filter((r) => chapterKind(r.chapter) === "back") },
|
||||||
];
|
];
|
||||||
const groupEnd: Record<ChapterKind, number> = {
|
const groupEnd: Record<ChapterKind, number> = {
|
||||||
front: groups[0].rows.length,
|
front: groups[0].rows.length,
|
||||||
body: groups[0].rows.length + groups[1].rows.length,
|
body: groups[0].rows.length + groups[1].rows.length,
|
||||||
part: groups[0].rows.length + groups[1].rows.length,
|
|
||||||
back: rows.length,
|
back: rows.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -139,7 +125,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onRowPointerDown = (e: React.PointerEvent, index: number) => {
|
const onRowPointerDown = (e: React.PointerEvent, index: number) => {
|
||||||
if (e.button !== 0 || chapters[index]?.id === menuOpenId || (e.target as HTMLElement).closest(".row-menu-btn")) return;
|
if (e.button !== 0 || (e.target as HTMLElement).closest(".row-menu-btn")) return;
|
||||||
suppressClick.current = false;
|
suppressClick.current = false;
|
||||||
gesture.current = { from: index, x: e.clientX, y: e.clientY, active: false };
|
gesture.current = { from: index, x: e.clientX, y: e.clientY, active: false };
|
||||||
window.addEventListener("pointermove", onPointerMove);
|
window.addEventListener("pointermove", onPointerMove);
|
||||||
@@ -152,7 +138,6 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActiveChapter(id);
|
setActiveChapter(id);
|
||||||
onNavigate?.();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -164,15 +149,12 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
<button
|
<button
|
||||||
className="cover-item"
|
className="cover-item"
|
||||||
data-active={activeChapterId === COVER_ID}
|
data-active={activeChapterId === COVER_ID}
|
||||||
onClick={() => {
|
onClick={() => setActiveChapter(COVER_ID)}
|
||||||
setActiveChapter(COVER_ID);
|
|
||||||
onNavigate?.();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Icon d="M5 4h11l3 3v13H5zM16 4v4h3" size={15} />
|
<Icon d="M5 4h11l3 3v13H5zM16 4v4h3" size={15} />
|
||||||
<span className="title">Cover</span>
|
<span className="title">Cover</span>
|
||||||
</button>
|
</button>
|
||||||
<AddPageMenu onAdd={addPage} onAddPart={addPart} />
|
<AddPageMenu onAdd={addPage} />
|
||||||
|
|
||||||
<div className="nav-scroll">
|
<div className="nav-scroll">
|
||||||
{groups.map((group) =>
|
{groups.map((group) =>
|
||||||
@@ -180,22 +162,12 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
<div key={group.kind} className="nav-section" data-kind={group.kind}>
|
<div key={group.kind} className="nav-section" data-kind={group.kind}>
|
||||||
<div className="nav-label">{group.label}</div>
|
<div className="nav-label">{group.label}</div>
|
||||||
<ul className="chapters">
|
<ul className="chapters">
|
||||||
{group.rows.map((row, i) => {
|
{group.rows.map((row, i) => (
|
||||||
const isPart = chapterKind(row.chapter) === "part";
|
|
||||||
const partLabel = `Part ${partRoman(row.part ?? 0)}`;
|
|
||||||
const rowLabel = isPart
|
|
||||||
? row.chapter.title
|
|
||||||
? `${partLabel}: ${row.chapter.title}`
|
|
||||||
: partLabel
|
|
||||||
: row.chapter.title || "Untitled";
|
|
||||||
return (
|
|
||||||
<li
|
<li
|
||||||
key={row.chapter.id}
|
key={row.chapter.id}
|
||||||
className="chapter"
|
className="chapter"
|
||||||
data-idx={row.index}
|
data-idx={row.index}
|
||||||
data-kind={group.kind}
|
data-kind={group.kind}
|
||||||
data-part={isPart}
|
|
||||||
data-indent={row.indent}
|
|
||||||
data-active={row.chapter.id === activeChapterId}
|
data-active={row.chapter.id === activeChapterId}
|
||||||
data-dragging={dragIndex === row.index}
|
data-dragging={dragIndex === row.index}
|
||||||
data-drop-before={dropTarget?.kind === group.kind && dropTarget.index === row.index}
|
data-drop-before={dropTarget?.kind === group.kind && dropTarget.index === row.index}
|
||||||
@@ -206,10 +178,8 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
<span className="grip" title="Drag to reorder">
|
<span className="grip" title="Drag to reorder">
|
||||||
<Icon d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" size={14} />
|
<Icon d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" size={14} />
|
||||||
</span>
|
</span>
|
||||||
{!isPart && <span className="num">{row.num ?? ""}</span>}
|
<span className="num">{row.num ?? ""}</span>
|
||||||
<span className="text">
|
<span className="text">
|
||||||
{isPart && <span className="part-eyebrow">Part {partRoman(row.part ?? 0)}</span>}
|
|
||||||
{(!isPart || row.chapter.title) && (
|
|
||||||
<span
|
<span
|
||||||
className="title"
|
className="title"
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
@@ -220,26 +190,17 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
>
|
>
|
||||||
{row.chapter.title || "Untitled"}
|
{row.chapter.title || "Untitled"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
{row.chapter.id === activeChapterId && (
|
{row.chapter.id === activeChapterId && (
|
||||||
<span className="meta">Edited {formatEdited(row.chapter.updatedAt, now)}</span>
|
<span className="meta">Edited {formatEdited(row.chapter.updatedAt, now)}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<RowMenu
|
<RowMenu
|
||||||
label="Page options"
|
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)}
|
onDuplicate={() => duplicateChapter(row.chapter.id)}
|
||||||
onDelete={() => setPendingDelete({ id: row.chapter.id, title: rowLabel })}
|
onDelete={() => setPendingDelete({ id: row.chapter.id, title: row.chapter.title || "Untitled" })}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
{!group.rows.length && (
|
{!group.rows.length && (
|
||||||
<li className="chapter-drop" data-on={dropTarget?.kind === group.kind}>
|
<li className="chapter-drop" data-on={dropTarget?.kind === group.kind}>
|
||||||
Drop here
|
Drop here
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { useEffect, useReducer, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useReducer, useRef, useState, type ReactNode } from "react";
|
||||||
import type { Editor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
import { Icon } from "../components/Icon";
|
import { Icon } from "../components/Icon";
|
||||||
import type { Alignment } from "./align";
|
|
||||||
|
|
||||||
const ALIGN_ICONS: Record<Alignment, string> = {
|
|
||||||
left: "M4 7h16M4 12h10M4 17h13",
|
|
||||||
center: "M4 7h16M7 12h10M5 17h14",
|
|
||||||
right: "M4 7h16M10 12h10M7 17h13",
|
|
||||||
};
|
|
||||||
|
|
||||||
function readImage(file: File): Promise<string> {
|
function readImage(file: File): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -30,7 +23,6 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
|||||||
const linkInputRef = useRef<HTMLInputElement>(null);
|
const linkInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [linkOpen, setLinkOpen] = useState(false);
|
const [linkOpen, setLinkOpen] = useState(false);
|
||||||
const [linkValue, setLinkValue] = useState("");
|
const [linkValue, setLinkValue] = useState("");
|
||||||
const [alignOpen, setAlignOpen] = useState(false);
|
|
||||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -75,13 +67,6 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
|||||||
setLinkOpen(false);
|
setLinkOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const align: Alignment = (editor.getAttributes("heading").align ?? editor.getAttributes("paragraph").align) || "left";
|
|
||||||
|
|
||||||
const setAlign = (value: Alignment) => {
|
|
||||||
editor.chain().focus().setTextAlign(value).run();
|
|
||||||
setAlignOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const tool = (active: boolean, onClick: () => void, title: string, content: ReactNode) => (
|
const tool = (active: boolean, onClick: () => void, title: string, content: ReactNode) => (
|
||||||
<button
|
<button
|
||||||
className="tool"
|
className="tool"
|
||||||
@@ -102,28 +87,6 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
|||||||
{tool(editor.isActive("heading", { level: 2 }), () => editor.chain().focus().toggleHeading({ level: 2 }).run(), "Heading", <Icon d="M5 5v14M5 12h8M13 5v14" />)}
|
{tool(editor.isActive("heading", { level: 2 }), () => editor.chain().focus().toggleHeading({ level: 2 }).run(), "Heading", <Icon d="M5 5v14M5 12h8M13 5v14" />)}
|
||||||
{tool(editor.isActive("blockquote"), () => editor.chain().focus().toggleBlockquote().run(), "Quote", <Icon d="M7 8h4v4a4 4 0 0 1-4 4M14 8h4v4a4 4 0 0 1-4 4" />)}
|
{tool(editor.isActive("blockquote"), () => editor.chain().focus().toggleBlockquote().run(), "Quote", <Icon d="M7 8h4v4a4 4 0 0 1-4 4M14 8h4v4a4 4 0 0 1-4 4" />)}
|
||||||
{tool(editor.isActive("bulletList"), () => editor.chain().focus().toggleBulletList().run(), "Bulleted list", <Icon d="M8 6h12M8 12h12M8 18h12M3.5 6h.01M3.5 12h.01M3.5 18h.01" />)}
|
{tool(editor.isActive("bulletList"), () => editor.chain().focus().toggleBulletList().run(), "Bulleted list", <Icon d="M8 6h12M8 12h12M8 18h12M3.5 6h.01M3.5 12h.01M3.5 18h.01" />)}
|
||||||
<span className="tool-wrap">
|
|
||||||
{tool(alignOpen || align !== "left", () => setAlignOpen((v) => !v), "Align", <Icon d={ALIGN_ICONS[align]} />)}
|
|
||||||
{alignOpen && (
|
|
||||||
<>
|
|
||||||
<div className="link-pop-backdrop" onMouseDown={() => setAlignOpen(false)} />
|
|
||||||
<div className="align-pop" onMouseDown={(e) => e.stopPropagation()}>
|
|
||||||
{(["left", "center", "right"] as Alignment[]).map((value) => (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
className="tool"
|
|
||||||
data-on={align === value}
|
|
||||||
title={`Align ${value}`}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => setAlign(value)}
|
|
||||||
>
|
|
||||||
<Icon d={ALIGN_ICONS[value]} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="tool-sep" />
|
<span className="tool-sep" />
|
||||||
<span className="tool-wrap">
|
<span className="tool-wrap">
|
||||||
{tool(editor.isActive("link") || linkOpen, () => (linkOpen ? setLinkOpen(false) : openLink()), "Link", <Icon d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" />)}
|
{tool(editor.isActive("link") || linkOpen, () => (linkOpen ? setLinkOpen(false) : openLink()), "Link", <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" />)}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Extension } from "@tiptap/core";
|
|
||||||
|
|
||||||
export type Alignment = "left" | "center" | "right";
|
|
||||||
|
|
||||||
const ALIGNED_TYPES = ["paragraph", "heading"];
|
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
|
||||||
interface Commands<ReturnType> {
|
|
||||||
textAlign: {
|
|
||||||
setTextAlign: (align: Alignment) => ReturnType;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TextAlign = Extension.create({
|
|
||||||
name: "textAlign",
|
|
||||||
|
|
||||||
addGlobalAttributes() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
types: ALIGNED_TYPES,
|
|
||||||
attributes: {
|
|
||||||
align: {
|
|
||||||
default: null,
|
|
||||||
keepOnSplit: true,
|
|
||||||
parseHTML: (el) => {
|
|
||||||
const value = el.style.textAlign || el.getAttribute("data-align");
|
|
||||||
return value === "center" || value === "right" ? value : null;
|
|
||||||
},
|
|
||||||
renderHTML: (attrs) =>
|
|
||||||
attrs.align ? { "data-align": attrs.align, style: `text-align: ${attrs.align}` } : {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
addCommands() {
|
|
||||||
return {
|
|
||||||
setTextAlign:
|
|
||||||
(align) =>
|
|
||||||
({ tr, dispatch }) => {
|
|
||||||
const value = align === "left" ? null : align;
|
|
||||||
const { from, to } = tr.selection;
|
|
||||||
let changed = false;
|
|
||||||
tr.doc.nodesBetween(from, to, (node, pos) => {
|
|
||||||
if (ALIGNED_TYPES.includes(node.type.name) && node.attrs.align !== value) {
|
|
||||||
if (dispatch) tr.setNodeAttribute(pos, "align", value);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return changed;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addKeyboardShortcuts() {
|
|
||||||
return {
|
|
||||||
"Mod-Shift-l": () => this.editor.commands.setTextAlign("left"),
|
|
||||||
"Mod-Shift-e": () => this.editor.commands.setTextAlign("center"),
|
|
||||||
"Mod-Shift-r": () => this.editor.commands.setTextAlign("right"),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -3,10 +3,8 @@ import StarterKit from "@tiptap/starter-kit";
|
|||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
import { Figure } from "./figure";
|
import { Figure } from "./figure";
|
||||||
import { ParagraphIndent } from "./indent";
|
import { ParagraphIndent } from "./indent";
|
||||||
import { TextAlign } from "./align";
|
|
||||||
import { SearchHighlight } from "./search";
|
import { SearchHighlight } from "./search";
|
||||||
import { Proofing } from "./proofing";
|
import { Proofing } from "./proofing";
|
||||||
import { Paste } from "./paste";
|
|
||||||
|
|
||||||
export const editorExtensions: Extensions = [
|
export const editorExtensions: Extensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
@@ -19,8 +17,6 @@ export const editorExtensions: Extensions = [
|
|||||||
}),
|
}),
|
||||||
Figure,
|
Figure,
|
||||||
ParagraphIndent,
|
ParagraphIndent,
|
||||||
TextAlign,
|
|
||||||
SearchHighlight,
|
SearchHighlight,
|
||||||
Proofing,
|
Proofing,
|
||||||
Paste,
|
|
||||||
];
|
];
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Extension } from "@tiptap/core";
|
|
||||||
import type { JSONContent } from "@tiptap/core";
|
|
||||||
import { Plugin } from "@tiptap/pm/state";
|
|
||||||
|
|
||||||
function readImage(file: File): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => resolve(reader.result as string);
|
|
||||||
reader.onerror = reject;
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function imageFiles(data: DataTransfer | null): File[] {
|
|
||||||
if (!data) return [];
|
|
||||||
const files = Array.from(data.files).filter((f) => f.type.startsWith("image/"));
|
|
||||||
if (files.length) return files;
|
|
||||||
return Array.from(data.items)
|
|
||||||
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
|
|
||||||
.map((item) => item.getAsFile())
|
|
||||||
.filter((f): f is File => f !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function plainContent(text: string): JSONContent[] {
|
|
||||||
return text
|
|
||||||
.replace(/\r\n?/g, "\n")
|
|
||||||
.split(/\n{2,}/)
|
|
||||||
.map((block) => {
|
|
||||||
const content: JSONContent[] = [];
|
|
||||||
block.split("\n").forEach((line, i) => {
|
|
||||||
if (i > 0) content.push({ type: "hardBreak" });
|
|
||||||
if (line) content.push({ type: "text", text: line });
|
|
||||||
});
|
|
||||||
return content.length ? { type: "paragraph", content } : { type: "paragraph" };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Paste = Extension.create({
|
|
||||||
name: "pasteHandler",
|
|
||||||
|
|
||||||
addKeyboardShortcuts() {
|
|
||||||
const editor = this.editor;
|
|
||||||
return {
|
|
||||||
"Mod-Shift-v": () => {
|
|
||||||
navigator.clipboard
|
|
||||||
.readText()
|
|
||||||
.then((text) => {
|
|
||||||
if (text) editor.chain().focus().insertContent(plainContent(text)).run();
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
const editor = this.editor;
|
|
||||||
return [
|
|
||||||
new Plugin({
|
|
||||||
props: {
|
|
||||||
handlePaste(_view, event) {
|
|
||||||
const files = imageFiles(event.clipboardData);
|
|
||||||
if (!files.length) return false;
|
|
||||||
event.preventDefault();
|
|
||||||
files.forEach((file) =>
|
|
||||||
readImage(file).then((src) =>
|
|
||||||
editor.chain().focus().insertContent({ type: "figure", attrs: { src, placement: "full-width" } }).run(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -37,11 +37,10 @@ interface ProofingState {
|
|||||||
|
|
||||||
export const proofingKey = new PluginKey<ProofingState>("proofing");
|
export const proofingKey = new PluginKey<ProofingState>("proofing");
|
||||||
|
|
||||||
function buildDecorations(doc: PMNode, issues: ProofIssue[], cursor: number | null): DecorationSet {
|
function buildDecorations(doc: PMNode, issues: ProofIssue[]): DecorationSet {
|
||||||
const size = doc.content.size;
|
const size = doc.content.size;
|
||||||
const decos = issues
|
const decos = issues
|
||||||
.filter((i) => i.from < i.to && i.to <= size)
|
.filter((i) => i.from < i.to && i.to <= size)
|
||||||
.filter((i) => cursor === null || cursor < i.from || cursor > i.to)
|
|
||||||
.map((i) => Decoration.inline(i.from, i.to, { class: `proof-mark sev-${severityFor(i.category)}` }));
|
.map((i) => Decoration.inline(i.from, i.to, { class: `proof-mark sev-${severityFor(i.category)}` }));
|
||||||
return DecorationSet.create(doc, decos);
|
return DecorationSet.create(doc, decos);
|
||||||
}
|
}
|
||||||
@@ -74,11 +73,9 @@ export const Proofing = Extension.create<unknown, ProofingStorage>({
|
|||||||
state: {
|
state: {
|
||||||
init: () => ({ issues: [], decorations: DecorationSet.empty }),
|
init: () => ({ issues: [], decorations: DecorationSet.empty }),
|
||||||
apply(tr, value, _old, newState) {
|
apply(tr, value, _old, newState) {
|
||||||
const sel = newState.selection;
|
|
||||||
const cursor = sel.empty ? sel.head : null;
|
|
||||||
const meta = tr.getMeta(proofingKey) as { issues: ProofIssue[] } | { clear: true } | undefined;
|
const meta = tr.getMeta(proofingKey) as { issues: ProofIssue[] } | { clear: true } | undefined;
|
||||||
if (meta && "issues" in meta) {
|
if (meta && "issues" in meta) {
|
||||||
return { issues: meta.issues, decorations: buildDecorations(newState.doc, meta.issues, cursor) };
|
return { issues: meta.issues, decorations: buildDecorations(newState.doc, meta.issues) };
|
||||||
}
|
}
|
||||||
if (meta && "clear" in meta) {
|
if (meta && "clear" in meta) {
|
||||||
return { issues: [], decorations: DecorationSet.empty };
|
return { issues: [], decorations: DecorationSet.empty };
|
||||||
@@ -87,10 +84,7 @@ export const Proofing = Extension.create<unknown, ProofingStorage>({
|
|||||||
const issues = value.issues
|
const issues = value.issues
|
||||||
.map((i) => ({ ...i, from: tr.mapping.map(i.from), to: tr.mapping.map(i.to) }))
|
.map((i) => ({ ...i, from: tr.mapping.map(i.from), to: tr.mapping.map(i.to) }))
|
||||||
.filter((i) => i.to > i.from);
|
.filter((i) => i.to > i.from);
|
||||||
return { issues, decorations: buildDecorations(newState.doc, issues, cursor) };
|
return { issues, decorations: value.decorations.map(tr.mapping, tr.doc) };
|
||||||
}
|
|
||||||
if (tr.selectionSet && value.issues.length) {
|
|
||||||
return { issues: value.issues, decorations: buildDecorations(newState.doc, value.issues, cursor) };
|
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { JSONContent } from "@tiptap/core";
|
import type { JSONContent } from "@tiptap/core";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { type Book, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement, partNumber, partRoman } from "../model/book";
|
import { type Book, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
|
||||||
import { fontStack, fontsUsed } from "../model/fonts";
|
|
||||||
|
|
||||||
export interface EpubFile {
|
export interface EpubFile {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -39,56 +38,6 @@ function mediaType(path: string): string {
|
|||||||
return "application/octet-stream";
|
return "application/octet-stream";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FontAssets {
|
|
||||||
files: EpubFile[];
|
|
||||||
faces: string;
|
|
||||||
manifest: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64FromBuffer(buffer: ArrayBuffer): string {
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
let binary = "";
|
|
||||||
const chunk = 0x8000;
|
|
||||||
for (let i = 0; i < bytes.length; i += chunk) {
|
|
||||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
||||||
}
|
|
||||||
return btoa(binary);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFontBase64(file: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/fonts/${file}`);
|
|
||||||
if (!res.ok) return null;
|
|
||||||
return base64FromBuffer(await res.arrayBuffer());
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFontAssets(book: Book): Promise<FontAssets> {
|
|
||||||
const used = fontsUsed(book.settings.fonts).bundled;
|
|
||||||
const files: EpubFile[] = [];
|
|
||||||
const faces: string[] = [];
|
|
||||||
const manifest: string[] = [];
|
|
||||||
let n = 0;
|
|
||||||
for (const font of used) {
|
|
||||||
const variants = [{ file: font.regular, style: "normal" }];
|
|
||||||
if (font.italic) variants.push({ file: font.italic, style: "italic" });
|
|
||||||
for (const variant of variants) {
|
|
||||||
const data = await fetchFontBase64(variant.file);
|
|
||||||
if (!data) continue;
|
|
||||||
const href = `fonts/${variant.file}`;
|
|
||||||
const id = `font-${++n}`;
|
|
||||||
files.push({ path: `OEBPS/${href}`, data, encoding: "base64" });
|
|
||||||
manifest.push(`<item id="${id}" href="${attr(href)}" media-type="font/ttf"/>`);
|
|
||||||
faces.push(
|
|
||||||
`@font-face {\n font-family: "${font.family}";\n font-style: ${variant.style};\n font-weight: ${font.weight};\n src: url("${href}");\n}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { files, faces: faces.join("\n\n"), manifest };
|
|
||||||
}
|
|
||||||
|
|
||||||
function inline(node: JSONContent): string {
|
function inline(node: JSONContent): string {
|
||||||
if (node.type === "text") {
|
if (node.type === "text") {
|
||||||
let text = esc(node.text ?? "");
|
let text = esc(node.text ?? "");
|
||||||
@@ -132,23 +81,17 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
|
|||||||
return `<figure class="placement-${attr(placement)}"${style}><img src="${attr(path)}" alt="${alt}"/>${caption}</figure>`;
|
return `<figure class="placement-${attr(placement)}"${style}><img src="${attr(path)}" alt="${alt}"/>${caption}</figure>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function alignStyle(align: unknown): string {
|
|
||||||
return align === "center" || align === "right" ? ` style="text-align:${align}"` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function block(node: JSONContent, paths: Map<string, string>): string {
|
function block(node: JSONContent, paths: Map<string, string>): string {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
case "paragraph": {
|
case "paragraph": {
|
||||||
const body = inlines(node.content);
|
const body = inlines(node.content);
|
||||||
if (!body.trim()) return "<p> </p>";
|
if (!body.trim()) return "<p> </p>";
|
||||||
const align = node.attrs?.align;
|
|
||||||
if (align === "center" || align === "right") return `<p${alignStyle(align)}>${body}</p>`;
|
|
||||||
return `<p${node.attrs?.indent ? ' data-indent="true"' : ""}>${body}</p>`;
|
return `<p${node.attrs?.indent ? ' data-indent="true"' : ""}>${body}</p>`;
|
||||||
}
|
}
|
||||||
case "heading": {
|
case "heading":
|
||||||
const tag = node.attrs?.level === 3 ? "h3" : "h2";
|
return node.attrs?.level === 3
|
||||||
return `<${tag}${alignStyle(node.attrs?.align)}>${inlines(node.content)}</${tag}>`;
|
? `<h3>${inlines(node.content)}</h3>`
|
||||||
}
|
: `<h2>${inlines(node.content)}</h2>`;
|
||||||
case "blockquote":
|
case "blockquote":
|
||||||
return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`;
|
return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`;
|
||||||
case "bulletList":
|
case "bulletList":
|
||||||
@@ -297,7 +240,6 @@ function contentOpf(
|
|||||||
chapterIds: string[],
|
chapterIds: string[],
|
||||||
assetPaths: string[],
|
assetPaths: string[],
|
||||||
cover: { href: string; mediaType: string },
|
cover: { href: string; mediaType: string },
|
||||||
fontManifest: string[],
|
|
||||||
): string {
|
): string {
|
||||||
const meta = book.metadata;
|
const meta = book.metadata;
|
||||||
const manifest: string[] = [
|
const manifest: string[] = [
|
||||||
@@ -305,7 +247,8 @@ function contentOpf(
|
|||||||
`<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>`,
|
`<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>`,
|
||||||
`<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`,
|
`<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`,
|
||||||
`<item id="css" href="style.css" media-type="text/css"/>`,
|
`<item id="css" href="style.css" media-type="text/css"/>`,
|
||||||
...fontManifest,
|
`<item id="font-regular" href="fonts/Literata-VF.ttf" media-type="font/ttf"/>`,
|
||||||
|
`<item id="font-italic" href="fonts/Literata-Italic-VF.ttf" media-type="font/ttf"/>`,
|
||||||
];
|
];
|
||||||
chapterIds.forEach((_, i) => {
|
chapterIds.forEach((_, i) => {
|
||||||
manifest.push(
|
manifest.push(
|
||||||
@@ -344,12 +287,9 @@ function contentOpf(
|
|||||||
function navXhtml(book: Book): string {
|
function navXhtml(book: Book): string {
|
||||||
const items = book.chapters
|
const items = book.chapters
|
||||||
.map((chapter, i) => {
|
.map((chapter, i) => {
|
||||||
const kind = chapterKind(chapter);
|
if (chapter.noTitle && chapterKind(chapter) !== "body") return "";
|
||||||
if (chapter.noTitle && kind !== "body" && kind !== "part") return "";
|
const fallback = chapterKind(chapter) === "body" ? `Chapter ${bodyNumber(book.chapters, i)}` : "Untitled";
|
||||||
const partLabel = `Part ${partRoman(partNumber(book.chapters, i) ?? 0)}`;
|
const label = chapter.noTitle ? fallback : chapter.title || fallback;
|
||||||
const fallback = kind === "body" ? `Chapter ${bodyNumber(book.chapters, i)}` : kind === "part" ? partLabel : "Untitled";
|
|
||||||
const base = chapter.noTitle ? fallback : chapter.title || fallback;
|
|
||||||
const label = kind === "part" && !chapter.noTitle && chapter.title ? `${partLabel}: ${chapter.title}` : base;
|
|
||||||
return ` <li><a href="chapter-${i + 1}.xhtml">${esc(label)}</a></li>`;
|
return ` <li><a href="chapter-${i + 1}.xhtml">${esc(label)}</a></li>`;
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -375,20 +315,16 @@ function chapterXhtml(book: Book, index: number, paths: Map<string, string>): st
|
|||||||
const chapter = book.chapters[index];
|
const chapter = book.chapters[index];
|
||||||
const kind = chapterKind(chapter);
|
const kind = chapterKind(chapter);
|
||||||
const num = bodyNumber(book.chapters, index);
|
const num = bodyNumber(book.chapters, index);
|
||||||
const partLabel = `Part ${partRoman(partNumber(book.chapters, index) ?? 0)}`;
|
const title = chapter.title || (kind === "body" ? `Chapter ${num}` : "Untitled");
|
||||||
const eyebrow =
|
const eyebrow = kind === "body" ? `<p class="eyebrow">Chapter ${num}</p>` : "";
|
||||||
kind === "body" ? `<p class="eyebrow">Chapter ${num}</p>` : kind === "part" ? `<p class="eyebrow">${esc(partLabel)}</p>` : "";
|
const heading = chapter.noTitle ? "" : `<h1>${esc(title)}</h1>`;
|
||||||
const headingText = kind === "part" ? chapter.title : chapter.title || (kind === "body" ? `Chapter ${num}` : "Untitled");
|
|
||||||
const heading = chapter.noTitle || (kind === "part" && !chapter.title) ? "" : `<h1>${esc(headingText)}</h1>`;
|
|
||||||
const docTitle = chapter.title || (kind === "part" ? partLabel : kind === "body" ? `Chapter ${num}` : "Untitled");
|
|
||||||
const epubType = kind === "body" ? "chapter" : kind === "part" ? "part" : kind === "front" ? "frontmatter" : "backmatter";
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
|
||||||
<head>
|
<head>
|
||||||
<title>${esc(docTitle)}</title>
|
<title>${esc(title)}</title>
|
||||||
<link rel="stylesheet" type="text/css" href="style.css"/>
|
<link rel="stylesheet" type="text/css" href="style.css"/>
|
||||||
</head>
|
</head>
|
||||||
<body epub:type="${epubType}">
|
<body epub:type="${kind === "body" ? "chapter" : kind === "front" ? "frontmatter" : "backmatter"}">
|
||||||
<header class="chapter-opener">
|
<header class="chapter-opener">
|
||||||
${eyebrow}
|
${eyebrow}
|
||||||
${heading}
|
${heading}
|
||||||
@@ -398,9 +334,23 @@ function chapterXhtml(book: Book, index: number, paths: Map<string, string>): st
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function styleCss(book: Book, faces: string): string {
|
function styleCss(): string {
|
||||||
return `${faces ? faces + "\n\n" : ""}html {
|
return `@font-face {
|
||||||
font-family: ${fontStack(book.settings.fonts.body)};
|
font-family: "Literata";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300 700;
|
||||||
|
src: url("fonts/Literata-VF.ttf");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Literata";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 300 700;
|
||||||
|
src: url("fonts/Literata-Italic-VF.ttf");
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: "Literata", Georgia, serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover {
|
.cover {
|
||||||
@@ -433,7 +383,6 @@ p[data-indent="true"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3 {
|
h1, h2, h3 {
|
||||||
font-family: ${fontStack(book.settings.fonts.heading)};
|
|
||||||
hyphens: none;
|
hyphens: none;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -448,18 +397,6 @@ h2, h3 {
|
|||||||
margin: 3em 0 2.2em;
|
margin: 3em 0 2.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[epub:type="part"] {
|
|
||||||
page-break-before: always;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[epub:type="part"] .chapter-opener {
|
|
||||||
margin-top: 32vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[epub:type="part"] .eyebrow {
|
|
||||||
font-size: 0.82em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.28em;
|
letter-spacing: 0.28em;
|
||||||
@@ -540,7 +477,7 @@ figure.placement-float-top {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bookToEpub(book: Book, fontAssets: FontAssets): EpubFile[] {
|
export function bookToEpub(book: Book): EpubFile[] {
|
||||||
const { assets, paths } = extractFigures(book);
|
const { assets, paths } = extractFigures(book);
|
||||||
const cover = buildCover(book);
|
const cover = buildCover(book);
|
||||||
const chapterIds = book.chapters.map((c) => c.id);
|
const chapterIds = book.chapters.map((c) => c.id);
|
||||||
@@ -551,12 +488,12 @@ export function bookToEpub(book: Book, fontAssets: FontAssets): EpubFile[] {
|
|||||||
{ path: "META-INF/container.xml", data: containerXml(), encoding: "utf8" },
|
{ path: "META-INF/container.xml", data: containerXml(), encoding: "utf8" },
|
||||||
{
|
{
|
||||||
path: "OEBPS/content.opf",
|
path: "OEBPS/content.opf",
|
||||||
data: contentOpf(book, chapterIds, assetPaths, cover, fontAssets.manifest),
|
data: contentOpf(book, chapterIds, assetPaths, cover),
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
},
|
},
|
||||||
{ path: "OEBPS/cover.xhtml", data: coverXhtml(book, cover.href), encoding: "utf8" },
|
{ path: "OEBPS/cover.xhtml", data: coverXhtml(book, cover.href), encoding: "utf8" },
|
||||||
{ path: "OEBPS/nav.xhtml", data: navXhtml(book), encoding: "utf8" },
|
{ path: "OEBPS/nav.xhtml", data: navXhtml(book), encoding: "utf8" },
|
||||||
{ path: "OEBPS/style.css", data: styleCss(book, fontAssets.faces), encoding: "utf8" },
|
{ path: "OEBPS/style.css", data: styleCss(), encoding: "utf8" },
|
||||||
cover.asset,
|
cover.asset,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -568,13 +505,12 @@ export function bookToEpub(book: Book, fontAssets: FontAssets): EpubFile[] {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
files.push(...assets, ...fontAssets.files);
|
files.push(...assets);
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildEpub(book: Book): Promise<Uint8Array> {
|
export async function buildEpub(book: Book): Promise<Uint8Array> {
|
||||||
const fontAssets = await loadFontAssets(book);
|
const files = bookToEpub(book);
|
||||||
const files = bookToEpub(book, fontAssets);
|
|
||||||
const buf = await invoke<ArrayBuffer>("package_epub", { files });
|
const buf = await invoke<ArrayBuffer>("package_epub", { files });
|
||||||
return new Uint8Array(buf);
|
return new Uint8Array(buf);
|
||||||
}
|
}
|
||||||
@@ -8,8 +8,8 @@ const PDF = [{ name: "PDF", extensions: ["pdf"] }];
|
|||||||
const EPUB = [{ name: "EPUB", extensions: ["epub"] }];
|
const EPUB = [{ name: "EPUB", extensions: ["epub"] }];
|
||||||
|
|
||||||
export async function exportPdf(book: Book): Promise<void> {
|
export async function exportPdf(book: Book): Promise<void> {
|
||||||
const { source, images, fonts } = bookToPdfInputs(book);
|
const { source, images } = bookToPdfInputs(book);
|
||||||
const bytes = await compilePdf(source, images, true, fonts);
|
const bytes = await compilePdf(source, images, true);
|
||||||
await saveBytes(bytes, `${book.metadata.title || "Untitled"}.pdf`, PDF);
|
await saveBytes(bytes, `${book.metadata.title || "Untitled"}.pdf`, PDF);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,7 @@
|
|||||||
import type { JSONContent } from "@tiptap/core";
|
import type { JSONContent } from "@tiptap/core";
|
||||||
import { type Book, type FigurePlacement, type TrimSize, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement, partNumber, partRoman } from "../model/book";
|
import { type Book, type FigurePlacement, type TrimSize, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
|
||||||
import { fontFamilyName, fontsUsed } from "../model/fonts";
|
|
||||||
import type { ImageInput } from "../ipc";
|
import type { ImageInput } from "../ipc";
|
||||||
|
|
||||||
export interface PdfFonts {
|
|
||||||
bundled: string[];
|
|
||||||
system: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function pdfFonts(book: Book): PdfFonts {
|
|
||||||
const used = fontsUsed(book.settings.fonts);
|
|
||||||
return { bundled: used.bundled.map((f) => f.id), system: used.system };
|
|
||||||
}
|
|
||||||
|
|
||||||
const TRIM: Record<TrimSize, { w: string; h: string }> = {
|
const TRIM: Record<TrimSize, { w: string; h: string }> = {
|
||||||
"6x9": { w: "6in", h: "9in" },
|
"6x9": { w: "6in", h: "9in" },
|
||||||
"5.5x8.5": { w: "5.5in", h: "8.5in" },
|
"5.5x8.5": { w: "5.5in", h: "8.5in" },
|
||||||
@@ -37,15 +26,6 @@ function str(value: string): string {
|
|||||||
return JSON.stringify(value);
|
return JSON.stringify(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function localeParams(tag: string): string {
|
|
||||||
const parts = (tag || "en").trim().split(/[-_]/);
|
|
||||||
const primary = parts[0] ?? "";
|
|
||||||
const lang = /^[a-zA-Z]{2,3}$/.test(primary) ? primary.toLowerCase() : "en";
|
|
||||||
const region = parts.slice(1).find((p) => /^[a-zA-Z]{2}$/.test(p));
|
|
||||||
const params = `lang: ${str(lang)}`;
|
|
||||||
return region ? `${params}, region: ${str(region.toUpperCase())}` : params;
|
|
||||||
}
|
|
||||||
|
|
||||||
function inline(node: JSONContent, atLineStart: boolean): string {
|
function inline(node: JSONContent, atLineStart: boolean): string {
|
||||||
if (node.type === "text") {
|
if (node.type === "text") {
|
||||||
const marks = node.marks ?? [];
|
const marks = node.marks ?? [];
|
||||||
@@ -89,22 +69,15 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
|
|||||||
return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`;
|
return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aligned(align: unknown, body: string): string {
|
|
||||||
if (align === "center" || align === "right") return `#align(${align})[#set par(justify: false)\n${body}]`;
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
function block(node: JSONContent, paths: Map<string, string>): string {
|
function block(node: JSONContent, paths: Map<string, string>): string {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
case "paragraph": {
|
case "paragraph": {
|
||||||
const body = inlines(node.content, true);
|
const body = inlines(node.content, true);
|
||||||
if (!body.trim()) return "~";
|
if (!body.trim()) return "~";
|
||||||
const align = node.attrs?.align;
|
|
||||||
if (align === "center" || align === "right") return aligned(align, body);
|
|
||||||
return node.attrs?.indent ? `#h(1.3em)${body}` : body;
|
return node.attrs?.indent ? `#h(1.3em)${body}` : body;
|
||||||
}
|
}
|
||||||
case "heading":
|
case "heading":
|
||||||
return aligned(node.attrs?.align, `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content, true)}]`);
|
return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content, true)}]`;
|
||||||
case "blockquote":
|
case "blockquote":
|
||||||
return `#blockquote[${(node.content ?? []).map((n) => block(n, paths)).join("\n\n")}]`;
|
return `#blockquote[${(node.content ?? []).map((n) => block(n, paths)).join("\n\n")}]`;
|
||||||
case "bulletList":
|
case "bulletList":
|
||||||
@@ -131,8 +104,6 @@ function preamble(book: Book): string {
|
|||||||
const trim = TRIM[book.settings.trim];
|
const trim = TRIM[book.settings.trim];
|
||||||
const meta = book.metadata;
|
const meta = book.metadata;
|
||||||
const bleed = book.settings.bleed ? "0.125in" : "0in";
|
const bleed = book.settings.bleed ? "0.125in" : "0in";
|
||||||
const body = fontFamilyName(book.settings.fonts.body);
|
|
||||||
const heading = fontFamilyName(book.settings.fonts.heading);
|
|
||||||
return `#set document(title: ${str(meta.title || "Untitled")}, author: ${str(meta.author)})
|
return `#set document(title: ${str(meta.title || "Untitled")}, author: ${str(meta.author)})
|
||||||
#set page(
|
#set page(
|
||||||
width: ${trim.w} + ${bleed},
|
width: ${trim.w} + ${bleed},
|
||||||
@@ -140,9 +111,9 @@ function preamble(book: Book): string {
|
|||||||
margin: (inside: 0.875in, outside: 0.625in + ${bleed}, top: 0.8in + ${bleed}, bottom: 0.8in + ${bleed}),
|
margin: (inside: 0.875in, outside: 0.625in + ${bleed}, top: 0.8in + ${bleed}, bottom: 0.8in + ${bleed}),
|
||||||
binding: left,
|
binding: left,
|
||||||
)
|
)
|
||||||
#set text(font: ${str(body)}, size: 11pt, ${localeParams(meta.language)}, hyphenate: true)
|
#set text(font: "Literata", size: 11pt, lang: ${str(meta.language || "en")}, hyphenate: true)
|
||||||
#set par(justify: true, leading: 0.72em, spacing: 0.72em)
|
#set par(justify: true, leading: 0.72em, spacing: 0.72em)
|
||||||
#show heading: set text(font: ${str(heading)}, weight: "medium")
|
#show heading: set text(font: "Literata", weight: "medium")
|
||||||
#show heading.where(level: 1): set text(size: 22pt)
|
#show heading.where(level: 1): set text(size: 22pt)
|
||||||
#show heading.where(level: 2): set block(above: 1.4em, below: 0.6em)
|
#show heading.where(level: 2): set block(above: 1.4em, below: 0.6em)
|
||||||
#show heading.where(level: 3): set block(above: 1.2em, below: 0.5em)
|
#show heading.where(level: 3): set block(above: 1.2em, below: 0.5em)
|
||||||
@@ -154,10 +125,10 @@ function preamble(book: Book): string {
|
|||||||
#let titlepage(title, subtitle, author) = {
|
#let titlepage(title, subtitle, author) = {
|
||||||
v(2.4in)
|
v(2.4in)
|
||||||
align(center)[
|
align(center)[
|
||||||
#text(font: ${str(heading)}, size: 30pt, weight: "medium")[#title]
|
#text(font: "Literata", size: 30pt, weight: "medium")[#title]
|
||||||
#if subtitle != "" {
|
#if subtitle != "" {
|
||||||
v(0.6em)
|
v(0.6em)
|
||||||
text(font: ${str(body)}, size: 15pt, style: "italic", fill: rgb("#6b6458"))[#subtitle]
|
text(font: "Literata", size: 15pt, style: "italic", fill: rgb("#6b6458"))[#subtitle]
|
||||||
}
|
}
|
||||||
#if author != "" {
|
#if author != "" {
|
||||||
v(1.7em)
|
v(1.7em)
|
||||||
@@ -169,7 +140,7 @@ function preamble(book: Book): string {
|
|||||||
#let openchapter(info) = {
|
#let openchapter(info) = {
|
||||||
pagebreak(weak: true)
|
pagebreak(weak: true)
|
||||||
[#metadata(info) <chap>]
|
[#metadata(info) <chap>]
|
||||||
if not info.nomargin { v(2.1in) }
|
v(2.1in)
|
||||||
align(center)[
|
align(center)[
|
||||||
#if info.kind == "body" {
|
#if info.kind == "body" {
|
||||||
text(font: "Hanken Grotesk", size: 8.5pt, weight: "semibold", tracking: 0.28em)[#upper("Chapter " + info.num)]
|
text(font: "Hanken Grotesk", size: 8.5pt, weight: "semibold", tracking: 0.28em)[#upper("Chapter " + info.num)]
|
||||||
@@ -182,20 +153,6 @@ function preamble(book: Book): string {
|
|||||||
v(1.5em)
|
v(1.5em)
|
||||||
}
|
}
|
||||||
|
|
||||||
#let openpart(info) = {
|
|
||||||
pagebreak(to: "odd", weak: true)
|
|
||||||
[#metadata(info) <chap>]
|
|
||||||
if not info.nomargin { v(3.1in) }
|
|
||||||
align(center)[
|
|
||||||
#text(font: "Hanken Grotesk", size: 10pt, weight: "semibold", tracking: 0.34em)[#upper("Part " + info.numeral)]
|
|
||||||
#if not info.notitle and info.title != "" {
|
|
||||||
v(0.85em)
|
|
||||||
heading(level: 1, numbering: none, outlined: info.toc)[#info.title]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
v(1.5em)
|
|
||||||
}
|
|
||||||
|
|
||||||
#let pageof(loc) = {
|
#let pageof(loc) = {
|
||||||
let pat = loc.page-numbering()
|
let pat = loc.page-numbering()
|
||||||
if pat == none { pat = "1" }
|
if pat == none { pat = "1" }
|
||||||
@@ -207,7 +164,7 @@ function preamble(book: Book): string {
|
|||||||
set par(justify: false, first-line-indent: 0pt)
|
set par(justify: false, first-line-indent: 0pt)
|
||||||
block(below: 2em)[
|
block(below: 2em)[
|
||||||
#show heading: set block(above: 0pt, below: 0pt)
|
#show heading: set block(above: 0pt, below: 0pt)
|
||||||
#heading(level: 1, numbering: none, outlined: false, bookmarked: true)[#text(font: ${str(heading)}, size: 24pt, weight: "medium")[Contents]]
|
#heading(level: 1, numbering: none, outlined: false, bookmarked: true)[#text(font: "Literata", size: 24pt, weight: "medium")[Contents]]
|
||||||
]
|
]
|
||||||
context {
|
context {
|
||||||
let items = query(<chap>).filter(it => it.value.toc != false)
|
let items = query(<chap>).filter(it => it.value.toc != false)
|
||||||
@@ -216,7 +173,6 @@ function preamble(book: Book): string {
|
|||||||
let info = it.value
|
let info = it.value
|
||||||
let pg = pageof(it.location())
|
let pg = pageof(it.location())
|
||||||
let body = info.kind == "body"
|
let body = info.kind == "body"
|
||||||
let part = info.kind == "part"
|
|
||||||
let gap = if prev != none and prev != info.kind { 1.7em } else { 0.95em }
|
let gap = if prev != none and prev != info.kind { 1.7em } else { 0.95em }
|
||||||
prev = info.kind
|
prev = info.kind
|
||||||
block(width: 100%, above: gap, below: 0.95em, {
|
block(width: 100%, above: gap, below: 0.95em, {
|
||||||
@@ -226,9 +182,7 @@ function preamble(book: Book): string {
|
|||||||
column-gutter: (0.65em, 1em),
|
column-gutter: (0.65em, 1em),
|
||||||
align: (right + top, left + top, right + top),
|
align: (right + top, left + top, right + top),
|
||||||
if body { info.num } else { [] },
|
if body { info.num } else { [] },
|
||||||
if part {
|
if body { info.title } else { emph(info.title) },
|
||||||
strong(if info.title != "" { "Part " + info.numeral + ": " + info.title } else { "Part " + info.numeral })
|
|
||||||
} else if body { info.title } else { emph(info.title) },
|
|
||||||
pg,
|
pg,
|
||||||
)]
|
)]
|
||||||
})
|
})
|
||||||
@@ -246,15 +200,13 @@ function imageExtension(dataUrl: string): string {
|
|||||||
function coverBlock(book: Book, coverPath?: string): string {
|
function coverBlock(book: Book, coverPath?: string): string {
|
||||||
const c = book.cover;
|
const c = book.cover;
|
||||||
const meta = book.metadata;
|
const meta = book.metadata;
|
||||||
const body = fontFamilyName(book.settings.fonts.body);
|
|
||||||
const heading = fontFamilyName(book.settings.fonts.heading);
|
|
||||||
|
|
||||||
if (c.kind === "image" && coverPath) {
|
if (c.kind === "image" && coverPath) {
|
||||||
return `#page(margin: 0pt, numbering: none)[#image(${str(coverPath)}, width: 100%, height: 100%, fit: "cover")]`;
|
return `#page(margin: 0pt, numbering: none)[#image(${str(coverPath)}, width: 100%, height: 100%, fit: "cover")]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subtitle = meta.subtitle
|
const subtitle = meta.subtitle
|
||||||
? `#v(0.55em)\n #text(font: ${str(body)}, size: 14pt, style: "italic")[${esc(meta.subtitle)}]`
|
? `#v(0.55em)\n #text(font: "Literata", size: 14pt, style: "italic")[${esc(meta.subtitle)}]`
|
||||||
: "";
|
: "";
|
||||||
const author = meta.author
|
const author = meta.author
|
||||||
? `#v(1fr)\n #text(font: "Hanken Grotesk", size: 11pt, weight: "semibold", tracking: 0.24em)[#upper[${esc(meta.author)}]]\n #v(0.55in)`
|
? `#v(1fr)\n #text(font: "Hanken Grotesk", size: 11pt, weight: "semibold", tracking: 0.24em)[#upper[${esc(meta.author)}]]\n #v(0.55in)`
|
||||||
@@ -264,7 +216,7 @@ function coverBlock(book: Book, coverPath?: string): string {
|
|||||||
#set align(center)
|
#set align(center)
|
||||||
#set text(fill: rgb(${str(c.ink)}))
|
#set text(fill: rgb(${str(c.ink)}))
|
||||||
#v(1fr)
|
#v(1fr)
|
||||||
#text(font: ${str(heading)}, size: 30pt, weight: "medium")[${esc(meta.title || "Untitled")}]
|
#text(font: "Literata", size: 30pt, weight: "medium")[${esc(meta.title || "Untitled")}]
|
||||||
#v(0.5em)
|
#v(0.5em)
|
||||||
#line(length: 16%, stroke: 0.6pt + rgb(${str(c.ink)}))
|
#line(length: 16%, stroke: 0.6pt + rgb(${str(c.ink)}))
|
||||||
${subtitle}
|
${subtitle}
|
||||||
@@ -345,26 +297,18 @@ function normTitle(title: string): string {
|
|||||||
|
|
||||||
function inToc(book: Book, index: number): boolean {
|
function inToc(book: Book, index: number): boolean {
|
||||||
const chapter = book.chapters[index];
|
const chapter = book.chapters[index];
|
||||||
const kind = chapterKind(chapter);
|
if (chapter.noTitle && chapterKind(chapter) !== "body") return false;
|
||||||
if (kind === "part") return true;
|
|
||||||
if (chapter.noTitle && kind !== "body") return false;
|
|
||||||
const title = cleanTitle(chapter.title) || "Untitled";
|
const title = cleanTitle(chapter.title) || "Untitled";
|
||||||
return !(kind === "front" && normTitle(title) === normTitle(book.metadata.title));
|
return !(chapterKind(chapter) === "front" && normTitle(title) === normTitle(book.metadata.title));
|
||||||
}
|
}
|
||||||
|
|
||||||
function openerCall(book: Book, index: number): string {
|
function openerCall(book: Book, index: number): string {
|
||||||
const chapter = book.chapters[index];
|
const chapter = book.chapters[index];
|
||||||
const kind = chapterKind(chapter);
|
const kind = chapterKind(chapter);
|
||||||
const 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 num = kind === "body" ? String(bodyNumber(book.chapters, index)) : "";
|
||||||
|
const notitle = !!chapter.noTitle;
|
||||||
const title = notitle ? "" : cleanTitle(chapter.title) || "Untitled";
|
const title = notitle ? "" : cleanTitle(chapter.title) || "Untitled";
|
||||||
return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, notitle: ${notitle}, nomargin: ${nomargin}, toc: ${inToc(book, index)}))`;
|
return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, notitle: ${notitle}, toc: ${inToc(book, index)}))`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bookToTypst(book: Book, paths: Map<string, string> = new Map(), coverPath?: string): string {
|
export function bookToTypst(book: Book, paths: Map<string, string> = new Map(), coverPath?: string): string {
|
||||||
@@ -376,13 +320,13 @@ export function bookToTypst(book: Book, paths: Map<string, string> = new Map(),
|
|||||||
#set page(numbering: "i")
|
#set page(numbering: "i")
|
||||||
#counter(page).update(1)`;
|
#counter(page).update(1)`;
|
||||||
|
|
||||||
const firstMain = book.chapters.findIndex((c) => chapterKind(c) === "body" || chapterKind(c) === "part");
|
const firstBody = book.chapters.findIndex((c) => chapterKind(c) === "body");
|
||||||
const firstToc = book.chapters.findIndex((_, i) => inToc(book, i));
|
const firstToc = book.chapters.findIndex((_, i) => inToc(book, i));
|
||||||
|
|
||||||
const body = book.chapters
|
const body = book.chapters
|
||||||
.map((chapter, i) => {
|
.map((chapter, i) => {
|
||||||
const toc = i === firstToc ? `#contents()\n\n` : "";
|
const toc = i === firstToc ? `#contents()\n\n` : "";
|
||||||
const reset = i === firstMain ? `#set page(numbering: "1")\n#counter(page).update(1)\n` : "";
|
const reset = i === firstBody ? `#set page(numbering: "1")\n#counter(page).update(1)\n` : "";
|
||||||
return `${toc}${reset}${openerCall(book, i)}\n\n${chapterBody(chapter.content, paths)}`;
|
return `${toc}${reset}${openerCall(book, i)}\n\n${chapterBody(chapter.content, paths)}`;
|
||||||
})
|
})
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
@@ -390,16 +334,16 @@ export function bookToTypst(book: Book, paths: Map<string, string> = new Map(),
|
|||||||
return `${preamble(book)}\n\n${cover}\n\n${front}\n\n${body}\n`;
|
return `${preamble(book)}\n\n${cover}\n\n${front}\n\n${body}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bookToPdfInputs(book: Book): { source: string; images: ImageInput[]; fonts: PdfFonts } {
|
export function bookToPdfInputs(book: Book): { source: string; images: ImageInput[] } {
|
||||||
const { images, paths } = extractImages(book);
|
const { images, paths } = extractImages(book);
|
||||||
const coverPath = pushCoverImage(book, images);
|
const coverPath = pushCoverImage(book, images);
|
||||||
return { source: bookToTypst(book, paths, coverPath), images, fonts: pdfFonts(book) };
|
return { source: bookToTypst(book, paths, coverPath), images };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function coverToPdfInputs(book: Book): { source: string; images: ImageInput[]; fonts: PdfFonts } {
|
export function coverToPdfInputs(book: Book): { source: string; images: ImageInput[] } {
|
||||||
const images: ImageInput[] = [];
|
const images: ImageInput[] = [];
|
||||||
const coverPath = pushCoverImage(book, images);
|
const coverPath = pushCoverImage(book, images);
|
||||||
return { source: `${preamble(book)}\n\n${coverBlock(book, coverPath)}\n`, images, fonts: pdfFonts(book) };
|
return { source: `${preamble(book)}\n\n${coverBlock(book, coverPath)}\n`, images };
|
||||||
}
|
}
|
||||||
|
|
||||||
function chapterToTypst(book: Book, index: number, paths: Map<string, string>): string {
|
function chapterToTypst(book: Book, index: number, paths: Map<string, string>): string {
|
||||||
@@ -414,8 +358,8 @@ ${chapterBody(book.chapters[index].content, paths)}
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function chapterToPdfInputs(book: Book, index: number): { source: string; images: ImageInput[]; fonts: PdfFonts } {
|
export function chapterToPdfInputs(book: Book, index: number): { source: string; images: ImageInput[] } {
|
||||||
const chapter = book.chapters[index];
|
const chapter = book.chapters[index];
|
||||||
const { images, paths } = collectImages([chapter.content]);
|
const { images, paths } = collectImages([chapter.content]);
|
||||||
return { source: chapterToTypst(book, index, paths), images, fonts: pdfFonts(book) };
|
return { source: chapterToTypst(book, index, paths), images };
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import type { JSONContent } from "@tiptap/core";
|
import type { JSONContent } from "@tiptap/core";
|
||||||
import { type Book, type BookMetadata, type Chapter, type Cover, clampLanguage, createCover } from "../model/book";
|
import { type Book, type BookMetadata, type Chapter, type Cover, createCover } from "../model/book";
|
||||||
import { DEFAULT_FONTS } from "../model/fonts";
|
|
||||||
|
|
||||||
export interface RawFile {
|
export interface RawFile {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -508,7 +507,7 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
|
|||||||
subtitle: "",
|
subtitle: "",
|
||||||
author: localText(opf, "creator"),
|
author: localText(opf, "creator"),
|
||||||
isbn: findIsbn(opf),
|
isbn: findIsbn(opf),
|
||||||
language: clampLanguage(localText(opf, "language") || "en"),
|
language: localText(opf, "language") || "en",
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -516,7 +515,7 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
|
|||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
metadata,
|
metadata,
|
||||||
theme: "quiet-press",
|
theme: "quiet-press",
|
||||||
settings: { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS },
|
settings: { trim: "6x9", bleed: true },
|
||||||
cover: findCover(opf, manifest, byPath),
|
cover: findCover(opf, manifest, byPath),
|
||||||
chapters,
|
chapters,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,32 +7,11 @@ export interface ImageInput {
|
|||||||
data: string;
|
data: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PdfFonts {
|
|
||||||
bundled: string[];
|
|
||||||
system: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function compilePdf(
|
export async function compilePdf(
|
||||||
source: string,
|
source: string,
|
||||||
images: ImageInput[] = [],
|
images: ImageInput[] = [],
|
||||||
emitWarnings = false,
|
emitWarnings = false,
|
||||||
fonts: PdfFonts = { bundled: [], system: [] },
|
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const buffer = await invoke<ArrayBuffer>("compile_pdf", {
|
const buffer = await invoke<ArrayBuffer>("compile_pdf", { source, images, emitWarnings });
|
||||||
source,
|
|
||||||
images,
|
|
||||||
emitWarnings,
|
|
||||||
bundledFonts: fonts.bundled,
|
|
||||||
systemFonts: fonts.system,
|
|
||||||
});
|
|
||||||
return new Uint8Array(buffer);
|
return new Uint8Array(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSystemFonts(): Promise<string[]> {
|
|
||||||
if (!isDesktop) return [];
|
|
||||||
try {
|
|
||||||
return await invoke<string[]>("list_system_fonts");
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { JSONContent } from "@tiptap/core";
|
import type { JSONContent } from "@tiptap/core";
|
||||||
import { type BookFonts, DEFAULT_FONTS } from "./fonts";
|
|
||||||
|
|
||||||
export type TrimSize = "6x9" | "5.5x8.5" | "5x8" | "a5";
|
export type TrimSize = "6x9" | "5.5x8.5" | "5x8" | "a5";
|
||||||
export type FigurePlacement = "inline" | "full-width" | "full-page" | "float-top";
|
export type FigurePlacement = "inline" | "full-width" | "full-page" | "float-top";
|
||||||
@@ -25,7 +24,6 @@ export interface BookMetadata {
|
|||||||
export interface BookSettings {
|
export interface BookSettings {
|
||||||
trim: TrimSize;
|
trim: TrimSize;
|
||||||
bleed: boolean;
|
bleed: boolean;
|
||||||
fonts: BookFonts;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CoverKind = "default" | "image";
|
export type CoverKind = "default" | "image";
|
||||||
@@ -64,7 +62,7 @@ export function trimRatio(trim: TrimSize): number {
|
|||||||
return w / h;
|
return w / h;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChapterKind = "front" | "body" | "part" | "back";
|
export type ChapterKind = "front" | "body" | "back";
|
||||||
|
|
||||||
export interface Chapter {
|
export interface Chapter {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -73,7 +71,6 @@ export interface Chapter {
|
|||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
kind?: ChapterKind;
|
kind?: ChapterKind;
|
||||||
noTitle?: boolean;
|
noTitle?: boolean;
|
||||||
noMargin?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageType {
|
export interface PageType {
|
||||||
@@ -108,31 +105,6 @@ export function bodyNumber(chapters: Chapter[], index: number): number | null {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function partNumber(chapters: Chapter[], index: number): number | null {
|
|
||||||
if (index < 0 || index >= chapters.length || chapterKind(chapters[index]) !== "part") return null;
|
|
||||||
let n = 0;
|
|
||||||
for (let i = 0; i <= index; i++) if (chapterKind(chapters[i]) === "part") n++;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROMAN: [number, string][] = [
|
|
||||||
[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"],
|
|
||||||
[50, "L"], [40, "XL"], [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"],
|
|
||||||
];
|
|
||||||
|
|
||||||
export function partRoman(n: number): string {
|
|
||||||
if (n < 1) return String(n);
|
|
||||||
let num = n;
|
|
||||||
let out = "";
|
|
||||||
for (const [value, sym] of ROMAN) {
|
|
||||||
while (num >= value) {
|
|
||||||
out += sym;
|
|
||||||
num -= value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Book {
|
export interface Book {
|
||||||
schema: "margin/1";
|
schema: "margin/1";
|
||||||
id: string;
|
id: string;
|
||||||
@@ -167,10 +139,6 @@ export function createPage(group: "front" | "back", title: string): Chapter {
|
|||||||
return { id: crypto.randomUUID(), title, content: emptyDoc(), kind: group, updatedAt: Date.now() };
|
return { id: crypto.randomUUID(), title, content: emptyDoc(), kind: group, updatedAt: Date.now() };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPart(title = ""): Chapter {
|
|
||||||
return { id: crypto.randomUUID(), title, content: emptyDoc(), kind: "part", updatedAt: Date.now() };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cloneChapter(chapter: Chapter): Chapter {
|
export function cloneChapter(chapter: Chapter): Chapter {
|
||||||
const title = chapter.noTitle || !chapter.title ? chapter.title : `${chapter.title} (copy)`;
|
const title = chapter.noTitle || !chapter.title ? chapter.title : `${chapter.title} (copy)`;
|
||||||
return { ...chapter, id: crypto.randomUUID(), title, content: structuredClone(chapter.content), updatedAt: Date.now() };
|
return { ...chapter, id: crypto.randomUUID(), title, content: structuredClone(chapter.content), updatedAt: Date.now() };
|
||||||
@@ -181,22 +149,14 @@ export function createCover(): Cover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_METADATA: BookMetadata = { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" };
|
const DEFAULT_METADATA: BookMetadata = { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" };
|
||||||
const DEFAULT_SETTINGS: BookSettings = { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS };
|
const DEFAULT_SETTINGS: BookSettings = { trim: "6x9", bleed: true };
|
||||||
|
|
||||||
export const SUPPORTED_LANGUAGES = ["en", "es", "fr", "de", "it", "pt"] as const;
|
|
||||||
|
|
||||||
export function clampLanguage(tag: string): string {
|
|
||||||
const primary = (tag || "").trim().split(/[-_]/)[0]?.toLowerCase() ?? "";
|
|
||||||
return (SUPPORTED_LANGUAGES as readonly string[]).includes(primary) ? primary : "en";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeBook(book: Book): Book {
|
export function normalizeBook(book: Book): Book {
|
||||||
const chapters = (Array.isArray(book.chapters) ? book.chapters : []).filter(Boolean);
|
const chapters = (Array.isArray(book.chapters) ? book.chapters : []).filter(Boolean);
|
||||||
const metadata = { ...DEFAULT_METADATA, ...book.metadata };
|
|
||||||
return {
|
return {
|
||||||
...book,
|
...book,
|
||||||
metadata: { ...metadata, language: clampLanguage(metadata.language) },
|
metadata: { ...DEFAULT_METADATA, ...book.metadata },
|
||||||
settings: { ...DEFAULT_SETTINGS, ...book.settings, fonts: { ...DEFAULT_FONTS, ...book.settings?.fonts } },
|
settings: { ...DEFAULT_SETTINGS, ...book.settings },
|
||||||
cover: book.cover ? { ...createCover(), ...book.cover } : createCover(),
|
cover: book.cover ? { ...createCover(), ...book.cover } : createCover(),
|
||||||
chapters: (chapters.length ? chapters : [createChapter()]).map((c) =>
|
chapters: (chapters.length ? chapters : [createChapter()]).map((c) =>
|
||||||
c.updatedAt ? c : { ...c, updatedAt: Date.now() },
|
c.updatedAt ? c : { ...c, updatedAt: Date.now() },
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
export type FontCategory = "serif" | "sans" | "display";
|
|
||||||
|
|
||||||
export interface BundledFont {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
family: string;
|
|
||||||
category: FontCategory;
|
|
||||||
regular: string;
|
|
||||||
italic?: string;
|
|
||||||
weight: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BUNDLED_FONTS: BundledFont[] = [
|
|
||||||
{ id: "literata", label: "Literata", family: "Literata", category: "serif", regular: "Literata-VF.ttf", italic: "Literata-Italic-VF.ttf", weight: "200 900" },
|
|
||||||
{ id: "eb-garamond", label: "EB Garamond", family: "EB Garamond", category: "serif", regular: "EBGaramond-VF.ttf", italic: "EBGaramond-Italic-VF.ttf", weight: "400 800" },
|
|
||||||
{ id: "lora", label: "Lora", family: "Lora", category: "serif", regular: "Lora-VF.ttf", italic: "Lora-Italic-VF.ttf", weight: "400 700" },
|
|
||||||
{ id: "source-serif", label: "Source Serif 4", family: "Source Serif 4", category: "serif", regular: "SourceSerif4-VF.ttf", italic: "SourceSerif4-Italic-VF.ttf", weight: "200 900" },
|
|
||||||
{ id: "fraunces", label: "Fraunces", family: "Fraunces", category: "display", regular: "Fraunces-VF.ttf", italic: "Fraunces-Italic-VF.ttf", weight: "100 900" },
|
|
||||||
{ id: "hanken", label: "Hanken Grotesk", family: "Hanken Grotesk", category: "sans", regular: "HankenGrotesk-VF.ttf", italic: "HankenGrotesk-Italic-VF.ttf", weight: "100 900" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export type FontRef =
|
|
||||||
| { kind: "bundled"; id: string }
|
|
||||||
| { kind: "system"; family: string };
|
|
||||||
|
|
||||||
export interface BookFonts {
|
|
||||||
body: FontRef;
|
|
||||||
heading: FontRef;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FontPairing {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
body: FontRef;
|
|
||||||
heading: FontRef;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bundled = (id: string): FontRef => ({ kind: "bundled", id });
|
|
||||||
|
|
||||||
export const FONT_PAIRINGS: FontPairing[] = [
|
|
||||||
{ id: "quiet-press", label: "Quiet Press", body: bundled("literata"), heading: bundled("literata") },
|
|
||||||
{ id: "classic", label: "Classic", body: bundled("eb-garamond"), heading: bundled("eb-garamond") },
|
|
||||||
{ id: "editorial", label: "Editorial", body: bundled("source-serif"), heading: bundled("fraunces") },
|
|
||||||
{ id: "modern", label: "Modern", body: bundled("lora"), heading: bundled("hanken") },
|
|
||||||
{ id: "contrast", label: "Contrast", body: bundled("literata"), heading: bundled("hanken") },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const DEFAULT_FONTS: BookFonts = { body: bundled("literata"), heading: bundled("literata") };
|
|
||||||
|
|
||||||
const SERIF_FALLBACK = `Georgia, "Times New Roman", serif`;
|
|
||||||
const SANS_FALLBACK = `ui-sans-serif, system-ui, -apple-system, sans-serif`;
|
|
||||||
|
|
||||||
export function bundledFont(id: string): BundledFont | undefined {
|
|
||||||
return BUNDLED_FONTS.find((f) => f.id === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encodeRef(ref: FontRef): string {
|
|
||||||
return ref.kind === "bundled" ? `b:${ref.id}` : `s:${ref.family}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeRef(value: string): FontRef {
|
|
||||||
return value.startsWith("s:") ? { kind: "system", family: value.slice(2) } : { kind: "bundled", id: value.slice(2) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fontFamilyName(ref: FontRef): string {
|
|
||||||
if (ref.kind === "system") return ref.family;
|
|
||||||
return bundledFont(ref.id)?.family ?? "Literata";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fontLabel(ref: FontRef): string {
|
|
||||||
if (ref.kind === "system") return ref.family;
|
|
||||||
return bundledFont(ref.id)?.label ?? ref.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fontStack(ref: FontRef): string {
|
|
||||||
if (ref.kind === "bundled") {
|
|
||||||
const font = bundledFont(ref.id);
|
|
||||||
if (!font) return SERIF_FALLBACK;
|
|
||||||
return `"${font.family}", ${font.category === "sans" ? SANS_FALLBACK : SERIF_FALLBACK}`;
|
|
||||||
}
|
|
||||||
return `"${ref.family}", ${SERIF_FALLBACK}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function refsEqual(a: FontRef, b: FontRef): boolean {
|
|
||||||
if (a.kind !== b.kind) return false;
|
|
||||||
return a.kind === "bundled" ? a.id === (b as { id: string }).id : a.family === (b as { family: string }).family;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pairingFor(fonts: BookFonts): string | null {
|
|
||||||
const match = FONT_PAIRINGS.find((p) => refsEqual(p.body, fonts.body) && refsEqual(p.heading, fonts.heading));
|
|
||||||
return match?.id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fontsUsed(fonts: BookFonts): { bundled: BundledFont[]; system: string[] } {
|
|
||||||
const usedBundled = new Map<string, BundledFont>();
|
|
||||||
const usedSystem = new Set<string>();
|
|
||||||
for (const ref of [fonts.body, fonts.heading]) {
|
|
||||||
if (ref.kind === "bundled") {
|
|
||||||
const font = bundledFont(ref.id);
|
|
||||||
if (font) usedBundled.set(font.id, font);
|
|
||||||
} else {
|
|
||||||
usedSystem.add(ref.family);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { bundled: [...usedBundled.values()], system: [...usedSystem] };
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import {
|
|
||||||
type BackupStatus,
|
|
||||||
gdriveBackup,
|
|
||||||
gdriveConnect,
|
|
||||||
gdriveDisconnect,
|
|
||||||
gdriveRestore,
|
|
||||||
gdriveStatus,
|
|
||||||
} from "../backup";
|
|
||||||
import { isDesktop } from "../ipc";
|
|
||||||
import { useBook } from "./useBook";
|
|
||||||
|
|
||||||
type Phase = "idle" | "connecting" | "working" | "error";
|
|
||||||
|
|
||||||
interface BackupState {
|
|
||||||
connected: boolean;
|
|
||||||
email: string | null;
|
|
||||||
lastBackup: number | null;
|
|
||||||
pending: boolean;
|
|
||||||
phase: Phase;
|
|
||||||
error: string | null;
|
|
||||||
authUrl: string | null;
|
|
||||||
settingsOpen: boolean;
|
|
||||||
restoreNonce: number;
|
|
||||||
resolveConnect: ((ok: boolean) => void) | null;
|
|
||||||
apply: (status: BackupStatus) => void;
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
connect: () => Promise<boolean>;
|
|
||||||
cancelConnect: () => void;
|
|
||||||
handleAuthEvent: (ok: boolean, error: string | null) => Promise<void>;
|
|
||||||
openAuthUrl: () => void;
|
|
||||||
copyAuthUrl: () => Promise<void>;
|
|
||||||
disconnect: () => Promise<void>;
|
|
||||||
backup: (silent?: boolean) => Promise<void>;
|
|
||||||
restore: () => Promise<void>;
|
|
||||||
restoreFromDrive: () => Promise<void>;
|
|
||||||
openSettings: () => void;
|
|
||||||
closeSettings: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function notify(message: string) {
|
|
||||||
useBook.getState().setNotice(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useBackup = create<BackupState>((set, get) => ({
|
|
||||||
connected: false,
|
|
||||||
email: null,
|
|
||||||
lastBackup: null,
|
|
||||||
pending: false,
|
|
||||||
phase: "idle",
|
|
||||||
error: null,
|
|
||||||
authUrl: null,
|
|
||||||
settingsOpen: false,
|
|
||||||
restoreNonce: 0,
|
|
||||||
resolveConnect: null,
|
|
||||||
apply: (status) =>
|
|
||||||
set({
|
|
||||||
connected: status.connected,
|
|
||||||
email: status.email,
|
|
||||||
lastBackup: status.lastBackup,
|
|
||||||
pending: status.pending,
|
|
||||||
}),
|
|
||||||
refresh: async () => {
|
|
||||||
if (!isDesktop) return;
|
|
||||||
try {
|
|
||||||
get().apply(await gdriveStatus());
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
connect: () =>
|
|
||||||
new Promise<boolean>((resolve) => {
|
|
||||||
get().resolveConnect?.(false);
|
|
||||||
set({ phase: "connecting", error: null, authUrl: null, resolveConnect: resolve });
|
|
||||||
gdriveConnect()
|
|
||||||
.then((url) => set({ authUrl: url }))
|
|
||||||
.catch((e) => {
|
|
||||||
set({ phase: "error", error: String(e), resolveConnect: null });
|
|
||||||
notify(`Could not connect: ${e}`);
|
|
||||||
resolve(false);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
cancelConnect: () => {
|
|
||||||
const resolve = get().resolveConnect;
|
|
||||||
set({ phase: "idle", authUrl: null, resolveConnect: null });
|
|
||||||
resolve?.(false);
|
|
||||||
},
|
|
||||||
handleAuthEvent: async (ok, error) => {
|
|
||||||
if (get().phase !== "connecting") return;
|
|
||||||
const resolve = get().resolveConnect;
|
|
||||||
if (ok) {
|
|
||||||
await get().refresh();
|
|
||||||
set({ phase: "idle", authUrl: null, error: null, resolveConnect: null });
|
|
||||||
notify("Connected to Google Drive");
|
|
||||||
} else {
|
|
||||||
set({ phase: "error", authUrl: null, error: error ?? "authorization failed", resolveConnect: null });
|
|
||||||
notify(`Could not connect: ${error ?? "authorization failed"}`);
|
|
||||||
}
|
|
||||||
resolve?.(ok);
|
|
||||||
},
|
|
||||||
openAuthUrl: () => {
|
|
||||||
const url = get().authUrl;
|
|
||||||
if (url) openUrl(url).catch(() => {});
|
|
||||||
},
|
|
||||||
copyAuthUrl: async () => {
|
|
||||||
const url = get().authUrl;
|
|
||||||
if (!url) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(url);
|
|
||||||
notify("Authorization link copied");
|
|
||||||
} catch {
|
|
||||||
notify("Could not copy the link");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
disconnect: async () => {
|
|
||||||
set({ phase: "working", error: null });
|
|
||||||
try {
|
|
||||||
get().apply(await gdriveDisconnect());
|
|
||||||
set({ phase: "idle" });
|
|
||||||
notify("Disconnected from Google Drive");
|
|
||||||
} catch (e) {
|
|
||||||
set({ phase: "error", error: String(e) });
|
|
||||||
notify(`Could not disconnect: ${e}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
backup: async (silent = false) => {
|
|
||||||
if (!get().connected || get().phase === "working") return;
|
|
||||||
set({ phase: "working", error: null });
|
|
||||||
try {
|
|
||||||
const outcome = await gdriveBackup();
|
|
||||||
get().apply(outcome);
|
|
||||||
set({ phase: "idle" });
|
|
||||||
if (!silent) notify(outcome.uploaded > 0 ? "Backed up to Google Drive" : "Nothing new to back up");
|
|
||||||
} catch (e) {
|
|
||||||
set({ phase: "error", error: String(e) });
|
|
||||||
if (!silent) notify(`Backup failed: ${e}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
restore: async () => {
|
|
||||||
set({ phase: "working", error: null });
|
|
||||||
try {
|
|
||||||
const result = await gdriveRestore();
|
|
||||||
await get().refresh();
|
|
||||||
set((s) => ({ phase: "idle", restoreNonce: s.restoreNonce + 1 }));
|
|
||||||
notify(result.restored > 0 ? `Restored ${result.restored} file${result.restored === 1 ? "" : "s"}` : "No backups found in Google Drive");
|
|
||||||
} catch (e) {
|
|
||||||
set({ phase: "error", error: String(e) });
|
|
||||||
notify(`Restore failed: ${e}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
restoreFromDrive: async () => {
|
|
||||||
const ok = get().connected || (await get().connect());
|
|
||||||
if (ok) await get().restore();
|
|
||||||
},
|
|
||||||
openSettings: () => set({ settingsOpen: true }),
|
|
||||||
closeSettings: () => set({ settingsOpen: false }),
|
|
||||||
}));
|
|
||||||
@@ -10,12 +10,10 @@ import {
|
|||||||
cloneChapter,
|
cloneChapter,
|
||||||
createChapter,
|
createChapter,
|
||||||
createPage,
|
createPage,
|
||||||
createPart,
|
|
||||||
normalizeBook,
|
normalizeBook,
|
||||||
} from "../model/book";
|
} from "../model/book";
|
||||||
import { loadActiveChapter, saveActiveChapter } from "../editor/positions";
|
import { loadActiveChapter, saveActiveChapter } from "../editor/positions";
|
||||||
import { saveBook } from "../library";
|
import { saveBook } from "../library";
|
||||||
import { applyBookFonts, resetBookFonts } from "../book-style";
|
|
||||||
|
|
||||||
export const COVER_ID = "__cover__";
|
export const COVER_ID = "__cover__";
|
||||||
|
|
||||||
@@ -33,10 +31,8 @@ interface BookState {
|
|||||||
setChapterContent: (id: string, content: JSONContent) => void;
|
setChapterContent: (id: string, content: JSONContent) => void;
|
||||||
setChapterTitle: (id: string, title: string) => void;
|
setChapterTitle: (id: string, title: string) => void;
|
||||||
setChapterNoTitle: (id: string, noTitle: boolean) => void;
|
setChapterNoTitle: (id: string, noTitle: boolean) => void;
|
||||||
setChapterNoMargin: (id: string, noMargin: boolean) => void;
|
|
||||||
addChapter: () => void;
|
addChapter: () => void;
|
||||||
addPage: (group: "front" | "back", title: string) => void;
|
addPage: (group: "front" | "back", title: string) => void;
|
||||||
addPart: () => void;
|
|
||||||
duplicateChapter: (id: string) => void;
|
duplicateChapter: (id: string) => void;
|
||||||
deleteChapter: (id: string) => void;
|
deleteChapter: (id: string) => void;
|
||||||
moveChapter: (from: number, to: number, toKind?: ChapterKind) => void;
|
moveChapter: (from: number, to: number, toKind?: ChapterKind) => void;
|
||||||
@@ -64,12 +60,10 @@ export const useBook = create<BookState>((set, get) => ({
|
|||||||
const saved = loadActiveChapter(normalized.id);
|
const saved = loadActiveChapter(normalized.id);
|
||||||
const valid = saved === COVER_ID || normalized.chapters.some((c) => c.id === saved);
|
const valid = saved === COVER_ID || normalized.chapters.some((c) => c.id === saved);
|
||||||
const activeChapterId = valid ? (saved as string) : normalized.chapters[0]?.id ?? "";
|
const activeChapterId = valid ? (saved as string) : normalized.chapters[0]?.id ?? "";
|
||||||
applyBookFonts(normalized.settings.fonts);
|
|
||||||
set({ book: normalized, activeChapterId, dirty: false });
|
set({ book: normalized, activeChapterId, dirty: false });
|
||||||
},
|
},
|
||||||
closeBook: () => {
|
closeBook: () => {
|
||||||
flushOutgoing(get().book, get().dirty);
|
flushOutgoing(get().book, get().dirty);
|
||||||
resetBookFonts();
|
|
||||||
set({ book: null, activeChapterId: "", dirty: false });
|
set({ book: null, activeChapterId: "", dirty: false });
|
||||||
},
|
},
|
||||||
setActiveChapter: (id) => set({ activeChapterId: id }),
|
setActiveChapter: (id) => set({ activeChapterId: id }),
|
||||||
@@ -88,13 +82,7 @@ export const useBook = create<BookState>((set, get) => ({
|
|||||||
setChapterNoTitle: (id, noTitle) =>
|
setChapterNoTitle: (id, noTitle) =>
|
||||||
set((s) =>
|
set((s) =>
|
||||||
s.book
|
s.book
|
||||||
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, noTitle, updatedAt: Date.now() } : c)) } }
|
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, noTitle, title: noTitle ? "" : c.title, updatedAt: Date.now() } : c)) } }
|
||||||
: {}
|
|
||||||
),
|
|
||||||
setChapterNoMargin: (id, noMargin) =>
|
|
||||||
set((s) =>
|
|
||||||
s.book
|
|
||||||
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, noMargin, updatedAt: Date.now() } : c)) } }
|
|
||||||
: {}
|
: {}
|
||||||
),
|
),
|
||||||
addChapter: () =>
|
addChapter: () =>
|
||||||
@@ -116,18 +104,6 @@ export const useBook = create<BookState>((set, get) => ({
|
|||||||
chapters.splice(insertAt, 0, page);
|
chapters.splice(insertAt, 0, page);
|
||||||
return { activeChapterId: page.id, dirty: true, book: { ...s.book, chapters } };
|
return { activeChapterId: page.id, dirty: true, book: { ...s.book, chapters } };
|
||||||
}),
|
}),
|
||||||
addPart: () =>
|
|
||||||
set((s) => {
|
|
||||||
if (!s.book) return {};
|
|
||||||
const part = createPart();
|
|
||||||
const chapters = [...s.book.chapters];
|
|
||||||
const bodyEnd = chapters.length - chapters.filter((c) => chapterKind(c) === "back").length;
|
|
||||||
const activeIdx = chapters.findIndex((c) => c.id === s.activeChapterId);
|
|
||||||
const activeKind = activeIdx >= 0 ? chapterKind(chapters[activeIdx]) : null;
|
|
||||||
const insertAt = activeKind === "body" || activeKind === "part" ? activeIdx + 1 : bodyEnd;
|
|
||||||
chapters.splice(insertAt, 0, part);
|
|
||||||
return { activeChapterId: part.id, dirty: true, book: { ...s.book, chapters } };
|
|
||||||
}),
|
|
||||||
duplicateChapter: (id) =>
|
duplicateChapter: (id) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
if (!s.book) return {};
|
if (!s.book) return {};
|
||||||
@@ -154,7 +130,7 @@ export const useBook = create<BookState>((set, get) => ({
|
|||||||
const chapters = [...s.book.chapters];
|
const chapters = [...s.book.chapters];
|
||||||
if (from < 0 || from >= chapters.length) return {};
|
if (from < 0 || from >= chapters.length) return {};
|
||||||
const target = chapters[from];
|
const target = chapters[from];
|
||||||
const nextKind = chapterKind(target) === "part" ? "part" : toKind ?? chapterKind(target);
|
const nextKind = toKind ?? chapterKind(target);
|
||||||
const dest = Math.max(0, Math.min(from < to ? to - 1 : to, chapters.length - 1));
|
const dest = Math.max(0, Math.min(from < to ? to - 1 : to, chapters.length - 1));
|
||||||
if (from === dest && chapterKind(target) === nextKind) return {};
|
if (from === dest && chapterKind(target) === nextKind) return {};
|
||||||
const [moved] = chapters.splice(from, 1);
|
const [moved] = chapters.splice(from, 1);
|
||||||
@@ -164,12 +140,7 @@ export const useBook = create<BookState>((set, get) => ({
|
|||||||
setMetadata: (patch) =>
|
setMetadata: (patch) =>
|
||||||
set((s) => (s.book ? { dirty: true, book: { ...s.book, metadata: { ...s.book.metadata, ...patch } } } : {})),
|
set((s) => (s.book ? { dirty: true, book: { ...s.book, metadata: { ...s.book.metadata, ...patch } } } : {})),
|
||||||
setSettings: (patch) =>
|
setSettings: (patch) =>
|
||||||
set((s) => {
|
set((s) => (s.book ? { dirty: true, book: { ...s.book, settings: { ...s.book.settings, ...patch } } } : {})),
|
||||||
if (!s.book) return {};
|
|
||||||
const book = { ...s.book, settings: { ...s.book.settings, ...patch } };
|
|
||||||
if (patch.fonts) applyBookFonts(book.settings.fonts);
|
|
||||||
return { dirty: true, book };
|
|
||||||
}),
|
|
||||||
setCover: (patch) =>
|
setCover: (patch) =>
|
||||||
set((s) => (s.book ? { dirty: true, book: { ...s.book, cover: { ...s.book.cover, ...patch } } } : {})),
|
set((s) => (s.book ? { dirty: true, book: { ...s.book, cover: { ...s.book.cover, ...patch } } } : {})),
|
||||||
markSaved: () => set({ dirty: false }),
|
markSaved: () => set({ dirty: false }),
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
|
|
||||||
interface ExportPreviewState {
|
|
||||||
open: boolean;
|
|
||||||
openPreview: () => void;
|
|
||||||
close: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useExportPreview = create<ExportPreviewState>((set) => ({
|
|
||||||
open: false,
|
|
||||||
openPreview: () => set({ open: true }),
|
|
||||||
close: () => set({ open: false }),
|
|
||||||
}));
|
|
||||||
@@ -8,17 +8,8 @@ body,
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
text-size-adjust: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
overscroll-behavior: none;
|
|
||||||
background: var(--shell);
|
background: var(--shell);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
@@ -44,15 +35,12 @@ button {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.titlebar {
|
.titlebar {
|
||||||
flex: none;
|
flex: none;
|
||||||
position: relative;
|
height: 46px;
|
||||||
z-index: 45;
|
|
||||||
height: var(--titlebar-h);
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -61,13 +49,6 @@ button {
|
|||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.titlebar .lead {
|
|
||||||
grid-column: 1;
|
|
||||||
justify-self: start;
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.titlebar .doc-title {
|
.titlebar .doc-title {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
font-size: var(--t-2);
|
font-size: var(--t-2);
|
||||||
@@ -169,17 +150,6 @@ body.resizing {
|
|||||||
padding: 6px 0 10px;
|
padding: 6px 0 10px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
transition: width 220ms var(--ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app:not([data-compact="true"])[data-sidebar="false"] .sidebar {
|
|
||||||
width: 0;
|
|
||||||
border-right-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.resizing .sidebar {
|
|
||||||
transition: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -263,37 +233,6 @@ body.resizing .sidebar {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter[data-part="true"] {
|
|
||||||
margin-top: 7px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter[data-part="true"] .text {
|
|
||||||
gap: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter .part-eyebrow {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--t-1);
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.16em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--ink-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter[data-part="true"][data-active="true"] .part-eyebrow {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter[data-part="true"] .title {
|
|
||||||
font-size: var(--t-2);
|
|
||||||
color: var(--ink-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter[data-indent="true"] {
|
|
||||||
padding-left: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter[data-active="true"] .num {
|
.chapter[data-active="true"] .num {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
@@ -488,7 +427,6 @@ body.resizing .sidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chapter-title {
|
.chapter-title {
|
||||||
font-family: var(--font-heading);
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 31px;
|
font-size: 31px;
|
||||||
line-height: 1.16;
|
line-height: 1.16;
|
||||||
@@ -496,10 +434,6 @@ body.resizing .sidebar {
|
|||||||
margin: 15px 0 0;
|
margin: 15px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose :is(h1, h2, h3) {
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose p {
|
.prose p {
|
||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
line-height: 1.68;
|
line-height: 1.68;
|
||||||
@@ -631,15 +565,11 @@ body.resizing .sidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page .p-title {
|
.page .p-title {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-book);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page :is(h2, h3) {
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page p {
|
.page p {
|
||||||
font-family: var(--font-book);
|
font-family: var(--font-book);
|
||||||
font-size: 6.6px;
|
font-size: 6.6px;
|
||||||
@@ -684,22 +614,18 @@ body.resizing .sidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chapter-title-input {
|
.chapter-title-input {
|
||||||
display: block;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 15px 0 0;
|
margin: 15px 0 0;
|
||||||
padding: 0 0 0.3em;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-book);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 31px;
|
font-size: 31px;
|
||||||
line-height: 1.4;
|
line-height: 1.16;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
resize: none;
|
|
||||||
overflow: hidden;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-title-input:focus {
|
.chapter-title-input:focus {
|
||||||
@@ -734,87 +660,6 @@ body.resizing .sidebar {
|
|||||||
accent-color: var(--ink-soft);
|
accent-color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.font-presets {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-preset {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 9px 12px;
|
|
||||||
min-width: 70px;
|
|
||||||
border: 1px solid var(--line-strong);
|
|
||||||
border-radius: var(--r-md);
|
|
||||||
background: var(--raised);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 120ms var(--ease), box-shadow 120ms var(--ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-preset:hover {
|
|
||||||
border-color: var(--ink-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-preset[data-on="true"] {
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: inset 0 0 0 1px var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-preset-demo {
|
|
||||||
font-size: 23px;
|
|
||||||
line-height: 1;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-preset-name {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--t-1);
|
|
||||||
color: var(--ink-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-advanced {
|
|
||||||
align-self: flex-start;
|
|
||||||
margin-top: 4px;
|
|
||||||
padding: 0;
|
|
||||||
background: none;
|
|
||||||
border: 0;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--t-2);
|
|
||||||
color: var(--accent);
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-note {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--t-1);
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--ink-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-sample {
|
|
||||||
padding: 15px 17px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--r-md);
|
|
||||||
background: var(--paper);
|
|
||||||
color: var(--ink);
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-sample-title {
|
|
||||||
display: block;
|
|
||||||
font-size: 21px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.2;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-host,
|
.editor-host,
|
||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -848,7 +693,7 @@ body.resizing .sidebar {
|
|||||||
|
|
||||||
.editor-toolbar {
|
.editor-toolbar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: max(22px, env(safe-area-inset-bottom));
|
bottom: 22px;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -921,24 +766,6 @@ body.resizing .sidebar {
|
|||||||
box-shadow: var(--shadow-pop);
|
box-shadow: var(--shadow-pop);
|
||||||
}
|
}
|
||||||
|
|
||||||
.align-pop {
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 10px);
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 7;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1px;
|
|
||||||
padding: 5px;
|
|
||||||
background: var(--glass);
|
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 999px;
|
|
||||||
box-shadow: var(--shadow-pop);
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-input {
|
.link-input {
|
||||||
width: 220px;
|
width: 220px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
@@ -1124,122 +951,6 @@ body.resizing .sidebar {
|
|||||||
padding: 44px 0;
|
padding: 44px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-overlay {
|
|
||||||
z-index: 55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-bar {
|
|
||||||
flex: none;
|
|
||||||
height: 52px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 0 12px;
|
|
||||||
background: var(--paper);
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-title {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
font-family: var(--font-book);
|
|
||||||
font-size: 15px;
|
|
||||||
color: var(--ink);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-count {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--t-2);
|
|
||||||
color: var(--ink-faint);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-zoom {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-zoom span {
|
|
||||||
width: 44px;
|
|
||||||
text-align: center;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--t-2);
|
|
||||||
color: var(--ink-soft);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-warn {
|
|
||||||
flex: none;
|
|
||||||
padding: 9px 18px;
|
|
||||||
background: var(--accent-wash);
|
|
||||||
color: var(--accent-ink);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--t-2);
|
|
||||||
text-align: center;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-stage {
|
|
||||||
flex: 1;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 28px;
|
|
||||||
background: var(--shell);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-col {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
width: max-content;
|
|
||||||
min-width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-page {
|
|
||||||
flex: none;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 2px;
|
|
||||||
box-shadow: var(--shadow-page);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-canvas {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-loading {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-loading p {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--t-3);
|
|
||||||
color: var(--ink-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner-dark {
|
|
||||||
border-color: var(--line-strong);
|
|
||||||
border-top-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1253,7 +964,8 @@ body.resizing .sidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
width: min(480px, calc(100vw - 32px));
|
width: 480px;
|
||||||
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1394,21 +1106,6 @@ body.resizing .sidebar {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-label {
|
|
||||||
padding: 7px 11px 3px;
|
|
||||||
font-size: var(--t-1);
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--ink-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-sep {
|
|
||||||
height: 1px;
|
|
||||||
margin: 5px 7px;
|
|
||||||
background: var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-overlay {
|
.export-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1464,17 +1161,12 @@ body.resizing .sidebar {
|
|||||||
|
|
||||||
.library {
|
.library {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--shell);
|
background: var(--shell);
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-head {
|
.library-head {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding: 0 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.shelf {
|
.shelf {
|
||||||
@@ -1740,7 +1432,7 @@ body.resizing .sidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cover-title {
|
.cover-title {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-book);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 9.5cqw;
|
font-size: 9.5cqw;
|
||||||
line-height: 1.08;
|
line-height: 1.08;
|
||||||
@@ -2416,7 +2108,7 @@ body.resizing .sidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.device-title {
|
.device-title {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-book);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: calc(24 * var(--dv-scale));
|
font-size: calc(24 * var(--dv-scale));
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
@@ -2446,7 +2138,7 @@ body.resizing .sidebar {
|
|||||||
.device-body h1,
|
.device-body h1,
|
||||||
.device-body h2,
|
.device-body h2,
|
||||||
.device-body h3 {
|
.device-body h3 {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-book);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
margin: 1.2em 0 0.5em;
|
margin: 1.2em 0 0.5em;
|
||||||
@@ -2501,262 +2193,3 @@ body.resizing .sidebar {
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Google Drive backup */
|
|
||||||
.backup-btn[data-state="pending"] {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-btn[data-state="error"] {
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-btn[data-state="working"] svg {
|
|
||||||
animation: backup-spin 0.9s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes backup-spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-restore {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0 44px 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restore-link {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
color: var(--ink-faint);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--t-2);
|
|
||||||
transition: color 120ms var(--ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.restore-link:hover:not(:disabled) {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.restore-link:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-intro,
|
|
||||||
.backup-connecting {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-intro p,
|
|
||||||
.backup-connecting p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--t-3);
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-text-btn {
|
|
||||||
color: var(--ink-faint);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--t-2);
|
|
||||||
transition: color 120ms var(--ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-text-btn:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-waiting {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 9px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--t-3);
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-waiting svg {
|
|
||||||
color: var(--ink-soft);
|
|
||||||
animation: backup-spin 0.9s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-links {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-link-sep {
|
|
||||||
color: var(--line-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-link-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-account {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-account-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-email {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--t-3);
|
|
||||||
color: var(--ink);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-status-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 9px;
|
|
||||||
margin: 18px 0;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--t-3);
|
|
||||||
color: var(--ink-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-dot.is-synced {
|
|
||||||
background: var(--ink-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-dot.is-pending {
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-note {
|
|
||||||
margin: 18px 0 0;
|
|
||||||
color: var(--ink-faint);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -29,67 +29,3 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-display: block;
|
font-display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "EB Garamond";
|
|
||||||
src: url("/fonts/EBGaramond-VF.ttf") format("truetype-variations");
|
|
||||||
font-weight: 400 800;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "EB Garamond";
|
|
||||||
src: url("/fonts/EBGaramond-Italic-VF.ttf") format("truetype-variations");
|
|
||||||
font-weight: 400 800;
|
|
||||||
font-style: italic;
|
|
||||||
font-display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Lora";
|
|
||||||
src: url("/fonts/Lora-VF.ttf") format("truetype-variations");
|
|
||||||
font-weight: 400 700;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Lora";
|
|
||||||
src: url("/fonts/Lora-Italic-VF.ttf") format("truetype-variations");
|
|
||||||
font-weight: 400 700;
|
|
||||||
font-style: italic;
|
|
||||||
font-display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Source Serif 4";
|
|
||||||
src: url("/fonts/SourceSerif4-VF.ttf") format("truetype-variations");
|
|
||||||
font-weight: 200 900;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Source Serif 4";
|
|
||||||
src: url("/fonts/SourceSerif4-Italic-VF.ttf") format("truetype-variations");
|
|
||||||
font-weight: 200 900;
|
|
||||||
font-style: italic;
|
|
||||||
font-display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Fraunces";
|
|
||||||
src: url("/fonts/Fraunces-VF.ttf") format("truetype-variations");
|
|
||||||
font-weight: 100 900;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Fraunces";
|
|
||||||
src: url("/fonts/Fraunces-Italic-VF.ttf") format("truetype-variations");
|
|
||||||
font-weight: 100 900;
|
|
||||||
font-style: italic;
|
|
||||||
font-display: block;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
:root {
|
:root {
|
||||||
--font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
--font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
--font-book: "Literata", Georgia, "Times New Roman", serif;
|
--font-book: "Literata", Georgia, "Times New Roman", serif;
|
||||||
--font-heading: "Literata", Georgia, "Times New Roman", serif;
|
|
||||||
|
|
||||||
--r-sm: 5px;
|
--r-sm: 5px;
|
||||||
--r-md: 8px;
|
--r-md: 8px;
|
||||||
@@ -10,7 +9,6 @@
|
|||||||
--pane-sidebar: 248px;
|
--pane-sidebar: 248px;
|
||||||
--pane-dock: 384px;
|
--pane-dock: 384px;
|
||||||
--measure: 46em;
|
--measure: 46em;
|
||||||
--titlebar-h: 46px;
|
|
||||||
|
|
||||||
--t-1: 11px;
|
--t-1: 11px;
|
||||||
--t-2: 12px;
|
--t-2: 12px;
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
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)");
|
|
||||||