your personal website on atproto - mirror blento.app
at fix-cached-posts 415 lines 10 kB view raw
1<script lang="ts"> 2 import { onMount, onDestroy } from 'svelte'; 3 import { Editor, mergeAttributes, type Content } from '@tiptap/core'; 4 import StarterKit from '@tiptap/starter-kit'; 5 import Placeholder from '@tiptap/extension-placeholder'; 6 import Image from '@tiptap/extension-image'; 7 import { all, createLowlight } from 'lowlight'; 8 import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; 9 import BubbleMenu from '@tiptap/extension-bubble-menu'; 10 import Underline from '@tiptap/extension-underline'; 11 import RichTextEditorMenu from './RichTextEditorMenu.svelte'; 12 import type { RichTextTypes } from '.'; 13 import RichTextEditorLinkMenu from './RichTextEditorLinkMenu.svelte'; 14 import Slash, { suggestion } from './slash-menu'; 15 import Typography from '@tiptap/extension-typography'; 16 import { Markdown } from '@tiptap/markdown'; 17 import { RichTextLink } from './RichTextLink'; 18 19 import './code.css'; 20 import { cn } from '@foxui/core'; 21 import { ImageUploadNode } from './image-upload/ImageUploadNode'; 22 import { Transaction } from '@tiptap/pm/state'; 23 24 let { 25 content = $bindable({}), 26 placeholder = 'Write or press / for commands', 27 editor = $bindable(null), 28 ref = $bindable(null), 29 class: className, 30 onupdate, 31 ontransaction 32 }: { 33 content?: Content; 34 placeholder?: string; 35 editor?: Editor | null; 36 ref?: HTMLDivElement | null; 37 class?: string; 38 onupdate?: (content: Content, context: { editor: Editor; transaction: Transaction }) => void; 39 ontransaction?: () => void; 40 } = $props(); 41 42 const lowlight = createLowlight(all); 43 44 let hasFocus = true; 45 46 let menu: HTMLElement | null = $state(null); 47 let menuLink: HTMLElement | null = $state(null); 48 49 let selectedType: RichTextTypes = $state('paragraph'); 50 51 let isBold = $state(false); 52 let isItalic = $state(false); 53 let isUnderline = $state(false); 54 let isStrikethrough = $state(false); 55 let isLink = $state(false); 56 let isImage = $state(false); 57 58 const CustomImage = Image.extend({ 59 // addAttributes(this) { 60 // return { 61 // inline: true, 62 // allowBase64: true, 63 // HTMLAttributes: {}, 64 // uploadImageHandler: { default: undefined } 65 // }; 66 // }, 67 renderHTML({ HTMLAttributes }) { 68 const attrs = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 69 uploadImageHandler: undefined 70 }); 71 const isLocal = attrs['data-local'] === 'true'; 72 73 // if (isLocal) { 74 // // For local images, wrap in a container with a label 75 // return [ 76 // 'div', 77 // { class: 'image-container' }, 78 // ['img', { ...attrs, class: `${attrs.class || ''} local-image` }], 79 // ['span', { class: 'local-image-label' }, 'Local preview'] 80 // ]; 81 // } 82 83 console.log('attrs', attrs); 84 85 // For regular images, just return the img tag 86 return ['img', attrs]; 87 } 88 }); 89 90 onMount(() => { 91 if (!ref) return; 92 93 let extensions = [ 94 StarterKit.configure({ 95 dropcursor: { 96 class: 'text-accent-500/30 rounded-2xl', 97 width: 2 98 }, 99 codeBlock: false, 100 heading: { 101 levels: [1, 2, 3] 102 } 103 }), 104 Placeholder.configure({ 105 placeholder: ({ node }) => { 106 // only show in paragraphs 107 if (node.type.name === 'paragraph' || node.type.name === 'heading') { 108 return placeholder; 109 } 110 return ''; 111 } 112 }), 113 CustomImage.configure({ 114 HTMLAttributes: { 115 class: 'max-w-full object-contain relative rounded-2xl' 116 }, 117 allowBase64: true 118 }), 119 CodeBlockLowlight.configure({ 120 lowlight, 121 defaultLanguage: 'js' 122 }), 123 BubbleMenu.configure({ 124 element: menu, 125 shouldShow: ({ editor }) => { 126 // dont show if image selected or no selection or is code block 127 return ( 128 !editor.isActive('image') && 129 !editor.view.state.selection.empty && 130 !editor.isActive('codeBlock') && 131 !editor.isActive('link') && 132 !editor.isActive('imageUpload') 133 ); 134 }, 135 pluginKey: 'bubble-menu-marks' 136 }), 137 BubbleMenu.configure({ 138 element: menuLink, 139 shouldShow: ({ editor }) => { 140 // only show if link is selected 141 return editor.isActive('link') && !editor.view.state.selection.empty; 142 }, 143 pluginKey: 'bubble-menu-links' 144 }), 145 Underline.configure({}), 146 RichTextLink.configure({ 147 openOnClick: false, 148 autolink: true, 149 defaultProtocol: 'https' 150 }), 151 Slash.configure({ 152 suggestion: suggestion({ 153 char: '/', 154 pluginKey: 'slash', 155 switchTo, 156 processImageFile 157 }) 158 }), 159 Typography.configure(), 160 Markdown.configure(), 161 ImageUploadNode.configure({}) 162 ]; 163 164 editor = new Editor({ 165 element: ref, 166 extensions, 167 editorProps: { 168 attributes: { 169 class: 'outline-none' 170 } 171 }, 172 onUpdate: (ctx) => { 173 content = ctx.editor.getJSON(); 174 onupdate?.(content, ctx); 175 }, 176 onFocus: () => { 177 hasFocus = true; 178 }, 179 onBlur: () => { 180 hasFocus = false; 181 }, 182 onTransaction: (ctx) => { 183 isBold = ctx.editor.isActive('bold'); 184 isItalic = ctx.editor.isActive('italic'); 185 isUnderline = ctx.editor.isActive('underline'); 186 isStrikethrough = ctx.editor.isActive('strike'); 187 isLink = ctx.editor.isActive('link'); 188 isImage = ctx.editor.isActive('image'); 189 190 if (ctx.editor.isActive('heading', { level: 1 })) { 191 selectedType = 'heading-1'; 192 } else if (ctx.editor.isActive('heading', { level: 2 })) { 193 selectedType = 'heading-2'; 194 } else if (ctx.editor.isActive('heading', { level: 3 })) { 195 selectedType = 'heading-3'; 196 } else if (ctx.editor.isActive('blockquote')) { 197 selectedType = 'blockquote'; 198 } else if (ctx.editor.isActive('code')) { 199 selectedType = 'code'; 200 } else if (ctx.editor.isActive('bulletList')) { 201 selectedType = 'bullet-list'; 202 } else if (ctx.editor.isActive('orderedList')) { 203 selectedType = 'ordered-list'; 204 } else { 205 selectedType = 'paragraph'; 206 } 207 ontransaction?.(); 208 }, 209 content 210 }); 211 }); 212 213 // Flag to track whether a file is being dragged over the drop area 214 let isDragOver = $state(false); 215 216 // Store local image files for later upload 217 let localImages: Map<string, File> = $state(new Map()); 218 219 // Track which image URLs in the editor are local previews 220 let localImageUrls: Set<string> = $state(new Set()); 221 222 // Process image file to create a local preview 223 async function processImageFile(file: File) { 224 if (!editor) { 225 console.warn('Tiptap editor not initialized'); 226 return; 227 } 228 229 try { 230 const localUrl = URL.createObjectURL(file); 231 232 localImages.set(localUrl, file); 233 localImageUrls.add(localUrl); 234 235 //editor.commands.setImageUploadNode(); 236 editor 237 .chain() 238 .focus() 239 .setImageUploadNode({ 240 preview: localUrl 241 }) 242 .run(); 243 244 // wait 2 seconds 245 // await new Promise((resolve) => setTimeout(resolve, 500)); 246 247 // content = editor.getJSON(); 248 249 // console.log('replacing image url in content'); 250 // replaceImageUrlInContent(content, localUrl, 'https://picsum.photos/200/300'); 251 // editor.commands.setContent(content); 252 } catch (error) { 253 console.error('Error creating image preview:', error); 254 } 255 } 256 257 const handlePaste = (event: ClipboardEvent) => { 258 const items = event.clipboardData?.items; 259 if (!items) return; 260 // Check for image data in clipboard 261 for (const item of Array.from(items)) { 262 if (!item.type.startsWith('image/')) continue; 263 const file = item.getAsFile(); 264 if (!file) continue; 265 event.preventDefault(); 266 processImageFile(file); 267 return; 268 } 269 }; 270 271 function handleDragOver(event: DragEvent) { 272 event.preventDefault(); 273 event.stopPropagation(); 274 isDragOver = true; 275 } 276 function handleDragLeave(event: DragEvent) { 277 event.preventDefault(); 278 event.stopPropagation(); 279 isDragOver = false; 280 } 281 function handleDrop(event: DragEvent) { 282 event.preventDefault(); 283 event.stopPropagation(); 284 isDragOver = false; 285 if (!event.dataTransfer?.files?.length) return; 286 const file = event.dataTransfer.files[0]; 287 if (file?.type.startsWith('image/')) { 288 processImageFile(file); 289 } 290 } 291 292 onDestroy(() => { 293 for (const localUrl of localImageUrls) { 294 URL.revokeObjectURL(localUrl); 295 } 296 297 editor?.destroy(); 298 }); 299 300 let link = $state(''); 301 302 let linkInput: HTMLInputElement | null = $state(null); 303 304 function clickedLink() { 305 if (isLink) { 306 //tiptap?.chain().focus().unsetLink().run(); 307 // get current link 308 link = editor?.getAttributes('link').href; 309 310 setTimeout(() => { 311 linkInput?.focus(); 312 }, 100); 313 } else { 314 link = ''; 315 // set link 316 editor?.chain().focus().setLink({ href: link }).run(); 317 318 setTimeout(() => { 319 linkInput?.focus(); 320 }, 100); 321 } 322 } 323 324 function switchTo(value: RichTextTypes) { 325 editor?.chain().focus().setParagraph().run(); 326 327 if (value === 'heading-1') { 328 editor?.chain().focus().setNode('heading', { level: 1 }).run(); 329 } else if (value === 'heading-2') { 330 editor?.chain().focus().setNode('heading', { level: 2 }).run(); 331 } else if (value === 'heading-3') { 332 editor?.chain().focus().setNode('heading', { level: 3 }).run(); 333 } else if (value === 'blockquote') { 334 editor?.chain().focus().setBlockquote().run(); 335 } else if (value === 'code') { 336 editor?.chain().focus().setCodeBlock().run(); 337 } else if (value === 'bullet-list') { 338 editor?.chain().focus().toggleBulletList().run(); 339 } else if (value === 'ordered-list') { 340 editor?.chain().focus().toggleOrderedList().run(); 341 } 342 } 343</script> 344 345<div 346 bind:this={ref} 347 class={cn('relative flex-1', className)} 348 onpaste={handlePaste} 349 ondragover={handleDragOver} 350 ondragleave={handleDragLeave} 351 ondrop={handleDrop} 352 role="region" 353></div> 354 355<RichTextEditorMenu 356 bind:ref={menu} 357 {editor} 358 {isBold} 359 {isItalic} 360 {isUnderline} 361 {isStrikethrough} 362 {isLink} 363 {isImage} 364 {clickedLink} 365 {processImageFile} 366 {switchTo} 367 bind:selectedType 368/> 369 370<RichTextEditorLinkMenu bind:ref={menuLink} {editor} bind:link bind:linkInput /> 371 372<style> 373 :global(.tiptap) { 374 :first-child { 375 margin-top: 0; 376 } 377 378 :global(img) { 379 display: block; 380 height: auto; 381 margin: 1.5rem 0; 382 max-width: 100%; 383 384 &.ProseMirror-selectednode { 385 outline: 3px solid var(--color-accent-500); 386 } 387 } 388 389 :global(div[data-type='image-upload']) { 390 &.ProseMirror-selectednode { 391 outline: 3px solid var(--color-accent-500); 392 } 393 } 394 395 :global(blockquote p:first-of-type::before) { 396 content: none; 397 } 398 399 :global(blockquote p:last-of-type::after) { 400 content: none; 401 } 402 403 :global(blockquote p) { 404 font-style: normal; 405 } 406 } 407 408 :global(.tiptap .is-empty::before) { 409 color: var(--color-base-500); 410 content: attr(data-placeholder); 411 float: left; 412 height: 0; 413 pointer-events: none; 414 } 415</style>