@@ -18,7 +18,7 @@ type EditorLike = {
1818 setImage ?: ( payload : { src : string , alt ?: string } ) => {
1919 run : ( ) => boolean
2020 }
21- insertContent ?: ( content : string ) => {
21+ insertContent ?: ( content : string | object ) => {
2222 run : ( ) => boolean
2323 }
2424 }
@@ -27,26 +27,30 @@ type EditorLike = {
2727
2828type EditorHandlerLike = {
2929 canExecute : ( editor : EditorLike ) => boolean
30- execute : ( editor : EditorLike ) => unknown
30+ execute : ( editor : EditorLike ) => boolean
3131 isActive : ( editor : EditorLike ) => boolean
3232 isDisabled ?: ( editor : EditorLike ) => boolean
3333}
3434
3535type EditoroMainContentMediaOptions = {
3636 canUploadImage : ( ) => boolean
3737 uploadImage : ( file : File ) => Promise < string | null >
38+ uploadFile : ( file : File ) => Promise < string | null >
3839}
3940
4041/**
41- * Encapsulates image upload interactions for editor toolbar, DnD and paste flows.
42+ * Encapsulates media/file upload interactions for editor toolbar, DnD and paste flows.
43+ * Also handles Ctrl/Cmd+Click on links inside editor content.
4244 * Used by `app/components/editoro/MainContent.vue`.
4345 */
4446export function useEditoroMainContentMedia ( options : EditoroMainContentMediaOptions ) {
4547 const imageInputRef = ref < HTMLInputElement > ( )
48+ const fileInputRef = ref < HTMLInputElement > ( )
4649 const pendingEditor = ref < EditorLike > ( )
50+ const pendingFileEditor = ref < EditorLike > ( )
4751 const currentEditor = ref < EditorLike > ( )
4852
49- let detachEditorDnDListeners : ( ( ) => void ) | null = null
53+ let detachEditorListeners : ( ( ) => void ) | null = null
5054
5155 const editorHandlers : Record < string , EditorHandlerLike > = {
5256 uploadImage : {
@@ -57,6 +61,15 @@ export function useEditoroMainContentMedia(options: EditoroMainContentMediaOptio
5761 } ,
5862 isActive : ( ) => false ,
5963 isDisabled : ( ) => ! options . canUploadImage ( )
64+ } ,
65+ uploadFile : {
66+ canExecute : ( ) => options . canUploadImage ( ) ,
67+ execute : ( editor ) => {
68+ openFilePicker ( editor )
69+ return true
70+ } ,
71+ isActive : ( ) => false ,
72+ isDisabled : ( ) => ! options . canUploadImage ( )
6073 }
6174 }
6275
@@ -70,6 +83,16 @@ export function useEditoroMainContentMedia(options: EditoroMainContentMediaOptio
7083 imageInputRef . value ?. click ( )
7184 }
7285
86+ function openFilePicker ( editor : EditorLike ) {
87+ if ( ! options . canUploadImage ( ) ) {
88+ return
89+ }
90+
91+ pendingFileEditor . value = editor
92+ currentEditor . value = editor
93+ fileInputRef . value ?. click ( )
94+ }
95+
7396 function isImageFile ( file : File ) {
7497 return file . type . startsWith ( 'image/' )
7598 }
@@ -95,6 +118,27 @@ export function useEditoroMainContentMedia(options: EditoroMainContentMediaOptio
95118 }
96119 }
97120
121+ function insertUploadedFileLink ( editor : EditorLike , fileUrl : string , fileName : string ) {
122+ const content = {
123+ type : 'text' ,
124+ text : fileName ,
125+ marks : [
126+ {
127+ type : 'link' ,
128+ attrs : { href : fileUrl }
129+ }
130+ ]
131+ }
132+
133+ const chain = editor . chain ( ) . focus ( )
134+ if ( chain . insertContent ) {
135+ chain . insertContent ( content ) . run ( )
136+ return
137+ }
138+
139+ editor . chain ( ) . focus ( ) . insertContent ?.( `[${ fileName } ](${ fileUrl } )` ) . run ( )
140+ }
141+
98142 async function uploadAndInsertImages ( editor : EditorLike , files : File [ ] , position ?: number ) {
99143 for ( const file of files ) {
100144 const imageUrl = await options . uploadImage ( file )
@@ -131,17 +175,48 @@ export function useEditoroMainContentMedia(options: EditoroMainContentMediaOptio
131175 input . value = ''
132176 }
133177
178+ async function onFileInputChange ( event : Event ) {
179+ const input = event . target as HTMLInputElement
180+ const editor = pendingFileEditor . value
181+ const file = input . files ?. [ 0 ]
182+
183+ if ( ! file || ! editor ) {
184+ input . value = ''
185+ return
186+ }
187+
188+ const fileUrl = await options . uploadFile ( file )
189+ if ( fileUrl ) {
190+ insertUploadedFileLink ( editor , fileUrl , file . name )
191+ }
192+
193+ input . value = ''
194+ }
195+
196+ function isExternalHref ( href : string ) {
197+ try {
198+ const url = new URL ( href , window . location . href )
199+ return url . origin !== window . location . origin
200+ } catch {
201+ return false
202+ }
203+ }
204+
134205 watch ( currentEditor , ( editor ) => {
135- if ( detachEditorDnDListeners ) {
136- detachEditorDnDListeners ( )
137- detachEditorDnDListeners = null
206+ if ( detachEditorListeners ) {
207+ detachEditorListeners ( )
208+ detachEditorListeners = null
138209 }
139210
140211 const target = editor ?. view ?. dom
141212 if ( ! target ) {
142213 return
143214 }
144215
216+ const setModifierCursorState = ( isPressed : boolean ) => {
217+ target . classList . toggle ( 'editoro-link-modifier' , isPressed )
218+ }
219+
145220 const onDrop = async ( event : DragEvent ) => {
146221 const files = getImageFilesFromDataTransfer ( event . dataTransfer || null )
147222 if ( files . length === 0 ) {
@@ -167,26 +242,81 @@ export function useEditoroMainContentMedia(options: EditoroMainContentMediaOptio
167242 await uploadAndInsertImages ( editor , files )
168243 }
169244
245+ const onClick = ( event : MouseEvent ) => {
246+ if ( ! event . ctrlKey && ! event . metaKey ) {
247+ return
248+ }
249+
250+ const targetNode = event . target
251+ if ( ! ( targetNode instanceof HTMLElement ) ) {
252+ return
253+ }
254+
255+ const anchor = targetNode . closest ( 'a' )
256+ if ( ! anchor ) {
257+ return
258+ }
259+
260+ const href = anchor . getAttribute ( 'href' ) || ''
261+ if ( ! href ) {
262+ return
263+ }
264+
265+ event . preventDefault ( )
266+ if ( isExternalHref ( href ) ) {
267+ window . open ( href , '_blank' , 'noopener,noreferrer' )
268+ } else {
269+ window . location . assign ( href )
270+ }
271+ }
272+
273+ const onKeyDown = ( event : KeyboardEvent ) => {
274+ if ( event . ctrlKey || event . metaKey ) {
275+ setModifierCursorState ( true )
276+ }
277+ }
278+
279+ const onKeyUp = ( event : KeyboardEvent ) => {
280+ if ( ! event . ctrlKey && ! event . metaKey ) {
281+ setModifierCursorState ( false )
282+ }
283+ }
284+
285+ const onWindowBlur = ( ) => {
286+ setModifierCursorState ( false )
287+ }
288+
170289 target . addEventListener ( 'drop' , onDrop )
171290 target . addEventListener ( 'paste' , onPaste )
291+ target . addEventListener ( 'click' , onClick )
292+ window . addEventListener ( 'keydown' , onKeyDown )
293+ window . addEventListener ( 'keyup' , onKeyUp )
294+ window . addEventListener ( 'blur' , onWindowBlur )
172295
173- detachEditorDnDListeners = ( ) => {
296+ detachEditorListeners = ( ) => {
174297 target . removeEventListener ( 'drop' , onDrop )
175298 target . removeEventListener ( 'paste' , onPaste )
299+ target . removeEventListener ( 'click' , onClick )
300+ window . removeEventListener ( 'keydown' , onKeyDown )
301+ window . removeEventListener ( 'keyup' , onKeyUp )
302+ window . removeEventListener ( 'blur' , onWindowBlur )
303+ setModifierCursorState ( false )
176304 }
177305 } , { flush : 'post' } )
178306
179307 onBeforeUnmount ( ( ) => {
180- if ( detachEditorDnDListeners ) {
181- detachEditorDnDListeners ( )
182- detachEditorDnDListeners = null
308+ if ( detachEditorListeners ) {
309+ detachEditorListeners ( )
310+ detachEditorListeners = null
183311 }
184312 } )
185313
186314 return {
187315 imageInputRef,
316+ fileInputRef,
188317 editorHandlers,
189318 bindEditor,
190- onImageInputChange
319+ onImageInputChange,
320+ onFileInputChange
191321 }
192322}
0 commit comments