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
11df8697b1
commit
c090fb595f
61 files changed
+52534
-107
No files matched your search
@@ -1,6 +1,13 @@
|
||||
import { useEffect, useReducer, useRef, useState, type ReactNode } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { Icon } from "../components/Icon";
|
||||
import type { Alignment } from "./align";
|
||||
|
||||
const ALIGN_ICONS: Record<Alignment, string> = {
|
||||
left: "M4 7h16M4 12h10M4 17h13",
|
||||
center: "M4 7h16M7 12h10M5 17h14",
|
||||
right: "M4 7h16M10 12h10M7 17h13",
|
||||
};
|
||||
|
||||
function readImage(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -23,6 +30,7 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
||||
const linkInputRef = useRef<HTMLInputElement>(null);
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
const [linkValue, setLinkValue] = useState("");
|
||||
const [alignOpen, setAlignOpen] = useState(false);
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -67,6 +75,13 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
||||
setLinkOpen(false);
|
||||
};
|
||||
|
||||
const align: Alignment = (editor.getAttributes("heading").align ?? editor.getAttributes("paragraph").align) || "left";
|
||||
|
||||
const setAlign = (value: Alignment) => {
|
||||
editor.chain().focus().setTextAlign(value).run();
|
||||
setAlignOpen(false);
|
||||
};
|
||||
|
||||
const tool = (active: boolean, onClick: () => void, title: string, content: ReactNode) => (
|
||||
<button
|
||||
className="tool"
|
||||
@@ -87,6 +102,28 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
||||
{tool(editor.isActive("heading", { level: 2 }), () => editor.chain().focus().toggleHeading({ level: 2 }).run(), "Heading", <Icon d="M5 5v14M5 12h8M13 5v14" />)}
|
||||
{tool(editor.isActive("blockquote"), () => editor.chain().focus().toggleBlockquote().run(), "Quote", <Icon d="M7 8h4v4a4 4 0 0 1-4 4M14 8h4v4a4 4 0 0 1-4 4" />)}
|
||||
{tool(editor.isActive("bulletList"), () => editor.chain().focus().toggleBulletList().run(), "Bulleted list", <Icon d="M8 6h12M8 12h12M8 18h12M3.5 6h.01M3.5 12h.01M3.5 18h.01" />)}
|
||||
<span className="tool-wrap">
|
||||
{tool(alignOpen || align !== "left", () => setAlignOpen((v) => !v), "Align", <Icon d={ALIGN_ICONS[align]} />)}
|
||||
{alignOpen && (
|
||||
<>
|
||||
<div className="link-pop-backdrop" onMouseDown={() => setAlignOpen(false)} />
|
||||
<div className="align-pop" onMouseDown={(e) => e.stopPropagation()}>
|
||||
{(["left", "center", "right"] as Alignment[]).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
className="tool"
|
||||
data-on={align === value}
|
||||
title={`Align ${value}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setAlign(value)}
|
||||
>
|
||||
<Icon d={ALIGN_ICONS[value]} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="tool-sep" />
|
||||
<span className="tool-wrap">
|
||||
{tool(editor.isActive("link") || linkOpen, () => (linkOpen ? setLinkOpen(false) : openLink()), "Link", <Icon d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" />)}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
|
||||
export type Alignment = "left" | "center" | "right";
|
||||
|
||||
const ALIGNED_TYPES = ["paragraph", "heading"];
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
textAlign: {
|
||||
setTextAlign: (align: Alignment) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const TextAlign = Extension.create({
|
||||
name: "textAlign",
|
||||
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: ALIGNED_TYPES,
|
||||
attributes: {
|
||||
align: {
|
||||
default: null,
|
||||
keepOnSplit: true,
|
||||
parseHTML: (el) => {
|
||||
const value = el.style.textAlign || el.getAttribute("data-align");
|
||||
return value === "center" || value === "right" ? value : null;
|
||||
},
|
||||
renderHTML: (attrs) =>
|
||||
attrs.align ? { "data-align": attrs.align, style: `text-align: ${attrs.align}` } : {},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setTextAlign:
|
||||
(align) =>
|
||||
({ tr, dispatch }) => {
|
||||
const value = align === "left" ? null : align;
|
||||
const { from, to } = tr.selection;
|
||||
let changed = false;
|
||||
tr.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (ALIGNED_TYPES.includes(node.type.name) && node.attrs.align !== value) {
|
||||
if (dispatch) tr.setNodeAttribute(pos, "align", value);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-Shift-l": () => this.editor.commands.setTextAlign("left"),
|
||||
"Mod-Shift-e": () => this.editor.commands.setTextAlign("center"),
|
||||
"Mod-Shift-r": () => this.editor.commands.setTextAlign("right"),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -3,8 +3,10 @@ import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Figure } from "./figure";
|
||||
import { ParagraphIndent } from "./indent";
|
||||
import { TextAlign } from "./align";
|
||||
import { SearchHighlight } from "./search";
|
||||
import { Proofing } from "./proofing";
|
||||
import { Paste } from "./paste";
|
||||
|
||||
export const editorExtensions: Extensions = [
|
||||
StarterKit.configure({
|
||||
@@ -17,6 +19,8 @@ export const editorExtensions: Extensions = [
|
||||
}),
|
||||
Figure,
|
||||
ParagraphIndent,
|
||||
TextAlign,
|
||||
SearchHighlight,
|
||||
Proofing,
|
||||
Paste,
|
||||
];
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { Plugin } from "@tiptap/pm/state";
|
||||
|
||||
function readImage(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function imageFiles(data: DataTransfer | null): File[] {
|
||||
if (!data) return [];
|
||||
const files = Array.from(data.files).filter((f) => f.type.startsWith("image/"));
|
||||
if (files.length) return files;
|
||||
return Array.from(data.items)
|
||||
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((f): f is File => f !== null);
|
||||
}
|
||||
|
||||
function plainContent(text: string): JSONContent[] {
|
||||
return text
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => {
|
||||
const content: JSONContent[] = [];
|
||||
block.split("\n").forEach((line, i) => {
|
||||
if (i > 0) content.push({ type: "hardBreak" });
|
||||
if (line) content.push({ type: "text", text: line });
|
||||
});
|
||||
return content.length ? { type: "paragraph", content } : { type: "paragraph" };
|
||||
});
|
||||
}
|
||||
|
||||
export const Paste = Extension.create({
|
||||
name: "pasteHandler",
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
const editor = this.editor;
|
||||
return {
|
||||
"Mod-Shift-v": () => {
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((text) => {
|
||||
if (text) editor.chain().focus().insertContent(plainContent(text)).run();
|
||||
})
|
||||
.catch(() => {});
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor;
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
handlePaste(_view, event) {
|
||||
const files = imageFiles(event.clipboardData);
|
||||
if (!files.length) return false;
|
||||
event.preventDefault();
|
||||
files.forEach((file) =>
|
||||
readImage(file).then((src) =>
|
||||
editor.chain().focus().insertContent({ type: "figure", attrs: { src, placement: "full-width" } }).run(),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -37,10 +37,11 @@ interface ProofingState {
|
||||
|
||||
export const proofingKey = new PluginKey<ProofingState>("proofing");
|
||||
|
||||
function buildDecorations(doc: PMNode, issues: ProofIssue[]): DecorationSet {
|
||||
function buildDecorations(doc: PMNode, issues: ProofIssue[], cursor: number | null): DecorationSet {
|
||||
const size = doc.content.size;
|
||||
const decos = issues
|
||||
.filter((i) => i.from < i.to && i.to <= size)
|
||||
.filter((i) => cursor === null || cursor < i.from || cursor > i.to)
|
||||
.map((i) => Decoration.inline(i.from, i.to, { class: `proof-mark sev-${severityFor(i.category)}` }));
|
||||
return DecorationSet.create(doc, decos);
|
||||
}
|
||||
@@ -73,9 +74,11 @@ export const Proofing = Extension.create<unknown, ProofingStorage>({
|
||||
state: {
|
||||
init: () => ({ issues: [], decorations: DecorationSet.empty }),
|
||||
apply(tr, value, _old, newState) {
|
||||
const sel = newState.selection;
|
||||
const cursor = sel.empty ? sel.head : null;
|
||||
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) };
|
||||
return { issues: meta.issues, decorations: buildDecorations(newState.doc, meta.issues, cursor) };
|
||||
}
|
||||
if (meta && "clear" in meta) {
|
||||
return { issues: [], decorations: DecorationSet.empty };
|
||||
@@ -84,7 +87,10 @@ export const Proofing = Extension.create<unknown, ProofingStorage>({
|
||||
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 { issues, decorations: buildDecorations(newState.doc, issues, cursor) };
|
||||
}
|
||||
if (tr.selectionSet && value.issues.length) {
|
||||
return { issues: value.issues, decorations: buildDecorations(newState.doc, value.issues, cursor) };
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user