@@ -10,7 +10,7 @@ import Underline from "@tiptap/extension-underline";
1010import { Plugin , PluginKey , } from "@tiptap/pm/state" ;
1111import { EditorContent , useEditor , } from "@tiptap/react" ;
1212import StarterKit from "@tiptap/starter-kit" ;
13- import { useEffect , useRef , } from "react" ;
13+ import { forwardRef , useEffect , useImperativeHandle , useRef , } from "react" ;
1414import { useDebounceCallback , } from "usehooks-ts" ;
1515import "../editor/Editor.css" ;
1616import { json2md , md2json , } from "../../lib/markdown" ;
@@ -24,138 +24,169 @@ import { CustomListKeymap, } from "../editor/extensions/list-keymap";
2424import { CustomTaskItem , } from "../editor/extensions/task-item/TaskItemNode" ;
2525import { WidgetExtension , } from "../editor/extensions/widget/WidgetExtension" ;
2626
27+ export interface EditableNoteHandle {
28+ focus ( ) : void ;
29+ editor : TiptapEditor | null ;
30+ }
31+
2732interface 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