Skip to content

Commit 681dbb9

Browse files
author
ComputelessComputer
committed
unify editor: today's note now uses same md2json/json2md pipeline as past notes
1 parent 22f6d2d commit 681dbb9

2 files changed

Lines changed: 166 additions & 302 deletions

File tree

src/components/journal/EditableNote.tsx

Lines changed: 152 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import Underline from "@tiptap/extension-underline";
1010
import { Plugin, PluginKey, } from "@tiptap/pm/state";
1111
import { EditorContent, useEditor, } from "@tiptap/react";
1212
import StarterKit from "@tiptap/starter-kit";
13-
import { useEffect, useRef, } from "react";
13+
import { forwardRef, useEffect, useImperativeHandle, useRef, } from "react";
1414
import { useDebounceCallback, } from "usehooks-ts";
1515
import "../editor/Editor.css";
1616
import { json2md, md2json, } from "../../lib/markdown";
@@ -24,138 +24,169 @@ import { CustomListKeymap, } from "../editor/extensions/list-keymap";
2424
import { CustomTaskItem, } from "../editor/extensions/task-item/TaskItemNode";
2525
import { WidgetExtension, } from "../editor/extensions/widget/WidgetExtension";
2626

27+
export interface EditableNoteHandle {
28+
focus(): void;
29+
editor: TiptapEditor | null;
30+
}
31+
2732
interface EditableNoteProps {
2833
note: DailyNote;
2934
placeholder?: string;
35+
onSave?: (note: DailyNote,) => void;
3036
}
3137

32-
export default function EditableNote({ note, placeholder = "Start writing...", }: EditableNoteProps,) {
33-
const noteRef = useRef(note,);
34-
noteRef.current = note;
38+
const EditableNote = forwardRef<EditableNoteHandle, EditableNoteProps>(
39+
function EditableNote({ note, placeholder = "Start writing...", onSave, }, ref,) {
40+
const noteRef = useRef(note,);
41+
noteRef.current = note;
3542

36-
const saveDebounced = useDebounceCallback((markdown: string,) => {
37-
saveDailyNote({ ...noteRef.current, content: markdown, },).catch(console.error,);
38-
}, 500,);
43+
const onSaveRef = useRef(onSave,);
44+
onSaveRef.current = onSave;
3945

40-
const editor = useEditor({
41-
extensions: [
42-
StarterKit.configure({
43-
heading: { levels: [1, 2, 3, 4, 5, 6,], },
44-
listKeymap: false,
45-
},),
46-
Image.configure({ inline: true, allowBase64: false, },),
47-
Underline,
48-
Placeholder.configure({ placeholder, },),
49-
Link.extend({
50-
inclusive() {
51-
return false;
52-
},
53-
addProseMirrorPlugins() {
54-
return [
55-
new Plugin({
56-
key: new PluginKey("linkCmdClick",),
57-
props: {
58-
handleClick(_view, _pos, event,) {
59-
if (!(event.metaKey || event.ctrlKey)) return false;
60-
const anchor = (event.target as HTMLElement).closest("a",);
61-
if (anchor && (anchor as HTMLAnchorElement).href) {
62-
event.preventDefault();
63-
window.open((anchor as HTMLAnchorElement).href, "_blank",);
64-
return true;
65-
}
66-
return false;
46+
const saveDebounced = useDebounceCallback((markdown: string,) => {
47+
const updated = { ...noteRef.current, content: markdown, };
48+
if (onSaveRef.current) {
49+
onSaveRef.current(updated,);
50+
} else {
51+
saveDailyNote(updated,).catch(console.error,);
52+
}
53+
}, 500,);
54+
55+
const editor = useEditor({
56+
extensions: [
57+
StarterKit.configure({
58+
heading: { levels: [1, 2, 3, 4, 5, 6,], },
59+
listKeymap: false,
60+
},),
61+
Image.configure({ inline: true, allowBase64: false, },),
62+
Underline,
63+
Placeholder.configure({ placeholder, },),
64+
Link.extend({
65+
inclusive() {
66+
return false;
67+
},
68+
addProseMirrorPlugins() {
69+
return [
70+
new Plugin({
71+
key: new PluginKey("linkCmdClick",),
72+
props: {
73+
handleClick(_view, _pos, event,) {
74+
if (!(event.metaKey || event.ctrlKey)) return false;
75+
const anchor = (event.target as HTMLElement).closest("a",);
76+
if (anchor && (anchor as HTMLAnchorElement).href) {
77+
event.preventDefault();
78+
window.open((anchor as HTMLAnchorElement).href, "_blank",);
79+
return true;
80+
}
81+
return false;
82+
},
6783
},
68-
},
69-
},),
70-
];
71-
},
72-
},).configure({ openOnClick: false, autolink: true, },),
73-
TaskList,
74-
CustomTaskItem.configure({ nested: true, },),
75-
Table.configure({ resizable: true, HTMLAttributes: { class: "tiptap-table", }, },),
76-
TableRow,
77-
TableHeader,
78-
TableCell,
79-
Highlight,
80-
HashtagExtension,
81-
WidgetExtension,
82-
CustomListKeymap,
83-
ClipboardTextSerializer,
84-
FileHandler.configure({
85-
allowedMimeTypes: ["image/png", "image/jpeg", "image/gif", "image/webp",],
86-
onDrop: (_editor: TiptapEditor, files: File[], pos: number,) => {
87-
(async () => {
88-
for (const file of files) {
89-
const relativePath = await saveImage(file,);
90-
const assetUrl = await resolveAssetUrl(relativePath,);
91-
_editor.chain().insertContentAt(pos, {
92-
type: "image",
93-
attrs: { src: assetUrl, alt: file.name, },
94-
},).focus().run();
95-
}
96-
})().catch(console.error,);
97-
return true;
84+
},),
85+
];
86+
},
87+
},).configure({ openOnClick: false, autolink: true, },),
88+
TaskList,
89+
CustomTaskItem.configure({ nested: true, },),
90+
Table.configure({ resizable: true, HTMLAttributes: { class: "tiptap-table", }, },),
91+
TableRow,
92+
TableHeader,
93+
TableCell,
94+
Highlight,
95+
HashtagExtension,
96+
WidgetExtension,
97+
CustomListKeymap,
98+
ClipboardTextSerializer,
99+
FileHandler.configure({
100+
allowedMimeTypes: ["image/png", "image/jpeg", "image/gif", "image/webp",],
101+
onDrop: (_editor: TiptapEditor, files: File[], pos: number,) => {
102+
(async () => {
103+
for (const file of files) {
104+
const relativePath = await saveImage(file,);
105+
const assetUrl = await resolveAssetUrl(relativePath,);
106+
_editor.chain().insertContentAt(pos, {
107+
type: "image",
108+
attrs: { src: assetUrl, alt: file.name, },
109+
},).focus().run();
110+
}
111+
})().catch(console.error,);
112+
return true;
113+
},
114+
onPaste: (_editor: TiptapEditor, files: File[],) => {
115+
(async () => {
116+
for (const file of files) {
117+
const relativePath = await saveImage(file,);
118+
const assetUrl = await resolveAssetUrl(relativePath,);
119+
_editor.chain().focus().insertContent({
120+
type: "image",
121+
attrs: { src: assetUrl, alt: file.name, },
122+
},).run();
123+
}
124+
})().catch(console.error,);
125+
return true;
126+
},
127+
},),
128+
],
129+
content: md2json(note.content,),
130+
editable: true,
131+
immediatelyRender: false,
132+
editorProps: {
133+
attributes: {
134+
class: "max-w-none focus:outline-hidden px-6 text-gray-900 dark:text-gray-100",
98135
},
99-
onPaste: (_editor: TiptapEditor, files: File[],) => {
100-
(async () => {
101-
for (const file of files) {
102-
const relativePath = await saveImage(file,);
103-
const assetUrl = await resolveAssetUrl(relativePath,);
104-
_editor.chain().focus().insertContent({
105-
type: "image",
106-
attrs: { src: assetUrl, alt: file.name, },
107-
},).run();
136+
handleKeyDown: (_view, event,) => {
137+
if (event.metaKey && event.key === "l") {
138+
event.preventDefault();
139+
editor?.chain().focus().toggleTaskList().run();
140+
return true;
141+
}
142+
if (event.key === "Backspace") {
143+
const { $from, empty, } = _view.state.selection;
144+
if (empty && $from.parentOffset === 0 && $from.parent.type.name === "heading") {
145+
const tr = _view.state.tr.setBlockType(
146+
$from.before(),
147+
$from.after(),
148+
_view.state.schema.nodes.paragraph,
149+
);
150+
_view.dispatch(tr,);
151+
return true;
108152
}
109-
})().catch(console.error,);
110-
return true;
153+
}
154+
return false;
111155
},
112-
},),
113-
],
114-
content: md2json(note.content,),
115-
editable: true,
116-
immediatelyRender: false,
117-
editorProps: {
118-
attributes: {
119-
class: "max-w-none focus:outline-hidden px-6 text-gray-900 dark:text-gray-100",
120156
},
121-
handleKeyDown: (_view, event,) => {
122-
if (event.metaKey && event.key === "l") {
123-
event.preventDefault();
124-
editor?.chain().focus().toggleTaskList().run();
125-
return true;
126-
}
127-
if (event.key === "Backspace") {
128-
const { $from, empty, } = _view.state.selection;
129-
if (empty && $from.parentOffset === 0 && $from.parent.type.name === "heading") {
130-
const tr = _view.state.tr.setBlockType(
131-
$from.before(),
132-
$from.after(),
133-
_view.state.schema.nodes.paragraph,
134-
);
135-
_view.dispatch(tr,);
136-
return true;
137-
}
138-
}
139-
return false;
157+
onUpdate: ({ editor, },) => {
158+
saveDebounced(json2md(editor.getJSON(),),);
140159
},
141-
},
142-
onUpdate: ({ editor, },) => {
143-
saveDebounced(json2md(editor.getJSON(),),);
144-
},
145-
},);
160+
},);
146161

147-
useEffect(() => {
148-
if (!editor || editor.isDestroyed) return;
149-
const incoming = md2json(note.content,);
150-
if (!editor.isFocused) {
151-
editor.commands.setContent(incoming, { emitUpdate: false, },);
152-
}
153-
}, [note.content,],);
162+
useImperativeHandle(
163+
ref,
164+
() => {
165+
return {
166+
focus: () => {
167+
editor?.commands.focus();
168+
},
169+
editor: editor ?? null,
170+
};
171+
},
172+
[editor,],
173+
);
154174

155-
return (
156-
<>
157-
{editor && <EditorBubbleMenu editor={editor} />}
158-
<EditorContent editor={editor} />
159-
</>
160-
);
161-
}
175+
useEffect(() => {
176+
if (!editor || editor.isDestroyed) return;
177+
const incoming = md2json(note.content,);
178+
if (!editor.isFocused) {
179+
editor.commands.setContent(incoming, { emitUpdate: false, },);
180+
}
181+
}, [note.content,],);
182+
183+
return (
184+
<>
185+
{editor && <EditorBubbleMenu editor={editor} />}
186+
<EditorContent editor={editor} />
187+
</>
188+
);
189+
},
190+
);
191+
192+
export default EditableNote;

0 commit comments

Comments
 (0)