your personal website on atproto - mirror
blento.app
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>