mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
changes
This commit is contained in:
1 parent
78fff9b6f4
commit
7cc36e3e40
37 files changed
+55361
-103
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user