This commit is contained in:
pj committed 2026-06-22 09:56:20 -04:00
1 parent 78fff9b6f4
commit 7cc36e3e40
37 files changed
+55361 -103

No files matched your search

+11 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useReducer, useRef, useState, type ReactNode } from "react";
import type { Editor } from "@tiptap/react";
import { Icon } from "../components/Icon";
@@ -23,11 +23,21 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
const linkInputRef = useRef<HTMLInputElement>(null);
const [linkOpen, setLinkOpen] = useState(false);
const [linkValue, setLinkValue] = useState("");
const [, forceUpdate] = useReducer((x) => x + 1, 0);
useEffect(() => {
if (linkOpen) linkInputRef.current?.focus();
}, [linkOpen]);
useEffect(() => {
if (!editor) return;
const update = () => forceUpdate();
editor.on("transaction", update);
return () => {
editor.off("transaction", update);
};
}, [editor]);
if (!editor) return null;
const insertImage = async (file: File) => {
+4
View File
@@ -3,6 +3,8 @@ import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { Figure } from "./figure";
import { ParagraphIndent } from "./indent";
import { SearchHighlight } from "./search";
import { Proofing } from "./proofing";
export const editorExtensions: Extensions = [
StarterKit.configure({
@@ -15,4 +17,6 @@ export const editorExtensions: Extensions = [
}),
Figure,
ParagraphIndent,
SearchHighlight,
Proofing,
];
+133
View File
@@ -0,0 +1,133 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import type { Node as PMNode } from "@tiptap/pm/model";
export interface ProofIssue {
from: number;
to: number;
kind: "spelling" | "grammar";
category: string;
message: string;
suggestions: string[];
word: string;
}
export type ProofSeverity = "error" | "warn" | "suggest";
const WARN_CATEGORIES = new Set(["Redundancy", "Repetition", "Formatting", "Regionalism"]);
const SUGGEST_CATEGORIES = new Set(["Style", "Readability", "Enhancement", "Miscellaneous"]);
export function severityFor(category: string): ProofSeverity {
if (WARN_CATEGORIES.has(category)) return "warn";
if (SUGGEST_CATEGORIES.has(category)) return "suggest";
return "error";
}
export interface ProofCoords {
left: number;
top: number;
bottom: number;
}
interface ProofingState {
issues: ProofIssue[];
decorations: DecorationSet;
}
export const proofingKey = new PluginKey<ProofingState>("proofing");
function buildDecorations(doc: PMNode, issues: ProofIssue[]): DecorationSet {
const size = doc.content.size;
const decos = issues
.filter((i) => i.from < i.to && i.to <= size)
.map((i) => Decoration.inline(i.from, i.to, { class: `proof-mark sev-${severityFor(i.category)}` }));
return DecorationSet.create(doc, decos);
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
proofing: {
setProofIssues: (issues: ProofIssue[]) => ReturnType;
clearProofIssues: () => ReturnType;
};
}
}
export interface ProofingStorage {
onClickIssue: ((issue: ProofIssue, coords: ProofCoords) => void) | null;
}
export const Proofing = Extension.create<unknown, ProofingStorage>({
name: "proofing",
addStorage() {
return { onClickIssue: null };
},
addProseMirrorPlugins() {
const storage = this.storage;
return [
new Plugin<ProofingState>({
key: proofingKey,
state: {
init: () => ({ issues: [], decorations: DecorationSet.empty }),
apply(tr, value, _old, newState) {
const meta = tr.getMeta(proofingKey) as { issues: ProofIssue[] } | { clear: true } | undefined;
if (meta && "issues" in meta) {
return { issues: meta.issues, decorations: buildDecorations(newState.doc, meta.issues) };
}
if (meta && "clear" in meta) {
return { issues: [], decorations: DecorationSet.empty };
}
if (tr.docChanged && value.issues.length) {
const issues = value.issues
.map((i) => ({ ...i, from: tr.mapping.map(i.from), to: tr.mapping.map(i.to) }))
.filter((i) => i.to > i.from);
return { issues, decorations: value.decorations.map(tr.mapping, tr.doc) };
}
return value;
},
},
props: {
decorations(state) {
return proofingKey.getState(state)?.decorations ?? DecorationSet.empty;
},
handleClick(view, pos) {
const s = proofingKey.getState(view.state);
if (!s || !s.issues.length) return false;
const hits = s.issues.filter((i) => pos >= i.from && pos <= i.to);
if (!hits.length) return false;
const issue = hits.find((i) => i.kind === "spelling") ?? hits[0];
if (!storage.onClickIssue) return false;
const start = view.coordsAtPos(issue.from);
const end = view.coordsAtPos(issue.to);
storage.onClickIssue(issue, {
left: (start.left + end.left) / 2,
top: start.top,
bottom: end.bottom,
});
return false;
},
},
}),
];
},
addCommands() {
return {
setProofIssues:
(issues) =>
({ tr, dispatch }) => {
if (dispatch) dispatch(tr.setMeta(proofingKey, { issues }));
return true;
},
clearProofIssues:
() =>
({ tr, dispatch }) => {
if (dispatch) dispatch(tr.setMeta(proofingKey, { clear: true }));
return true;
},
};
},
});
+233
View File
@@ -0,0 +1,233 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view";
import type { Node as PMNode } from "@tiptap/pm/model";
export interface SearchOptions {
caseSensitive: boolean;
wholeWord: boolean;
}
export interface SearchMatch {
from: number;
to: number;
}
interface SearchState {
query: string;
options: SearchOptions;
matches: SearchMatch[];
current: number;
decorations: DecorationSet;
}
export const searchKey = new PluginKey<SearchState>("search");
const EMPTY: SearchState = {
query: "",
options: { caseSensitive: false, wholeWord: false },
matches: [],
current: 0,
decorations: DecorationSet.empty,
};
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function buildRegex(query: string, options: SearchOptions): RegExp | null {
if (!query) return null;
let pattern = escapeRegExp(query);
if (options.wholeWord) pattern = `\\b${pattern}\\b`;
try {
return new RegExp(pattern, options.caseSensitive ? "g" : "gi");
} catch {
return null;
}
}
export function findMatches(doc: PMNode, regex: RegExp): SearchMatch[] {
const matches: SearchMatch[] = [];
let runText = "";
let runStart = 0;
const flush = () => {
if (!runText) return;
regex.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = regex.exec(runText)) !== null) {
const from = runStart + m.index;
matches.push({ from, to: from + m[0].length });
if (m.index === regex.lastIndex) regex.lastIndex++;
}
runText = "";
};
doc.descendants((node, pos) => {
if (node.isText) {
if (!runText) runStart = pos;
runText += node.text ?? "";
} else {
flush();
}
return true;
});
flush();
return matches;
}
function buildDecorations(doc: PMNode, matches: SearchMatch[], current: number): DecorationSet {
if (!matches.length) return DecorationSet.empty;
const decos = matches.map((m, i) =>
Decoration.inline(m.from, m.to, {
class: i === current ? "search-match search-match-current" : "search-match",
})
);
return DecorationSet.create(doc, decos);
}
function recompute(doc: PMNode, query: string, options: SearchOptions, desiredCurrent: number): SearchState {
const regex = buildRegex(query, options);
if (!regex) return { ...EMPTY, query, options };
const matches = findMatches(doc, regex);
const current = matches.length ? Math.max(0, Math.min(desiredCurrent, matches.length - 1)) : 0;
return { query, options, matches, current, decorations: buildDecorations(doc, matches, current) };
}
function scrollMatchIntoView(view: EditorView, match: SearchMatch) {
const { node } = view.domAtPos(match.from);
const el = node.nodeType === Node.TEXT_NODE ? (node as Text).parentElement : (node as HTMLElement);
el?.scrollIntoView({ block: "center", behavior: "smooth" });
}
export function getSearchState(state: { plugins: unknown } | unknown): SearchState | undefined {
return searchKey.getState(state as never);
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
search: {
setSearch: (query: string, options: SearchOptions) => ReturnType;
clearSearch: () => ReturnType;
findNext: () => ReturnType;
findPrev: () => ReturnType;
goToMatch: (index: number) => ReturnType;
replaceCurrent: (replacement: string) => ReturnType;
replaceAllInChapter: (replacement: string) => ReturnType;
};
}
}
export const SearchHighlight = Extension.create({
name: "searchHighlight",
addProseMirrorPlugins() {
return [
new Plugin<SearchState>({
key: searchKey,
state: {
init: () => EMPTY,
apply(tr, value, _old, newState) {
const meta = tr.getMeta(searchKey) as
| { type: "search"; query: string; options: SearchOptions }
| { type: "nav"; current: number }
| { type: "clear" }
| undefined;
if (meta?.type === "search") {
return recompute(newState.doc, meta.query, meta.options, 0);
}
if (meta?.type === "nav" && value.matches.length) {
const current = ((meta.current % value.matches.length) + value.matches.length) % value.matches.length;
return { ...value, current, decorations: buildDecorations(newState.doc, value.matches, current) };
}
if (meta?.type === "clear") {
return { ...EMPTY };
}
if (tr.docChanged && value.query) {
return recompute(newState.doc, value.query, value.options, value.current);
}
return value;
},
},
props: {
decorations(state) {
return searchKey.getState(state)?.decorations ?? DecorationSet.empty;
},
},
}),
];
},
addCommands() {
return {
setSearch:
(query, options) =>
({ tr, dispatch }) => {
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "search", query, options }));
return true;
},
clearSearch:
() =>
({ tr, dispatch }) => {
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "clear" }));
return true;
},
findNext:
() =>
({ state, dispatch, view, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
const current = (s.current + 1) % s.matches.length;
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "nav", current }));
scrollMatchIntoView(view, s.matches[current]);
return true;
},
findPrev:
() =>
({ state, dispatch, view, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
const current = (s.current - 1 + s.matches.length) % s.matches.length;
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "nav", current }));
scrollMatchIntoView(view, s.matches[current]);
return true;
},
goToMatch:
(index) =>
({ state, dispatch, view, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
const current = ((index % s.matches.length) + s.matches.length) % s.matches.length;
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "nav", current }));
scrollMatchIntoView(view, s.matches[current]);
return true;
},
replaceCurrent:
(replacement) =>
({ state, dispatch, view, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
const m = s.matches[s.current];
if (dispatch) {
tr.insertText(replacement, m.from, m.to);
dispatch(tr);
const ns = searchKey.getState(view.state);
if (ns && ns.matches.length) scrollMatchIntoView(view, ns.matches[ns.current]);
}
return true;
},
replaceAllInChapter:
(replacement) =>
({ state, dispatch, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
if (dispatch) {
for (let i = s.matches.length - 1; i >= 0; i--) {
const m = s.matches[i];
tr.insertText(replacement, m.from, m.to);
}
dispatch(tr);
}
return true;
},
};
},
});