mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04a0a49660 | ||
|
|
457209f934 | ||
|
|
b94b51140a | ||
|
|
5caf66a6fc | ||
|
|
983d8fd2c0 | ||
|
|
f4079a6f54 | ||
|
|
a54e7a42ef | ||
|
|
0485177282 | ||
|
|
3572c7a6b9 | ||
|
|
2f98f5ec1c | ||
|
|
608ba2f21b | ||
|
|
b63951f814 | ||
|
|
aa75b3ebda | ||
|
|
4e5ea6d855 | ||
|
|
9eabef9041 | ||
|
|
5c0040955e | ||
|
|
6e2ca9c4bc | ||
|
|
258d5e5209 | ||
|
|
14eb3c4427 | ||
|
|
0bc3e36ee6 | ||
|
|
c0da4c53db | ||
|
|
92941a2758 |
No files matched your search
Generated
+1
-1
@@ -4704,7 +4704,7 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "margin-app"
|
name = "margin-app"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"harper-core",
|
"harper-core",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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());
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,11 +268,15 @@ export function EditorView() {
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<Editor
|
<Editor
|
||||||
|
key={chapter.id}
|
||||||
bookId={book.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} />
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ export function PdfPreview({ data }: { data: Uint8Array }) {
|
|||||||
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 () => {
|
||||||
try {
|
try {
|
||||||
const doc = await pdfjs.getDocument({ data: data.slice() }).promise;
|
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;
|
||||||
@@ -47,6 +48,7 @@ export function PdfPreview({ data }: { data: Uint8Array }) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
loadingTask.destroy();
|
||||||
};
|
};
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
|||||||
+19
-19
@@ -2,7 +2,7 @@ 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 } from "./positions";
|
import { loadPosition, savePosition, type ChapterPosition } from "./positions";
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
bookId: string;
|
bookId: string;
|
||||||
@@ -10,24 +10,30 @@ interface EditorProps {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Editor({ bookId, chapterId, content, onChange, onReady }: EditorProps) {
|
export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: EditorProps) {
|
||||||
const onChangeRef = useRef(onChange);
|
const onChangeRef = useRef(onChange);
|
||||||
onChangeRef.current = onChange;
|
onChangeRef.current = onChange;
|
||||||
|
const onContentErrorRef = useRef(onContentError);
|
||||||
|
onContentErrorRef.current = onContentError;
|
||||||
|
|
||||||
const openChapter = useRef(chapterId);
|
const latest = useRef<ChapterPosition | null>(null);
|
||||||
|
|
||||||
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(() => {
|
||||||
@@ -35,15 +41,6 @@ export function Editor({ bookId, chapterId, content, onChange, onReady }: Editor
|
|||||||
let active = true;
|
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;
|
|
||||||
if (previous !== chapterId) {
|
|
||||||
const { from, to } = editor.state.selection;
|
|
||||||
savePosition(bookId, previous, { from, to, scroll: scroller?.scrollTop ?? 0 });
|
|
||||||
}
|
|
||||||
openChapter.current = chapterId;
|
|
||||||
|
|
||||||
editor.commands.setContent(content, { emitUpdate: false });
|
|
||||||
|
|
||||||
const saved = loadPosition(bookId, chapterId);
|
const saved = loadPosition(bookId, chapterId);
|
||||||
const size = editor.state.doc.content.size;
|
const size = editor.state.doc.content.size;
|
||||||
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
|
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
|
||||||
@@ -63,18 +60,20 @@ export function Editor({ bookId, chapterId, content, onChange, onReady }: Editor
|
|||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [chapterId, editor, bookId]);
|
}, [editor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
|
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
const persist = () => {
|
const capture = () => {
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
const { from, to } = editor.state.selection;
|
const { from, to } = editor.state.selection;
|
||||||
savePosition(bookId, openChapter.current, { from, to, scroll: scroller?.scrollTop ?? 0 });
|
latest.current = { from, to, scroll: scroller?.scrollTop ?? 0 };
|
||||||
}, 400);
|
};
|
||||||
|
const persist = () => {
|
||||||
|
capture();
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => savePosition(bookId, chapterId, latest.current!), 400);
|
||||||
};
|
};
|
||||||
editor.on("selectionUpdate", persist);
|
editor.on("selectionUpdate", persist);
|
||||||
scroller?.addEventListener("scroll", persist, { passive: true });
|
scroller?.addEventListener("scroll", persist, { passive: true });
|
||||||
@@ -82,8 +81,9 @@ export function Editor({ bookId, chapterId, content, onChange, onReady }: Editor
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
editor.off("selectionUpdate", persist);
|
editor.off("selectionUpdate", persist);
|
||||||
scroller?.removeEventListener("scroll", persist);
|
scroller?.removeEventListener("scroll", persist);
|
||||||
|
if (latest.current) savePosition(bookId, chapterId, latest.current);
|
||||||
};
|
};
|
||||||
}, [editor, bookId]);
|
}, [editor, bookId, chapterId]);
|
||||||
|
|
||||||
return <EditorContent editor={editor} className="editor-host" />;
|
return <EditorContent editor={editor} className="editor-host" />;
|
||||||
}
|
}
|
||||||
+38
-1
@@ -16,6 +16,14 @@ function readAll(): Store {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
export function loadPosition(bookId: string, chapterId: string): ChapterPosition | null {
|
||||||
return readAll()[bookId]?.[chapterId] ?? null;
|
return readAll()[bookId]?.[chapterId] ?? null;
|
||||||
}
|
}
|
||||||
@@ -23,5 +31,34 @@ export function loadPosition(bookId: string, chapterId: string): ChapterPosition
|
|||||||
export function savePosition(bookId: string, chapterId: string, position: ChapterPosition): void {
|
export function savePosition(bookId: string, chapterId: string, position: ChapterPosition): void {
|
||||||
const all = readAll();
|
const all = readAll();
|
||||||
(all[bookId] ||= {})[chapterId] = position;
|
(all[bookId] ||= {})[chapterId] = position;
|
||||||
localStorage.setItem(KEY, JSON.stringify(all));
|
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);
|
||||||
}
|
}
|
||||||
+14
-6
@@ -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,10 +59,10 @@ 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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,7 +146,7 @@ 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",
|
||||||
@@ -145,6 +154,16 @@ function imgFigure(img: Element, caption = ""): JSONContent[] {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
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[] {
|
||||||
const img = el.querySelector("img");
|
const img = el.querySelector("img");
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user