Author SHA1 Message Date
pj 04a0a49660 changes 2026-06-22 13:44:00 -04:00
pj 457209f934 fix(import): isolate bad chapters and bound nesting depth
A deeply nested document could overflow the stack and abort the entire
import. Bound block recursion depth and wrap each chapter build in a
try/catch so one unreadable chapter is skipped instead of failing the
whole import.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:40:24 -04:00
pj b94b51140a fix(import): validate ISBN with checksum
The importer accepted any 9-13 digit run as the ISBN, so a UUID or
arbitrary number became a bogus ISBN. Prefer scheme-tagged identifiers
and accept only values that pass the ISBN-10/13 length and checksum
test, leaving it empty otherwise.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:39:25 -04:00
pj 5caf66a6fc fix(import): import the cover image
The EPUB cover was discarded and replaced with the default generated
cover. Locate the cover via the manifest cover-image property or the
meta name="cover" reference, resolve it to a data URI, and import it as
an image cover.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:38:35 -04:00
pj 983d8fd2c0 fix(import): keep table rows together
Tables were flattened to one paragraph per cell, losing row grouping.
Join each row's cells into a single paragraph so the row structure
survives (the model has no table node).

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:37:31 -04:00
pj f4079a6f54 fix(import): keep inline svg and unresolved image alt text
Inline svg elements were dropped entirely and an img whose source could
not be resolved was discarded. Serialize a standalone svg to a
data:image/svg+xml figure, and fall back to an unresolved image's alt
text as a paragraph instead of losing it.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:36:50 -04:00
pj a54e7a42ef fix(import): preserve link, underline, strike and code marks
EPUB import only mapped bold and italic; a, u, s/del/strike and code
elements lost their formatting (kept as plain text). Map them to the
matching marks, consistent with what the exporters now render.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:36:07 -04:00
pj 0485177282 chore(security): restrictive content security policy
security.csp was null (no CSP). Add a restrictive policy as
defense-in-depth: self-only by default, data:/blob: images for cover
and figure URLs, inline styles for React/inline style attributes, and
blob: workers for pdf.js. Needs verification against a production build
(covers, preview, export, IPC).

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:35:09 -04:00
pj 3572c7a6b9 fix(security): validate book id paths
load/save/delete_book built paths from the raw id, and ids round-trip
from file JSON, so a crafted id could escape the library directory.
Route all three through a book_path helper that rejects anything but
[A-Za-z0-9_-].

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:34:51 -04:00
pj 2f98f5ec1c fix(editor): prune chapter positions on delete and guard quota
Saved cursor/scroll and active-chapter entries accumulated per book
forever and were never removed when a book was deleted. Add
clearPositions and call it from the delete flow, and guard localStorage
writes against quota or unavailable storage.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:33:23 -04:00
pj 608ba2f21b fix(persist): schema-version guard and editor content check
A book file's declared schema was never read, so a file from a newer
app version would be opened, normalized and autosaved over in the old
format. Refuse to open books whose schema is newer than supported.
Enable the editor content check so chapters with unreadable content
warn the user instead of silently loading stripped content that the
next edit would persist.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:32:33 -04:00
pj b63951f814 fix(library): surface unreadable books
list_books silently skipped any .margin file it could not read, parse,
or that lacked an id, so a truncated manuscript just vanished. Emit a
corrupt placeholder entry instead and show it in the library as a
non-openable card with a recovery hint pointing at the .bak backup.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:31:04 -04:00
pj aa75b3ebda fix(preview): destroy pdf.js document on recompile
The preview created a new PDF document on every recompile but never
destroyed the old one, leaking the document and its worker transport.
Keep the loading task and destroy it on effect cleanup.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:29:30 -04:00
pj 4e5ea6d855 fix(ui): handle library-open errors
Opening a book card swallowed failures: a corrupt or unreadable file
failed silently and normalizeBook could throw on a missing chapters
array. Surface load errors as a toast, throw a clear message on
unparseable JSON, and make normalizeBook tolerant of missing chapters,
metadata and settings.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:29:29 -04:00
pj 9eabef9041 feat(export): surface typst warnings and glyph-coverage notice
Typst compile warnings were discarded. Capture them via the Warned
result and emit a pdf-warnings event (only from the export path, not
the live preview) that App shows as a non-fatal toast; the export still
succeeds. Also scan book text before reporting success and warn when it
contains scripts the embedded Latin fonts cannot render (CJK, Arabic,
Hebrew, Devanagari, Thai, emoji).

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:28:16 -04:00
pj 5c0040955e fix(export): correct list nesting, marks, line-start escaping, svg type
Four export-correctness fixes intermingled across the Typst and EPUB
renderers:

- H7: neutralize newlines in escaped text and apply leading-marker
  escaping (= - + / and N.) to the first text node of every block
  (paragraph, heading, list item, blockquote) and figure captions, even
  when that run carries a mark, so author text can never inject Typst
  markup or phantom headings/TOC entries.
- H9: render lists recursively so nested lists survive and multi-
  paragraph list items keep their paragraph breaks (Typst functional
  list()/enum(); EPUB nested <ul>/<ol> and multiple <p> in <li>).
- H10: render strike, underline and inline code marks in both exporters
  (Typst #strike/#underline/#raw; EPUB <s>/<u>/<code> with css).
- H8b: normalize +-bearing image subtypes so an imported svg+xml cover
  becomes cover.svg with the correct media type.

Validated the generated Typst constructs compile with typst 0.15.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:24:19 -04:00
pj 6e2ca9c4bc fix(persist): persist new books on create
A freshly created book lived only in memory until the first edit, so
quitting beforehand lost it and it never appeared in the library. Save
it to disk on creation via a shared createAndOpenBook used by both the
Library card and the New Book menu action.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:17:50 -04:00
pj 258d5e5209 fix(persist): guard markSaved against in-flight edits
markSaved cleared dirty unconditionally, so an edit made while a save
was in flight had its dirty flag wiped and its follow-up save dropped.
Only clear dirty when the book reference is unchanged since the save
started (immutable updates give a fresh reference per edit).

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:17:50 -04:00
pj 14eb3c4427 fix(persist): flush pending save on navigate-away and quit
Edits are saved on an 800ms debounce, so leaving a book within that
window lost the latest changes. Flush a dirty book before openBook /
closeBook swap state (covers All books, open-another, Cmd+N), and add a
window close-request handler that saves before destroying on quit.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:16:33 -04:00
pj 0bc3e36ee6 feat(editor): persist active chapter per book
Remember which chapter was open per book and restore it on reopen,
falling back to the first chapter when the saved id is no longer valid.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:15:01 -04:00
pj c0da4c53db fix(editor): isolate undo history per chapter
Key the editor by chapter id so each chapter gets a fresh TipTap
instance with its own history. Previously a single instance was reused
across chapters, so the content swap entered the undo stack and Ctrl+Z
after switching restored the prior chapter's doc into the current one,
which autosave then persisted.

With remount, save the cursor/scroll position on unmount (from a ref,
not the editor, which is destroyed by then) and load it on mount,
replacing the in-place chapter-switch bookkeeping.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:13:12 -04:00
pj 92941a2758 fix(persist): atomic+durable .margin writes
Route save_book, write_file, write_bytes through a shared atomic_write
helper: write to a temp file, fsync, then rename into place so a crash
mid-write can never leave a truncated or empty manuscript. Rotate the
prior copy to .bak for manuscripts (not exports) so one good copy
always survives.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:12:39 -04:00
pj 61bd7cf6e5 changes 2026-06-22 13:03:11 -04:00
github-actions[bot] b4097cc47f chore(release): v0.1.3 2026-06-22 16:50:04 +00:00
pj 72d698e427 changes 2026-06-22 12:40:29 -04:00
pj 8b83f80d52 fix(editor): persist per-chapter cursor and scroll across restarts
Positions were kept in an in-memory Map, so they were lost on quit and
the cursor jumped to the top on reopen. Store them in localStorage
(keyed by book + chapter, like theme/width) and restore on app open,
not just on in-session chapter switches. Also save the current chapter
continuously (debounced on selection + scroll) so closing the app
records where you left off, and re-apply scroll after fonts load.
2026-06-22 12:09:34 -04:00
pj 39609fbd09 perf(proofing): run proof_text on a blocking thread
proof_text loaded the dictionary and ran spell/grammar checks
synchronously inside an async command, blocking an async-runtime worker
and holding the engine mutex for the whole call. Move the work to
spawn_blocking so IPC stays responsive under load.
2026-06-22 12:01:25 -04:00
pj 3177b2f1eb fix(preview): show render errors instead of a blank pane
The render loop swallowed getDocument/render rejections, leaving the
preview silently blank with no signal about what failed. Surface the
error in the pane and recover on the next compile. Also drop the
redundant canvasContext arg (pdf.js v6 renders via the canvas itself).
2026-06-22 12:01:25 -04:00
pj 19c4823d3d changes 2026-06-22 11:56:31 -04:00
43 changed files with 1480 additions and 342 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "margin-app", "name": "margin-app",
"private": true, "private": true,
"version": "0.1.2", "version": "0.1.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -4704,7 +4704,7 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]] [[package]]
name = "margin-app" name = "margin-app"
version = "0.1.1" version = "0.1.3"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"harper-core", "harper-core",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "margin-app" name = "margin-app"
version = "0.1.2" version = "0.1.3"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
+1
View File
@@ -5,6 +5,7 @@
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-destroy",
"opener:default", "opener:default",
"dialog:default", "dialog:default",
"updater:default", "updater:default",
+32 -6
View File
@@ -7,6 +7,16 @@ pub struct BookSummary {
id: String, id: String,
title: String, title: String,
author: String, author: String,
corrupt: bool,
}
fn corrupt_summary(stem: &str) -> BookSummary {
BookSummary {
id: stem.to_string(),
title: "Unreadable book".to_string(),
author: String::new(),
corrupt: true,
}
} }
fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> { fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
@@ -19,6 +29,13 @@ fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(dir) 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] #[tauri::command]
pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> { pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
let dir = library_dir(&app)?; let dir = library_dir(&app)?;
@@ -31,16 +48,24 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
if path.extension().and_then(|e| e.to_str()) != Some("margin") { if path.extension().and_then(|e| e.to_str()) != Some("margin") {
continue; continue;
} }
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let contents = match fs::read_to_string(&path) { let contents = match fs::read_to_string(&path) {
Ok(contents) => contents, Ok(contents) => contents,
Err(_) => continue, Err(_) => {
books.push(corrupt_summary(stem));
continue;
}
}; };
let value: serde_json::Value = match serde_json::from_str(&contents) { let value: serde_json::Value = match serde_json::from_str(&contents) {
Ok(value) => value, 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(""); let id = value.get("id").and_then(|v| v.as_str()).unwrap_or("");
if id.is_empty() { if id.is_empty() {
books.push(corrupt_summary(stem));
continue; continue;
} }
let metadata = value.get("metadata"); let metadata = value.get("metadata");
@@ -58,6 +83,7 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
id: id.to_string(), id: id.to_string(),
title, title,
author, author,
corrupt: false,
}); });
} }
Ok(books) Ok(books)
@@ -65,19 +91,19 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
#[tauri::command] #[tauri::command]
pub fn load_book(app: tauri::AppHandle, id: String) -> Result<String, String> { 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()) fs::read_to_string(&path).map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
pub fn save_book(app: tauri::AppHandle, id: String, contents: String) -> Result<(), String> { pub fn save_book(app: tauri::AppHandle, id: String, contents: String) -> Result<(), String> {
let path = library_dir(&app)?.join(format!("{id}.margin")); let path = book_path(&app, &id)?;
fs::write(&path, contents).map_err(|e| e.to_string()) crate::project::atomic_write(&path, contents.as_bytes(), true)
} }
#[tauri::command] #[tauri::command]
pub fn delete_book(app: tauri::AppHandle, id: String) -> Result<(), String> { 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() { if path.exists() {
fs::remove_file(&path).map_err(|e| e.to_string())?; fs::remove_file(&path).map_err(|e| e.to_string())?;
} }
+24 -7
View File
@@ -1,7 +1,8 @@
use base64::Engine; use base64::Engine;
use base64::engine::general_purpose::STANDARD; use base64::engine::general_purpose::STANDARD;
use serde::Deserialize; use serde::Deserialize;
use typst::diag::{Severity, SourceDiagnostic}; use tauri::Emitter;
use typst::diag::{Severity, SourceDiagnostic, Warned};
use typst::layout::PagedDocument; use typst::layout::PagedDocument;
use typst_as_lib::TypstEngine; use typst_as_lib::TypstEngine;
@@ -16,7 +17,7 @@ pub struct ImageInput {
data: String, data: String,
} }
fn compile(source: String, images: &[ImageInput]) -> Result<Vec<u8>, String> { fn compile(source: String, images: &[ImageInput]) -> Result<(Vec<u8>, String), String> {
let mut binaries: Vec<(&str, Vec<u8>)> = Vec::with_capacity(images.len()); let mut binaries: Vec<(&str, Vec<u8>)> = Vec::with_capacity(images.len());
for image in images { for image in images {
let bytes = STANDARD let bytes = STANDARD
@@ -31,14 +32,30 @@ fn compile(source: String, images: &[ImageInput]) -> Result<Vec<u8>, String> {
.with_static_file_resolver(binaries) .with_static_file_resolver(binaries)
.build(); .build();
let document: PagedDocument = engine.compile().output.map_err(|e| format_diagnostics(&e))?; let Warned { output, warnings } = engine.compile();
let document: PagedDocument = output.map_err(|e| format_diagnostics(&e))?;
typst_pdf::pdf(&document, &Default::default()).map_err(|d| format_source_diagnostics(&d)) 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)] #[tauri::command(async)]
pub fn compile_pdf(source: String, images: Vec<ImageInput>) -> Result<tauri::ipc::Response, String> { pub fn compile_pdf(
compile(source, &images).map(tauri::ipc::Response::new) app: tauri::AppHandle,
source: String,
images: Vec<ImageInput>,
emit_warnings: bool,
) -> Result<tauri::ipc::Response, String> {
let (bytes, warnings) = compile(source, &images)?;
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 { fn format_diagnostics(error: &typst_as_lib::TypstAsLibError) -> String {
+30 -3
View File
@@ -1,6 +1,33 @@
use base64::Engine; use base64::Engine;
use base64::engine::general_purpose::STANDARD; 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] #[tauri::command]
pub fn read_file(path: String) -> Result<String, String> { pub fn read_file(path: String) -> Result<String, String> {
@@ -9,11 +36,11 @@ pub fn read_file(path: String) -> Result<String, String> {
#[tauri::command] #[tauri::command]
pub fn write_file(path: String, contents: String) -> Result<(), String> { 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] #[tauri::command]
pub fn write_bytes(path: String, data: String) -> Result<(), String> { pub fn write_bytes(path: String, data: String) -> Result<(), String> {
let bytes = STANDARD.decode(data.as_bytes()).map_err(|e| e.to_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)
} }
+6 -2
View File
@@ -176,12 +176,13 @@ fn collect_grammar(harper: &mut Harper, text: &str, chars: &[char], issues: &mut
#[tauri::command] #[tauri::command]
pub async fn proof_text( pub async fn proof_text(
app: tauri::AppHandle, app: tauri::AppHandle,
state: tauri::State<'_, ProofState>,
text: String, text: String,
spelling: bool, spelling: bool,
grammar: bool, grammar: bool,
) -> Result<Vec<Issue>, String> { ) -> Result<Vec<Issue>, String> {
let mut guard = state.inner().lock().map_err(|e| e.to_string())?; 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() { if guard.is_none() {
*guard = Some(Engine { *guard = Some(Engine {
speller: None, speller: None,
@@ -206,6 +207,9 @@ pub async fn proof_text(
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] #[tauri::command]
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "margin", "productName": "margin",
"version": "0.1.2", "version": "0.1.3",
"identifier": "studio.margin.app", "identifier": "studio.margin.app",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
@@ -21,7 +21,7 @@
} }
], ],
"security": { "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": { "bundle": {
+22 -2
View File
@@ -1,10 +1,11 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Library } from "./components/Library"; import { Library } from "./components/Library";
import { EditorView } from "./components/EditorView"; import { EditorView } from "./components/EditorView";
import { useBook } from "./store/useBook"; import { useBook } from "./store/useBook";
import { isDesktop } from "./ipc"; import { isDesktop } from "./ipc";
import { newBook } from "./library"; import { createAndOpenBook, saveBook } from "./library";
import { runExport } from "./export/run"; import { runExport } from "./export/run";
import { checkForUpdates } from "./updater"; import { checkForUpdates } from "./updater";
@@ -17,11 +18,30 @@ function App() {
checkForUpdates(true); checkForUpdates(true);
const unlisten = listen<string>("menu-action", (event) => { const unlisten = listen<string>("menu-action", (event) => {
const state = useBook.getState(); const state = useBook.getState();
if (event.payload === "new-book") 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-pdf") runExport("pdf");
else if (event.payload === "export-epub") runExport("epub"); else if (event.payload === "export-epub") runExport("epub");
else if (event.payload === "check-updates") checkForUpdates(false); else if (event.payload === "check-updates") checkForUpdates(false);
}); });
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();
if (!book || !dirty) return;
event.preventDefault();
await saveBook(book).catch(() => {});
win.destroy();
});
return () => { return () => {
unlisten.then((stop) => stop()); unlisten.then((stop) => stop());
}; };
+67
View File
@@ -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>
);
}
+109 -37
View File
@@ -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 { generateHTML } from "@tiptap/core";
import { COVER_ID, useBook } from "../store/useBook"; 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 { editorExtensions } from "../editor/extensions";
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst"; import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
import { compilePdf, isDesktop } from "../ipc"; import { compilePdf, isDesktop } from "../ipc";
import { DEVICES, type Device, findDevice } from "../devices";
import { usePreviewMode } from "../store/usePreviewMode";
import { PdfPreview } from "./PdfPreview"; import { PdfPreview } from "./PdfPreview";
import { CoverArt } from "./CoverArt"; import { CoverArt } from "./CoverArt";
import { DeviceFrame } from "./DeviceFrame";
const TRIMS: { value: TrimSize; label: string }[] = [ const TRIMS: { value: TrimSize; label: string }[] = [
{ value: "6x9", label: "6 × 9 in" }, { value: "6x9", label: "6 × 9 in" },
@@ -15,26 +18,77 @@ const TRIMS: { value: TrimSize; label: string }[] = [
{ value: "a5", label: "A5" }, { value: "a5", label: "A5" },
]; ];
function TrimSelect() { function PreviewSelect() {
const trim = useBook((s) => s.book?.settings.trim ?? "6x9"); const trim = useBook((s) => s.book?.settings.trim ?? "6x9");
const setSettings = useBook((s) => s.setSettings); 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 ( return (
<select <select
className="trim-select" className="trim-select"
value={trim} value={value}
onChange={(e) => setSettings({ trim: e.target.value as TrimSize })} onChange={(e) => {
title="Trim size" const v = e.target.value;
if (findDevice(v)) {
setMode(v as Device["id"]);
} else {
setSettings({ trim: v as TrimSize });
setMode("print");
}
}}
title="Preview format"
> >
<optgroup label="Print">
{TRIMS.map((t) => ( {TRIMS.map((t) => (
<option key={t.value} value={t.value}> <option key={t.value} value={t.value}>
{t.label} {t.label}
</option> </option>
))} ))}
</optgroup>
<optgroup label="Devices">
{DEVICES.map((d) => (
<option key={d.id} value={d.id}>
{d.label}
</option>
))}
</optgroup>
</select> </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() { export function Dock() {
const mode = usePreviewMode((s) => s.mode);
const device = findDevice(mode);
if (device) return <DeviceDock device={device} />;
return isDesktop ? <PdfDock /> : <HtmlDock />; return isDesktop ? <PdfDock /> : <HtmlDock />;
} }
@@ -67,13 +121,7 @@ function PdfDock() {
return ( return (
<section className="dock"> <section className="dock">
<div className="dock-head"> <DockHead meta={busy ? <span className="meta">updating…</span> : null} />
<span className="label">Preview</span>
<div className="dock-tools">
{busy && <span className="meta">updating…</span>}
<TrimSelect />
</div>
</div>
{error ? ( {error ? (
<pre className="dock-error">{error}</pre> <pre className="dock-error">{error}</pre>
) : pdf ? ( ) : pdf ? (
@@ -86,26 +134,14 @@ function PdfDock() {
} }
function HtmlDock() { function HtmlDock() {
const book = useBook((s) => s.book); const { book, coverActive, chapters, idx, chapter, html, eyebrow } = useActiveChapter();
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]);
if (!book) return null; if (!book) return null;
if (coverActive) { if (coverActive) {
return ( return (
<section className="dock"> <section className="dock">
<div className="dock-head"> <DockHead meta={<span className="meta">Cover</span>} />
<span className="label">Preview</span>
<div className="dock-tools">
<span className="meta">Cover</span>
<TrimSelect />
</div>
</div>
<div className="cover-stage"> <div className="cover-stage">
<CoverArt book={book} /> <CoverArt book={book} />
</div> </div>
@@ -115,21 +151,15 @@ function HtmlDock() {
if (!chapter) return null; if (!chapter) return null;
const kind = chapterKind(chapter);
const eyebrow =
kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
return ( return (
<section className="dock"> <section className="dock">
<div className="dock-head"> <DockHead
<span className="label">Preview</span> meta={
<div className="dock-tools">
<span className="meta"> <span className="meta">
{idx + 1} of {chapters.length} {idx + 1} of {chapters.length}
</span> </span>
<TrimSelect /> }
</div> />
</div>
<div className="page"> <div className="page">
<div className="p-opener"> <div className="p-opener">
<div className="p-num">{eyebrow}</div> <div className="p-num">{eyebrow}</div>
@@ -141,3 +171,45 @@ function HtmlDock() {
</section> </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>
);
}
+12 -2
View File
@@ -75,8 +75,13 @@ export function EditorView() {
const saveNow = useCallback(() => { const saveNow = useCallback(() => {
const current = useBook.getState().book; const current = useBook.getState().book;
if (current) saveBook(current).then(markSaved).catch((e) => setNotice(`Save failed: ${e}`)); if (!current) return;
}, [markSaved]); saveBook(current)
.then(() => {
if (useBook.getState().book === current) markSaved();
})
.catch((e) => setNotice(`Save failed: ${e}`));
}, [markSaved, setNotice]);
useEffect(() => { useEffect(() => {
if (!book || !dirty || !isDesktop) return; if (!book || !dirty || !isDesktop) return;
@@ -263,10 +268,15 @@ export function EditorView() {
)} )}
</header> </header>
<Editor <Editor
key={chapter.id}
bookId={book.id}
chapterId={chapter.id} chapterId={chapter.id}
content={chapter.content} content={chapter.content}
onChange={(content) => setChapterContent(chapter.id, content)} onChange={(content) => setChapterContent(chapter.id, content)}
onReady={setEditor} onReady={setEditor}
onContentError={() =>
setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.")
}
/> />
</article> </article>
<FloatingToolbar editor={editor} /> <FloatingToolbar editor={editor} />
+19 -5
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react"; 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 type { Book } from "../model/book";
import { importEpub } from "../import/epub"; import { importEpub } from "../import/epub";
import { clearPositions } from "../editor/positions";
import { isDesktop } from "../ipc"; import { isDesktop } from "../ipc";
import { ConfirmDialog } from "./ConfirmDialog"; import { ConfirmDialog } from "./ConfirmDialog";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
@@ -38,6 +39,7 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
const removeBook = async () => { const removeBook = async () => {
if (!pendingDelete) return; if (!pendingDelete) return;
await deleteBook(pendingDelete.id); await deleteBook(pendingDelete.id);
clearPositions(pendingDelete.id);
setPendingDelete(null); setPendingDelete(null);
refresh(); refresh();
}; };
@@ -64,7 +66,7 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
<div className="library"> <div className="library">
<header className="library-head" data-tauri-drag-region /> <header className="library-head" data-tauri-drag-region />
<div className="shelf"> <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} /> <Icon d="M12 5v14M5 12h14" size={20} />
<span>New book</span> <span>New book</span>
</button> </button>
@@ -78,13 +80,25 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
<span className="card-badge">Example</span> <span className="card-badge">Example</span>
</button> </button>
)} )}
{books.map((b) => ( {books.map((b) =>
<div key={b.id} className="card card-book" onClick={() => loadBook(b.id).then(onOpen)}> 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> <span className="card-title">{b.title || "Untitled"}</span>
{b.author && <span className="card-author">{b.author}</span>} {b.author && <span className="card-author">{b.author}</span>}
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} /> <RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
</div> </div>
))} ),
)}
</div> </div>
{notice && ( {notice && (
<div className="toast" onClick={() => setNotice(null)}> <div className="toast" onClick={() => setNotice(null)}>
+19 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import * as pdfjs from "pdfjs-dist"; import * as pdfjs from "pdfjs-dist";
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
@@ -6,14 +6,17 @@ pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
export function PdfPreview({ data }: { data: Uint8Array }) { export function PdfPreview({ data }: { data: Uint8Array }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const container = ref.current; const container = ref.current;
if (!container) return; if (!container) return;
let cancelled = false; let cancelled = false;
const loadingTask = pdfjs.getDocument({ data: data.slice() });
(async () => { (async () => {
const doc = await pdfjs.getDocument({ data: data.slice() }).promise; try {
const doc = await loadingTask.promise;
if (cancelled) return; if (cancelled) return;
const width = container.clientWidth; const width = container.clientWidth;
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
@@ -30,18 +33,29 @@ export function PdfPreview({ data }: { data: Uint8Array }) {
canvas.width = viewport.width; canvas.width = viewport.width;
canvas.height = viewport.height; canvas.height = viewport.height;
canvas.style.width = "100%"; canvas.style.width = "100%";
const context = canvas.getContext("2d")!; await page.render({ canvas, viewport }).promise;
await page.render({ canvas, canvasContext: context, viewport }).promise;
if (cancelled) return; if (cancelled) return;
canvases.push(canvas); canvases.push(canvas);
} }
container.replaceChildren(...canvases); container.replaceChildren(...canvases);
setError(null);
} catch (e) {
if (cancelled) return;
container.replaceChildren();
setError(String(e));
}
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
loadingTask.destroy();
}; };
}, [data]); }, [data]);
return <div className="pdf-pages" ref={ref} />; return (
<>
{error && <pre className="dock-error">{error}</pre>}
<div className="pdf-pages" ref={ref} />
</>
);
} }
+115
View File
@@ -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
View File
@@ -2,67 +2,88 @@ import { useEffect, useRef } from "react";
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react"; import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
import type { JSONContent } from "@tiptap/core"; import type { JSONContent } from "@tiptap/core";
import { editorExtensions } from "./extensions"; import { editorExtensions } from "./extensions";
import { loadPosition, savePosition, type ChapterPosition } from "./positions";
interface EditorProps { interface EditorProps {
bookId: string;
chapterId: string; chapterId: string;
content: JSONContent; content: JSONContent;
onChange: (content: JSONContent) => void; onChange: (content: JSONContent) => void;
onReady: (editor: TiptapEditor | null) => void; onReady: (editor: TiptapEditor | null) => void;
onContentError: (error: Error) => void;
} }
interface ChapterPosition { export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: EditorProps) {
from: number;
to: number;
scroll: number;
}
export function Editor({ chapterId, content, onChange, onReady }: EditorProps) {
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
onChangeRef.current = onChange; onChangeRef.current = onChange;
const onContentErrorRef = useRef(onContentError);
onContentErrorRef.current = onContentError;
const positions = useRef(new Map<string, ChapterPosition>()); const latest = useRef<ChapterPosition | null>(null);
const openChapter = useRef(chapterId);
const editor = useEditor({ const editor = useEditor({
extensions: editorExtensions, extensions: editorExtensions,
content, content,
immediatelyRender: false, immediatelyRender: false,
enableContentCheck: true,
editorProps: { attributes: { class: "prose" } }, editorProps: { attributes: { class: "prose" } },
onContentError: ({ error }) => onContentErrorRef.current(error),
onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()), onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()),
}); });
useEffect(() => { useEffect(() => {
onReady(editor); onReady(editor);
return () => onReady(null);
}, [editor, onReady]); }, [editor, onReady]);
useEffect(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
let active = true;
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null; const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
const previous = openChapter.current; const saved = loadPosition(bookId, chapterId);
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 size = editor.state.doc.content.size; const size = editor.state.doc.content.size;
editor.commands.setTextSelection( const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
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) { if (scroller) {
const top = saved?.scroll ?? 0; const top = saved?.scroll ?? 0;
scroller.scrollTop = top; const restore = () => {
requestAnimationFrame(() => { if (active) scroller.scrollTop = top;
scroller.scrollTop = top; };
}); restore();
requestAnimationFrame(restore);
document.fonts?.ready.then(restore).catch(() => {});
} }
return () => {
active = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps // 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" />; return <EditorContent editor={editor} className="editor-host" />;
} }
+64
View File
@@ -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);
}
+15 -7
View File
@@ -25,7 +25,7 @@ function slug(text: string): string {
function imageExtension(dataUrl: string): string { function imageExtension(dataUrl: string): string {
const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl); 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; return kind === "jpeg" ? "jpg" : kind;
} }
@@ -42,8 +42,11 @@ function inline(node: JSONContent): string {
if (node.type === "text") { if (node.type === "text") {
let text = esc(node.text ?? ""); let text = esc(node.text ?? "");
const marks = node.marks ?? []; 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 === "bold")) text = `<strong>${text}</strong>`;
if (marks.some((m) => m.type === "italic")) text = `<em>${text}</em>`; 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; const href = marks.find((m) => m.type === "link")?.attrs?.href;
if (href) text = `<a href="${attr(href)}">${text}</a>`; if (href) text = `<a href="${attr(href)}">${text}</a>`;
return text; return text;
@@ -56,11 +59,11 @@ function inlines(content: JSONContent[] = []): string {
return content.map(inline).join(""); return content.map(inline).join("");
} }
function listItem(item: JSONContent): string { function listItem(item: JSONContent, paths: Map<string, string>): string {
const inner = (item.content ?? []) const inner = (item.content ?? [])
.filter((c) => c.type === "paragraph") .map((child) => block(child, paths))
.map((p) => inlines(p.content)) .filter((s) => s.length > 0)
.join(" "); .join("");
return `<li>${inner}</li>`; return `<li>${inner}</li>`;
} }
@@ -92,9 +95,9 @@ function block(node: JSONContent, paths: Map<string, string>): string {
case "blockquote": case "blockquote":
return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`; return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`;
case "bulletList": case "bulletList":
return `<ul>${(node.content ?? []).map(listItem).join("")}</ul>`; return `<ul>${(node.content ?? []).map((li) => listItem(li, paths)).join("")}</ul>`;
case "orderedList": case "orderedList":
return `<ol>${(node.content ?? []).map(listItem).join("")}</ol>`; return `<ol>${(node.content ?? []).map((li) => listItem(li, paths)).join("")}</ol>`;
case "horizontalRule": case "horizontalRule":
return `<hr class="scene-break"/>`; return `<hr class="scene-break"/>`;
case "figure": case "figure":
@@ -406,6 +409,11 @@ h2, h3 {
margin: 0; margin: 0;
} }
code {
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 0.9em;
}
blockquote { blockquote {
margin: 1.2em 1.6em; margin: 1.2em 1.6em;
font-style: italic; font-style: italic;
+1 -1
View File
@@ -9,7 +9,7 @@ const EPUB = [{ name: "EPUB", extensions: ["epub"] }];
export async function exportPdf(book: Book): Promise<void> { export async function exportPdf(book: Book): Promise<void> {
const { source, images } = bookToPdfInputs(book); const { source, images } = bookToPdfInputs(book);
const bytes = await compilePdf(source, images); const bytes = await compilePdf(source, images, true);
await saveBytes(bytes, `${book.metadata.title || "Untitled"}.pdf`, PDF); await saveBytes(bytes, `${book.metadata.title || "Untitled"}.pdf`, PDF);
} }
+6
View File
@@ -1,5 +1,6 @@
import type { Book } from "../model/book"; import type { Book } from "../model/book";
import { exportEpub, exportPdf } from "./exporters"; import { exportEpub, exportPdf } from "./exporters";
import { unsupportedScripts } from "./typst";
import { useBook } from "../store/useBook"; import { useBook } from "../store/useBook";
import { isDesktop } from "../ipc"; import { isDesktop } from "../ipc";
@@ -29,6 +30,11 @@ export async function runExport(format: "pdf" | "epub"): Promise<void> {
await nextPaint(); await nextPaint();
try { try {
await run(book); 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) { } catch (e) {
setNotice(`${label} export failed: ${e}`); setNotice(`${label} export failed: ${e}`);
} finally { } finally {
+53 -20
View File
@@ -10,27 +10,32 @@ const TRIM: Record<TrimSize, { w: string; h: string }> = {
}; };
const INLINE_SPECIAL = /[\\#$*_`<>@~[\]]/g; const INLINE_SPECIAL = /[\\#$*_`<>@~[\]]/g;
const LINE_SEPARATORS = /[\n\r\v\f\u2028\u2029]+/g;
function esc(text: string): string { 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 { function guardLineStart(text: string): string {
if (/^[=\-+/]/.test(line)) return "\\" + line; if (/^[=\-+/]/.test(text)) return "\\" + text;
if (/^\d+\./.test(line)) return line.replace(".", "\\."); if (/^\d+\./.test(text)) return text.replace(".", "\\.");
return line; return text;
} }
function str(value: string): string { function str(value: string): string {
return JSON.stringify(value); return JSON.stringify(value);
} }
function inline(node: JSONContent): string { function inline(node: JSONContent, atLineStart: boolean): string {
if (node.type === "text") { if (node.type === "text") {
let text = esc(node.text ?? "");
const marks = node.marks ?? []; 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 === "italic")) text = `#emph[${text}]`;
if (marks.some((m) => m.type === "bold")) text = `#strong[${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; const href = marks.find((m) => m.type === "link")?.attrs?.href;
if (href) text = `#link(${str(href)})[${text}]`; if (href) text = `#link(${str(href)})[${text}]`;
return text; return text;
@@ -39,15 +44,15 @@ function inline(node: JSONContent): string {
return ""; return "";
} }
function inlines(content: JSONContent[] = []): string { function inlines(content: JSONContent[] = [], atLineStart = false): string {
return content.map(inline).join(""); return content.map((node, i) => inline(node, atLineStart && i === 0)).join("");
} }
function listItem(item: JSONContent): string { function listItem(item: JSONContent, paths: Map<string, string>): string {
return (item.content ?? []) const parts = (item.content ?? [])
.filter((c) => c.type === "paragraph") .map((child) => block(child, paths))
.map((p) => inlines(p.content)) .filter((s) => s.length > 0);
.join(" "); return `[${parts.join("\n\n")}]`;
} }
function figure(node: JSONContent, paths: Map<string, string>): string { function figure(node: JSONContent, paths: Map<string, string>): string {
@@ -59,7 +64,7 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
} }
const placed = placement as FigurePlacement; const placed = placement as FigurePlacement;
const width = (isResizablePlacement(placed) ? node.attrs?.width : null) ?? FIGURE_WIDTH[placed] ?? 100; 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" : ""; const float = placement === "float-top" ? ", placement: top" : "";
return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`; return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`;
} }
@@ -67,18 +72,18 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
function block(node: JSONContent, paths: Map<string, string>): string { function block(node: JSONContent, paths: Map<string, string>): string {
switch (node.type) { switch (node.type) {
case "paragraph": { case "paragraph": {
const body = guardLineStart(inlines(node.content)); const body = inlines(node.content, true);
if (!body.trim()) return "~"; if (!body.trim()) return "~";
return node.attrs?.indent ? `#h(1.3em)${body}` : body; return node.attrs?.indent ? `#h(1.3em)${body}` : body;
} }
case "heading": case "heading":
return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content)}]`; return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content, true)}]`;
case "blockquote": case "blockquote":
return `#blockquote[${(node.content ?? []).map((n) => block(n, paths)).join("\n\n")}]`; return `#blockquote[${(node.content ?? []).map((n) => block(n, paths)).join("\n\n")}]`;
case "bulletList": case "bulletList":
return (node.content ?? []).map((li) => `- ${listItem(li)}`).join("\n"); return `#list(${(node.content ?? []).map((li) => listItem(li, paths)).join(", ")})`;
case "orderedList": case "orderedList":
return (node.content ?? []).map((li) => `+ ${listItem(li)}`).join("\n"); return `#enum(${(node.content ?? []).map((li) => listItem(li, paths)).join(", ")})`;
case "horizontalRule": case "horizontalRule":
return "#scenebreak"; return "#scenebreak";
case "figure": case "figure":
@@ -188,7 +193,7 @@ function preamble(book: Book): string {
function imageExtension(dataUrl: string): string { function imageExtension(dataUrl: string): string {
const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl); 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; return kind === "jpeg" ? "jpg" : kind;
} }
@@ -251,6 +256,34 @@ export function extractImages(book: Book): { images: ImageInput[]; paths: Map<st
return collectImages(book.chapters.map((chapter) => chapter.content)); 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 { function cleanTitle(title: string): string {
return (title || "") return (title || "")
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ") .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")
+121 -20
View File
@@ -1,7 +1,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import type { JSONContent } from "@tiptap/core"; import type { JSONContent } from "@tiptap/core";
import { type Book, type BookMetadata, type Chapter, createCover } from "../model/book"; import { type Book, type BookMetadata, type Chapter, type Cover, createCover } from "../model/book";
export interface RawFile { export interface RawFile {
path: string; path: string;
@@ -9,7 +9,7 @@ export interface RawFile {
encoding: "utf8" | "base64"; encoding: "utf8" | "base64";
} }
type Mark = { type: string }; type Mark = { type: string; attrs?: Record<string, unknown> };
const BLOCK_TAGS = new Set([ const BLOCK_TAGS = new Set([
"p", "div", "section", "article", "main", "header", "footer", "aside", "p", "div", "section", "article", "main", "header", "footer", "aside",
@@ -81,8 +81,8 @@ function hasMark(marks: Mark[], type: string): boolean {
return marks.some((m) => m.type === type); return marks.some((m) => m.type === type);
} }
function addMark(marks: Mark[], type: string): Mark[] { function addMark(marks: Mark[], type: string, attrs?: Record<string, unknown>): Mark[] {
return hasMark(marks, type) ? marks : [...marks, { type }]; return hasMark(marks, type) ? marks : [...marks, attrs ? { type, attrs } : { type }];
} }
function collectInline(node: Node, marks: Mark[], out: JSONContent[]): void { function collectInline(node: Node, marks: Mark[], out: JSONContent[]): void {
@@ -102,6 +102,15 @@ function collectInline(node: Node, marks: Mark[], out: JSONContent[]): void {
collectInline(el, addMark(marks, "bold"), out); collectInline(el, addMark(marks, "bold"), out);
} else if (tag === "em" || tag === "i") { } else if (tag === "em" || tag === "i") {
collectInline(el, addMark(marks, "italic"), out); 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)) { } else if (!BLOCK_TAGS.has(tag)) {
collectInline(el, marks, out); collectInline(el, marks, out);
} }
@@ -137,13 +146,23 @@ function meaningful(inline: JSONContent[]): boolean {
function imgFigure(img: Element, caption = ""): JSONContent[] { function imgFigure(img: Element, caption = ""): JSONContent[] {
const src = img.getAttribute("src"); const src = img.getAttribute("src");
if (!src || !src.startsWith("data:")) return []; if (src && src.startsWith("data:")) {
return [ return [
{ {
type: "figure", type: "figure",
attrs: { src, alt: img.getAttribute("alt") ?? "", caption, placement: "full-width" }, 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[] { function figureFrom(el: Element): JSONContent[] {
@@ -165,18 +184,35 @@ function hasBlockChild(el: Element): boolean {
return Array.from(el.children).some((c) => BLOCK_TAGS.has(c.localName.toLowerCase())); 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[] = []; const items: JSONContent[] = [];
Array.from(el.children) Array.from(el.children)
.filter((c) => c.localName.toLowerCase() === "li") .filter((c) => c.localName.toLowerCase() === "li")
.forEach((li) => { .forEach((li) => {
const content = blocksFrom(li); const content = blocksFrom(li, depth + 1);
items.push({ type: "listItem", content: content.length ? content : [{ type: "paragraph" }] }); items.push({ type: "listItem", content: content.length ? content : [{ type: "paragraph" }] });
}); });
return items; 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(); const tag = el.localName.toLowerCase();
switch (tag) { switch (tag) {
case "p": case "p":
@@ -192,15 +228,15 @@ function blockFromElement(el: Element): JSONContent[] {
return meaningful(content) ? [{ type: "heading", attrs: { level }, content }] : []; return meaningful(content) ? [{ type: "heading", attrs: { level }, content }] : [];
} }
case "blockquote": { case "blockquote": {
const inner = blocksFrom(el); const inner = blocksFrom(el, depth + 1);
return [{ type: "blockquote", content: inner.length ? inner : [{ type: "paragraph" }] }]; return [{ type: "blockquote", content: inner.length ? inner : [{ type: "paragraph" }] }];
} }
case "ul": { case "ul": {
const items = listItems(el); const items = listItems(el, depth);
return items.length ? [{ type: "bulletList", content: items }] : []; return items.length ? [{ type: "bulletList", content: items }] : [];
} }
case "ol": { case "ol": {
const items = listItems(el); const items = listItems(el, depth);
return items.length ? [{ type: "orderedList", content: items }] : []; return items.length ? [{ type: "orderedList", content: items }] : [];
} }
case "hr": case "hr":
@@ -209,22 +245,27 @@ function blockFromElement(el: Element): JSONContent[] {
return figureFrom(el); return figureFrom(el);
case "img": case "img":
return imgFigure(el); return imgFigure(el);
case "svg":
return svgFigure(el);
case "table":
return tableBlocks(el);
case "script": case "script":
case "style": case "style":
return []; return [];
default: 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[] = []; const out: JSONContent[] = [];
parent.childNodes.forEach((child) => { parent.childNodes.forEach((child) => {
if (child.nodeType === 3) { if (child.nodeType === 3) {
const text = (child.nodeValue ?? "").replace(/\s+/g, " ").trim(); const text = (child.nodeValue ?? "").replace(/\s+/g, " ").trim();
if (text !== "") out.push({ type: "paragraph", content: [{ type: "text", text }] }); if (text !== "") out.push({ type: "paragraph", content: [{ type: "text", text }] });
} else if (child.nodeType === 1) { } else if (child.nodeType === 1) {
out.push(...blockFromElement(child as Element)); out.push(...blockFromElement(child as Element, depth));
} }
}); });
return out; return out;
@@ -234,6 +275,41 @@ function headingText(node: JSONContent): string {
return (node.content ?? []).map((n) => n.text ?? "").join("").trim(); 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 { interface ManifestItem {
href: string; href: string;
mediaType: string; mediaType: string;
@@ -317,6 +393,11 @@ function readNavTitles(
return titles; 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( function resolveImages(
doc: Document, doc: Document,
baseDir: string, baseDir: string,
@@ -335,12 +416,29 @@ function resolveImages(
img.removeAttribute("src"); img.removeAttribute("src");
return; return;
} }
const mime = mimeByPath.get(resolved) || mimeFromExt(resolved); img.setAttribute("src", fileToDataUri(file, mimeByPath.get(resolved) || mimeFromExt(resolved)));
const data = file.encoding === "base64" ? file.data : btoa(unescape(encodeURIComponent(file.data)));
img.setAttribute("src", `data:${mime};base64,${data}`);
}); });
} }
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( function buildChapter(
file: RawFile, file: RawFile,
baseDir: string, baseDir: string,
@@ -394,18 +492,21 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
spine.forEach((item, i) => { spine.forEach((item, i) => {
const file = byPath.get(normalize(item.href)); const file = byPath.get(normalize(item.href));
if (!file) return; if (!file) return;
try {
chapters.push( chapters.push(
buildChapter(file, dirOf(item.href), navTitles.get(item.href), i, byPath, manifest), 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."); if (!chapters.length) throw new Error("Not a valid EPUB: no readable chapters found.");
const identifier = localText(opf, "identifier");
const metadata: BookMetadata = { const metadata: BookMetadata = {
title: localText(opf, "title") || fallbackName, title: localText(opf, "title") || fallbackName,
subtitle: "", subtitle: "",
author: localText(opf, "creator"), author: localText(opf, "creator"),
isbn: /\d{9,13}/.test(identifier) ? identifier.replace(/[^0-9Xx]/g, "") : "", isbn: findIsbn(opf),
language: localText(opf, "language") || "en", language: localText(opf, "language") || "en",
}; };
@@ -415,7 +516,7 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
metadata, metadata,
theme: "quiet-press", theme: "quiet-press",
settings: { trim: "6x9", bleed: true }, settings: { trim: "6x9", bleed: true },
cover: createCover(), cover: findCover(opf, manifest, byPath),
chapters, chapters,
}; };
} }
+6 -2
View File
@@ -7,7 +7,11 @@ export interface ImageInput {
data: string; data: string;
} }
export async function compilePdf(source: string, images: ImageInput[] = []): Promise<Uint8Array> { export async function compilePdf(
const buffer = await invoke<ArrayBuffer>("compile_pdf", { source, images }); source: string,
images: ImageInput[] = [],
emitWarnings = false,
): Promise<Uint8Array> {
const buffer = await invoke<ArrayBuffer>("compile_pdf", { source, images, emitWarnings });
return new Uint8Array(buffer); return new Uint8Array(buffer);
} }
+27 -2
View File
@@ -1,11 +1,12 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { isDesktop } from "./ipc"; import { isDesktop } from "./ipc";
import { type Book, createBook, starterBook } from "./model/book"; import { type Book, SCHEMA_VERSION, createBook, schemaVersion, starterBook } from "./model/book";
export interface BookSummary { export interface BookSummary {
id: string; id: string;
title: string; title: string;
author: string; author: string;
corrupt?: boolean;
} }
export async function listBooks(): Promise<BookSummary[]> { export async function listBooks(): Promise<BookSummary[]> {
@@ -15,7 +16,16 @@ export async function listBooks(): Promise<BookSummary[]> {
export async function loadBook(id: string): Promise<Book> { export async function loadBook(id: string): Promise<Book> {
const contents = await invoke<string>("load_book", { id }); 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> { export async function saveBook(book: Book): Promise<void> {
@@ -35,3 +45,18 @@ export function newBook(): Book {
export function exampleBook(): Book { export function exampleBook(): Book {
return starterBook(); 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);
}
+19 -3
View File
@@ -115,6 +115,14 @@ export interface Book {
chapters: Chapter[]; 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 { function paragraph(text: string): JSONContent {
return { type: "paragraph", content: [{ type: "text", text }] }; return { type: "paragraph", content: [{ type: "text", text }] };
} }
@@ -140,11 +148,19 @@ export function createCover(): Cover {
return { kind: "default", image: "", bg: COVER_PALETTES[0].bg, ink: COVER_PALETTES[0].ink }; 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 };
export function normalizeBook(book: Book): Book { export function normalizeBook(book: Book): Book {
const chapters = (Array.isArray(book.chapters) ? book.chapters : []).filter(Boolean);
return { return {
...book, ...book,
metadata: { ...DEFAULT_METADATA, ...book.metadata },
settings: { ...DEFAULT_SETTINGS, ...book.settings },
cover: book.cover ? { ...createCover(), ...book.cover } : createCover(), 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 +168,9 @@ export function createBook(): Book {
return { return {
schema: "margin/1", schema: "margin/1",
id: crypto.randomUUID(), id: crypto.randomUUID(),
metadata: { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" }, metadata: { ...DEFAULT_METADATA },
theme: "quiet-press", theme: "quiet-press",
settings: { trim: "6x9", bleed: true }, settings: { ...DEFAULT_SETTINGS },
cover: createCover(), cover: createCover(),
chapters: [createChapter("Chapter One")], chapters: [createChapter("Chapter One")],
}; };
+24 -3
View File
@@ -12,6 +12,8 @@ import {
createPage, createPage,
normalizeBook, normalizeBook,
} from "../model/book"; } from "../model/book";
import { loadActiveChapter, saveActiveChapter } from "../editor/positions";
import { saveBook } from "../library";
export const COVER_ID = "__cover__"; export const COVER_ID = "__cover__";
@@ -40,7 +42,11 @@ interface BookState {
markSaved: () => void; 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, book: null,
activeChapterId: "", activeChapterId: "",
dirty: false, dirty: false,
@@ -49,10 +55,17 @@ export const useBook = create<BookState>((set) => ({
setExporting: (label) => set({ exporting: label }), setExporting: (label) => set({ exporting: label }),
setNotice: (message) => set({ notice: message }), setNotice: (message) => set({ notice: message }),
openBook: (book) => { openBook: (book) => {
flushOutgoing(get().book, get().dirty);
const normalized = normalizeBook(book); 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 ?? "";
set({ book: normalized, activeChapterId, dirty: false });
},
closeBook: () => {
flushOutgoing(get().book, get().dirty);
set({ book: null, activeChapterId: "", dirty: false });
}, },
closeBook: () => set({ book: null, activeChapterId: "", dirty: false }),
setActiveChapter: (id) => set({ activeChapterId: id }), setActiveChapter: (id) => set({ activeChapterId: id }),
setChapterContent: (id, content) => setChapterContent: (id, content) =>
set((s) => set((s) =>
@@ -132,3 +145,11 @@ export const useBook = create<BookState>((set) => ({
set((s) => (s.book ? { dirty: true, book: { ...s.book, cover: { ...s.book.cover, ...patch } } } : {})), set((s) => (s.book ? { dirty: true, book: { ...s.book, cover: { ...s.book.cover, ...patch } } } : {})),
markSaved: () => set({ dirty: false }), markSaved: () => set({ dirty: false }),
})); }));
let lastActive = "";
useBook.subscribe((s) => {
if (s.book && s.activeChapterId && s.activeChapterId !== lastActive) {
lastActive = s.activeChapterId;
saveActiveChapter(s.book.id, s.activeChapterId);
}
});
+15
View File
@@ -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 });
},
}));
+201
View File
@@ -1209,6 +1209,16 @@ body.resizing {
box-shadow: 0 4px 10px rgba(35, 32, 27, 0.1), 0 20px 38px rgba(35, 32, 27, 0.13); 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 { .card-title {
font-family: var(--font-book); font-family: var(--font-book);
font-size: 20px; font-size: 20px;
@@ -1992,3 +2002,194 @@ body.resizing {
background: var(--accent-wash); background: var(--accent-wash);
color: var(--ink); 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-book);
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-book);
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;
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default defineConfig(async () => ({
: undefined, : undefined,
watch: { watch: {
// 3. tell Vite to ignore watching `src-tauri` // 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"], ignored: ["**/src-tauri/**", "**/website/**"],
}, },
}, },
})); }));
+1 -1
View File
@@ -3,5 +3,5 @@ import { defineConfig } from "astro/config";
// The canonical URL is only used for absolute links (OG tags, sitemap). // The canonical URL is only used for absolute links (OG tags, sitemap).
// Change it to the real domain when the site goes live. // Change it to the real domain when the site goes live.
export default defineConfig({ export default defineConfig({
site: "https://margin.studio", site: "https://margin.73ai.org",
}); });
+2 -1
View File
@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview" "preview": "astro preview",
"deploy": "astro build && npx wrangler pages deploy dist --project-name=margin --commit-dirty=true"
}, },
"dependencies": { "dependencies": {
"astro": "^5.6.1" "astro": "^5.6.1"
+276 -87
View File
@@ -1,37 +1,70 @@
--- ---
// A faithful CSS mock-up of the Margin desktop app: chapter sidebar, the // A faithful CSS mock-up of the Margin desktop app: the All-books sidebar with
// writing surface set in Literata, and the live PDF preview dock. The editor // front-matter / chapter sections, the writing surface set in Literata with live
// shows live proofing underlines; the PDF dock shows the clean typeset page. // 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" 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"> <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__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>
<div class="win__body"> <div class="win__body">
<aside class="side"> <aside class="side">
<p class="side__label">Manuscript</p> <span class="side__brand">
<ul> <svg class="i" viewBox="0 0 24 24"><path d="M14 7l-5 5 5 5" /></svg>All books
<li>Title Page</li> </span>
<li>Dedication</li> <span class="side__cover">
<li class="is-active">1 · The Cliff</li> <svg class="i" viewBox="0 0 24 24"><path d="M5 4h11l3 3v13H5zM16 4v4h3" /></svg>Cover
<li>2 · A Light at Sea</li> </span>
<li>3 · Salt and Silence</li> <span class="side__add">
<li>4 · The Long Night</li> <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> </ul>
<p class="side__label">Book setup</p> </div>
<ul class="side__meta">
<li>Trim · 5 × 8 in</li> <div class="side__section">
<li>Language · English</li> <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> </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> </aside>
<main class="page"> <main class="page">
<div class="page__sheet"> <div class="page__sheet">
<p class="chapno">Chapter One</p> <p class="chapno">Chapter 1</p>
<h4 class="chaptitle">The Cliff</h4> <h4 class="chaptitle">The Cliff</h4>
<p> <p>
The lighthouse had stood on the headland for ninety years, and in all that time it had The lighthouse had stood on the headland for ninety years, and in all that time it had
@@ -47,10 +80,17 @@
</main> </main>
<aside class="dock"> <aside class="dock">
<p class="dock__label">Live PDF · pg 7</p> <div class="dock__head">
<div class="dock__pdf"> <span class="dock__label">Preview</span>
<span class="rh">THE LIGHTHOUSE KEEPER</span> <span class="dock__trim">
<p class="pch">Chapter One</p> 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="pdt">The Cliff</p>
<p class="pp pp--first"> <p class="pp pp--first">
The lighthouse had stood on the headland for ninety years, and in all that time it had The lighthouse had stood on the headland for ninety years, and in all that time it had
@@ -61,7 +101,15 @@
of habit, and watched the lamp turn its slow circle across the water. of habit, and watched the lamp turn its slow circle across the water.
</p> </p>
<p class="pp">The sea, tonight, was the colour of pewter.</p> <p class="pp">The sea, tonight, was the colour of pewter.</p>
<span class="pg">7</span> <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> </div>
</aside> </aside>
</div> </div>
@@ -76,115 +124,231 @@
box-shadow: var(--shadow-deep); box-shadow: var(--shadow-deep);
overflow: hidden; 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 { .win__bar {
display: flex; display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center; align-items: center;
gap: 8px; height: 44px;
height: 40px; padding-inline: 14px;
padding-inline: 16px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
background: var(--sidebar); background: var(--shell);
font-size: 0.78rem; }
color: var(--ink-faint); .win__chrome {
display: flex;
gap: 8px;
} }
.dot { .dot {
width: 11px; width: 11px;
height: 11px; height: 11px;
border-radius: 50%; border-radius: 50%;
} }
.win__bar .dot:nth-child(1) { .win__chrome .dot:nth-child(1) {
background: #ff5f57; background: #ff5f57;
} }
.win__bar .dot:nth-child(2) { .win__chrome .dot:nth-child(2) {
background: #febc2e; background: #febc2e;
} }
.win__bar .dot:nth-child(3) { .win__chrome .dot:nth-child(3) {
background: #28c840; background: #28c840;
} }
.win__title { .win__title {
margin-inline: auto; grid-column: 2;
font-weight: 500; font-size: 0.82rem;
} letter-spacing: 0.01em;
.win__menu {
color: var(--ink-soft); 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 { .win__body {
display: grid; display: grid;
grid-template-columns: 172px 1fr 196px; grid-template-columns: 196px 1fr 200px;
min-height: 392px; min-height: 432px;
} }
.side { .side {
display: flex;
flex-direction: column;
background: var(--sidebar); background: var(--sidebar);
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
padding: 16px 12px; padding: 12px 10px;
font-size: 0.8rem; 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 { .side__label {
font-size: 0.66rem; font-size: 0.62rem;
letter-spacing: 0.1em; letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
color: var(--ink-faint); color: var(--ink-faint);
font-weight: 650; font-weight: 650;
margin: 0 0 8px; margin: 0 0 6px;
padding-inline: 8px;
} }
.side ul { .side__list {
list-style: none; list-style: none;
margin: 0 0 18px; margin: 0;
padding: 0; padding: 0;
display: flex; display: flex;
flex-direction: column; 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; padding: 5px 8px;
border-radius: var(--r-sm); border-radius: var(--r-sm);
color: var(--ink-soft); color: var(--ink-soft);
} }
.side li.is-active { .row .grip {
background: var(--accent-wash);
color: var(--ink);
font-weight: 600;
}
.side__meta li {
color: var(--ink-faint); 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 { .page {
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 26px 18px; padding: 30px 18px;
overflow: hidden; overflow: hidden;
} }
.page__sheet { .page__sheet {
width: 100%; width: 100%;
max-width: 360px; max-width: 372px;
font-family: var(--font-book); font-family: var(--font-book);
font-size: 0.92rem; font-size: 0.92rem;
line-height: 1.62; line-height: 1.6;
color: var(--ink); color: var(--ink);
} }
.page__sheet p { .page__sheet p {
margin: 0 0 0.7em; margin: 0;
text-indent: 1.1em; text-indent: 1.3em;
text-align: justify;
hyphens: auto;
-webkit-hyphens: auto;
}
.page__sheet p + p {
margin-top: 0.2em;
} }
.chapno { .chapno {
text-indent: 0 !important; text-indent: 0 !important;
text-align: center; text-align: center !important;
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 0.62rem; font-size: 0.6rem;
letter-spacing: 0.18em; font-weight: 600;
letter-spacing: 0.24em;
text-transform: uppercase; text-transform: uppercase;
color: var(--ink-faint); color: var(--ink-faint);
margin-bottom: 0.4em !important; margin: 0 0 0.5em !important;
} }
.chaptitle { .chaptitle {
text-indent: 0;
text-align: center; text-align: center;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 560; font-weight: 540;
margin: 0 0 1.1em; letter-spacing: -0.01em;
margin: 0 0 1.4em;
}
.chaptitle + p {
text-indent: 0;
} }
.u-spell { .u-spell {
text-decoration: wavy underline var(--danger); text-decoration: wavy underline var(--danger);
@@ -200,17 +364,45 @@
.dock { .dock {
background: var(--shell); background: var(--shell);
border-left: 1px solid var(--line); 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 { .dock__label {
font-size: 0.66rem; font-size: 0.62rem;
letter-spacing: 0.1em; letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
color: var(--ink-faint); color: var(--ink-faint);
font-weight: 650; 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; position: relative;
aspect-ratio: 5 / 8; aspect-ratio: 5 / 8;
background: #fcfbf7; background: #fcfbf7;
@@ -219,26 +411,18 @@
padding: 18px 16px 22px; padding: 18px 16px 22px;
overflow: hidden; overflow: hidden;
color: #2a2622; color: #2a2622;
flex: none;
} }
.dock__pdf .rh { .dock__page .pch {
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 {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 4px; font-size: 4px;
letter-spacing: 0.22em; letter-spacing: 0.22em;
text-transform: uppercase; text-transform: uppercase;
color: #9b9484; color: #9b9484;
text-align: center; text-align: center;
margin: 0 0 4px; margin: 6px 0 4px;
} }
.dock__pdf .pdt { .dock__page .pdt {
font-family: var(--font-book); font-family: var(--font-book);
font-size: 9px; font-size: 9px;
font-weight: 560; font-weight: 560;
@@ -246,19 +430,21 @@
text-align: center; text-align: center;
margin: 0 0 11px; margin: 0 0 11px;
} }
.dock__pdf .pp { .dock__page .pp {
font-family: var(--font-book); font-family: var(--font-book);
font-size: 5.6px; font-size: 5.6px;
line-height: 1.62; line-height: 1.62;
text-align: justify; text-align: justify;
hyphens: auto;
-webkit-hyphens: auto;
text-indent: 1.3em; text-indent: 1.3em;
margin: 0 0 0.5em; margin: 0 0 0.5em;
color: #2a2622; color: #2a2622;
} }
.dock__pdf .pp--first { .dock__page .pp--first {
text-indent: 0; text-indent: 0;
} }
.dock__pdf .pg { .dock__page .pg {
position: absolute; position: absolute;
bottom: 9px; bottom: 9px;
left: 0; left: 0;
@@ -268,10 +454,13 @@
font-size: 5px; font-size: 5px;
color: #b3ab9c; color: #b3ab9c;
} }
.dock__page--peek {
padding-top: 18px;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.win__body { .win__body {
grid-template-columns: 1fr 168px; grid-template-columns: 1fr 180px;
} }
.side { .side {
display: none; display: none;
-6
View File
@@ -38,12 +38,6 @@ import OfferBadge from "./OfferBadge.astro";
justify-content: center; justify-content: center;
margin-top: 32px; margin-top: 32px;
} }
.cta__btns :global(.dl) {
align-items: center;
}
.cta__btns :global(.dl__os) {
justify-content: center;
}
.cta__offer { .cta__offer {
display: flex; display: flex;
justify-content: center; justify-content: center;
+17 -17
View File
@@ -1,10 +1,12 @@
--- ---
import { site, platforms } from "../data/site"; import { site, platforms } from "../data/site";
import { getRelease } from "../data/release";
interface Props { interface Props {
large?: boolean; large?: boolean;
} }
const { large = false } = Astro.props; const { large = false } = Astro.props;
const release = await getRelease();
--- ---
<div class="dl"> <div class="dl">
@@ -22,17 +24,16 @@ const { large = false } = Astro.props;
</a> </a>
<div class="dl__os"> <div class="dl__os">
<span class="dl__also">Also for</span>
{ {
platforms.map((p) => ( platforms.map((p) => (
<a <a
class="dl__chip" class="dl__chip"
href={site.releasesUrl} href={release.urls[p.id]}
data-download-os={p.id} data-download-os={p.id}
rel="noopener" rel="noopener"
download download
aria-label={`Download for ${p.label}`} 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} /> <img class="dl__logo" src={`/logos/${p.logo}`} alt={p.label} />
</a> </a>
@@ -45,38 +46,37 @@ const { large = false } = Astro.props;
.dl { .dl {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; align-items: center;
align-items: flex-start; gap: 16px;
} }
.dl__os { .dl__os {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; justify-content: center;
flex-wrap: wrap; gap: 12px;
}
.dl__also {
font-size: 0.82rem;
color: var(--ink-faint);
font-weight: 500;
} }
.dl__chip { .dl__chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 40px; flex: none;
height: 40px; width: 44px;
height: 44px;
background: #ffffff; background: #ffffff;
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
border-radius: var(--r-md); 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 { .dl__chip:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: var(--shadow-pop); box-shadow: var(--shadow-pop);
} }
.dl__logo { .dl__logo {
width: 20px; width: 22px;
height: 20px; height: 22px;
object-fit: contain; object-fit: contain;
} }
</style> </style>
+24 -26
View File
@@ -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 { repo, releasesUrl } = site;
const { urls, version } = await getRelease();
const matches = Object.fromEntries(platforms.map((p) => [p.id, p.match]));
--- ---
<script define:vars={{ repo, releasesUrl }}> <script define:vars={{ repo, releasesUrl, urls, version, matches }}>
const SUFFIXES = {
mac: [".dmg"],
windows: [".msi", ".exe"],
linux: [".AppImage", ".deb", ".rpm"],
};
const LABELS = { mac: "macOS", windows: "Windows", linux: "Linux" }; const LABELS = { mac: "macOS", windows: "Windows", linux: "Linux" };
function detectOS() { function detectOS() {
@@ -21,20 +20,31 @@ const { repo, releasesUrl } = site;
} }
function pickAsset(assets, os) { 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())); const hit = assets.find((a) => a.name.toLowerCase().endsWith(sfx.toLowerCase()));
if (hit) return hit.browser_download_url; if (hit) return hit.browser_download_url;
} }
return null; return null;
} }
function apply(map, ver) {
const os = detectOS(); const os = detectOS();
document.querySelectorAll("[data-download]").forEach((el) => { document.querySelectorAll("[data-download]").forEach((el) => {
el.dataset.detectedOs = os;
const label = el.querySelector("[data-dl-label]"); const label = el.querySelector("[data-dl-label]");
if (label) label.textContent = "Download for " + LABELS[os]; 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));
}
apply(urls, version);
(async () => { (async () => {
try { try {
@@ -44,23 +54,11 @@ const { repo, releasesUrl } = site;
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const assets = data.assets || []; const assets = data.assets || [];
const version = data.tag_name || ""; const fresh = {};
for (const os of Object.keys(matches)) fresh[os] = pickAsset(assets, os) || urls[os] || releasesUrl;
document.querySelectorAll("[data-download]").forEach((el) => { apply(fresh, data.tag_name || version);
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;
});
}
} catch (e) { } catch (e) {
/* keep the releases-page fallback links */ /* keep the build-time links */
} }
})(); })();
</script> </script>
+6 -3
View File
@@ -1,14 +1,17 @@
--- ---
import { site } from "../data/site"; import { site } from "../data/site";
import { getRelease } from "../data/release";
import Logo from "./Logo.astro"; import Logo from "./Logo.astro";
const release = await getRelease();
const cols = [ const cols = [
{ {
title: "Get Margin", title: "Get Margin",
links: [ links: [
{ label: "Download for macOS", href: site.releasesUrl, os: "mac" }, { label: "Download for macOS", href: release.urls.mac, os: "mac" },
{ label: "Download for Windows", href: site.releasesUrl, os: "windows" }, { label: "Download for Windows", href: release.urls.windows, os: "windows" },
{ label: "Download for Linux", href: site.releasesUrl, os: "linux" }, { label: "Download for Linux", href: release.urls.linux, os: "linux" },
], ],
}, },
{ {
+22 -30
View File
@@ -11,63 +11,55 @@ import OfferBadge from "./OfferBadge.astro";
<span class="book-accent">Own</span> every word. <span class="book-accent">Own</span> every word.
</h1> </h1>
<p class="lede hero__lede"> <p class="lede hero__lede">
Margin is a calm, offline writing studio for authors. Write your book, format it A calm, offline studio to write, format, and publish your book to every store and to
beautifully, and send it to every store and to print, all from your own machine. No print. No subscription, no account. Buy once, own it forever.
subscription, no account. Buy once, own it forever.
</p> </p>
<div class="hero__cta">
<DownloadButtons large />
</div>
<div class="hero__offer">
<OfferBadge />
</div>
</div> </div>
<div class="container hero__art"> <div class="container hero__art">
<AppWindow /> <AppWindow />
</div> </div>
<div class="container hero__get" id="download">
<DownloadButtons large />
<div class="hero__offer">
<OfferBadge />
</div>
</div>
</section> </section>
<style> <style>
.hero { .hero {
padding-top: clamp(48px, 7vw, 92px); padding-top: clamp(36px, 4.5vw, 60px);
padding-bottom: clamp(40px, 6vw, 72px); padding-bottom: clamp(48px, 6vw, 80px);
background: background:
radial-gradient(120% 90% at 50% -10%, var(--paper) 0%, transparent 60%), radial-gradient(120% 90% at 50% -10%, var(--paper) 0%, transparent 60%),
var(--shell); var(--shell);
} }
.hero__head { .hero__head {
text-align: center; text-align: center;
max-width: 760px; max-width: 720px;
} }
h1 { 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; letter-spacing: -0.02em;
margin-bottom: 22px; margin-bottom: 18px;
} }
.hero__lede { .hero__lede {
margin-inline: auto; margin-inline: auto;
max-width: 56ch; max-width: 50ch;
} }
.hero__cta { .hero__art {
display: flex; margin-top: clamp(32px, 4vw, 48px);
justify-content: center; max-width: 1000px;
margin-top: 34px;
} }
.hero__cta :global(.dl) { .hero__get {
align-items: center; margin-top: clamp(24px, 3vw, 32px);
} scroll-margin-top: 96px;
.hero__cta :global(.dl__os) {
justify-content: center;
} }
.hero__offer { .hero__offer {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-top: 30px; margin-top: 22px;
}
.hero__art {
margin-top: clamp(44px, 6vw, 72px);
max-width: 1000px;
} }
</style> </style>
+14
View File
@@ -19,6 +19,12 @@ import Logo from "./Logo.astro";
</nav> </nav>
<div class="nav__actions"> <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"> <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"> <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> <circle cx="12" cy="12" r="4"></circle>
@@ -89,6 +95,14 @@ import Logo from "./Logo.astro";
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
.nav__cta {
padding: 0.5em 1.05em;
font-size: 0.9rem;
}
.nav__cta svg {
width: 1em;
height: 1em;
}
.iconlink { .iconlink {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
-1
View File
@@ -20,7 +20,6 @@ const { cap, left } = site.launch;
border-radius: 999px; border-radius: 999px;
background: color-mix(in srgb, var(--good) 12%, var(--paper)); background: color-mix(in srgb, var(--good) 12%, var(--paper));
border: 1px solid color-mix(in srgb, var(--good) 32%, transparent); border: 1px solid color-mix(in srgb, var(--good) 32%, transparent);
box-shadow: var(--shadow-page);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
color: var(--ink); color: var(--ink);
-6
View File
@@ -182,12 +182,6 @@ const included = [
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
.price-card__cta :global(.dl) {
align-items: center;
}
.price-card__cta :global(.dl__os) {
justify-content: center;
}
.price-card__note { .price-card__note {
margin-top: 26px; margin-top: 26px;
font-size: 0.84rem; font-size: 0.84rem;
+52
View File
@@ -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;
}
+4 -4
View File
@@ -5,7 +5,7 @@ export const site = {
tagline: "Write your book. Own every word.", tagline: "Write your book. Own every word.",
description: 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.", "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. // The regular one-time price, shown struck through during the launch offer.
price: "$10", price: "$10",
@@ -16,7 +16,7 @@ export const site = {
left: 12, left: 12,
}, },
supportEmail: "[email protected]", supportEmail: "[email protected]",
// Repo that hosts the release files. Used only to resolve the right download // Repo that hosts the release files. Used only to resolve the right download
// per OS at runtime. It is never shown to the reader. // per OS at runtime. It is never shown to the reader.
@@ -39,8 +39,8 @@ export type Platform = {
export const platforms: Platform[] = [ export const platforms: Platform[] = [
{ id: "mac", label: "macOS", logo: "macos.svg", match: [".dmg"] }, { id: "mac", label: "macOS", logo: "macos.svg", match: [".dmg"] },
{ id: "windows", label: "Windows", logo: "windows.svg", match: [".msi", ".exe"] }, { id: "windows", label: "Windows", logo: "windows.svg", match: [".exe", ".msi"] },
{ id: "linux", label: "Linux", logo: "linux.svg", match: [".AppImage", ".deb", ".rpm"] }, { id: "linux", label: "Linux", logo: "linux.svg", match: [".deb", ".AppImage", ".rpm"] },
]; ];
// Full-color store logos, served from /public/logos. Shown on white tiles so // Full-color store logos, served from /public/logos. Shown on white tiles so