mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0abeb3c5d4 | ||
|
|
a31f99cb76 | ||
|
|
20365dd02d | ||
|
|
af389c7097 | ||
|
|
f11ddd290f | ||
|
|
4975e76358 |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "margin-app",
|
"name": "margin-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.10",
|
"version": "0.1.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Generated
+26
-1
@@ -4713,11 +4713,14 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "margin-app"
|
name = "margin-app"
|
||||||
version = "0.1.7"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"fontdb",
|
"fontdb",
|
||||||
"harper-core",
|
"harper-core",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-foundation",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -5138,9 +5141,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
|
"objc2-cloud-kit",
|
||||||
|
"objc2-core-data",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-core-image",
|
||||||
|
"objc2-core-text",
|
||||||
|
"objc2-core-video",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
|
"objc2-quartz-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5160,6 +5171,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
@@ -5220,6 +5232,19 @@ dependencies = [
|
|||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-video"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-io-surface",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-encode"
|
name = "objc2-encode"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
|
|||||||
+13
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "margin-app"
|
name = "margin-app"
|
||||||
version = "0.1.10"
|
version = "0.1.13"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -31,7 +31,6 @@ fontdb = "0.23"
|
|||||||
typst = "0.14.2"
|
typst = "0.14.2"
|
||||||
typst-pdf = "0.14.2"
|
typst-pdf = "0.14.2"
|
||||||
typst-as-lib = "0.15.5"
|
typst-as-lib = "0.15.5"
|
||||||
spellbook = "0.4"
|
|
||||||
# Pinned exactly: the [patch] stubs below are tied to this version's burn/cubecl graph. A minor
|
# Pinned exactly: the [patch] stubs below are tied to this version's burn/cubecl graph. A minor
|
||||||
# bump could silently invalidate the patch ("unused") and pull the broken CUDA subtree back in,
|
# bump could silently invalidate the patch ("unused") and pull the broken CUDA subtree back in,
|
||||||
# so bump deliberately and re-audit the stubs. (see [patch.crates-io])
|
# so bump deliberately and re-audit the stubs. (see [patch.crates-io])
|
||||||
@@ -41,6 +40,18 @@ sha2 = "0.10"
|
|||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
url = "2"
|
url = "2"
|
||||||
|
|
||||||
|
# Spelling on macOS uses the system NSSpellChecker (same engine as TextEdit/Safari): far better
|
||||||
|
# vocabulary than the bundled Hunspell dictionary and it skips URLs. These objc2 crates are already
|
||||||
|
# in the graph via Tauri, so this adds nothing new to the build.
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
objc2 = "0.6"
|
||||||
|
objc2-app-kit = { version = "0.3", features = ["NSSpellChecker"] }
|
||||||
|
objc2-foundation = { version = "0.3", features = ["NSString", "NSArray", "NSRange", "NSTextCheckingResult"] }
|
||||||
|
|
||||||
|
# Other platforms fall back to the bundled Hunspell dictionary via spellbook.
|
||||||
|
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||||
|
spellbook = "0.4"
|
||||||
|
|
||||||
# harper-core transitively declares an optional, disabled `burn-cuda` (CUDA backend).
|
# harper-core transitively declares an optional, disabled `burn-cuda` (CUDA backend).
|
||||||
# Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm
|
# Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm
|
||||||
# prereleases) and links-collides with `zip`'s lzma. Replace it with an empty stub so the
|
# prereleases) and links-collides with `zip`'s lzma. Replace it with an empty stub so the
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ mod epub;
|
|||||||
mod fonts;
|
mod fonts;
|
||||||
mod gdrive;
|
mod gdrive;
|
||||||
mod library;
|
mod library;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod macspell;
|
||||||
mod pdf;
|
mod pdf;
|
||||||
mod project;
|
mod project;
|
||||||
mod proofing;
|
mod proofing;
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use objc2::rc::autoreleasepool;
|
||||||
|
use objc2_app_kit::NSSpellChecker;
|
||||||
|
use objc2_foundation::{NSRange, NSString, NSTextCheckingType};
|
||||||
|
|
||||||
|
pub struct MacIssue {
|
||||||
|
pub start: usize,
|
||||||
|
pub end: usize,
|
||||||
|
pub word: String,
|
||||||
|
pub suggestions: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn utf16_to_codepoint(text: &str, utf16_len: usize) -> Vec<usize> {
|
||||||
|
let mut map = Vec::with_capacity(utf16_len + 1);
|
||||||
|
let mut cp = 0;
|
||||||
|
for ch in text.chars() {
|
||||||
|
for _ in 0..ch.len_utf16() {
|
||||||
|
map.push(cp);
|
||||||
|
}
|
||||||
|
cp += 1;
|
||||||
|
}
|
||||||
|
map.push(cp);
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check(text: &str, custom: &HashSet<String>) -> Vec<MacIssue> {
|
||||||
|
autoreleasepool(|_| {
|
||||||
|
let checker = NSSpellChecker::sharedSpellChecker();
|
||||||
|
let ns = NSString::from_str(text);
|
||||||
|
let len = ns.length();
|
||||||
|
let results = unsafe {
|
||||||
|
checker.checkString_range_types_options_inSpellDocumentWithTag_orthography_wordCount(
|
||||||
|
&ns,
|
||||||
|
NSRange { location: 0, length: len },
|
||||||
|
(NSTextCheckingType::Spelling | NSTextCheckingType::Link).bits(),
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let map = utf16_to_codepoint(text, len);
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let mut issues = Vec::new();
|
||||||
|
for result in results.iter() {
|
||||||
|
if result.resultType() != NSTextCheckingType::Spelling {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let range = result.range();
|
||||||
|
let start = map[range.location.min(len)];
|
||||||
|
let end = map[(range.location + range.length).min(len)];
|
||||||
|
let word: String = chars[start..end].iter().collect();
|
||||||
|
if custom.contains(&word.to_lowercase()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut suggestions = Vec::new();
|
||||||
|
if let Some(guesses) =
|
||||||
|
checker.guessesForWordRange_inString_language_inSpellDocumentWithTag(range, &ns, None, 0)
|
||||||
|
{
|
||||||
|
for guess in guesses.iter() {
|
||||||
|
suggestions.push(guess.to_string());
|
||||||
|
if suggestions.len() >= 5 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issues.push(MacIssue { start, end, word, suggestions });
|
||||||
|
}
|
||||||
|
issues
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ struct Harper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Engine {
|
pub struct Engine {
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
speller: Option<spellbook::Dictionary>,
|
speller: Option<spellbook::Dictionary>,
|
||||||
harper: Option<Harper>,
|
harper: Option<Harper>,
|
||||||
custom: HashSet<String>,
|
custom: HashSet<String>,
|
||||||
@@ -87,6 +88,7 @@ fn load_custom(app: &tauri::AppHandle) -> HashSet<String> {
|
|||||||
set
|
set
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
fn build_speller(app: &tauri::AppHandle) -> Result<spellbook::Dictionary, String> {
|
fn build_speller(app: &tauri::AppHandle) -> Result<spellbook::Dictionary, String> {
|
||||||
let aff = dict_file(app, "index.aff")?;
|
let aff = dict_file(app, "index.aff")?;
|
||||||
let dic = dict_file(app, "index.dic")?;
|
let dic = dict_file(app, "index.dic")?;
|
||||||
@@ -100,18 +102,22 @@ fn build_harper() -> Harper {
|
|||||||
Harper { linter, dict }
|
Harper { linter, dict }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
fn is_word_char(c: char) -> bool {
|
fn is_word_char(c: char) -> bool {
|
||||||
c.is_alphabetic()
|
c.is_alphabetic()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
fn has_irregular_case(word: &str) -> bool {
|
fn has_irregular_case(word: &str) -> bool {
|
||||||
word.chars().skip(1).any(|c| c.is_uppercase())
|
word.chars().skip(1).any(|c| c.is_uppercase())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
fn is_apostrophe(c: char) -> bool {
|
fn is_apostrophe(c: char) -> bool {
|
||||||
c == '\'' || c == '\u{2019}'
|
c == '\'' || c == '\u{2019}'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
fn collect_spelling(
|
fn collect_spelling(
|
||||||
speller: &spellbook::Dictionary,
|
speller: &spellbook::Dictionary,
|
||||||
custom: &HashSet<String>,
|
custom: &HashSet<String>,
|
||||||
@@ -143,6 +149,10 @@ fn collect_spelling(
|
|||||||
}
|
}
|
||||||
let mut suggestions = Vec::new();
|
let mut suggestions = Vec::new();
|
||||||
speller.suggest(&word, &mut suggestions);
|
speller.suggest(&word, &mut suggestions);
|
||||||
|
let lower = word.to_lowercase();
|
||||||
|
if suggestions.iter().any(|s| s.to_lowercase() == lower) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
suggestions.truncate(5);
|
suggestions.truncate(5);
|
||||||
issues.push(Issue {
|
issues.push(Issue {
|
||||||
start,
|
start,
|
||||||
@@ -197,6 +207,7 @@ pub async fn proof_text(
|
|||||||
let mut custom = load_custom(&app);
|
let mut custom = load_custom(&app);
|
||||||
load_tech(&app, &mut custom);
|
load_tech(&app, &mut custom);
|
||||||
*guard = Some(Engine {
|
*guard = Some(Engine {
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
speller: None,
|
speller: None,
|
||||||
harper: None,
|
harper: None,
|
||||||
custom,
|
custom,
|
||||||
@@ -207,11 +218,25 @@ pub async fn proof_text(
|
|||||||
let chars: Vec<char> = text.chars().collect();
|
let chars: Vec<char> = text.chars().collect();
|
||||||
let mut issues = Vec::new();
|
let mut issues = Vec::new();
|
||||||
if spelling {
|
if spelling {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
for mi in crate::macspell::check(&text, &engine.custom) {
|
||||||
|
issues.push(Issue {
|
||||||
|
start: mi.start,
|
||||||
|
end: mi.end,
|
||||||
|
kind: "spelling".into(),
|
||||||
|
category: "Spelling".into(),
|
||||||
|
message: format!("“{}” may be misspelled", mi.word),
|
||||||
|
suggestions: mi.suggestions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
if engine.speller.is_none() {
|
if engine.speller.is_none() {
|
||||||
engine.speller = Some(build_speller(&app)?);
|
engine.speller = Some(build_speller(&app)?);
|
||||||
}
|
}
|
||||||
collect_spelling(engine.speller.as_ref().unwrap(), &engine.custom, &chars, &mut issues);
|
collect_spelling(engine.speller.as_ref().unwrap(), &engine.custom, &chars, &mut issues);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if grammar {
|
if grammar {
|
||||||
if engine.harper.is_none() {
|
if engine.harper.is_none() {
|
||||||
engine.harper = Some(build_harper());
|
engine.harper = Some(build_harper());
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Margin",
|
"productName": "Margin",
|
||||||
"version": "0.1.10",
|
"version": "0.1.13",
|
||||||
"identifier": "studio.margin.app",
|
"identifier": "studio.margin.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
||||||
const btnRef = useRef<HTMLButtonElement>(null);
|
const btnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const popRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
if (!open && btnRef.current) {
|
if (!open && btnRef.current) {
|
||||||
@@ -38,6 +39,25 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
popRef.current?.querySelector<HTMLElement>(".add-page-item")?.focus();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const onMenuKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(false);
|
||||||
|
btnRef.current?.focus();
|
||||||
|
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
const items = Array.from(popRef.current?.querySelectorAll<HTMLElement>(".add-page-item") ?? []);
|
||||||
|
const idx = items.indexOf(document.activeElement as HTMLElement);
|
||||||
|
const nextIdx = e.key === "ArrowDown" ? (idx + 1) % items.length : (idx - 1 + items.length) % items.length;
|
||||||
|
items[nextIdx]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const pick = (group: "front" | "back", label: string) => {
|
const pick = (group: "front" | "back", label: string) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
onAdd(group, label);
|
onAdd(group, label);
|
||||||
@@ -56,9 +76,11 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
|
|||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
|
ref={popRef}
|
||||||
className="add-page-pop"
|
className="add-page-pop"
|
||||||
style={{ top: coords.top, left: coords.left }}
|
style={{ top: coords.top, left: coords.left }}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={onMenuKeyDown}
|
||||||
>
|
>
|
||||||
<div className="add-page-group">
|
<div className="add-page-group">
|
||||||
<div className="add-page-label">Body</div>
|
<div className="add-page-label">Body</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from "react";
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
@@ -10,6 +10,21 @@ interface ConfirmDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onClose }: ConfirmDialogProps) {
|
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onClose }: ConfirmDialogProps) {
|
||||||
|
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
confirmRef.current?.focus();
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay" onClick={onClose}>
|
<div className="overlay" onClick={onClose}>
|
||||||
<div className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
|
<div className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
|
||||||
@@ -26,7 +41,7 @@ export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfi
|
|||||||
<button className="btn-ghost" onClick={onClose}>
|
<button className="btn-ghost" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-danger" onClick={onConfirm}>
|
<button ref={confirmRef} className="btn-danger" onClick={onConfirm}>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export function EditorView() {
|
|||||||
|
|
||||||
const isCompact = useCompact();
|
const isCompact = useCompact();
|
||||||
const [editor, setEditor] = useState<TiptapEditor | null>(null);
|
const [editor, setEditor] = useState<TiptapEditor | null>(null);
|
||||||
|
const editorRef = useRef<TiptapEditor | null>(null);
|
||||||
|
editorRef.current = editor;
|
||||||
|
const lastChapterRef = useRef<string>("");
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(!isCompact);
|
const [sidebarOpen, setSidebarOpen] = useState(!isCompact);
|
||||||
const [dock, setDock] = useState(!isCompact);
|
const [dock, setDock] = useState(!isCompact);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
@@ -83,6 +86,18 @@ export function EditorView() {
|
|||||||
setFindOpen(true);
|
setFindOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const focusEditorSoon = useCallback(() => {
|
||||||
|
requestAnimationFrame(() => editorRef.current?.commands.focus(undefined, { scrollIntoView: false }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const switchChapter = useCallback(
|
||||||
|
(dir: 1 | -1) => {
|
||||||
|
useBook.getState().goToAdjacentChapter(dir);
|
||||||
|
focusEditorSoon();
|
||||||
|
},
|
||||||
|
[focusEditorSoon]
|
||||||
|
);
|
||||||
|
|
||||||
const reproof = useCallback(() => {
|
const reproof = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const state = useProofing.getState();
|
const state = useProofing.getState();
|
||||||
@@ -120,11 +135,36 @@ export function EditorView() {
|
|||||||
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") {
|
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openFind(e.altKey);
|
openFind(e.altKey);
|
||||||
|
} else if (e.metaKey && e.altKey && (e.key === "ArrowDown" || e.key === "ArrowRight")) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
switchChapter(1);
|
||||||
|
} else if (e.metaKey && e.altKey && (e.key === "ArrowUp" || e.key === "ArrowLeft")) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
switchChapter(-1);
|
||||||
|
} else if (e.ctrlKey && !e.metaKey && e.key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
switchChapter(e.shiftKey ? -1 : 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey, true);
|
||||||
|
return () => window.removeEventListener("keydown", onKey, true);
|
||||||
|
}, [saveNow, openFind, switchChapter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!widthOpen && !exportOpen && !moreOpen) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setWidthOpen(false);
|
||||||
|
setExportOpen(false);
|
||||||
|
setMoreOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [saveNow, openFind]);
|
}, [widthOpen, exportOpen, moreOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!notice) return;
|
if (!notice) return;
|
||||||
@@ -177,8 +217,11 @@ export function EditorView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!book) return null;
|
if (!book) return null;
|
||||||
const idx = book.chapters.findIndex((c) => c.id === activeChapterId);
|
const activeIdx = book.chapters.findIndex((c) => c.id === activeChapterId);
|
||||||
const chapter = book.chapters[idx] ?? book.chapters[0];
|
const activeChapter = book.chapters[activeIdx];
|
||||||
|
if (activeChapter) lastChapterRef.current = activeChapter.id;
|
||||||
|
const editorIdx = activeChapter ? activeIdx : book.chapters.findIndex((c) => c.id === lastChapterRef.current);
|
||||||
|
const chapter = book.chapters[editorIdx] ?? book.chapters[0];
|
||||||
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
|
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
|
||||||
const kind = chapter ? chapterKind(chapter) : "body";
|
const kind = chapter ? chapterKind(chapter) : "body";
|
||||||
const eyebrow =
|
const eyebrow =
|
||||||
@@ -342,14 +385,16 @@ export function EditorView() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="body">
|
<div className="body">
|
||||||
<Sidebar onNavigate={() => isCompact && setSidebarOpen(false)} />
|
<Sidebar
|
||||||
|
onNavigate={() => {
|
||||||
|
if (isCompact) setSidebarOpen(false);
|
||||||
|
focusEditorSoon();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{sidebarOpen && !isCompact && <ResizeHandle pane="sidebar" />}
|
{sidebarOpen && !isCompact && <ResizeHandle pane="sidebar" />}
|
||||||
<main className="editor-pane">
|
<main className="editor-pane">
|
||||||
{coverActive ? (
|
{coverActive && <CoverView />}
|
||||||
<CoverView />
|
<article className="sheet" hidden={coverActive}>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<article className="sheet">
|
|
||||||
<header className="chapter-opener">
|
<header className="chapter-opener">
|
||||||
<div className="chapter-num">{eyebrow}</div>
|
<div className="chapter-num">{eyebrow}</div>
|
||||||
<ChapterTitleInput
|
<ChapterTitleInput
|
||||||
@@ -372,7 +417,6 @@ 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}
|
||||||
@@ -383,9 +427,7 @@ export function EditorView() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
<FloatingToolbar editor={editor} />
|
{!coverActive && <FloatingToolbar editor={editor} />}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
{dock && !isCompact && <ResizeHandle pane="dock" />}
|
{dock && !isCompact && <ResizeHandle pane="dock" />}
|
||||||
{dock && <Dock />}
|
{dock && <Dock />}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
|
|||||||
return () => {
|
return () => {
|
||||||
editor.off("transaction", update);
|
editor.off("transaction", update);
|
||||||
};
|
};
|
||||||
}, [editor]);
|
}, [editor, activeChapterId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
@@ -67,7 +67,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
|
|||||||
} else {
|
} else {
|
||||||
editor.commands.clearSearch();
|
editor.commands.clearSearch();
|
||||||
}
|
}
|
||||||
}, [editor, open, query, caseSensitive, wholeWord]);
|
}, [editor, open, query, caseSensitive, wholeWord, activeChapterId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
onOpen(copy);
|
onOpen(copy);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openById = (id: string) =>
|
||||||
|
loadBook(id)
|
||||||
|
.then(onOpen)
|
||||||
|
.catch((e) => setNotice(`Could not open book: ${e}`));
|
||||||
|
|
||||||
const removeBook = async () => {
|
const removeBook = async () => {
|
||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
await deleteBook(pendingDelete.id);
|
await deleteBook(pendingDelete.id);
|
||||||
@@ -108,7 +113,15 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
<div
|
<div
|
||||||
key={b.id}
|
key={b.id}
|
||||||
className="card card-book"
|
className="card card-book"
|
||||||
onClick={() => loadBook(b.id).then(onOpen).catch((e) => setNotice(`Could not open book: ${e}`))}
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => openById(b.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
openById(b.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<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>}
|
||||||
|
|||||||
@@ -33,6 +33,25 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
|
|||||||
setOpen((v) => !v);
|
setOpen((v) => !v);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
popRef.current?.querySelector<HTMLElement>(".row-menu-item")?.focus();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const onMenuKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(false);
|
||||||
|
btnRef.current?.focus();
|
||||||
|
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
const items = Array.from(popRef.current?.querySelectorAll<HTMLElement>(".row-menu-item") ?? []);
|
||||||
|
const idx = items.indexOf(document.activeElement as HTMLElement);
|
||||||
|
const nextIdx = e.key === "ArrowDown" ? (idx + 1) % items.length : (idx - 1 + items.length) % items.length;
|
||||||
|
items[nextIdx]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onDown = (e: MouseEvent) => {
|
const onDown = (e: MouseEvent) => {
|
||||||
@@ -88,7 +107,7 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
|
|||||||
</button>
|
</button>
|
||||||
{open &&
|
{open &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div ref={popRef} className="row-menu-pop" style={{ top: coords.top, right: coords.right }}>
|
<div ref={popRef} className="row-menu-pop" style={{ top: coords.top, right: coords.right }} onKeyDown={onMenuKeyDown}>
|
||||||
{onToggleTitle && (
|
{onToggleTitle && (
|
||||||
<button className="row-menu-item" onClick={toggleTitle}>
|
<button className="row-menu-item" onClick={toggleTitle}>
|
||||||
{titleHidden ? (
|
{titleHidden ? (
|
||||||
|
|||||||
@@ -94,6 +94,17 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
if (!book) return null;
|
if (!book) return null;
|
||||||
|
|
||||||
const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
|
const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
|
||||||
@@ -121,7 +132,7 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
|||||||
</div>
|
</div>
|
||||||
<div className="panel-body">
|
<div className="panel-body">
|
||||||
<Field label="Title">
|
<Field label="Title">
|
||||||
<input value={draft.title} placeholder="Untitled" onChange={(e) => set({ title: e.target.value })} />
|
<input autoFocus value={draft.title} placeholder="Untitled" onChange={(e) => set({ title: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Subtitle">
|
<Field label="Subtitle">
|
||||||
<input value={draft.subtitle} onChange={(e) => set({ subtitle: e.target.value })} />
|
<input value={draft.subtitle} onChange={(e) => set({ subtitle: e.target.value })} />
|
||||||
|
|||||||
@@ -155,6 +155,33 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
onNavigate?.();
|
onNavigate?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const focusRow = (id: string) =>
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.querySelector<HTMLElement>(`.chapter[data-id="${CSS.escape(id)}"]`)?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
const onRowKeyDown = (e: React.KeyboardEvent, index: number, id: string) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveChapter(id);
|
||||||
|
onNavigate?.();
|
||||||
|
} else if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Home" || e.key === "End") {
|
||||||
|
e.preventDefault();
|
||||||
|
const target =
|
||||||
|
e.key === "Home"
|
||||||
|
? chapters[0]
|
||||||
|
: e.key === "End"
|
||||||
|
? chapters[chapters.length - 1]
|
||||||
|
: chapters[index + (e.key === "ArrowDown" ? 1 : -1)];
|
||||||
|
if (target) {
|
||||||
|
setActiveChapter(target.id);
|
||||||
|
focusRow(target.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabStopId = chapters.some((c) => c.id === activeChapterId) ? activeChapterId : chapters[0]?.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<button className="brand" onClick={closeBook} title="All books">
|
<button className="brand" onClick={closeBook} title="All books">
|
||||||
@@ -193,6 +220,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
key={row.chapter.id}
|
key={row.chapter.id}
|
||||||
className="chapter"
|
className="chapter"
|
||||||
data-idx={row.index}
|
data-idx={row.index}
|
||||||
|
data-id={row.chapter.id}
|
||||||
data-kind={group.kind}
|
data-kind={group.kind}
|
||||||
data-part={isPart}
|
data-part={isPart}
|
||||||
data-indent={row.indent}
|
data-indent={row.indent}
|
||||||
@@ -200,7 +228,11 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
data-dragging={dragIndex === row.index}
|
data-dragging={dragIndex === row.index}
|
||||||
data-drop-before={dropTarget?.kind === group.kind && dropTarget.index === row.index}
|
data-drop-before={dropTarget?.kind === group.kind && dropTarget.index === row.index}
|
||||||
data-drop-after={dropTarget?.kind === group.kind && i === group.rows.length - 1 && dropTarget.index === row.index + 1}
|
data-drop-after={dropTarget?.kind === group.kind && i === group.rows.length - 1 && dropTarget.index === row.index + 1}
|
||||||
|
role="button"
|
||||||
|
tabIndex={row.chapter.id === tabStopId ? 0 : -1}
|
||||||
|
aria-current={row.chapter.id === activeChapterId}
|
||||||
onClick={() => onRowClick(row.chapter.id)}
|
onClick={() => onRowClick(row.chapter.id)}
|
||||||
|
onKeyDown={(e) => onRowKeyDown(e, row.index, row.chapter.id)}
|
||||||
onPointerDown={(e) => onRowPointerDown(e, row.index)}
|
onPointerDown={(e) => onRowPointerDown(e, row.index)}
|
||||||
>
|
>
|
||||||
<span className="grip" title="Drag to reorder">
|
<span className="grip" title="Drag to reorder">
|
||||||
|
|||||||
+89
-31
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||||
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
|
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
|
||||||
|
import { EditorState } from "@tiptap/pm/state";
|
||||||
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";
|
import { loadPosition, savePosition, type ChapterPosition } from "./positions";
|
||||||
|
import { useBook } from "../store/useBook";
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
bookId: string;
|
bookId: string;
|
||||||
@@ -13,13 +15,34 @@ interface EditorProps {
|
|||||||
onContentError: (error: Error) => void;
|
onContentError: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Cached {
|
||||||
|
state: EditorState;
|
||||||
|
content: JSONContent;
|
||||||
|
scroll: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildState(editor: TiptapEditor, content: JSONContent, onError: (error: Error) => void): EditorState {
|
||||||
|
const base = editor.view.state;
|
||||||
|
try {
|
||||||
|
const doc = editor.schema.nodeFromJSON(content);
|
||||||
|
doc.check();
|
||||||
|
return EditorState.create({ doc, plugins: base.plugins });
|
||||||
|
} catch (error) {
|
||||||
|
onError(error as Error);
|
||||||
|
return EditorState.create({ schema: base.schema, plugins: base.plugins });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: 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);
|
const onContentErrorRef = useRef(onContentError);
|
||||||
onContentErrorRef.current = onContentError;
|
onContentErrorRef.current = onContentError;
|
||||||
|
|
||||||
|
const cache = useRef(new Map<string, Cached>());
|
||||||
|
const activeId = useRef(chapterId);
|
||||||
const latest = useRef<ChapterPosition | null>(null);
|
const latest = useRef<ChapterPosition | null>(null);
|
||||||
|
const restoreToken = useRef(0);
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: editorExtensions,
|
extensions: editorExtensions,
|
||||||
@@ -31,49 +54,84 @@ export function Editor({ bookId, chapterId, content, onChange, onReady, onConten
|
|||||||
onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()),
|
onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const scrollerOf = (ed: TiptapEditor) => ed.view.dom.closest(".editor-pane") as HTMLElement | null;
|
||||||
onReady(editor);
|
|
||||||
return () => onReady(null);
|
const restorePosition = (ed: TiptapEditor, id: string, focus: boolean) => {
|
||||||
}, [editor, onReady]);
|
const scroller = scrollerOf(ed);
|
||||||
|
const saved = loadPosition(bookId, id);
|
||||||
|
const size = ed.state.doc.content.size;
|
||||||
|
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
|
||||||
|
const chain = ed.chain().setTextSelection(selection);
|
||||||
|
if (focus) chain.focus(undefined, { scrollIntoView: false });
|
||||||
|
chain.run();
|
||||||
|
if (scroller) {
|
||||||
|
const token = ++restoreToken.current;
|
||||||
|
const top = saved?.scroll ?? 0;
|
||||||
|
const apply = () => {
|
||||||
|
if (restoreToken.current === token) scroller.scrollTop = top;
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
requestAnimationFrame(apply);
|
||||||
|
document.fonts?.ready.then(apply).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stash = (ed: TiptapEditor, id: string) => {
|
||||||
|
const scroller = scrollerOf(ed);
|
||||||
|
const { from, to } = ed.state.selection;
|
||||||
|
const scroll = scroller?.scrollTop ?? 0;
|
||||||
|
const stored = useBook.getState().book?.chapters.find((c) => c.id === id)?.content ?? cache.current.get(id)?.content ?? ed.getJSON();
|
||||||
|
cache.current.set(id, { state: ed.view.state, content: stored, scroll });
|
||||||
|
savePosition(bookId, id, { from, to, scroll });
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
let active = true;
|
cache.current.set(chapterId, { state: editor.view.state, content, scroll: 0 });
|
||||||
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
|
activeId.current = chapterId;
|
||||||
|
restorePosition(editor, chapterId, true);
|
||||||
const saved = loadPosition(bookId, chapterId);
|
onReady(editor);
|
||||||
const size = editor.state.doc.content.size;
|
return () => onReady(null);
|
||||||
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
|
|
||||||
editor.chain().setTextSelection(selection).focus(undefined, { scrollIntoView: false }).run();
|
|
||||||
|
|
||||||
if (scroller) {
|
|
||||||
const top = saved?.scroll ?? 0;
|
|
||||||
const restore = () => {
|
|
||||||
if (active) scroller.scrollTop = top;
|
|
||||||
};
|
|
||||||
restore();
|
|
||||||
requestAnimationFrame(restore);
|
|
||||||
document.fonts?.ready.then(restore).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const prev = activeId.current;
|
||||||
|
if (prev === chapterId) return;
|
||||||
|
|
||||||
|
stash(editor, prev);
|
||||||
|
const entry = cache.current.get(chapterId);
|
||||||
|
if (entry && entry.content === content) {
|
||||||
|
editor.view.updateState(entry.state);
|
||||||
|
const scroller = scrollerOf(editor);
|
||||||
|
if (scroller) scroller.scrollTop = entry.scroll;
|
||||||
|
++restoreToken.current;
|
||||||
|
} else {
|
||||||
|
editor.view.updateState(buildState(editor, content, onContentErrorRef.current));
|
||||||
|
cache.current.set(chapterId, { state: editor.view.state, content, scroll: 0 });
|
||||||
|
restorePosition(editor, chapterId, false);
|
||||||
|
}
|
||||||
|
activeId.current = chapterId;
|
||||||
|
latest.current = null;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [chapterId, content, editor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
|
const scroller = scrollerOf(editor);
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
const capture = () => {
|
const capture = () => {
|
||||||
const { from, to } = editor.state.selection;
|
const { from, to } = editor.state.selection;
|
||||||
latest.current = { from, to, scroll: scroller?.scrollTop ?? 0 };
|
const scroll = scroller?.scrollTop ?? 0;
|
||||||
|
latest.current = { from, to, scroll };
|
||||||
|
const entry = cache.current.get(activeId.current);
|
||||||
|
if (entry) entry.scroll = scroll;
|
||||||
};
|
};
|
||||||
const persist = () => {
|
const persist = () => {
|
||||||
capture();
|
capture();
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timer = setTimeout(() => savePosition(bookId, chapterId, latest.current!), 400);
|
timer = setTimeout(() => savePosition(bookId, activeId.current, latest.current!), 400);
|
||||||
};
|
};
|
||||||
editor.on("selectionUpdate", persist);
|
editor.on("selectionUpdate", persist);
|
||||||
scroller?.addEventListener("scroll", persist, { passive: true });
|
scroller?.addEventListener("scroll", persist, { passive: true });
|
||||||
@@ -81,9 +139,9 @@ export function Editor({ bookId, chapterId, content, onChange, onReady, onConten
|
|||||||
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);
|
if (latest.current) savePosition(bookId, activeId.current, latest.current);
|
||||||
};
|
};
|
||||||
}, [editor, bookId, chapterId]);
|
}, [editor, bookId]);
|
||||||
|
|
||||||
return <EditorContent editor={editor} className="editor-host" />;
|
return <EditorContent editor={editor} className="editor-host" />;
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,7 @@ interface BookState {
|
|||||||
openBook: (book: Book) => void;
|
openBook: (book: Book) => void;
|
||||||
closeBook: () => void;
|
closeBook: () => void;
|
||||||
setActiveChapter: (id: string) => void;
|
setActiveChapter: (id: string) => void;
|
||||||
|
goToAdjacentChapter: (dir: 1 | -1) => void;
|
||||||
setChapterContent: (id: string, content: JSONContent) => void;
|
setChapterContent: (id: string, content: JSONContent) => void;
|
||||||
setChapterTitle: (id: string, title: string) => void;
|
setChapterTitle: (id: string, title: string) => void;
|
||||||
setChapterNoTitle: (id: string, noTitle: boolean) => void;
|
setChapterNoTitle: (id: string, noTitle: boolean) => void;
|
||||||
@@ -73,6 +74,15 @@ export const useBook = create<BookState>((set, get) => ({
|
|||||||
set({ book: null, activeChapterId: "", dirty: false });
|
set({ book: null, activeChapterId: "", dirty: false });
|
||||||
},
|
},
|
||||||
setActiveChapter: (id) => set({ activeChapterId: id }),
|
setActiveChapter: (id) => set({ activeChapterId: id }),
|
||||||
|
goToAdjacentChapter: (dir) => {
|
||||||
|
const { book, activeChapterId } = get();
|
||||||
|
if (!book) return;
|
||||||
|
const ids = [COVER_ID, ...book.chapters.map((c) => c.id)];
|
||||||
|
const i = ids.indexOf(activeChapterId);
|
||||||
|
if (i === -1) return;
|
||||||
|
const next = ids[(i + dir + ids.length) % ids.length];
|
||||||
|
if (next !== activeChapterId) set({ activeChapterId: next });
|
||||||
|
},
|
||||||
setChapterContent: (id, content) =>
|
setChapterContent: (id, content) =>
|
||||||
set((s) =>
|
set((s) =>
|
||||||
s.book
|
s.book
|
||||||
|
|||||||
@@ -40,6 +40,22 @@ button {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter:focus-visible,
|
||||||
|
.card:focus-visible {
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose:focus,
|
||||||
|
.prose:focus-visible,
|
||||||
|
.editor-host:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in new issue
Block a user