mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb6f14165f | ||
|
|
c79897c3db | ||
|
|
5cc083d670 | ||
|
|
ecc94e6c31 | ||
|
|
baf1154683 | ||
|
|
7a2d7c6aff | ||
|
|
9a38976000 | ||
|
|
28367b6670 | ||
|
|
124c1947c1 | ||
|
|
33ef53936e | ||
|
|
738eb9c9cf | ||
|
|
61bd7cf6e5 | ||
|
|
b4097cc47f | ||
|
|
72d698e427 | ||
|
|
8b83f80d52 | ||
|
|
39609fbd09 | ||
|
|
3177b2f1eb | ||
|
|
19c4823d3d |
No files matched your search
@@ -26,6 +26,10 @@ dist-ssr
|
||||
# Tauri build output
|
||||
src-tauri/target/
|
||||
|
||||
# Google OAuth desktop client (real values, never commit)
|
||||
/google-credentials.json
|
||||
/client_secret_*.json
|
||||
|
||||
# Screenshots & Playwright MCP artifacts
|
||||
.playwright-mcp/
|
||||
/*.png
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## Git Commit Rules
|
||||
|
||||
- Do not make branches, commit in main only
|
||||
- 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.
|
||||
- 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,37 +1,3 @@
|
||||
# margin
|
||||
|
||||
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).
|
||||
Write your book. Own every word. A calm, offline studio to write, format, and publish your book to every store and to print.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "margin-app",
|
||||
"private": true,
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
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.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
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.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
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.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
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.
|
||||
Binary file not shown.
Generated
+135
-3
@@ -3089,8 +3089,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3754,6 +3756,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4632,6 +4635,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "lzma-rs"
|
||||
version = "0.3.0"
|
||||
@@ -4704,12 +4713,16 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||
|
||||
[[package]]
|
||||
name = "margin-app"
|
||||
version = "0.1.1"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"fontdb",
|
||||
"harper-core",
|
||||
"rand 0.8.6",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"spellbook",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
@@ -4720,6 +4733,7 @@ dependencies = [
|
||||
"typst",
|
||||
"typst-as-lib",
|
||||
"typst-pdf",
|
||||
"url",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
@@ -5966,6 +5980,61 @@ dependencies = [
|
||||
"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]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -5993,6 +6062,8 @@ version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
]
|
||||
@@ -6033,6 +6104,7 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -6203,6 +6275,44 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "reqwest"
|
||||
version = "0.13.4"
|
||||
@@ -6425,6 +6535,7 @@ version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -6773,6 +6884,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "serde_with"
|
||||
version = "3.21.0"
|
||||
@@ -7421,7 +7544,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -7610,7 +7733,7 @@ dependencies = [
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -9149,6 +9272,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "margin-app"
|
||||
version = "0.1.2"
|
||||
version = "0.1.5"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -27,6 +27,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
zip = "2"
|
||||
fontdb = "0.23"
|
||||
typst = "0.14.2"
|
||||
typst-pdf = "0.14.2"
|
||||
typst-as-lib = "0.15.5"
|
||||
@@ -35,6 +36,10 @@ spellbook = "0.4"
|
||||
# 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])
|
||||
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).
|
||||
# Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-destroy",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"updater:default",
|
||||
|
||||
@@ -5,9 +5,6 @@ use std::io::{Read, Write};
|
||||
use zip::write::SimpleFileOptions;
|
||||
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)]
|
||||
pub struct EpubFile {
|
||||
path: String,
|
||||
@@ -48,14 +45,6 @@ fn build(files: &[EpubFile]) -> Result<Vec<u8>, 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())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
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())
|
||||
}
|
||||
+15
-1
@@ -1,4 +1,6 @@
|
||||
mod epub;
|
||||
mod fonts;
|
||||
mod gdrive;
|
||||
mod library;
|
||||
mod pdf;
|
||||
mod project;
|
||||
@@ -71,6 +73,11 @@ pub fn run() {
|
||||
|
||||
builder
|
||||
.manage(proofing::new_state())
|
||||
.manage(gdrive::GDriveState::default())
|
||||
.setup(|app| {
|
||||
gdrive::init_session(app.handle());
|
||||
Ok(())
|
||||
})
|
||||
.menu(|handle| build_menu(handle))
|
||||
.on_menu_event(|app, event| {
|
||||
if matches!(
|
||||
@@ -83,6 +90,7 @@ pub fn run() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
epub::package_epub,
|
||||
epub::unzip_epub,
|
||||
fonts::list_system_fonts,
|
||||
pdf::compile_pdf,
|
||||
project::read_file,
|
||||
project::write_file,
|
||||
@@ -92,7 +100,13 @@ pub fn run() {
|
||||
library::save_book,
|
||||
library::delete_book,
|
||||
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)
|
||||
.expect("error while running margin");
|
||||
|
||||
+40
-12
@@ -7,18 +7,37 @@ pub struct BookSummary {
|
||||
id: String,
|
||||
title: String,
|
||||
author: String,
|
||||
corrupt: bool,
|
||||
}
|
||||
|
||||
fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("library");
|
||||
fn corrupt_summary(stem: &str) -> BookSummary {
|
||||
BookSummary {
|
||||
id: stem.to_string(),
|
||||
title: "Unreadable book".to_string(),
|
||||
author: String::new(),
|
||||
corrupt: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
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())?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn book_path(app: &tauri::AppHandle, id: &str) -> Result<PathBuf, String> {
|
||||
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||
return Err("invalid book id".to_string());
|
||||
}
|
||||
Ok(library_dir(app)?.join(format!("{id}.margin")))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
|
||||
let dir = library_dir(&app)?;
|
||||
@@ -31,16 +50,24 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("margin") {
|
||||
continue;
|
||||
}
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let contents = match fs::read_to_string(&path) {
|
||||
Ok(contents) => contents,
|
||||
Err(_) => continue,
|
||||
Err(_) => {
|
||||
books.push(corrupt_summary(stem));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let value: serde_json::Value = match serde_json::from_str(&contents) {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
Err(_) => {
|
||||
books.push(corrupt_summary(stem));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let id = value.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if id.is_empty() {
|
||||
books.push(corrupt_summary(stem));
|
||||
continue;
|
||||
}
|
||||
let metadata = value.get("metadata");
|
||||
@@ -58,6 +85,7 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
|
||||
id: id.to_string(),
|
||||
title,
|
||||
author,
|
||||
corrupt: false,
|
||||
});
|
||||
}
|
||||
Ok(books)
|
||||
@@ -65,19 +93,19 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_book(app: tauri::AppHandle, id: String) -> Result<String, String> {
|
||||
let path = library_dir(&app)?.join(format!("{id}.margin"));
|
||||
let path = book_path(&app, &id)?;
|
||||
fs::read_to_string(&path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_book(app: tauri::AppHandle, id: String, contents: String) -> Result<(), String> {
|
||||
let path = library_dir(&app)?.join(format!("{id}.margin"));
|
||||
fs::write(&path, contents).map_err(|e| e.to_string())
|
||||
let path = book_path(&app, &id)?;
|
||||
crate::project::atomic_write(&path, contents.as_bytes(), true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_book(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
||||
let path = library_dir(&app)?.join(format!("{id}.margin"));
|
||||
let path = book_path(&app, &id)?;
|
||||
if path.exists() {
|
||||
fs::remove_file(&path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
+70
-8
@@ -1,7 +1,8 @@
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use serde::Deserialize;
|
||||
use typst::diag::{Severity, SourceDiagnostic};
|
||||
use tauri::Emitter;
|
||||
use typst::diag::{Severity, SourceDiagnostic, Warned};
|
||||
use typst::layout::PagedDocument;
|
||||
use typst_as_lib::TypstEngine;
|
||||
|
||||
@@ -9,6 +10,29 @@ static LITERATA: &[u8] = include_bytes!("../../public/fonts/Literata-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_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)]
|
||||
pub struct ImageInput {
|
||||
@@ -16,7 +40,12 @@ pub struct ImageInput {
|
||||
data: String,
|
||||
}
|
||||
|
||||
fn compile(source: String, images: &[ImageInput]) -> Result<Vec<u8>, String> {
|
||||
fn compile(
|
||||
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());
|
||||
for image in images {
|
||||
let bytes = STANDARD
|
||||
@@ -25,20 +54,53 @@ fn compile(source: String, images: &[ImageInput]) -> Result<Vec<u8>, String> {
|
||||
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()
|
||||
.main_file(source)
|
||||
.fonts([LITERATA, LITERATA_ITALIC, HANKEN, HANKEN_ITALIC])
|
||||
.fonts(fonts)
|
||||
.with_static_file_resolver(binaries)
|
||||
.build();
|
||||
|
||||
let document: PagedDocument = engine.compile().output.map_err(|e| format_diagnostics(&e))?;
|
||||
|
||||
typst_pdf::pdf(&document, &Default::default()).map_err(|d| format_source_diagnostics(&d))
|
||||
let Warned { output, warnings } = engine.compile();
|
||||
let document: PagedDocument = output.map_err(|e| format_diagnostics(&e))?;
|
||||
let bytes =
|
||||
typst_pdf::pdf(&document, &Default::default()).map_err(|d| format_source_diagnostics(&d))?;
|
||||
let warning_text = if warnings.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format_source_diagnostics(&warnings)
|
||||
};
|
||||
Ok((bytes, warning_text))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn compile_pdf(source: String, images: Vec<ImageInput>) -> Result<tauri::ipc::Response, String> {
|
||||
compile(source, &images).map(tauri::ipc::Response::new)
|
||||
pub fn compile_pdf(
|
||||
app: tauri::AppHandle,
|
||||
source: String,
|
||||
images: Vec<ImageInput>,
|
||||
emit_warnings: bool,
|
||||
bundled_fonts: Vec<String>,
|
||||
system_fonts: Vec<String>,
|
||||
) -> Result<tauri::ipc::Response, String> {
|
||||
let (bytes, warnings) = compile(source, &images, &bundled_fonts, &system_fonts)?;
|
||||
if emit_warnings && !warnings.is_empty() {
|
||||
app.emit("pdf-warnings", warnings).ok();
|
||||
}
|
||||
Ok(tauri::ipc::Response::new(bytes))
|
||||
}
|
||||
|
||||
fn format_diagnostics(error: &typst_as_lib::TypstAsLibError) -> String {
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use std::fs;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
|
||||
let mut name = path.as_os_str().to_owned();
|
||||
name.push(suffix);
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
pub fn atomic_write(path: &Path, bytes: &[u8], backup: bool) -> Result<(), String> {
|
||||
let tmp = with_suffix(path, ".tmp");
|
||||
{
|
||||
let mut f = File::create(&tmp).map_err(|e| e.to_string())?;
|
||||
f.write_all(bytes).map_err(|e| e.to_string())?;
|
||||
f.sync_all().map_err(|e| e.to_string())?;
|
||||
}
|
||||
if path.exists() {
|
||||
if backup {
|
||||
let bak = with_suffix(path, ".bak");
|
||||
let _ = fs::remove_file(&bak);
|
||||
fs::rename(path, &bak).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
fs::remove_file(path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
fs::rename(&tmp, path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_file(path: String) -> Result<String, String> {
|
||||
@@ -9,11 +36,11 @@ pub fn read_file(path: String) -> Result<String, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn write_file(path: String, contents: String) -> Result<(), String> {
|
||||
fs::write(&path, contents).map_err(|e| e.to_string())
|
||||
atomic_write(Path::new(&path), contents.as_bytes(), true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn write_bytes(path: String, data: String) -> Result<(), String> {
|
||||
let bytes = STANDARD.decode(data.as_bytes()).map_err(|e| e.to_string())?;
|
||||
fs::write(&path, bytes).map_err(|e| e.to_string())
|
||||
atomic_write(Path::new(&path), &bytes, false)
|
||||
}
|
||||
+27
-23
@@ -176,36 +176,40 @@ fn collect_grammar(harper: &mut Harper, text: &str, chars: &[char], issues: &mut
|
||||
#[tauri::command]
|
||||
pub async fn proof_text(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, ProofState>,
|
||||
text: String,
|
||||
spelling: bool,
|
||||
grammar: bool,
|
||||
) -> Result<Vec<Issue>, String> {
|
||||
let mut guard = state.inner().lock().map_err(|e| e.to_string())?;
|
||||
if guard.is_none() {
|
||||
*guard = Some(Engine {
|
||||
speller: None,
|
||||
harper: None,
|
||||
custom: load_custom(&app),
|
||||
});
|
||||
}
|
||||
let engine = guard.as_mut().unwrap();
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<Vec<Issue>, String> {
|
||||
let state = app.state::<ProofState>();
|
||||
let mut guard = state.lock().map_err(|e| e.to_string())?;
|
||||
if guard.is_none() {
|
||||
*guard = Some(Engine {
|
||||
speller: None,
|
||||
harper: None,
|
||||
custom: load_custom(&app),
|
||||
});
|
||||
}
|
||||
let engine = guard.as_mut().unwrap();
|
||||
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut issues = Vec::new();
|
||||
if spelling {
|
||||
if engine.speller.is_none() {
|
||||
engine.speller = Some(build_speller(&app)?);
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut issues = Vec::new();
|
||||
if spelling {
|
||||
if engine.speller.is_none() {
|
||||
engine.speller = Some(build_speller(&app)?);
|
||||
}
|
||||
collect_spelling(engine.speller.as_ref().unwrap(), &engine.custom, &chars, &mut issues);
|
||||
}
|
||||
collect_spelling(engine.speller.as_ref().unwrap(), &engine.custom, &chars, &mut issues);
|
||||
}
|
||||
if grammar {
|
||||
if engine.harper.is_none() {
|
||||
engine.harper = Some(build_harper());
|
||||
if grammar {
|
||||
if engine.harper.is_none() {
|
||||
engine.harper = Some(build_harper());
|
||||
}
|
||||
collect_grammar(engine.harper.as_mut().unwrap(), &text, &chars, &mut issues);
|
||||
}
|
||||
collect_grammar(engine.harper.as_mut().unwrap(), &text, &chars, &mut issues);
|
||||
}
|
||||
Ok(issues)
|
||||
Ok(issues)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "margin",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.5",
|
||||
"identifier": "studio.margin.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": "default-src 'self'; img-src 'self' data: blob:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; connect-src 'self' ipc: http://ipc.localhost"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
+59
-3
@@ -1,10 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { Library } from "./components/Library";
|
||||
import { EditorView } from "./components/EditorView";
|
||||
import { BackupSettings } from "./components/BackupSettings";
|
||||
import { useBook } from "./store/useBook";
|
||||
import { useBackup } from "./store/useBackup";
|
||||
import { isDesktop } from "./ipc";
|
||||
import { newBook } from "./library";
|
||||
import { createAndOpenBook, saveBook } from "./library";
|
||||
import { runExport } from "./export/run";
|
||||
import { checkForUpdates } from "./updater";
|
||||
|
||||
@@ -17,17 +20,70 @@ function App() {
|
||||
checkForUpdates(true);
|
||||
const unlisten = listen<string>("menu-action", (event) => {
|
||||
const state = useBook.getState();
|
||||
if (event.payload === "new-book") state.openBook(newBook());
|
||||
if (event.payload === "new-book") createAndOpenBook(state.openBook, state.setNotice);
|
||||
else if (event.payload === "export-pdf") runExport("pdf");
|
||||
else if (event.payload === "export-epub") runExport("epub");
|
||||
else if (event.payload === "check-updates") checkForUpdates(false);
|
||||
});
|
||||
const unlistenWarn = listen<string>("pdf-warnings", (event) => {
|
||||
useBook.getState().setNotice(`PDF exported with warnings:\n${event.payload}`);
|
||||
});
|
||||
return () => {
|
||||
unlisten.then((stop) => stop());
|
||||
unlistenWarn.then((stop) => stop());
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return;
|
||||
const win = getCurrentWindow();
|
||||
const unlisten = win.onCloseRequested(async (event) => {
|
||||
const { book, dirty } = useBook.getState();
|
||||
const { connected } = useBackup.getState();
|
||||
if ((!book || !dirty) && !connected) return;
|
||||
event.preventDefault();
|
||||
if (book && dirty) await saveBook(book).catch(() => {});
|
||||
if (connected) {
|
||||
await Promise.race([
|
||||
useBackup.getState().backup(true),
|
||||
new Promise((resolve) => setTimeout(resolve, 8000)),
|
||||
]);
|
||||
}
|
||||
win.destroy();
|
||||
});
|
||||
return () => {
|
||||
unlisten.then((stop) => stop());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return book ? <EditorView /> : <Library onOpen={openBook} />;
|
||||
useEffect(() => {
|
||||
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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,63 @@
|
||||
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()}`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import type { Device } from "../devices";
|
||||
import type { Book } from "../model/book";
|
||||
import { CoverArt } from "./CoverArt";
|
||||
|
||||
interface DeviceFrameProps {
|
||||
device: Device;
|
||||
eyebrow?: ReactNode;
|
||||
title?: ReactNode;
|
||||
html?: string;
|
||||
cover?: Book;
|
||||
}
|
||||
|
||||
function frameVars(device: Device): CSSProperties {
|
||||
return {
|
||||
"--dv-bg": device.bg,
|
||||
"--dv-ink": device.ink,
|
||||
"--dv-fs": device.fontSize,
|
||||
"--dv-lh": device.lineHeight,
|
||||
"--dv-mx": device.marginX,
|
||||
"--dv-my": device.marginY,
|
||||
"--dv-bezel": device.bezel,
|
||||
"--dv-bezel-w": device.bezelWidth,
|
||||
"--dv-radius": device.radius,
|
||||
"--dv-aspect": device.aspect,
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
function StatusBar() {
|
||||
return (
|
||||
<div className="device-statusbar">
|
||||
<span className="device-clock">9:41</span>
|
||||
<span className="device-batt" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceFrame({ device, eyebrow, title, html, cover }: DeviceFrameProps) {
|
||||
return (
|
||||
<div className="device-stage">
|
||||
<div className="device-frame" data-kind={device.kind} style={frameVars(device)}>
|
||||
<div className="device-screen">
|
||||
{device.kind === "tablet" && <StatusBar />}
|
||||
<div className="device-content">
|
||||
{cover ? (
|
||||
<CoverArt book={cover} />
|
||||
) : (
|
||||
<>
|
||||
<div className="device-opener">
|
||||
{eyebrow && <div className="device-eyebrow">{eyebrow}</div>}
|
||||
{title && <div className="device-title">{title}</div>}
|
||||
</div>
|
||||
<div className="device-body" dangerouslySetInnerHTML={{ __html: html ?? "" }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{device.kind === "eink" && (
|
||||
<div className="device-footer">
|
||||
<span className="device-foot-title">{cover ? device.label : title}</span>
|
||||
<span className="device-batt" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+116
-44
@@ -1,12 +1,15 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { generateHTML } from "@tiptap/core";
|
||||
import { COVER_ID, useBook } from "../store/useBook";
|
||||
import { type TrimSize, bodyNumber, chapterKind } from "../model/book";
|
||||
import { type Chapter, type TrimSize, bodyNumber, chapterKind } from "../model/book";
|
||||
import { editorExtensions } from "../editor/extensions";
|
||||
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
|
||||
import { compilePdf, isDesktop } from "../ipc";
|
||||
import { DEVICES, type Device, findDevice } from "../devices";
|
||||
import { usePreviewMode } from "../store/usePreviewMode";
|
||||
import { PdfPreview } from "./PdfPreview";
|
||||
import { CoverArt } from "./CoverArt";
|
||||
import { DeviceFrame } from "./DeviceFrame";
|
||||
|
||||
const TRIMS: { value: TrimSize; label: string }[] = [
|
||||
{ value: "6x9", label: "6 × 9 in" },
|
||||
@@ -15,26 +18,77 @@ const TRIMS: { value: TrimSize; label: string }[] = [
|
||||
{ value: "a5", label: "A5" },
|
||||
];
|
||||
|
||||
function TrimSelect() {
|
||||
function PreviewSelect() {
|
||||
const trim = useBook((s) => s.book?.settings.trim ?? "6x9");
|
||||
const setSettings = useBook((s) => s.setSettings);
|
||||
const mode = usePreviewMode((s) => s.mode);
|
||||
const setMode = usePreviewMode((s) => s.setMode);
|
||||
const value = mode === "print" ? trim : mode;
|
||||
return (
|
||||
<select
|
||||
className="trim-select"
|
||||
value={trim}
|
||||
onChange={(e) => setSettings({ trim: e.target.value as TrimSize })}
|
||||
title="Trim size"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (findDevice(v)) {
|
||||
setMode(v as Device["id"]);
|
||||
} else {
|
||||
setSettings({ trim: v as TrimSize });
|
||||
setMode("print");
|
||||
}
|
||||
}}
|
||||
title="Preview format"
|
||||
>
|
||||
{TRIMS.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
<optgroup label="Print">
|
||||
{TRIMS.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Devices">
|
||||
{DEVICES.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function DockHead({ meta }: { meta?: ReactNode }) {
|
||||
return (
|
||||
<div className="dock-head">
|
||||
<span className="label">Preview</span>
|
||||
<div className="dock-tools">
|
||||
{meta}
|
||||
<PreviewSelect />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function chapterEyebrow(chapter: Chapter, chapters: Chapter[], idx: number): string {
|
||||
const kind = chapterKind(chapter);
|
||||
return kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
|
||||
}
|
||||
|
||||
function useActiveChapter() {
|
||||
const book = useBook((s) => s.book);
|
||||
const activeChapterId = useBook((s) => s.activeChapterId);
|
||||
const chapters = book?.chapters ?? [];
|
||||
const idx = chapters.findIndex((c) => c.id === activeChapterId);
|
||||
const chapter = chapters[idx] ?? chapters[0];
|
||||
const html = useMemo(() => (chapter ? generateHTML(chapter.content, editorExtensions) : ""), [chapter]);
|
||||
const eyebrow = chapter ? chapterEyebrow(chapter, chapters, idx) : "";
|
||||
return { book, coverActive: activeChapterId === COVER_ID, chapters, idx, chapter, html, eyebrow };
|
||||
}
|
||||
|
||||
export function Dock() {
|
||||
const mode = usePreviewMode((s) => s.mode);
|
||||
const device = findDevice(mode);
|
||||
if (device) return <DeviceDock device={device} />;
|
||||
return isDesktop ? <PdfDock /> : <HtmlDock />;
|
||||
}
|
||||
|
||||
@@ -53,8 +107,8 @@ function PdfDock() {
|
||||
clearTimeout(timer.current);
|
||||
setBusy(true);
|
||||
timer.current = setTimeout(() => {
|
||||
const { source, images } = coverActive ? coverToPdfInputs(book) : chapterToPdfInputs(book, idx);
|
||||
compilePdf(source, images)
|
||||
const { source, images, fonts } = coverActive ? coverToPdfInputs(book) : chapterToPdfInputs(book, idx);
|
||||
compilePdf(source, images, false, fonts)
|
||||
.then((bytes) => {
|
||||
setPdf(bytes);
|
||||
setError(null);
|
||||
@@ -67,13 +121,7 @@ function PdfDock() {
|
||||
|
||||
return (
|
||||
<section className="dock">
|
||||
<div className="dock-head">
|
||||
<span className="label">Preview</span>
|
||||
<div className="dock-tools">
|
||||
{busy && <span className="meta">updating…</span>}
|
||||
<TrimSelect />
|
||||
</div>
|
||||
</div>
|
||||
<DockHead meta={busy ? <span className="meta">updating…</span> : null} />
|
||||
{error ? (
|
||||
<pre className="dock-error">{error}</pre>
|
||||
) : pdf ? (
|
||||
@@ -86,26 +134,14 @@ function PdfDock() {
|
||||
}
|
||||
|
||||
function HtmlDock() {
|
||||
const book = useBook((s) => s.book);
|
||||
const activeChapterId = useBook((s) => s.activeChapterId);
|
||||
const coverActive = activeChapterId === COVER_ID;
|
||||
const chapters = book?.chapters ?? [];
|
||||
const idx = chapters.findIndex((c) => c.id === activeChapterId);
|
||||
const chapter = chapters[idx] ?? chapters[0];
|
||||
const html = useMemo(() => (chapter ? generateHTML(chapter.content, editorExtensions) : ""), [chapter]);
|
||||
const { book, coverActive, chapters, idx, chapter, html, eyebrow } = useActiveChapter();
|
||||
|
||||
if (!book) return null;
|
||||
|
||||
if (coverActive) {
|
||||
return (
|
||||
<section className="dock">
|
||||
<div className="dock-head">
|
||||
<span className="label">Preview</span>
|
||||
<div className="dock-tools">
|
||||
<span className="meta">Cover</span>
|
||||
<TrimSelect />
|
||||
</div>
|
||||
</div>
|
||||
<DockHead meta={<span className="meta">Cover</span>} />
|
||||
<div className="cover-stage">
|
||||
<CoverArt book={book} />
|
||||
</div>
|
||||
@@ -115,21 +151,15 @@ function HtmlDock() {
|
||||
|
||||
if (!chapter) return null;
|
||||
|
||||
const kind = chapterKind(chapter);
|
||||
const eyebrow =
|
||||
kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
|
||||
|
||||
return (
|
||||
<section className="dock">
|
||||
<div className="dock-head">
|
||||
<span className="label">Preview</span>
|
||||
<div className="dock-tools">
|
||||
<DockHead
|
||||
meta={
|
||||
<span className="meta">
|
||||
{idx + 1} of {chapters.length}
|
||||
</span>
|
||||
<TrimSelect />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="page">
|
||||
<div className="p-opener">
|
||||
<div className="p-num">{eyebrow}</div>
|
||||
@@ -141,3 +171,45 @@ function HtmlDock() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceDock({ device }: { device: Device }) {
|
||||
const { book, coverActive, chapters, idx, chapter, html, eyebrow } = useActiveChapter();
|
||||
|
||||
if (!book) return null;
|
||||
|
||||
if (coverActive) {
|
||||
return (
|
||||
<section className="dock">
|
||||
<DockHead meta={<span className="meta">Cover</span>} />
|
||||
<DeviceFrame device={device} cover={book} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!chapter) {
|
||||
return (
|
||||
<section className="dock">
|
||||
<DockHead />
|
||||
<div className="dock-empty">No chapter selected.</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="dock">
|
||||
<DockHead
|
||||
meta={
|
||||
<span className="meta">
|
||||
{idx + 1} of {chapters.length}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DeviceFrame
|
||||
device={device}
|
||||
eyebrow={eyebrow}
|
||||
title={chapter.noTitle ? undefined : chapter.title || "Untitled"}
|
||||
html={html}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Dock } from "./Dock";
|
||||
import { ResizeHandle } from "./ResizeHandle";
|
||||
import { Icon } from "./Icon";
|
||||
import { Settings } from "./Settings";
|
||||
import { BackupButton } from "./BackupButton";
|
||||
import { CoverView } from "./CoverView";
|
||||
import { FindBar } from "./FindBar";
|
||||
import { ProofPopover } from "./ProofPopover";
|
||||
@@ -12,6 +13,7 @@ import { Editor } from "../editor/Editor";
|
||||
import { FloatingToolbar } from "../editor/FloatingToolbar";
|
||||
import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing";
|
||||
import { COVER_ID, useBook } from "../store/useBook";
|
||||
import { useBackup } from "../store/useBackup";
|
||||
import { useProofing } from "../store/useProofing";
|
||||
import { useTheme } from "../store/useTheme";
|
||||
import { useWidth } from "../store/useWidth";
|
||||
@@ -75,8 +77,14 @@ export function EditorView() {
|
||||
|
||||
const saveNow = useCallback(() => {
|
||||
const current = useBook.getState().book;
|
||||
if (current) saveBook(current).then(markSaved).catch((e) => setNotice(`Save failed: ${e}`));
|
||||
}, [markSaved]);
|
||||
if (!current) return;
|
||||
saveBook(current)
|
||||
.then(() => {
|
||||
if (useBook.getState().book === current) markSaved();
|
||||
useBackup.getState().refresh();
|
||||
})
|
||||
.catch((e) => setNotice(`Save failed: ${e}`));
|
||||
}, [markSaved, setNotice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!book || !dirty || !isDesktop) return;
|
||||
@@ -158,6 +166,7 @@ export function EditorView() {
|
||||
{dirty && <span className="dirty-dot" />}
|
||||
</button>
|
||||
<div className="actions">
|
||||
{isDesktop && <BackupButton />}
|
||||
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)">
|
||||
<Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" />
|
||||
</button>
|
||||
@@ -263,10 +272,15 @@ export function EditorView() {
|
||||
)}
|
||||
</header>
|
||||
<Editor
|
||||
key={chapter.id}
|
||||
bookId={book.id}
|
||||
chapterId={chapter.id}
|
||||
content={chapter.content}
|
||||
onChange={(content) => setChapterContent(chapter.id, content)}
|
||||
onReady={setEditor}
|
||||
onContentError={() =>
|
||||
setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.")
|
||||
}
|
||||
/>
|
||||
</article>
|
||||
<FloatingToolbar editor={editor} />
|
||||
|
||||
+50
-11
@@ -1,18 +1,26 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { type BookSummary, deleteBook, exampleBook, listBooks, loadBook, newBook, saveBook } from "../library";
|
||||
import { type BookSummary, createAndOpenBook, deleteBook, exampleBook, listBooks, loadBook, saveBook } from "../library";
|
||||
import type { Book } from "../model/book";
|
||||
import { importEpub } from "../import/epub";
|
||||
import { clearPositions } from "../editor/positions";
|
||||
import { isDesktop } from "../ipc";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { Icon } from "./Icon";
|
||||
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 }) {
|
||||
const [books, setBooks] = useState<BookSummary[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState<string | 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 = () =>
|
||||
listBooks()
|
||||
@@ -22,6 +30,14 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (restoreNonce) refresh();
|
||||
}, [restoreNonce]);
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const timer = setTimeout(() => setNotice(null), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [notice]);
|
||||
|
||||
const handleExample = async () => {
|
||||
const copy = exampleBook();
|
||||
@@ -38,6 +54,7 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||
const removeBook = async () => {
|
||||
if (!pendingDelete) return;
|
||||
await deleteBook(pendingDelete.id);
|
||||
clearPositions(pendingDelete.id);
|
||||
setPendingDelete(null);
|
||||
refresh();
|
||||
};
|
||||
@@ -62,9 +79,11 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<button className="card card-action" onClick={() => onOpen(newBook())}>
|
||||
<button className="card card-action" onClick={() => createAndOpenBook(onOpen, setNotice)}>
|
||||
<Icon d="M12 5v14M5 12h14" size={20} />
|
||||
<span>New book</span>
|
||||
</button>
|
||||
@@ -78,14 +97,34 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||
<span className="card-badge">Example</span>
|
||||
</button>
|
||||
)}
|
||||
{books.map((b) => (
|
||||
<div key={b.id} className="card card-book" onClick={() => loadBook(b.id).then(onOpen)}>
|
||||
<span className="card-title">{b.title || "Untitled"}</span>
|
||||
{b.author && <span className="card-author">{b.author}</span>}
|
||||
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
|
||||
</div>
|
||||
))}
|
||||
{books.map((b) =>
|
||||
b.corrupt ? (
|
||||
<div key={b.id} className="card card-book card-corrupt">
|
||||
<span className="card-title">{b.title}</span>
|
||||
<span className="card-author">Couldn't be read; a .bak backup may sit beside it.</span>
|
||||
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={b.id}
|
||||
className="card card-book"
|
||||
onClick={() => loadBook(b.id).then(onOpen).catch((e) => setNotice(`Could not open book: ${e}`))}
|
||||
>
|
||||
<span className="card-title">{b.title || "Untitled"}</span>
|
||||
{b.author && <span className="card-author">{b.author}</span>}
|
||||
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
|
||||
</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 && (
|
||||
<div className="toast" onClick={() => setNotice(null)}>
|
||||
{notice}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as pdfjs from "pdfjs-dist";
|
||||
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||
|
||||
@@ -6,42 +6,56 @@ pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||
|
||||
export function PdfPreview({ data }: { data: Uint8Array }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = ref.current;
|
||||
if (!container) return;
|
||||
let cancelled = false;
|
||||
const loadingTask = pdfjs.getDocument({ data: data.slice() });
|
||||
|
||||
(async () => {
|
||||
const doc = await pdfjs.getDocument({ data: data.slice() }).promise;
|
||||
if (cancelled) return;
|
||||
const width = container.clientWidth;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const canvases: HTMLCanvasElement[] = [];
|
||||
try {
|
||||
const doc = await loadingTask.promise;
|
||||
if (cancelled) return;
|
||||
const width = container.clientWidth;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const canvases: HTMLCanvasElement[] = [];
|
||||
|
||||
for (let n = 1; n <= doc.numPages; n++) {
|
||||
const page = await doc.getPage(n);
|
||||
for (let n = 1; n <= doc.numPages; n++) {
|
||||
const page = await doc.getPage(n);
|
||||
if (cancelled) return;
|
||||
const base = page.getViewport({ scale: 1 });
|
||||
const scale = width / base.width;
|
||||
const viewport = page.getViewport({ scale: scale * dpr });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "pdf-page";
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
canvas.style.width = "100%";
|
||||
await page.render({ canvas, viewport }).promise;
|
||||
if (cancelled) return;
|
||||
canvases.push(canvas);
|
||||
}
|
||||
container.replaceChildren(...canvases);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
const base = page.getViewport({ scale: 1 });
|
||||
const scale = width / base.width;
|
||||
const viewport = page.getViewport({ scale: scale * dpr });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "pdf-page";
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
canvas.style.width = "100%";
|
||||
const context = canvas.getContext("2d")!;
|
||||
await page.render({ canvas, canvasContext: context, viewport }).promise;
|
||||
if (cancelled) return;
|
||||
canvases.push(canvas);
|
||||
container.replaceChildren();
|
||||
setError(String(e));
|
||||
}
|
||||
container.replaceChildren(...canvases);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
loadingTask.destroy();
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
return <div className="pdf-pages" ref={ref} />;
|
||||
return (
|
||||
<>
|
||||
{error && <pre className="dock-error">{error}</pre>}
|
||||
<div className="pdf-pages" ref={ref} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+102
-3
@@ -1,6 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
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";
|
||||
|
||||
const LANGUAGES = [
|
||||
@@ -12,6 +24,8 @@ const LANGUAGES = [
|
||||
{ value: "pt", label: "Portuguese" },
|
||||
];
|
||||
|
||||
let systemFontCache: string[] | null = null;
|
||||
|
||||
interface Draft {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -19,6 +33,7 @@ interface Draft {
|
||||
isbn: string;
|
||||
language: string;
|
||||
bleed: boolean;
|
||||
fonts: BookFonts;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
@@ -30,6 +45,30 @@ 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 }) {
|
||||
const book = useBook((s) => s.book);
|
||||
const setMetadata = useBook((s) => s.setMetadata);
|
||||
@@ -42,16 +81,31 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
||||
isbn: book?.metadata.isbn ?? "",
|
||||
language: book?.metadata.language ?? "en",
|
||||
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;
|
||||
|
||||
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 { bleed, ...metadata } = draft;
|
||||
const { bleed, fonts, ...metadata } = draft;
|
||||
setMetadata(metadata);
|
||||
setSettings({ bleed });
|
||||
setSettings({ bleed, fonts });
|
||||
onSave();
|
||||
onClose();
|
||||
};
|
||||
@@ -87,6 +141,51 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
||||
))}
|
||||
</select>
|
||||
</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">
|
||||
<input type="checkbox" checked={draft.bleed} onChange={(e) => set({ bleed: e.target.checked })} />
|
||||
Add bleed for full-page images (print)
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
export type DeviceId = "kindle" | "kobo" | "ipad" | "iphone" | "android";
|
||||
export type PreviewMode = "print" | DeviceId;
|
||||
export type DeviceKind = "eink" | "phone" | "tablet";
|
||||
|
||||
export interface Device {
|
||||
id: DeviceId;
|
||||
label: string;
|
||||
kind: DeviceKind;
|
||||
bg: string;
|
||||
ink: string;
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
marginX: number;
|
||||
marginY: number;
|
||||
aspect: number;
|
||||
bezel: string;
|
||||
bezelWidth: number;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
export const DEVICES: Device[] = [
|
||||
{
|
||||
id: "kindle",
|
||||
label: "Kindle Paperwhite",
|
||||
kind: "eink",
|
||||
bg: "#f4f3ee",
|
||||
ink: "#2b2b2b",
|
||||
fontSize: 16,
|
||||
lineHeight: 1.55,
|
||||
marginX: 34,
|
||||
marginY: 40,
|
||||
aspect: 1240 / 1680,
|
||||
bezel: "#1c1c1c",
|
||||
bezelWidth: 18,
|
||||
radius: 14,
|
||||
},
|
||||
{
|
||||
id: "kobo",
|
||||
label: "Kobo",
|
||||
kind: "eink",
|
||||
bg: "#f6f5f0",
|
||||
ink: "#262626",
|
||||
fontSize: 16,
|
||||
lineHeight: 1.6,
|
||||
marginX: 34,
|
||||
marginY: 40,
|
||||
aspect: 1264 / 1680,
|
||||
bezel: "#2a2a2a",
|
||||
bezelWidth: 16,
|
||||
radius: 16,
|
||||
},
|
||||
{
|
||||
id: "ipad",
|
||||
label: "iPad",
|
||||
kind: "tablet",
|
||||
bg: "#fbfaf7",
|
||||
ink: "#1d1d1f",
|
||||
fontSize: 18,
|
||||
lineHeight: 1.65,
|
||||
marginX: 56,
|
||||
marginY: 56,
|
||||
aspect: 1640 / 2360,
|
||||
bezel: "#3a3a3c",
|
||||
bezelWidth: 20,
|
||||
radius: 28,
|
||||
},
|
||||
{
|
||||
id: "iphone",
|
||||
label: "iPhone",
|
||||
kind: "phone",
|
||||
bg: "#ffffff",
|
||||
ink: "#1d1d1f",
|
||||
fontSize: 17,
|
||||
lineHeight: 1.6,
|
||||
marginX: 26,
|
||||
marginY: 30,
|
||||
aspect: 1179 / 2556,
|
||||
bezel: "#1a1a1c",
|
||||
bezelWidth: 12,
|
||||
radius: 44,
|
||||
},
|
||||
{
|
||||
id: "android",
|
||||
label: "Android phone",
|
||||
kind: "phone",
|
||||
bg: "#ffffff",
|
||||
ink: "#202124",
|
||||
fontSize: 16,
|
||||
lineHeight: 1.6,
|
||||
marginX: 24,
|
||||
marginY: 28,
|
||||
aspect: 1080 / 2400,
|
||||
bezel: "#0f0f0f",
|
||||
bezelWidth: 11,
|
||||
radius: 36,
|
||||
},
|
||||
];
|
||||
|
||||
const DEVICE_MAP = new Map<string, Device>(DEVICES.map((d) => [d.id, d]));
|
||||
|
||||
const KEY = "margin-preview-mode";
|
||||
|
||||
export function findDevice(id: string): Device | undefined {
|
||||
return DEVICE_MAP.get(id);
|
||||
}
|
||||
|
||||
export function initialPreviewMode(): PreviewMode {
|
||||
const saved = localStorage.getItem(KEY);
|
||||
if (saved === "print" || (saved && DEVICE_MAP.has(saved))) return saved as PreviewMode;
|
||||
return "print";
|
||||
}
|
||||
|
||||
export function persistPreviewMode(mode: PreviewMode) {
|
||||
localStorage.setItem(KEY, mode);
|
||||
}
|
||||
+48
-27
@@ -2,67 +2,88 @@ import { useEffect, useRef } from "react";
|
||||
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { editorExtensions } from "./extensions";
|
||||
import { loadPosition, savePosition, type ChapterPosition } from "./positions";
|
||||
|
||||
interface EditorProps {
|
||||
bookId: string;
|
||||
chapterId: string;
|
||||
content: JSONContent;
|
||||
onChange: (content: JSONContent) => void;
|
||||
onReady: (editor: TiptapEditor | null) => void;
|
||||
onContentError: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface ChapterPosition {
|
||||
from: number;
|
||||
to: number;
|
||||
scroll: number;
|
||||
}
|
||||
|
||||
export function Editor({ chapterId, content, onChange, onReady }: EditorProps) {
|
||||
export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: EditorProps) {
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
const onContentErrorRef = useRef(onContentError);
|
||||
onContentErrorRef.current = onContentError;
|
||||
|
||||
const positions = useRef(new Map<string, ChapterPosition>());
|
||||
const openChapter = useRef(chapterId);
|
||||
const latest = useRef<ChapterPosition | null>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: editorExtensions,
|
||||
content,
|
||||
immediatelyRender: false,
|
||||
enableContentCheck: true,
|
||||
editorProps: { attributes: { class: "prose" } },
|
||||
onContentError: ({ error }) => onContentErrorRef.current(error),
|
||||
onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onReady(editor);
|
||||
return () => onReady(null);
|
||||
}, [editor, onReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
let active = true;
|
||||
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
|
||||
|
||||
const previous = openChapter.current;
|
||||
if (previous !== chapterId) {
|
||||
const { from, to } = editor.state.selection;
|
||||
positions.current.set(previous, { from, to, scroll: scroller?.scrollTop ?? 0 });
|
||||
}
|
||||
openChapter.current = chapterId;
|
||||
|
||||
editor.commands.setContent(content, { emitUpdate: false });
|
||||
|
||||
const saved = positions.current.get(chapterId);
|
||||
const saved = loadPosition(bookId, chapterId);
|
||||
const size = editor.state.doc.content.size;
|
||||
editor.commands.setTextSelection(
|
||||
saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0
|
||||
);
|
||||
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
|
||||
editor.chain().setTextSelection(selection).focus(undefined, { scrollIntoView: false }).run();
|
||||
|
||||
if (scroller) {
|
||||
const top = saved?.scroll ?? 0;
|
||||
scroller.scrollTop = top;
|
||||
requestAnimationFrame(() => {
|
||||
scroller.scrollTop = top;
|
||||
});
|
||||
const restore = () => {
|
||||
if (active) scroller.scrollTop = top;
|
||||
};
|
||||
restore();
|
||||
requestAnimationFrame(restore);
|
||||
document.fonts?.ready.then(restore).catch(() => {});
|
||||
}
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chapterId, editor]);
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const capture = () => {
|
||||
const { from, to } = editor.state.selection;
|
||||
latest.current = { from, to, scroll: scroller?.scrollTop ?? 0 };
|
||||
};
|
||||
const persist = () => {
|
||||
capture();
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => savePosition(bookId, chapterId, latest.current!), 400);
|
||||
};
|
||||
editor.on("selectionUpdate", persist);
|
||||
scroller?.addEventListener("scroll", persist, { passive: true });
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
editor.off("selectionUpdate", persist);
|
||||
scroller?.removeEventListener("scroll", persist);
|
||||
if (latest.current) savePosition(bookId, chapterId, latest.current);
|
||||
};
|
||||
}, [editor, bookId, chapterId]);
|
||||
|
||||
return <EditorContent editor={editor} className="editor-host" />;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export interface ChapterPosition {
|
||||
from: number;
|
||||
to: number;
|
||||
scroll: number;
|
||||
}
|
||||
|
||||
const KEY = "margin-positions";
|
||||
|
||||
type Store = Record<string, Record<string, ChapterPosition>>;
|
||||
|
||||
function readAll(): Store {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEY) || "{}") as Store;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function write(key: string, value: unknown): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadPosition(bookId: string, chapterId: string): ChapterPosition | null {
|
||||
return readAll()[bookId]?.[chapterId] ?? null;
|
||||
}
|
||||
|
||||
export function savePosition(bookId: string, chapterId: string, position: ChapterPosition): void {
|
||||
const all = readAll();
|
||||
(all[bookId] ||= {})[chapterId] = position;
|
||||
write(KEY, all);
|
||||
}
|
||||
|
||||
const ACTIVE_KEY = "margin-active-chapter";
|
||||
|
||||
function readActive(): Record<string, string> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(ACTIVE_KEY) || "{}") as Record<string, string>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function loadActiveChapter(bookId: string): string | null {
|
||||
return readActive()[bookId] ?? null;
|
||||
}
|
||||
|
||||
export function saveActiveChapter(bookId: string, chapterId: string): void {
|
||||
const all = readActive();
|
||||
all[bookId] = chapterId;
|
||||
write(ACTIVE_KEY, all);
|
||||
}
|
||||
|
||||
export function clearPositions(bookId: string): void {
|
||||
const positions = readAll();
|
||||
const active = readActive();
|
||||
delete positions[bookId];
|
||||
delete active[bookId];
|
||||
write(KEY, positions);
|
||||
write(ACTIVE_KEY, active);
|
||||
}
|
||||
+78
-31
@@ -1,6 +1,7 @@
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { type Book, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
|
||||
import { fontStack, fontsUsed } from "../model/fonts";
|
||||
|
||||
export interface EpubFile {
|
||||
path: string;
|
||||
@@ -25,7 +26,7 @@ function slug(text: string): string {
|
||||
|
||||
function imageExtension(dataUrl: string): string {
|
||||
const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl);
|
||||
const kind = (match?.[1] ?? "png").toLowerCase();
|
||||
const kind = (match?.[1] ?? "png").toLowerCase().split("+")[0];
|
||||
return kind === "jpeg" ? "jpg" : kind;
|
||||
}
|
||||
|
||||
@@ -38,12 +39,65 @@ function mediaType(path: string): string {
|
||||
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 {
|
||||
if (node.type === "text") {
|
||||
let text = esc(node.text ?? "");
|
||||
const marks = node.marks ?? [];
|
||||
if (marks.some((m) => m.type === "code")) text = `<code>${text}</code>`;
|
||||
if (marks.some((m) => m.type === "bold")) text = `<strong>${text}</strong>`;
|
||||
if (marks.some((m) => m.type === "italic")) text = `<em>${text}</em>`;
|
||||
if (marks.some((m) => m.type === "strike")) text = `<s>${text}</s>`;
|
||||
if (marks.some((m) => m.type === "underline")) text = `<u>${text}</u>`;
|
||||
const href = marks.find((m) => m.type === "link")?.attrs?.href;
|
||||
if (href) text = `<a href="${attr(href)}">${text}</a>`;
|
||||
return text;
|
||||
@@ -56,11 +110,11 @@ function inlines(content: JSONContent[] = []): string {
|
||||
return content.map(inline).join("");
|
||||
}
|
||||
|
||||
function listItem(item: JSONContent): string {
|
||||
function listItem(item: JSONContent, paths: Map<string, string>): string {
|
||||
const inner = (item.content ?? [])
|
||||
.filter((c) => c.type === "paragraph")
|
||||
.map((p) => inlines(p.content))
|
||||
.join(" ");
|
||||
.map((child) => block(child, paths))
|
||||
.filter((s) => s.length > 0)
|
||||
.join("");
|
||||
return `<li>${inner}</li>`;
|
||||
}
|
||||
|
||||
@@ -92,9 +146,9 @@ function block(node: JSONContent, paths: Map<string, string>): string {
|
||||
case "blockquote":
|
||||
return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`;
|
||||
case "bulletList":
|
||||
return `<ul>${(node.content ?? []).map(listItem).join("")}</ul>`;
|
||||
return `<ul>${(node.content ?? []).map((li) => listItem(li, paths)).join("")}</ul>`;
|
||||
case "orderedList":
|
||||
return `<ol>${(node.content ?? []).map(listItem).join("")}</ol>`;
|
||||
return `<ol>${(node.content ?? []).map((li) => listItem(li, paths)).join("")}</ol>`;
|
||||
case "horizontalRule":
|
||||
return `<hr class="scene-break"/>`;
|
||||
case "figure":
|
||||
@@ -237,6 +291,7 @@ function contentOpf(
|
||||
chapterIds: string[],
|
||||
assetPaths: string[],
|
||||
cover: { href: string; mediaType: string },
|
||||
fontManifest: string[],
|
||||
): string {
|
||||
const meta = book.metadata;
|
||||
const manifest: string[] = [
|
||||
@@ -244,8 +299,7 @@ function contentOpf(
|
||||
`<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="css" href="style.css" media-type="text/css"/>`,
|
||||
`<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"/>`,
|
||||
...fontManifest,
|
||||
];
|
||||
chapterIds.forEach((_, i) => {
|
||||
manifest.push(
|
||||
@@ -331,23 +385,9 @@ function chapterXhtml(book: Book, index: number, paths: Map<string, string>): st
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function styleCss(): string {
|
||||
return `@font-face {
|
||||
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;
|
||||
function styleCss(book: Book, faces: string): string {
|
||||
return `${faces ? faces + "\n\n" : ""}html {
|
||||
font-family: ${fontStack(book.settings.fonts.body)};
|
||||
}
|
||||
|
||||
.cover {
|
||||
@@ -380,6 +420,7 @@ p[data-indent="true"] {
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: ${fontStack(book.settings.fonts.heading)};
|
||||
hyphens: none;
|
||||
line-height: 1.25;
|
||||
font-weight: 500;
|
||||
@@ -406,6 +447,11 @@ h2, h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1.2em 1.6em;
|
||||
font-style: italic;
|
||||
@@ -469,7 +515,7 @@ figure.placement-float-top {
|
||||
`;
|
||||
}
|
||||
|
||||
export function bookToEpub(book: Book): EpubFile[] {
|
||||
export function bookToEpub(book: Book, fontAssets: FontAssets): EpubFile[] {
|
||||
const { assets, paths } = extractFigures(book);
|
||||
const cover = buildCover(book);
|
||||
const chapterIds = book.chapters.map((c) => c.id);
|
||||
@@ -480,12 +526,12 @@ export function bookToEpub(book: Book): EpubFile[] {
|
||||
{ path: "META-INF/container.xml", data: containerXml(), encoding: "utf8" },
|
||||
{
|
||||
path: "OEBPS/content.opf",
|
||||
data: contentOpf(book, chapterIds, assetPaths, cover),
|
||||
data: contentOpf(book, chapterIds, assetPaths, cover, fontAssets.manifest),
|
||||
encoding: "utf8",
|
||||
},
|
||||
{ path: "OEBPS/cover.xhtml", data: coverXhtml(book, cover.href), encoding: "utf8" },
|
||||
{ path: "OEBPS/nav.xhtml", data: navXhtml(book), encoding: "utf8" },
|
||||
{ path: "OEBPS/style.css", data: styleCss(), encoding: "utf8" },
|
||||
{ path: "OEBPS/style.css", data: styleCss(book, fontAssets.faces), encoding: "utf8" },
|
||||
cover.asset,
|
||||
];
|
||||
|
||||
@@ -497,12 +543,13 @@ export function bookToEpub(book: Book): EpubFile[] {
|
||||
});
|
||||
});
|
||||
|
||||
files.push(...assets);
|
||||
files.push(...assets, ...fontAssets.files);
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function buildEpub(book: Book): Promise<Uint8Array> {
|
||||
const files = bookToEpub(book);
|
||||
const fontAssets = await loadFontAssets(book);
|
||||
const files = bookToEpub(book, fontAssets);
|
||||
const buf = await invoke<ArrayBuffer>("package_epub", { files });
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
@@ -8,8 +8,8 @@ const PDF = [{ name: "PDF", extensions: ["pdf"] }];
|
||||
const EPUB = [{ name: "EPUB", extensions: ["epub"] }];
|
||||
|
||||
export async function exportPdf(book: Book): Promise<void> {
|
||||
const { source, images } = bookToPdfInputs(book);
|
||||
const bytes = await compilePdf(source, images);
|
||||
const { source, images, fonts } = bookToPdfInputs(book);
|
||||
const bytes = await compilePdf(source, images, true, fonts);
|
||||
await saveBytes(bytes, `${book.metadata.title || "Untitled"}.pdf`, PDF);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Book } from "../model/book";
|
||||
import { exportEpub, exportPdf } from "./exporters";
|
||||
import { unsupportedScripts } from "./typst";
|
||||
import { useBook } from "../store/useBook";
|
||||
import { isDesktop } from "../ipc";
|
||||
|
||||
@@ -29,6 +30,11 @@ export async function runExport(format: "pdf" | "epub"): Promise<void> {
|
||||
await nextPaint();
|
||||
try {
|
||||
await run(book);
|
||||
if (format === "pdf") {
|
||||
const scripts = unsupportedScripts(book);
|
||||
if (scripts.length)
|
||||
setNotice(`Some ${scripts.join(", ")} characters may not render — the PDF fonts cover Latin scripts only.`);
|
||||
}
|
||||
} catch (e) {
|
||||
setNotice(`${label} export failed: ${e}`);
|
||||
} finally {
|
||||
|
||||
+81
-33
@@ -1,7 +1,18 @@
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
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";
|
||||
|
||||
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 }> = {
|
||||
"6x9": { w: "6in", h: "9in" },
|
||||
"5.5x8.5": { w: "5.5in", h: "8.5in" },
|
||||
@@ -10,27 +21,32 @@ const TRIM: Record<TrimSize, { w: string; h: string }> = {
|
||||
};
|
||||
|
||||
const INLINE_SPECIAL = /[\\#$*_`<>@~[\]]/g;
|
||||
const LINE_SEPARATORS = /[\n\r\v\f\u2028\u2029]+/g;
|
||||
|
||||
function esc(text: string): string {
|
||||
return text.replace(INLINE_SPECIAL, (m) => "\\" + m);
|
||||
return text.replace(LINE_SEPARATORS, " ").replace(INLINE_SPECIAL, (m) => "\\" + m);
|
||||
}
|
||||
|
||||
function guardLineStart(line: string): string {
|
||||
if (/^[=\-+/]/.test(line)) return "\\" + line;
|
||||
if (/^\d+\./.test(line)) return line.replace(".", "\\.");
|
||||
return line;
|
||||
function guardLineStart(text: string): string {
|
||||
if (/^[=\-+/]/.test(text)) return "\\" + text;
|
||||
if (/^\d+\./.test(text)) return text.replace(".", "\\.");
|
||||
return text;
|
||||
}
|
||||
|
||||
function str(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function inline(node: JSONContent): string {
|
||||
function inline(node: JSONContent, atLineStart: boolean): string {
|
||||
if (node.type === "text") {
|
||||
let text = esc(node.text ?? "");
|
||||
const marks = node.marks ?? [];
|
||||
if (marks.some((m) => m.type === "code")) return `#raw(${str(node.text ?? "")})`;
|
||||
let text = esc(node.text ?? "");
|
||||
if (atLineStart) text = guardLineStart(text);
|
||||
if (marks.some((m) => m.type === "italic")) text = `#emph[${text}]`;
|
||||
if (marks.some((m) => m.type === "bold")) text = `#strong[${text}]`;
|
||||
if (marks.some((m) => m.type === "strike")) text = `#strike[${text}]`;
|
||||
if (marks.some((m) => m.type === "underline")) text = `#underline[${text}]`;
|
||||
const href = marks.find((m) => m.type === "link")?.attrs?.href;
|
||||
if (href) text = `#link(${str(href)})[${text}]`;
|
||||
return text;
|
||||
@@ -39,15 +55,15 @@ function inline(node: JSONContent): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function inlines(content: JSONContent[] = []): string {
|
||||
return content.map(inline).join("");
|
||||
function inlines(content: JSONContent[] = [], atLineStart = false): string {
|
||||
return content.map((node, i) => inline(node, atLineStart && i === 0)).join("");
|
||||
}
|
||||
|
||||
function listItem(item: JSONContent): string {
|
||||
return (item.content ?? [])
|
||||
.filter((c) => c.type === "paragraph")
|
||||
.map((p) => inlines(p.content))
|
||||
.join(" ");
|
||||
function listItem(item: JSONContent, paths: Map<string, string>): string {
|
||||
const parts = (item.content ?? [])
|
||||
.map((child) => block(child, paths))
|
||||
.filter((s) => s.length > 0);
|
||||
return `[${parts.join("\n\n")}]`;
|
||||
}
|
||||
|
||||
function figure(node: JSONContent, paths: Map<string, string>): string {
|
||||
@@ -59,7 +75,7 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
|
||||
}
|
||||
const placed = placement as FigurePlacement;
|
||||
const width = (isResizablePlacement(placed) ? node.attrs?.width : null) ?? FIGURE_WIDTH[placed] ?? 100;
|
||||
const caption = node.attrs?.caption ? `, caption: [${esc(node.attrs.caption)}]` : "";
|
||||
const caption = node.attrs?.caption ? `, caption: [${guardLineStart(esc(node.attrs.caption))}]` : "";
|
||||
const float = placement === "float-top" ? ", placement: top" : "";
|
||||
return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`;
|
||||
}
|
||||
@@ -67,18 +83,18 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
|
||||
function block(node: JSONContent, paths: Map<string, string>): string {
|
||||
switch (node.type) {
|
||||
case "paragraph": {
|
||||
const body = guardLineStart(inlines(node.content));
|
||||
const body = inlines(node.content, true);
|
||||
if (!body.trim()) return "~";
|
||||
return node.attrs?.indent ? `#h(1.3em)${body}` : body;
|
||||
}
|
||||
case "heading":
|
||||
return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content)}]`;
|
||||
return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content, true)}]`;
|
||||
case "blockquote":
|
||||
return `#blockquote[${(node.content ?? []).map((n) => block(n, paths)).join("\n\n")}]`;
|
||||
case "bulletList":
|
||||
return (node.content ?? []).map((li) => `- ${listItem(li)}`).join("\n");
|
||||
return `#list(${(node.content ?? []).map((li) => listItem(li, paths)).join(", ")})`;
|
||||
case "orderedList":
|
||||
return (node.content ?? []).map((li) => `+ ${listItem(li)}`).join("\n");
|
||||
return `#enum(${(node.content ?? []).map((li) => listItem(li, paths)).join(", ")})`;
|
||||
case "horizontalRule":
|
||||
return "#scenebreak";
|
||||
case "figure":
|
||||
@@ -99,6 +115,8 @@ function preamble(book: Book): string {
|
||||
const trim = TRIM[book.settings.trim];
|
||||
const meta = book.metadata;
|
||||
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)})
|
||||
#set page(
|
||||
width: ${trim.w} + ${bleed},
|
||||
@@ -106,9 +124,9 @@ function preamble(book: Book): string {
|
||||
margin: (inside: 0.875in, outside: 0.625in + ${bleed}, top: 0.8in + ${bleed}, bottom: 0.8in + ${bleed}),
|
||||
binding: left,
|
||||
)
|
||||
#set text(font: "Literata", size: 11pt, lang: ${str(meta.language || "en")}, hyphenate: true)
|
||||
#set text(font: ${str(body)}, size: 11pt, lang: ${str(meta.language || "en")}, hyphenate: true)
|
||||
#set par(justify: true, leading: 0.72em, spacing: 0.72em)
|
||||
#show heading: set text(font: "Literata", weight: "medium")
|
||||
#show heading: set text(font: ${str(heading)}, weight: "medium")
|
||||
#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: 3): set block(above: 1.2em, below: 0.5em)
|
||||
@@ -120,10 +138,10 @@ function preamble(book: Book): string {
|
||||
#let titlepage(title, subtitle, author) = {
|
||||
v(2.4in)
|
||||
align(center)[
|
||||
#text(font: "Literata", size: 30pt, weight: "medium")[#title]
|
||||
#text(font: ${str(heading)}, size: 30pt, weight: "medium")[#title]
|
||||
#if subtitle != "" {
|
||||
v(0.6em)
|
||||
text(font: "Literata", size: 15pt, style: "italic", fill: rgb("#6b6458"))[#subtitle]
|
||||
text(font: ${str(body)}, size: 15pt, style: "italic", fill: rgb("#6b6458"))[#subtitle]
|
||||
}
|
||||
#if author != "" {
|
||||
v(1.7em)
|
||||
@@ -159,7 +177,7 @@ function preamble(book: Book): string {
|
||||
set par(justify: false, first-line-indent: 0pt)
|
||||
block(below: 2em)[
|
||||
#show heading: set block(above: 0pt, below: 0pt)
|
||||
#heading(level: 1, numbering: none, outlined: false, bookmarked: true)[#text(font: "Literata", size: 24pt, weight: "medium")[Contents]]
|
||||
#heading(level: 1, numbering: none, outlined: false, bookmarked: true)[#text(font: ${str(heading)}, size: 24pt, weight: "medium")[Contents]]
|
||||
]
|
||||
context {
|
||||
let items = query(<chap>).filter(it => it.value.toc != false)
|
||||
@@ -188,20 +206,22 @@ function preamble(book: Book): string {
|
||||
|
||||
function imageExtension(dataUrl: string): string {
|
||||
const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl);
|
||||
const kind = (match?.[1] ?? "png").toLowerCase();
|
||||
const kind = (match?.[1] ?? "png").toLowerCase().split("+")[0];
|
||||
return kind === "jpeg" ? "jpg" : kind;
|
||||
}
|
||||
|
||||
function coverBlock(book: Book, coverPath?: string): string {
|
||||
const c = book.cover;
|
||||
const meta = book.metadata;
|
||||
const body = fontFamilyName(book.settings.fonts.body);
|
||||
const heading = fontFamilyName(book.settings.fonts.heading);
|
||||
|
||||
if (c.kind === "image" && coverPath) {
|
||||
return `#page(margin: 0pt, numbering: none)[#image(${str(coverPath)}, width: 100%, height: 100%, fit: "cover")]`;
|
||||
}
|
||||
|
||||
const subtitle = meta.subtitle
|
||||
? `#v(0.55em)\n #text(font: "Literata", size: 14pt, style: "italic")[${esc(meta.subtitle)}]`
|
||||
? `#v(0.55em)\n #text(font: ${str(body)}, size: 14pt, style: "italic")[${esc(meta.subtitle)}]`
|
||||
: "";
|
||||
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)`
|
||||
@@ -211,7 +231,7 @@ function coverBlock(book: Book, coverPath?: string): string {
|
||||
#set align(center)
|
||||
#set text(fill: rgb(${str(c.ink)}))
|
||||
#v(1fr)
|
||||
#text(font: "Literata", size: 30pt, weight: "medium")[${esc(meta.title || "Untitled")}]
|
||||
#text(font: ${str(heading)}, size: 30pt, weight: "medium")[${esc(meta.title || "Untitled")}]
|
||||
#v(0.5em)
|
||||
#line(length: 16%, stroke: 0.6pt + rgb(${str(c.ink)}))
|
||||
${subtitle}
|
||||
@@ -251,6 +271,34 @@ export function extractImages(book: Book): { images: ImageInput[]; paths: Map<st
|
||||
return collectImages(book.chapters.map((chapter) => chapter.content));
|
||||
}
|
||||
|
||||
const SCRIPT_RANGES: { label: string; test: RegExp }[] = [
|
||||
{ label: "CJK", test: /[-鿿가-豈-]/u },
|
||||
{ label: "Arabic", test: /[-ۿݐ-ݿ]/u },
|
||||
{ label: "Hebrew", test: /[-]/u },
|
||||
{ label: "Devanagari", test: /[ऀ-ॿ]/u },
|
||||
{ label: "Thai", test: /[-]/u },
|
||||
{ label: "emoji", test: /[☀-➿]|[\u{1f000}-\u{1faff}]/u },
|
||||
];
|
||||
|
||||
function collectText(book: Book): string {
|
||||
const parts = [book.metadata.title, book.metadata.subtitle, book.metadata.author];
|
||||
const visit = (node: JSONContent) => {
|
||||
if (node.type === "text" && node.text) parts.push(node.text);
|
||||
if (node.attrs?.caption) parts.push(String(node.attrs.caption));
|
||||
(node.content ?? []).forEach(visit);
|
||||
};
|
||||
book.chapters.forEach((chapter) => {
|
||||
parts.push(chapter.title);
|
||||
visit(chapter.content);
|
||||
});
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
export function unsupportedScripts(book: Book): string[] {
|
||||
const text = collectText(book);
|
||||
return SCRIPT_RANGES.filter((s) => s.test.test(text)).map((s) => s.label);
|
||||
}
|
||||
|
||||
function cleanTitle(title: string): string {
|
||||
return (title || "")
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")
|
||||
@@ -301,16 +349,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`;
|
||||
}
|
||||
|
||||
export function bookToPdfInputs(book: Book): { source: string; images: ImageInput[] } {
|
||||
export function bookToPdfInputs(book: Book): { source: string; images: ImageInput[]; fonts: PdfFonts } {
|
||||
const { images, paths } = extractImages(book);
|
||||
const coverPath = pushCoverImage(book, images);
|
||||
return { source: bookToTypst(book, paths, coverPath), images };
|
||||
return { source: bookToTypst(book, paths, coverPath), images, fonts: pdfFonts(book) };
|
||||
}
|
||||
|
||||
export function coverToPdfInputs(book: Book): { source: string; images: ImageInput[] } {
|
||||
export function coverToPdfInputs(book: Book): { source: string; images: ImageInput[]; fonts: PdfFonts } {
|
||||
const images: ImageInput[] = [];
|
||||
const coverPath = pushCoverImage(book, images);
|
||||
return { source: `${preamble(book)}\n\n${coverBlock(book, coverPath)}\n`, images };
|
||||
return { source: `${preamble(book)}\n\n${coverBlock(book, coverPath)}\n`, images, fonts: pdfFonts(book) };
|
||||
}
|
||||
|
||||
function chapterToTypst(book: Book, index: number, paths: Map<string, string>): string {
|
||||
@@ -325,8 +373,8 @@ ${chapterBody(book.chapters[index].content, paths)}
|
||||
`;
|
||||
}
|
||||
|
||||
export function chapterToPdfInputs(book: Book, index: number): { source: string; images: ImageInput[] } {
|
||||
export function chapterToPdfInputs(book: Book, index: number): { source: string; images: ImageInput[]; fonts: PdfFonts } {
|
||||
const chapter = book.chapters[index];
|
||||
const { images, paths } = collectImages([chapter.content]);
|
||||
return { source: chapterToTypst(book, index, paths), images };
|
||||
return { source: chapterToTypst(book, index, paths), images, fonts: pdfFonts(book) };
|
||||
}
|
||||
+132
-30
@@ -1,7 +1,8 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { type Book, type BookMetadata, type Chapter, 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 {
|
||||
path: string;
|
||||
@@ -9,7 +10,7 @@ export interface RawFile {
|
||||
encoding: "utf8" | "base64";
|
||||
}
|
||||
|
||||
type Mark = { type: string };
|
||||
type Mark = { type: string; attrs?: Record<string, unknown> };
|
||||
|
||||
const BLOCK_TAGS = new Set([
|
||||
"p", "div", "section", "article", "main", "header", "footer", "aside",
|
||||
@@ -81,8 +82,8 @@ function hasMark(marks: Mark[], type: string): boolean {
|
||||
return marks.some((m) => m.type === type);
|
||||
}
|
||||
|
||||
function addMark(marks: Mark[], type: string): Mark[] {
|
||||
return hasMark(marks, type) ? marks : [...marks, { type }];
|
||||
function addMark(marks: Mark[], type: string, attrs?: Record<string, unknown>): Mark[] {
|
||||
return hasMark(marks, type) ? marks : [...marks, attrs ? { type, attrs } : { type }];
|
||||
}
|
||||
|
||||
function collectInline(node: Node, marks: Mark[], out: JSONContent[]): void {
|
||||
@@ -102,6 +103,15 @@ function collectInline(node: Node, marks: Mark[], out: JSONContent[]): void {
|
||||
collectInline(el, addMark(marks, "bold"), out);
|
||||
} else if (tag === "em" || tag === "i") {
|
||||
collectInline(el, addMark(marks, "italic"), out);
|
||||
} else if (tag === "u") {
|
||||
collectInline(el, addMark(marks, "underline"), out);
|
||||
} else if (tag === "s" || tag === "strike" || tag === "del") {
|
||||
collectInline(el, addMark(marks, "strike"), out);
|
||||
} else if (tag === "code") {
|
||||
collectInline(el, addMark(marks, "code"), out);
|
||||
} else if (tag === "a") {
|
||||
const href = el.getAttribute("href");
|
||||
collectInline(el, href ? addMark(marks, "link", { href }) : marks, out);
|
||||
} else if (!BLOCK_TAGS.has(tag)) {
|
||||
collectInline(el, marks, out);
|
||||
}
|
||||
@@ -137,13 +147,23 @@ function meaningful(inline: JSONContent[]): boolean {
|
||||
|
||||
function imgFigure(img: Element, caption = ""): JSONContent[] {
|
||||
const src = img.getAttribute("src");
|
||||
if (!src || !src.startsWith("data:")) return [];
|
||||
return [
|
||||
{
|
||||
type: "figure",
|
||||
attrs: { src, alt: img.getAttribute("alt") ?? "", caption, placement: "full-width" },
|
||||
},
|
||||
];
|
||||
if (src && src.startsWith("data:")) {
|
||||
return [
|
||||
{
|
||||
type: "figure",
|
||||
attrs: { src, alt: img.getAttribute("alt") ?? "", caption, placement: "full-width" },
|
||||
},
|
||||
];
|
||||
}
|
||||
const alt = (img.getAttribute("alt") ?? "").trim();
|
||||
return alt ? [{ type: "paragraph", content: [{ type: "text", text: alt }] }] : [];
|
||||
}
|
||||
|
||||
function svgFigure(el: Element): JSONContent[] {
|
||||
const markup = el.outerHTML;
|
||||
if (!markup) return [];
|
||||
const src = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(markup)))}`;
|
||||
return [{ type: "figure", attrs: { src, alt: "", caption: "", placement: "full-width" } }];
|
||||
}
|
||||
|
||||
function figureFrom(el: Element): JSONContent[] {
|
||||
@@ -165,18 +185,35 @@ function hasBlockChild(el: Element): boolean {
|
||||
return Array.from(el.children).some((c) => BLOCK_TAGS.has(c.localName.toLowerCase()));
|
||||
}
|
||||
|
||||
function listItems(el: Element): JSONContent[] {
|
||||
function listItems(el: Element, depth: number): JSONContent[] {
|
||||
const items: JSONContent[] = [];
|
||||
Array.from(el.children)
|
||||
.filter((c) => c.localName.toLowerCase() === "li")
|
||||
.forEach((li) => {
|
||||
const content = blocksFrom(li);
|
||||
const content = blocksFrom(li, depth + 1);
|
||||
items.push({ type: "listItem", content: content.length ? content : [{ type: "paragraph" }] });
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
function blockFromElement(el: Element): JSONContent[] {
|
||||
function tableBlocks(el: Element): JSONContent[] {
|
||||
const out: JSONContent[] = [];
|
||||
Array.from(el.querySelectorAll("tr")).forEach((tr) => {
|
||||
const content: JSONContent[] = [];
|
||||
Array.from(tr.children)
|
||||
.filter((c) => c.localName.toLowerCase() === "td" || c.localName.toLowerCase() === "th")
|
||||
.forEach((cell) => {
|
||||
const inline = inlineOf(cell);
|
||||
if (!meaningful(inline)) return;
|
||||
if (content.length) content.push({ type: "text", text: " · " });
|
||||
content.push(...inline);
|
||||
});
|
||||
if (meaningful(content)) out.push({ type: "paragraph", content });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function blockFromElement(el: Element, depth: number): JSONContent[] {
|
||||
const tag = el.localName.toLowerCase();
|
||||
switch (tag) {
|
||||
case "p":
|
||||
@@ -192,15 +229,15 @@ function blockFromElement(el: Element): JSONContent[] {
|
||||
return meaningful(content) ? [{ type: "heading", attrs: { level }, content }] : [];
|
||||
}
|
||||
case "blockquote": {
|
||||
const inner = blocksFrom(el);
|
||||
const inner = blocksFrom(el, depth + 1);
|
||||
return [{ type: "blockquote", content: inner.length ? inner : [{ type: "paragraph" }] }];
|
||||
}
|
||||
case "ul": {
|
||||
const items = listItems(el);
|
||||
const items = listItems(el, depth);
|
||||
return items.length ? [{ type: "bulletList", content: items }] : [];
|
||||
}
|
||||
case "ol": {
|
||||
const items = listItems(el);
|
||||
const items = listItems(el, depth);
|
||||
return items.length ? [{ type: "orderedList", content: items }] : [];
|
||||
}
|
||||
case "hr":
|
||||
@@ -209,22 +246,27 @@ function blockFromElement(el: Element): JSONContent[] {
|
||||
return figureFrom(el);
|
||||
case "img":
|
||||
return imgFigure(el);
|
||||
case "svg":
|
||||
return svgFigure(el);
|
||||
case "table":
|
||||
return tableBlocks(el);
|
||||
case "script":
|
||||
case "style":
|
||||
return [];
|
||||
default:
|
||||
return hasBlockChild(el) ? blocksFrom(el) : paragraphAndFigures(el);
|
||||
return hasBlockChild(el) ? blocksFrom(el, depth + 1) : paragraphAndFigures(el);
|
||||
}
|
||||
}
|
||||
|
||||
function blocksFrom(parent: Node): JSONContent[] {
|
||||
function blocksFrom(parent: Node, depth = 0): JSONContent[] {
|
||||
if (depth > 64) return [];
|
||||
const out: JSONContent[] = [];
|
||||
parent.childNodes.forEach((child) => {
|
||||
if (child.nodeType === 3) {
|
||||
const text = (child.nodeValue ?? "").replace(/\s+/g, " ").trim();
|
||||
if (text !== "") out.push({ type: "paragraph", content: [{ type: "text", text }] });
|
||||
} else if (child.nodeType === 1) {
|
||||
out.push(...blockFromElement(child as Element));
|
||||
out.push(...blockFromElement(child as Element, depth));
|
||||
}
|
||||
});
|
||||
return out;
|
||||
@@ -234,6 +276,41 @@ function headingText(node: JSONContent): string {
|
||||
return (node.content ?? []).map((n) => n.text ?? "").join("").trim();
|
||||
}
|
||||
|
||||
function isbnChecksum(s: string): boolean {
|
||||
if (s.length === 10) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const v = s[i] === "X" ? 10 : Number(s[i]);
|
||||
if (Number.isNaN(v) || (s[i] === "X" && i !== 9)) return false;
|
||||
sum += v * (10 - i);
|
||||
}
|
||||
return sum % 11 === 0;
|
||||
}
|
||||
if (s.length === 13 && /^\d{13}$/.test(s)) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 13; i++) sum += Number(s[i]) * (i % 2 === 0 ? 1 : 3);
|
||||
return sum % 10 === 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findIsbn(opf: Document): string {
|
||||
const ids = Array.from(opf.getElementsByTagName("*")).filter((el) => el.localName === "identifier");
|
||||
const ranked = ids
|
||||
.map((el) => ({
|
||||
text: el.textContent ?? "",
|
||||
tagged:
|
||||
/isbn/i.test(el.getAttribute("opf:scheme") || el.getAttribute("scheme") || "") ||
|
||||
/urn:isbn:/i.test(el.textContent ?? ""),
|
||||
}))
|
||||
.sort((a, b) => Number(b.tagged) - Number(a.tagged));
|
||||
for (const { text } of ranked) {
|
||||
const cleaned = text.replace(/[^0-9Xx]/g, "").toUpperCase();
|
||||
if (isbnChecksum(cleaned)) return cleaned;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
interface ManifestItem {
|
||||
href: string;
|
||||
mediaType: string;
|
||||
@@ -317,6 +394,11 @@ function readNavTitles(
|
||||
return titles;
|
||||
}
|
||||
|
||||
function fileToDataUri(file: RawFile, mime: string): string {
|
||||
const data = file.encoding === "base64" ? file.data : btoa(unescape(encodeURIComponent(file.data)));
|
||||
return `data:${mime};base64,${data}`;
|
||||
}
|
||||
|
||||
function resolveImages(
|
||||
doc: Document,
|
||||
baseDir: string,
|
||||
@@ -335,12 +417,29 @@ function resolveImages(
|
||||
img.removeAttribute("src");
|
||||
return;
|
||||
}
|
||||
const mime = mimeByPath.get(resolved) || mimeFromExt(resolved);
|
||||
const data = file.encoding === "base64" ? file.data : btoa(unescape(encodeURIComponent(file.data)));
|
||||
img.setAttribute("src", `data:${mime};base64,${data}`);
|
||||
img.setAttribute("src", fileToDataUri(file, mimeByPath.get(resolved) || mimeFromExt(resolved)));
|
||||
});
|
||||
}
|
||||
|
||||
function findCover(
|
||||
opf: Document,
|
||||
manifest: Map<string, ManifestItem>,
|
||||
byPath: Map<string, RawFile>,
|
||||
): Cover {
|
||||
let item = Array.from(manifest.values()).find((m) => m.properties.split(/\s+/).includes("cover-image"));
|
||||
if (!item) {
|
||||
const metaCover = Array.from(opf.getElementsByTagName("*")).find(
|
||||
(el) => el.localName === "meta" && el.getAttribute("name") === "cover",
|
||||
);
|
||||
const id = metaCover?.getAttribute("content");
|
||||
if (id) item = manifest.get(id);
|
||||
}
|
||||
if (!item) return createCover();
|
||||
const file = byPath.get(normalize(item.href));
|
||||
if (!file) return createCover();
|
||||
return { ...createCover(), kind: "image", image: fileToDataUri(file, item.mediaType || mimeFromExt(item.href)) };
|
||||
}
|
||||
|
||||
function buildChapter(
|
||||
file: RawFile,
|
||||
baseDir: string,
|
||||
@@ -394,18 +493,21 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
|
||||
spine.forEach((item, i) => {
|
||||
const file = byPath.get(normalize(item.href));
|
||||
if (!file) return;
|
||||
chapters.push(
|
||||
buildChapter(file, dirOf(item.href), navTitles.get(item.href), i, byPath, manifest),
|
||||
);
|
||||
try {
|
||||
chapters.push(
|
||||
buildChapter(file, dirOf(item.href), navTitles.get(item.href), i, byPath, manifest),
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
});
|
||||
if (!chapters.length) throw new Error("Not a valid EPUB: no readable chapters found.");
|
||||
|
||||
const identifier = localText(opf, "identifier");
|
||||
const metadata: BookMetadata = {
|
||||
title: localText(opf, "title") || fallbackName,
|
||||
subtitle: "",
|
||||
author: localText(opf, "creator"),
|
||||
isbn: /\d{9,13}/.test(identifier) ? identifier.replace(/[^0-9Xx]/g, "") : "",
|
||||
isbn: findIsbn(opf),
|
||||
language: localText(opf, "language") || "en",
|
||||
};
|
||||
|
||||
@@ -414,8 +516,8 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
|
||||
id: crypto.randomUUID(),
|
||||
metadata,
|
||||
theme: "quiet-press",
|
||||
settings: { trim: "6x9", bleed: true },
|
||||
cover: createCover(),
|
||||
settings: { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS },
|
||||
cover: findCover(opf, manifest, byPath),
|
||||
chapters,
|
||||
};
|
||||
}
|
||||
|
||||
+27
-2
@@ -7,7 +7,32 @@ export interface ImageInput {
|
||||
data: string;
|
||||
}
|
||||
|
||||
export async function compilePdf(source: string, images: ImageInput[] = []): Promise<Uint8Array> {
|
||||
const buffer = await invoke<ArrayBuffer>("compile_pdf", { source, images });
|
||||
export interface PdfFonts {
|
||||
bundled: string[];
|
||||
system: string[];
|
||||
}
|
||||
|
||||
export async function compilePdf(
|
||||
source: string,
|
||||
images: ImageInput[] = [],
|
||||
emitWarnings = false,
|
||||
fonts: PdfFonts = { bundled: [], system: [] },
|
||||
): Promise<Uint8Array> {
|
||||
const buffer = await invoke<ArrayBuffer>("compile_pdf", {
|
||||
source,
|
||||
images,
|
||||
emitWarnings,
|
||||
bundledFonts: fonts.bundled,
|
||||
systemFonts: fonts.system,
|
||||
});
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
export async function listSystemFonts(): Promise<string[]> {
|
||||
if (!isDesktop) return [];
|
||||
try {
|
||||
return await invoke<string[]>("list_system_fonts");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+27
-2
@@ -1,11 +1,12 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { isDesktop } from "./ipc";
|
||||
import { type Book, createBook, starterBook } from "./model/book";
|
||||
import { type Book, SCHEMA_VERSION, createBook, schemaVersion, starterBook } from "./model/book";
|
||||
|
||||
export interface BookSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
corrupt?: boolean;
|
||||
}
|
||||
|
||||
export async function listBooks(): Promise<BookSummary[]> {
|
||||
@@ -15,7 +16,16 @@ export async function listBooks(): Promise<BookSummary[]> {
|
||||
|
||||
export async function loadBook(id: string): Promise<Book> {
|
||||
const contents = await invoke<string>("load_book", { id });
|
||||
return JSON.parse(contents) as Book;
|
||||
let book: Book;
|
||||
try {
|
||||
book = JSON.parse(contents) as Book;
|
||||
} catch {
|
||||
throw new Error("the book file is corrupt or unreadable");
|
||||
}
|
||||
if (schemaVersion(book.schema) > SCHEMA_VERSION) {
|
||||
throw new Error("this book was made with a newer version of Margin; update the app to open it");
|
||||
}
|
||||
return book;
|
||||
}
|
||||
|
||||
export async function saveBook(book: Book): Promise<void> {
|
||||
@@ -35,3 +45,18 @@ export function newBook(): Book {
|
||||
export function exampleBook(): Book {
|
||||
return starterBook();
|
||||
}
|
||||
|
||||
export async function createAndOpenBook(
|
||||
open: (book: Book) => void,
|
||||
onError?: (message: string) => void,
|
||||
): Promise<void> {
|
||||
const book = newBook();
|
||||
if (isDesktop) {
|
||||
try {
|
||||
await saveBook(book);
|
||||
} catch (e) {
|
||||
onError?.(`Could not create book: ${e}`);
|
||||
}
|
||||
}
|
||||
open(book);
|
||||
}
|
||||
+21
-3
@@ -1,4 +1,5 @@
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { type BookFonts, DEFAULT_FONTS } from "./fonts";
|
||||
|
||||
export type TrimSize = "6x9" | "5.5x8.5" | "5x8" | "a5";
|
||||
export type FigurePlacement = "inline" | "full-width" | "full-page" | "float-top";
|
||||
@@ -24,6 +25,7 @@ export interface BookMetadata {
|
||||
export interface BookSettings {
|
||||
trim: TrimSize;
|
||||
bleed: boolean;
|
||||
fonts: BookFonts;
|
||||
}
|
||||
|
||||
export type CoverKind = "default" | "image";
|
||||
@@ -115,6 +117,14 @@ export interface Book {
|
||||
chapters: Chapter[];
|
||||
}
|
||||
|
||||
export const SCHEMA_VERSION = 1;
|
||||
|
||||
export function schemaVersion(schema: unknown): number {
|
||||
if (typeof schema !== "string") return 0;
|
||||
const match = /^margin\/(\d+)$/.exec(schema);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
function paragraph(text: string): JSONContent {
|
||||
return { type: "paragraph", content: [{ type: "text", text }] };
|
||||
}
|
||||
@@ -140,11 +150,19 @@ export function createCover(): Cover {
|
||||
return { kind: "default", image: "", bg: COVER_PALETTES[0].bg, ink: COVER_PALETTES[0].ink };
|
||||
}
|
||||
|
||||
const DEFAULT_METADATA: BookMetadata = { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" };
|
||||
const DEFAULT_SETTINGS: BookSettings = { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS };
|
||||
|
||||
export function normalizeBook(book: Book): Book {
|
||||
const chapters = (Array.isArray(book.chapters) ? book.chapters : []).filter(Boolean);
|
||||
return {
|
||||
...book,
|
||||
metadata: { ...DEFAULT_METADATA, ...book.metadata },
|
||||
settings: { ...DEFAULT_SETTINGS, ...book.settings, fonts: { ...DEFAULT_FONTS, ...book.settings?.fonts } },
|
||||
cover: book.cover ? { ...createCover(), ...book.cover } : createCover(),
|
||||
chapters: book.chapters.map((c) => (c.updatedAt ? c : { ...c, updatedAt: Date.now() })),
|
||||
chapters: (chapters.length ? chapters : [createChapter()]).map((c) =>
|
||||
c.updatedAt ? c : { ...c, updatedAt: Date.now() },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,9 +170,9 @@ export function createBook(): Book {
|
||||
return {
|
||||
schema: "margin/1",
|
||||
id: crypto.randomUUID(),
|
||||
metadata: { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" },
|
||||
metadata: { ...DEFAULT_METADATA },
|
||||
theme: "quiet-press",
|
||||
settings: { trim: "6x9", bleed: true },
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
cover: createCover(),
|
||||
chapters: [createChapter("Chapter One")],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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] };
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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 }),
|
||||
}));
|
||||
+33
-4
@@ -12,6 +12,9 @@ import {
|
||||
createPage,
|
||||
normalizeBook,
|
||||
} from "../model/book";
|
||||
import { loadActiveChapter, saveActiveChapter } from "../editor/positions";
|
||||
import { saveBook } from "../library";
|
||||
import { applyBookFonts, resetBookFonts } from "../book-style";
|
||||
|
||||
export const COVER_ID = "__cover__";
|
||||
|
||||
@@ -40,7 +43,11 @@ interface BookState {
|
||||
markSaved: () => void;
|
||||
}
|
||||
|
||||
export const useBook = create<BookState>((set) => ({
|
||||
function flushOutgoing(book: Book | null, dirty: boolean): void {
|
||||
if (book && dirty) saveBook(book).catch(() => {});
|
||||
}
|
||||
|
||||
export const useBook = create<BookState>((set, get) => ({
|
||||
book: null,
|
||||
activeChapterId: "",
|
||||
dirty: false,
|
||||
@@ -49,10 +56,19 @@ export const useBook = create<BookState>((set) => ({
|
||||
setExporting: (label) => set({ exporting: label }),
|
||||
setNotice: (message) => set({ notice: message }),
|
||||
openBook: (book) => {
|
||||
flushOutgoing(get().book, get().dirty);
|
||||
const normalized = normalizeBook(book);
|
||||
set({ book: normalized, activeChapterId: normalized.chapters[0]?.id ?? "", dirty: false });
|
||||
const saved = loadActiveChapter(normalized.id);
|
||||
const valid = saved === COVER_ID || normalized.chapters.some((c) => c.id === saved);
|
||||
const activeChapterId = valid ? (saved as string) : normalized.chapters[0]?.id ?? "";
|
||||
applyBookFonts(normalized.settings.fonts);
|
||||
set({ book: normalized, activeChapterId, dirty: false });
|
||||
},
|
||||
closeBook: () => {
|
||||
flushOutgoing(get().book, get().dirty);
|
||||
resetBookFonts();
|
||||
set({ book: null, activeChapterId: "", dirty: false });
|
||||
},
|
||||
closeBook: () => set({ book: null, activeChapterId: "", dirty: false }),
|
||||
setActiveChapter: (id) => set({ activeChapterId: id }),
|
||||
setChapterContent: (id, content) =>
|
||||
set((s) =>
|
||||
@@ -127,8 +143,21 @@ export const useBook = create<BookState>((set) => ({
|
||||
setMetadata: (patch) =>
|
||||
set((s) => (s.book ? { dirty: true, book: { ...s.book, metadata: { ...s.book.metadata, ...patch } } } : {})),
|
||||
setSettings: (patch) =>
|
||||
set((s) => (s.book ? { dirty: true, book: { ...s.book, settings: { ...s.book.settings, ...patch } } } : {})),
|
||||
set((s) => {
|
||||
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) =>
|
||||
set((s) => (s.book ? { dirty: true, book: { ...s.book, cover: { ...s.book.cover, ...patch } } } : {})),
|
||||
markSaved: () => set({ dirty: false }),
|
||||
}));
|
||||
|
||||
let lastActive = "";
|
||||
useBook.subscribe((s) => {
|
||||
if (s.book && s.activeChapterId && s.activeChapterId !== lastActive) {
|
||||
lastActive = s.activeChapterId;
|
||||
saveActiveChapter(s.book.id, s.activeChapterId);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { create } from "zustand";
|
||||
import { type PreviewMode, initialPreviewMode, persistPreviewMode } from "../devices";
|
||||
|
||||
interface PreviewState {
|
||||
mode: PreviewMode;
|
||||
setMode: (mode: PreviewMode) => void;
|
||||
}
|
||||
|
||||
export const usePreviewMode = create<PreviewState>((set) => ({
|
||||
mode: initialPreviewMode(),
|
||||
setMode: (mode) => {
|
||||
persistPreviewMode(mode);
|
||||
set({ mode });
|
||||
},
|
||||
}));
|
||||
+465
-3
@@ -427,6 +427,7 @@ body.resizing {
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: 31px;
|
||||
line-height: 1.16;
|
||||
@@ -434,6 +435,10 @@ body.resizing {
|
||||
margin: 15px 0 0;
|
||||
}
|
||||
|
||||
.prose :is(h1, h2, h3) {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.prose p {
|
||||
font-size: 19px;
|
||||
line-height: 1.68;
|
||||
@@ -565,11 +570,15 @@ body.resizing {
|
||||
}
|
||||
|
||||
.page .p-title {
|
||||
font-family: var(--font-book);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 12px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.page :is(h2, h3) {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.page p {
|
||||
font-family: var(--font-book);
|
||||
font-size: 6.6px;
|
||||
@@ -620,7 +629,7 @@ body.resizing {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
font-family: var(--font-book);
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: 31px;
|
||||
line-height: 1.16;
|
||||
@@ -660,6 +669,87 @@ body.resizing {
|
||||
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,
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
@@ -1167,6 +1257,10 @@ body.resizing {
|
||||
|
||||
.library-head {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.shelf {
|
||||
@@ -1209,6 +1303,16 @@ body.resizing {
|
||||
box-shadow: 0 4px 10px rgba(35, 32, 27, 0.1), 0 20px 38px rgba(35, 32, 27, 0.13);
|
||||
}
|
||||
|
||||
.card-corrupt {
|
||||
cursor: default;
|
||||
opacity: 0.65;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.card-corrupt:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--font-book);
|
||||
font-size: 20px;
|
||||
@@ -1422,7 +1526,7 @@ body.resizing {
|
||||
}
|
||||
|
||||
.cover-title {
|
||||
font-family: var(--font-book);
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: 9.5cqw;
|
||||
line-height: 1.08;
|
||||
@@ -1992,3 +2096,361 @@ body.resizing {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.device-stage {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
padding-block: 6px;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.device-frame {
|
||||
--dv-scale: calc(100cqi / 380);
|
||||
width: 100%;
|
||||
padding: calc(var(--dv-bezel-w) * var(--dv-scale));
|
||||
background: var(--dv-bezel);
|
||||
border-radius: calc(var(--dv-radius) * var(--dv-scale));
|
||||
box-shadow: var(--shadow-page);
|
||||
}
|
||||
|
||||
.device-screen {
|
||||
aspect-ratio: var(--dv-aspect);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--dv-bg);
|
||||
color: var(--dv-ink);
|
||||
border-radius: calc(max(0px, (var(--dv-radius) - var(--dv-bezel-w)) * var(--dv-scale)));
|
||||
}
|
||||
|
||||
.device-statusbar {
|
||||
flex: none;
|
||||
height: calc(30 * var(--dv-scale));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 calc(18 * var(--dv-scale));
|
||||
font-family: var(--font-ui);
|
||||
font-size: calc(11 * var(--dv-scale));
|
||||
font-weight: 600;
|
||||
color: var(--dv-ink);
|
||||
}
|
||||
|
||||
.device-clock {
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.device-batt {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: calc(22 * var(--dv-scale));
|
||||
height: calc(11 * var(--dv-scale));
|
||||
border: calc(1.4 * var(--dv-scale)) solid var(--dv-ink);
|
||||
border-radius: calc(3 * var(--dv-scale));
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.device-batt::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: calc(1.5 * var(--dv-scale));
|
||||
bottom: calc(1.5 * var(--dv-scale));
|
||||
left: calc(1.5 * var(--dv-scale));
|
||||
width: 55%;
|
||||
background: var(--dv-ink);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.device-batt::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: calc(-3 * var(--dv-scale));
|
||||
top: 30%;
|
||||
height: 40%;
|
||||
width: calc(2 * var(--dv-scale));
|
||||
border-radius: 1px;
|
||||
background: var(--dv-ink);
|
||||
}
|
||||
|
||||
.device-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: calc(var(--dv-my) * var(--dv-scale)) calc(var(--dv-mx) * var(--dv-scale));
|
||||
font-family: var(--font-book);
|
||||
font-size: calc(var(--dv-fs) * var(--dv-scale));
|
||||
line-height: var(--dv-lh);
|
||||
}
|
||||
|
||||
.device-frame[data-kind="eink"] .device-content::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.device-opener {
|
||||
text-align: center;
|
||||
margin-bottom: calc(20 * var(--dv-scale));
|
||||
}
|
||||
|
||||
.device-eyebrow {
|
||||
font-family: var(--font-ui);
|
||||
font-size: calc(10 * var(--dv-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.5;
|
||||
margin-bottom: calc(8 * var(--dv-scale));
|
||||
}
|
||||
|
||||
.device-title {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: calc(24 * var(--dv-scale));
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.device-body p {
|
||||
font-family: var(--font-book);
|
||||
hyphens: auto;
|
||||
margin: 0 0 0.8em;
|
||||
color: var(--dv-ink);
|
||||
}
|
||||
|
||||
.device-body p[data-indent="true"] {
|
||||
text-indent: 1.2em;
|
||||
}
|
||||
|
||||
.device-frame[data-kind="eink"] .device-body p {
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.device-frame[data-kind="phone"] .device-body p,
|
||||
.device-frame[data-kind="tablet"] .device-body p {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.device-body h1,
|
||||
.device-body h2,
|
||||
.device-body h3 {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin: 1.2em 0 0.5em;
|
||||
}
|
||||
|
||||
.device-body blockquote {
|
||||
margin: 0 0 0.8em;
|
||||
padding-left: 1em;
|
||||
border-left: 2px solid currentColor;
|
||||
font-style: italic;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.device-body hr {
|
||||
border: none;
|
||||
border-top: 1px solid currentColor;
|
||||
width: calc(40 * var(--dv-scale));
|
||||
margin: calc(16 * var(--dv-scale)) auto;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.device-body img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 0.8em auto;
|
||||
}
|
||||
|
||||
.device-footer {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: calc(10 * var(--dv-scale));
|
||||
padding: calc(8 * var(--dv-scale)) calc(var(--dv-mx) * var(--dv-scale));
|
||||
font-family: var(--font-ui);
|
||||
font-size: calc(11 * var(--dv-scale));
|
||||
color: var(--dv-ink);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.device-foot-title {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.device-content .cover-art {
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
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;
|
||||
}
|
||||
@@ -29,3 +29,67 @@
|
||||
font-style: italic;
|
||||
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,6 +1,7 @@
|
||||
:root {
|
||||
--font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-book: "Literata", Georgia, "Times New Roman", serif;
|
||||
--font-heading: "Literata", Georgia, "Times New Roman", serif;
|
||||
|
||||
--r-sm: 5px;
|
||||
--r-md: 8px;
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export default defineConfig(async () => ({
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
ignored: ["**/src-tauri/**", "**/website/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -3,5 +3,5 @@ import { defineConfig } from "astro/config";
|
||||
// The canonical URL is only used for absolute links (OG tags, sitemap).
|
||||
// Change it to the real domain when the site goes live.
|
||||
export default defineConfig({
|
||||
site: "https://margin.studio",
|
||||
site: "https://margin.73ai.org",
|
||||
});
|
||||
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview"
|
||||
"preview": "astro preview",
|
||||
"deploy": "astro build && npx wrangler pages deploy dist --project-name=margin --commit-dirty=true"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.6.1"
|
||||
|
||||
@@ -1,37 +1,70 @@
|
||||
---
|
||||
// A faithful CSS mock-up of the Margin desktop app: chapter sidebar, the
|
||||
// writing surface set in Literata, and the live PDF preview dock. The editor
|
||||
// shows live proofing underlines; the PDF dock shows the clean typeset page.
|
||||
// A faithful CSS mock-up of the Margin desktop app: the All-books sidebar with
|
||||
// front-matter / chapter sections, the writing surface set in Literata with live
|
||||
// proofing, and the live PDF preview dock with its trim selector.
|
||||
---
|
||||
|
||||
<div class="win" role="img" aria-label="The Margin app: a chapter sidebar, a writing surface with live proofing, and a live PDF page preview.">
|
||||
<div class="win__bar">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="win__chrome"><span class="dot"></span><span class="dot"></span><span class="dot"></span></span>
|
||||
<span class="win__title">The Lighthouse Keeper</span>
|
||||
<span class="win__menu">Export ▾</span>
|
||||
<span class="win__actions">
|
||||
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" /></svg></span>
|
||||
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" /></svg></span>
|
||||
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M4 7h16M4 12h12M4 17h7M13.5 18.5c1-1.2 2-1.2 3 0s2 1.2 3 0" /></svg></span>
|
||||
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" /></svg></span>
|
||||
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" /></svg></span>
|
||||
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></svg></span>
|
||||
<span class="ibtn is-on"><svg class="i" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM14 4.5v15" /></svg></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="win__body">
|
||||
<aside class="side">
|
||||
<p class="side__label">Manuscript</p>
|
||||
<ul>
|
||||
<li>Title Page</li>
|
||||
<li>Dedication</li>
|
||||
<li class="is-active">1 · The Cliff</li>
|
||||
<li>2 · A Light at Sea</li>
|
||||
<li>3 · Salt and Silence</li>
|
||||
<li>4 · The Long Night</li>
|
||||
</ul>
|
||||
<p class="side__label">Book setup</p>
|
||||
<ul class="side__meta">
|
||||
<li>Trim · 5 × 8 in</li>
|
||||
<li>Language · English</li>
|
||||
</ul>
|
||||
<span class="side__brand">
|
||||
<svg class="i" viewBox="0 0 24 24"><path d="M14 7l-5 5 5 5" /></svg>All books
|
||||
</span>
|
||||
<span class="side__cover">
|
||||
<svg class="i" viewBox="0 0 24 24"><path d="M5 4h11l3 3v13H5zM16 4v4h3" /></svg>Cover
|
||||
</span>
|
||||
<span class="side__add">
|
||||
<svg class="i" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>Add page
|
||||
</span>
|
||||
|
||||
<div class="side__scroll">
|
||||
<div class="side__section">
|
||||
<p class="side__label">Front matter</p>
|
||||
<ul class="side__list">
|
||||
<li class="row"><span class="grip"></span><span class="num"></span><span class="text"><span class="title">Title Page</span></span></li>
|
||||
<li class="row"><span class="grip"></span><span class="num"></span><span class="text"><span class="title">Dedication</span></span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="side__section">
|
||||
<p class="side__label">Chapters</p>
|
||||
<ul class="side__list">
|
||||
<li class="row is-active">
|
||||
<span class="grip"><svg class="i" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||
<span class="num">1</span>
|
||||
<span class="text">
|
||||
<span class="title">The Cliff</span>
|
||||
<span class="meta">Edited yesterday</span>
|
||||
</span>
|
||||
</li>
|
||||
<li class="row"><span class="grip"></span><span class="num">2</span><span class="text"><span class="title">A Light at Sea</span></span></li>
|
||||
<li class="row"><span class="grip"></span><span class="num">3</span><span class="text"><span class="title">Salt and Silence</span></span></li>
|
||||
<li class="row"><span class="grip"></span><span class="num">4</span><span class="text"><span class="title">The Long Night</span></span></li>
|
||||
</ul>
|
||||
<span class="side__newchapter">
|
||||
<svg class="i" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>New chapter
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="page">
|
||||
<div class="page__sheet">
|
||||
<p class="chapno">Chapter One</p>
|
||||
<p class="chapno">Chapter 1</p>
|
||||
<h4 class="chaptitle">The Cliff</h4>
|
||||
<p>
|
||||
The lighthouse had stood on the headland for ninety years, and in all that time it had
|
||||
@@ -47,21 +80,36 @@
|
||||
</main>
|
||||
|
||||
<aside class="dock">
|
||||
<p class="dock__label">Live PDF · pg 7</p>
|
||||
<div class="dock__pdf">
|
||||
<span class="rh">THE LIGHTHOUSE KEEPER</span>
|
||||
<p class="pch">Chapter One</p>
|
||||
<p class="pdt">The Cliff</p>
|
||||
<p class="pp pp--first">
|
||||
The lighthouse had stood on the headland for ninety years, and in all that time it had
|
||||
never once gone dark. Mara intended to keep it that way.
|
||||
</p>
|
||||
<p class="pp">
|
||||
She climbed the iron stair each evening at dusk, counting the hundred and one steps out
|
||||
of habit, and watched the lamp turn its slow circle across the water.
|
||||
</p>
|
||||
<p class="pp">The sea, tonight, was the colour of pewter.</p>
|
||||
<span class="pg">7</span>
|
||||
<div class="dock__head">
|
||||
<span class="dock__label">Preview</span>
|
||||
<span class="dock__trim">
|
||||
5 × 8 in
|
||||
<svg class="i" viewBox="0 0 24 24"><path d="M8 10l4-4 4 4M8 14l4 4 4-4" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="dock__pages">
|
||||
<div class="dock__page">
|
||||
<p class="pch">Chapter 1</p>
|
||||
<p class="pdt">The Cliff</p>
|
||||
<p class="pp pp--first">
|
||||
The lighthouse had stood on the headland for ninety years, and in all that time it had
|
||||
never once gone dark. Mara intended to keep it that way.
|
||||
</p>
|
||||
<p class="pp">
|
||||
She climbed the iron stair each evening at dusk, counting the hundred and one steps out
|
||||
of habit, and watched the lamp turn its slow circle across the water.
|
||||
</p>
|
||||
<p class="pp">The sea, tonight, was the colour of pewter.</p>
|
||||
<span class="pg">1</span>
|
||||
</div>
|
||||
<div class="dock__page dock__page--peek">
|
||||
<p class="pp pp--first">
|
||||
Morning came grey and slow. She brewed coffee in the keeper's kitchen and listened to the
|
||||
gulls argue over the rocks below.
|
||||
</p>
|
||||
<p class="pp">The radio crackled once, then fell silent.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -76,115 +124,231 @@
|
||||
box-shadow: var(--shadow-deep);
|
||||
overflow: hidden;
|
||||
}
|
||||
.i {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex: none;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.win__bar {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 40px;
|
||||
padding-inline: 16px;
|
||||
height: 44px;
|
||||
padding-inline: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--sidebar);
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-faint);
|
||||
background: var(--shell);
|
||||
}
|
||||
.win__chrome {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.dot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.win__bar .dot:nth-child(1) {
|
||||
.win__chrome .dot:nth-child(1) {
|
||||
background: #ff5f57;
|
||||
}
|
||||
.win__bar .dot:nth-child(2) {
|
||||
.win__chrome .dot:nth-child(2) {
|
||||
background: #febc2e;
|
||||
}
|
||||
.win__bar .dot:nth-child(3) {
|
||||
.win__chrome .dot:nth-child(3) {
|
||||
background: #28c840;
|
||||
}
|
||||
.win__title {
|
||||
margin-inline: auto;
|
||||
font-weight: 500;
|
||||
}
|
||||
.win__menu {
|
||||
grid-column: 2;
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--ink-soft);
|
||||
font-weight: 500;
|
||||
}
|
||||
.win__actions {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.ibtn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.ibtn.is-on {
|
||||
color: var(--accent);
|
||||
background: var(--accent-wash);
|
||||
box-shadow: inset 0 0 0 1px var(--line-strong);
|
||||
}
|
||||
|
||||
.win__body {
|
||||
display: grid;
|
||||
grid-template-columns: 172px 1fr 196px;
|
||||
min-height: 392px;
|
||||
grid-template-columns: 196px 1fr 200px;
|
||||
min-height: 432px;
|
||||
}
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--sidebar);
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 16px 12px;
|
||||
padding: 12px 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.side__brand,
|
||||
.side__cover,
|
||||
.side__add,
|
||||
.side__newchapter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.side__brand {
|
||||
color: var(--ink-faint);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
.side__cover {
|
||||
color: var(--ink);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.side__add {
|
||||
color: var(--ink-faint);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.side__scroll {
|
||||
flex: 1;
|
||||
}
|
||||
.side__section + .side__section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.side__label {
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
font-weight: 650;
|
||||
margin: 0 0 8px;
|
||||
margin: 0 0 6px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
.side ul {
|
||||
.side__list {
|
||||
list-style: none;
|
||||
margin: 0 0 18px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 1px;
|
||||
}
|
||||
.side li {
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 14px 16px 1fr;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 8px;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.side li.is-active {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.side__meta li {
|
||||
.row .grip {
|
||||
color: var(--ink-faint);
|
||||
font-size: 0.74rem;
|
||||
opacity: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.row .grip .i {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
.row .num {
|
||||
font-size: 0.72rem;
|
||||
color: var(--ink-faint);
|
||||
text-align: center;
|
||||
}
|
||||
.row .text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.row .title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row .meta {
|
||||
font-size: 0.66rem;
|
||||
color: var(--ink-faint);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.row.is-active {
|
||||
background: var(--raised);
|
||||
color: var(--ink);
|
||||
box-shadow: 0 1px 2px rgba(35, 32, 27, 0.08), inset 0 0 0 1px var(--line);
|
||||
}
|
||||
.row.is-active .grip {
|
||||
opacity: 1;
|
||||
}
|
||||
.row.is-active .num {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.side__newchapter {
|
||||
color: var(--ink-faint);
|
||||
font-size: 0.76rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 26px 18px;
|
||||
padding: 30px 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.page__sheet {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
max-width: 372px;
|
||||
font-family: var(--font-book);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.62;
|
||||
line-height: 1.6;
|
||||
color: var(--ink);
|
||||
}
|
||||
.page__sheet p {
|
||||
margin: 0 0 0.7em;
|
||||
text-indent: 1.1em;
|
||||
margin: 0;
|
||||
text-indent: 1.3em;
|
||||
text-align: justify;
|
||||
hyphens: auto;
|
||||
-webkit-hyphens: auto;
|
||||
}
|
||||
.page__sheet p + p {
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
.chapno {
|
||||
text-indent: 0 !important;
|
||||
text-align: center;
|
||||
text-align: center !important;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
margin-bottom: 0.4em !important;
|
||||
margin: 0 0 0.5em !important;
|
||||
}
|
||||
.chaptitle {
|
||||
text-indent: 0;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 560;
|
||||
margin: 0 0 1.1em;
|
||||
font-weight: 540;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 1.4em;
|
||||
}
|
||||
.chaptitle + p {
|
||||
text-indent: 0;
|
||||
}
|
||||
.u-spell {
|
||||
text-decoration: wavy underline var(--danger);
|
||||
@@ -200,17 +364,45 @@
|
||||
.dock {
|
||||
background: var(--shell);
|
||||
border-left: 1px solid var(--line);
|
||||
padding: 16px 14px;
|
||||
padding: 14px 14px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dock__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.dock__label {
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
font-weight: 650;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.dock__pdf {
|
||||
.dock__trim {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.66rem;
|
||||
color: var(--ink-soft);
|
||||
padding: 3px 5px 3px 8px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--raised);
|
||||
}
|
||||
.dock__trim .i {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.dock__pages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.dock__page {
|
||||
position: relative;
|
||||
aspect-ratio: 5 / 8;
|
||||
background: #fcfbf7;
|
||||
@@ -219,26 +411,18 @@
|
||||
padding: 18px 16px 22px;
|
||||
overflow: hidden;
|
||||
color: #2a2622;
|
||||
flex: none;
|
||||
}
|
||||
.dock__pdf .rh {
|
||||
display: block;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 4.4px;
|
||||
letter-spacing: 0.16em;
|
||||
color: #b3ab9c;
|
||||
text-align: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.dock__pdf .pch {
|
||||
.dock__page .pch {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 4px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: #9b9484;
|
||||
text-align: center;
|
||||
margin: 0 0 4px;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.dock__pdf .pdt {
|
||||
.dock__page .pdt {
|
||||
font-family: var(--font-book);
|
||||
font-size: 9px;
|
||||
font-weight: 560;
|
||||
@@ -246,19 +430,21 @@
|
||||
text-align: center;
|
||||
margin: 0 0 11px;
|
||||
}
|
||||
.dock__pdf .pp {
|
||||
.dock__page .pp {
|
||||
font-family: var(--font-book);
|
||||
font-size: 5.6px;
|
||||
line-height: 1.62;
|
||||
text-align: justify;
|
||||
hyphens: auto;
|
||||
-webkit-hyphens: auto;
|
||||
text-indent: 1.3em;
|
||||
margin: 0 0 0.5em;
|
||||
color: #2a2622;
|
||||
}
|
||||
.dock__pdf .pp--first {
|
||||
.dock__page .pp--first {
|
||||
text-indent: 0;
|
||||
}
|
||||
.dock__pdf .pg {
|
||||
.dock__page .pg {
|
||||
position: absolute;
|
||||
bottom: 9px;
|
||||
left: 0;
|
||||
@@ -268,10 +454,13 @@
|
||||
font-size: 5px;
|
||||
color: #b3ab9c;
|
||||
}
|
||||
.dock__page--peek {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.win__body {
|
||||
grid-template-columns: 1fr 168px;
|
||||
grid-template-columns: 1fr 180px;
|
||||
}
|
||||
.side {
|
||||
display: none;
|
||||
|
||||
@@ -38,12 +38,6 @@ import OfferBadge from "./OfferBadge.astro";
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
}
|
||||
.cta__btns :global(.dl) {
|
||||
align-items: center;
|
||||
}
|
||||
.cta__btns :global(.dl__os) {
|
||||
justify-content: center;
|
||||
}
|
||||
.cta__offer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
---
|
||||
import { site, platforms } from "../data/site";
|
||||
import { getRelease } from "../data/release";
|
||||
|
||||
interface Props {
|
||||
large?: boolean;
|
||||
}
|
||||
const { large = false } = Astro.props;
|
||||
const release = await getRelease();
|
||||
---
|
||||
|
||||
<div class="dl">
|
||||
@@ -22,17 +24,16 @@ const { large = false } = Astro.props;
|
||||
</a>
|
||||
|
||||
<div class="dl__os">
|
||||
<span class="dl__also">Also for</span>
|
||||
{
|
||||
platforms.map((p) => (
|
||||
<a
|
||||
class="dl__chip"
|
||||
href={site.releasesUrl}
|
||||
href={release.urls[p.id]}
|
||||
data-download-os={p.id}
|
||||
rel="noopener"
|
||||
download
|
||||
aria-label={`Download for ${p.label}`}
|
||||
title={p.label}
|
||||
title={`Download for ${p.label}`}
|
||||
>
|
||||
<img class="dl__logo" src={`/logos/${p.logo}`} alt={p.label} />
|
||||
</a>
|
||||
@@ -45,38 +46,37 @@ const { large = false } = Astro.props;
|
||||
.dl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.dl__os {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dl__also {
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink-faint);
|
||||
font-weight: 500;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.dl__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-md);
|
||||
transition: box-shadow 0.18s var(--ease), transform 0.18s var(--ease);
|
||||
box-shadow: var(--shadow-page);
|
||||
transition:
|
||||
box-shadow 0.18s var(--ease),
|
||||
transform 0.18s var(--ease);
|
||||
}
|
||||
.dl__chip:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
.dl__logo {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,13 @@
|
||||
---
|
||||
import { site } from "../data/site";
|
||||
import { site, platforms } from "../data/site";
|
||||
import { getRelease } from "../data/release";
|
||||
|
||||
const { repo, releasesUrl } = site;
|
||||
const { urls, version } = await getRelease();
|
||||
const matches = Object.fromEntries(platforms.map((p) => [p.id, p.match]));
|
||||
---
|
||||
|
||||
<script define:vars={{ repo, releasesUrl }}>
|
||||
const SUFFIXES = {
|
||||
mac: [".dmg"],
|
||||
windows: [".msi", ".exe"],
|
||||
linux: [".AppImage", ".deb", ".rpm"],
|
||||
};
|
||||
<script define:vars={{ repo, releasesUrl, urls, version, matches }}>
|
||||
const LABELS = { mac: "macOS", windows: "Windows", linux: "Linux" };
|
||||
|
||||
function detectOS() {
|
||||
@@ -21,20 +20,31 @@ const { repo, releasesUrl } = site;
|
||||
}
|
||||
|
||||
function pickAsset(assets, os) {
|
||||
for (const sfx of SUFFIXES[os] || []) {
|
||||
for (const sfx of matches[os] || []) {
|
||||
const hit = assets.find((a) => a.name.toLowerCase().endsWith(sfx.toLowerCase()));
|
||||
if (hit) return hit.browser_download_url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const os = detectOS();
|
||||
function apply(map, ver) {
|
||||
const os = detectOS();
|
||||
document.querySelectorAll("[data-download]").forEach((el) => {
|
||||
const label = el.querySelector("[data-dl-label]");
|
||||
if (label) label.textContent = "Download for " + LABELS[os];
|
||||
if (map[os]) el.href = map[os];
|
||||
});
|
||||
document.querySelectorAll("[data-download-os]").forEach((el) => {
|
||||
const url = map[el.dataset.downloadOs];
|
||||
if (url) el.href = url;
|
||||
});
|
||||
document.querySelectorAll(".dl__os [data-download-os]").forEach((el) => {
|
||||
if (el.dataset.downloadOs === os) el.remove();
|
||||
});
|
||||
if (ver) document.querySelectorAll("[data-version]").forEach((el) => (el.textContent = ver));
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-download]").forEach((el) => {
|
||||
el.dataset.detectedOs = os;
|
||||
const label = el.querySelector("[data-dl-label]");
|
||||
if (label) label.textContent = "Download for " + LABELS[os];
|
||||
});
|
||||
apply(urls, version);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
@@ -44,23 +54,11 @@ const { repo, releasesUrl } = site;
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const assets = data.assets || [];
|
||||
const version = data.tag_name || "";
|
||||
|
||||
document.querySelectorAll("[data-download]").forEach((el) => {
|
||||
const url = pickAsset(assets, el.dataset.detectedOs || os);
|
||||
if (url) el.href = url;
|
||||
});
|
||||
document.querySelectorAll("[data-download-os]").forEach((el) => {
|
||||
const url = pickAsset(assets, el.dataset.downloadOs);
|
||||
if (url) el.href = url;
|
||||
});
|
||||
if (version) {
|
||||
document.querySelectorAll("[data-version]").forEach((el) => {
|
||||
el.textContent = version;
|
||||
});
|
||||
}
|
||||
const fresh = {};
|
||||
for (const os of Object.keys(matches)) fresh[os] = pickAsset(assets, os) || urls[os] || releasesUrl;
|
||||
apply(fresh, data.tag_name || version);
|
||||
} catch (e) {
|
||||
/* keep the releases-page fallback links */
|
||||
/* keep the build-time links */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -1,14 +1,17 @@
|
||||
---
|
||||
import { site } from "../data/site";
|
||||
import { getRelease } from "../data/release";
|
||||
import Logo from "./Logo.astro";
|
||||
|
||||
const release = await getRelease();
|
||||
|
||||
const cols = [
|
||||
{
|
||||
title: "Get Margin",
|
||||
links: [
|
||||
{ label: "Download for macOS", href: site.releasesUrl, os: "mac" },
|
||||
{ label: "Download for Windows", href: site.releasesUrl, os: "windows" },
|
||||
{ label: "Download for Linux", href: site.releasesUrl, os: "linux" },
|
||||
{ label: "Download for macOS", href: release.urls.mac, os: "mac" },
|
||||
{ label: "Download for Windows", href: release.urls.windows, os: "windows" },
|
||||
{ label: "Download for Linux", href: release.urls.linux, os: "linux" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,63 +11,55 @@ import OfferBadge from "./OfferBadge.astro";
|
||||
<span class="book-accent">Own</span> every word.
|
||||
</h1>
|
||||
<p class="lede hero__lede">
|
||||
Margin is a calm, offline writing studio for authors. Write your book, format it
|
||||
beautifully, and send it to every store and to print, all from your own machine. No
|
||||
subscription, no account. Buy once, own it forever.
|
||||
A calm, offline studio to write, format, and publish your book to every store and to
|
||||
print. No subscription, no account. Buy once, own it forever.
|
||||
</p>
|
||||
|
||||
<div class="hero__cta">
|
||||
<DownloadButtons large />
|
||||
</div>
|
||||
<div class="hero__offer">
|
||||
<OfferBadge />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container hero__art">
|
||||
<AppWindow />
|
||||
</div>
|
||||
|
||||
<div class="container hero__get" id="download">
|
||||
<DownloadButtons large />
|
||||
<div class="hero__offer">
|
||||
<OfferBadge />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-top: clamp(48px, 7vw, 92px);
|
||||
padding-bottom: clamp(40px, 6vw, 72px);
|
||||
padding-top: clamp(36px, 4.5vw, 60px);
|
||||
padding-bottom: clamp(48px, 6vw, 80px);
|
||||
background:
|
||||
radial-gradient(120% 90% at 50% -10%, var(--paper) 0%, transparent 60%),
|
||||
var(--shell);
|
||||
}
|
||||
.hero__head {
|
||||
text-align: center;
|
||||
max-width: 760px;
|
||||
max-width: 720px;
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(2.6rem, 1.6rem + 4.4vw, 4.5rem);
|
||||
font-size: clamp(2.4rem, 1.6rem + 3.4vw, 3.9rem);
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 22px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.hero__lede {
|
||||
margin-inline: auto;
|
||||
max-width: 56ch;
|
||||
max-width: 50ch;
|
||||
}
|
||||
.hero__cta {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 34px;
|
||||
.hero__art {
|
||||
margin-top: clamp(32px, 4vw, 48px);
|
||||
max-width: 1000px;
|
||||
}
|
||||
.hero__cta :global(.dl) {
|
||||
align-items: center;
|
||||
}
|
||||
.hero__cta :global(.dl__os) {
|
||||
justify-content: center;
|
||||
.hero__get {
|
||||
margin-top: clamp(24px, 3vw, 32px);
|
||||
scroll-margin-top: 96px;
|
||||
}
|
||||
.hero__offer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.hero__art {
|
||||
margin-top: clamp(44px, 6vw, 72px);
|
||||
max-width: 1000px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
</style>
|
||||
@@ -19,6 +19,12 @@ import Logo from "./Logo.astro";
|
||||
</nav>
|
||||
|
||||
<div class="nav__actions">
|
||||
<a class="btn nav__cta" href="#download">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||
<path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
Download
|
||||
</a>
|
||||
<button class="iconlink" id="theme-toggle" aria-label="Toggle theme" type="button">
|
||||
<svg class="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
@@ -89,6 +95,14 @@ import Logo from "./Logo.astro";
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.nav__cta {
|
||||
padding: 0.5em 1.05em;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.nav__cta svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
.iconlink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -20,7 +20,6 @@ const { cap, left } = site.launch;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--good) 12%, var(--paper));
|
||||
border: 1px solid color-mix(in srgb, var(--good) 32%, transparent);
|
||||
box-shadow: var(--shadow-page);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
|
||||
@@ -182,12 +182,6 @@ const included = [
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.price-card__cta :global(.dl) {
|
||||
align-items: center;
|
||||
}
|
||||
.price-card__cta :global(.dl__os) {
|
||||
justify-content: center;
|
||||
}
|
||||
.price-card__note {
|
||||
margin-top: 26px;
|
||||
font-size: 0.84rem;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Resolves the latest GitHub release to a direct download URL per OS, at build
|
||||
// time. The picked file follows each platform's `match` order in site.ts.
|
||||
// Falls back to the releases page if the release can't be read.
|
||||
|
||||
import { site, platforms } from "./site";
|
||||
|
||||
type Asset = { name: string; browser_download_url: string };
|
||||
|
||||
export type Release = {
|
||||
version: string;
|
||||
urls: Record<string, string>;
|
||||
};
|
||||
|
||||
function pickAsset(assets: Asset[], match: string[]): string | null {
|
||||
for (const sfx of match) {
|
||||
const hit = assets.find((a) => a.name.toLowerCase().endsWith(sfx.toLowerCase()));
|
||||
if (hit) return hit.browser_download_url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fallback(): Release {
|
||||
return {
|
||||
version: "",
|
||||
urls: Object.fromEntries(platforms.map((p) => [p.id, site.releasesUrl])),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRelease(): Promise<Release> {
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/${site.repo}/releases/latest`, {
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return fallback();
|
||||
const data = await res.json();
|
||||
const assets: Asset[] = data.assets || [];
|
||||
const urls = Object.fromEntries(
|
||||
platforms.map((p) => [p.id, pickAsset(assets, p.match) ?? site.releasesUrl]),
|
||||
);
|
||||
return { version: data.tag_name ?? "", urls };
|
||||
} catch {
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
|
||||
let cached: Promise<Release> | null = null;
|
||||
|
||||
export function getRelease(): Promise<Release> {
|
||||
cached ??= fetchRelease();
|
||||
return cached;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export const site = {
|
||||
tagline: "Write your book. Own every word.",
|
||||
description:
|
||||
"Margin is a calm, offline writing studio for authors. Write your book and send it to print and to every ebook store, all from your own machine. Buy once, own it forever.",
|
||||
domain: "https://margin.studio",
|
||||
domain: "https://margin.73ai.org",
|
||||
|
||||
// The regular one-time price, shown struck through during the launch offer.
|
||||
price: "$10",
|
||||
@@ -16,7 +16,7 @@ export const site = {
|
||||
left: 12,
|
||||
},
|
||||
|
||||
supportEmail: "[email protected]",
|
||||
supportEmail: "[email protected]",
|
||||
|
||||
// Repo that hosts the release files. Used only to resolve the right download
|
||||
// per OS at runtime. It is never shown to the reader.
|
||||
@@ -39,8 +39,8 @@ export type Platform = {
|
||||
|
||||
export const platforms: Platform[] = [
|
||||
{ id: "mac", label: "macOS", logo: "macos.svg", match: [".dmg"] },
|
||||
{ id: "windows", label: "Windows", logo: "windows.svg", match: [".msi", ".exe"] },
|
||||
{ id: "linux", label: "Linux", logo: "linux.svg", match: [".AppImage", ".deb", ".rpm"] },
|
||||
{ id: "windows", label: "Windows", logo: "windows.svg", match: [".exe", ".msi"] },
|
||||
{ id: "linux", label: "Linux", logo: "linux.svg", match: [".deb", ".AppImage", ".rpm"] },
|
||||
];
|
||||
|
||||
// Full-color store logos, served from /public/logos. Shown on white tiles so
|
||||
|
||||
Reference in new issue
Block a user