[READ-ONLY] Mirror of https://github.com/flo-bit/ui-kit. 🦊 fox ui, svelte 5 and tailwind 4 flo-bit.dev/ui-kit/
svelte tailwindcss ui-components
0

Configure Feed

Select the types of activity you want to include in your feed.

add bluesky post creator, refactor text package, small fixes

Florian (Mar 11, 2026, 12:32 PM +0100) 8672aa39 824f5b7a

+7444 -1840
+3
pnpm-lock.yaml
··· 514 514 '@foxui/core': 515 515 specifier: workspace:* 516 516 version: link:../core 517 + '@foxui/text': 518 + specifier: workspace:* 519 + version: link:../text 517 520 '@foxui/time': 518 521 specifier: workspace:* 519 522 version: link:../time
+12
.changeset/every-turkeys-dress.md
··· 1 + --- 2 + '@foxui/colors': minor 3 + '@foxui/visual': minor 4 + '@foxui/text': minor 5 + '@foxui/time': minor 6 + '@foxui/all': minor 7 + '@foxui/3d': minor 8 + '@foxui/social': patch 9 + '@foxui/core': patch 10 + --- 11 + 12 + add bluesky post creator, refactor text package, small fixes
+4 -1
packages/3d/eslint.config.js
··· 18 18 ...svelte.configs.prettier, 19 19 { 20 20 languageOptions: { 21 - globals: { ...globals.browser, ...globals.node } 21 + globals: { ...globals.browser, ...globals.node }, 22 + parserOptions: { 23 + tsconfigRootDir: fileURLToPath(new URL('.', import.meta.url)) 24 + } 22 25 }, 23 26 rules: { 'no-undef': 'off' } 24 27 },
+4 -1
packages/all/eslint.config.js
··· 18 18 ...svelte.configs.prettier, 19 19 { 20 20 languageOptions: { 21 - globals: { ...globals.browser, ...globals.node } 21 + globals: { ...globals.browser, ...globals.node }, 22 + parserOptions: { 23 + tsconfigRootDir: fileURLToPath(new URL('.', import.meta.url)) 24 + } 22 25 }, 23 26 rules: { 'no-undef': 'off' } 24 27 },
+4 -1
packages/colors/eslint.config.js
··· 18 18 ...svelte.configs.prettier, 19 19 { 20 20 languageOptions: { 21 - globals: { ...globals.browser, ...globals.node } 21 + globals: { ...globals.browser, ...globals.node }, 22 + parserOptions: { 23 + tsconfigRootDir: fileURLToPath(new URL('.', import.meta.url)) 24 + } 22 25 }, 23 26 rules: { 'no-undef': 'off' } 24 27 },
+4 -1
packages/core/eslint.config.js
··· 18 18 ...svelte.configs.prettier, 19 19 { 20 20 languageOptions: { 21 - globals: { ...globals.browser, ...globals.node } 21 + globals: { ...globals.browser, ...globals.node }, 22 + parserOptions: { 23 + tsconfigRootDir: fileURLToPath(new URL('.', import.meta.url)) 24 + } 22 25 }, 23 26 rules: { 'no-undef': 'off' } 24 27 },
+1
packages/social/package.json
··· 71 71 "@atcute/bluesky": "^3.2.19", 72 72 "@atcute/bluesky-richtext-segmenter": "^3.0.0", 73 73 "@foxui/core": "workspace:*", 74 + "@foxui/text": "workspace:*", 74 75 "@foxui/time": "workspace:*", 75 76 "@use-gesture/vanilla": "^10.3.1", 76 77 "bits-ui": "^2.16.2",
+4 -1
packages/text/eslint.config.js
··· 18 18 ...svelte.configs.prettier, 19 19 { 20 20 languageOptions: { 21 - globals: { ...globals.browser, ...globals.node } 21 + globals: { ...globals.browser, ...globals.node }, 22 + parserOptions: { 23 + tsconfigRootDir: fileURLToPath(new URL('.', import.meta.url)) 24 + } 22 25 }, 23 26 rules: { 'no-undef': 'off' } 24 27 },
+4 -1
packages/time/eslint.config.js
··· 18 18 ...svelte.configs.prettier, 19 19 { 20 20 languageOptions: { 21 - globals: { ...globals.browser, ...globals.node } 21 + globals: { ...globals.browser, ...globals.node }, 22 + parserOptions: { 23 + tsconfigRootDir: fileURLToPath(new URL('.', import.meta.url)) 24 + } 22 25 }, 23 26 rules: { 'no-undef': 'off' } 24 27 },
+4 -1
packages/visual/eslint.config.js
··· 18 18 ...svelte.configs.prettier, 19 19 { 20 20 languageOptions: { 21 - globals: { ...globals.browser, ...globals.node } 21 + globals: { ...globals.browser, ...globals.node }, 22 + parserOptions: { 23 + tsconfigRootDir: fileURLToPath(new URL('.', import.meta.url)) 24 + } 22 25 }, 23 26 rules: { 'no-undef': 'off' } 24 27 },
+1
packages/social/src/lib/components/index.ts
··· 12 12 export * from './link-card'; 13 13 export * from './animated-emoji-picker'; 14 14 export * from './atproto-handle-popup'; 15 + export * from './bluesky-post-creator'; 15 16 16 17 export function numberToHumanReadable(number: number) { 17 18 if (number < 1000) {
+15
packages/text/src/lib/components/index.ts
··· 1 1 export * from './plain-text-editor/'; 2 2 export * from './rich-text-editor/'; 3 3 export * from './advanced-text-area/'; 4 + 5 + export * from './core-rich-text-editor/'; 6 + export * from './rich-text-bubble-menu/'; 7 + export * from './mention/'; 8 + export * from './slash-menu/'; 9 + export * from './image/'; 10 + export * from './link/'; 11 + export * from './code-block/'; 12 + export * from './block-type-menu/'; 13 + export * from './embed/'; 14 + export * from './formatting/'; 15 + export * from './hashtag-decoration/'; 16 + export * from '../extensions/'; 17 + 18 + export * as SvelteTiptap from 'svelte-tiptap';
+11
packages/text/src/lib/extensions/index.ts
··· 1 + export { HashtagDecoration } from '../components/hashtag-decoration'; 2 + 3 + // Re-export commonly used tiptap extensions so consumers 4 + // don't need direct @tiptap/* dependencies. 5 + export { default as Link } from '@tiptap/extension-link'; 6 + export { default as Placeholder } from '@tiptap/extension-placeholder'; 7 + export { default as History } from '@tiptap/extension-history'; 8 + export { default as Underline } from '@tiptap/extension-underline'; 9 + export { default as Strike } from '@tiptap/extension-strike'; 10 + // Re-export core tiptap types 11 + export type { JSONContent, Content, Extensions } from '@tiptap/core';
+1 -1
packages/core/src/lib/components/box/Box.svelte
··· 14 14 <div 15 15 bind:this={ref} 16 16 class={cn( 17 - 'border-base-300/50 bg-base-50/70 backdrop-blur-2xl dark:border-base-800/70 dark:bg-base-950/60 text-base-900 dark:text-base-100 relative isolate w-full overflow-hidden rounded-2xl border p-4', 17 + 'border-base-300/50 bg-base-50/70 backdrop-blur-2xl dark:border-base-800/70 dark:bg-base-950/60 text-base-900 dark:text-base-100 w-full rounded-2xl border p-4', 18 18 className 19 19 )} 20 20 {...restProps}
+117
packages/social/src/lib/components/bluesky-post-creator/BlueskyPostCreator.svelte
··· 1 + <script lang="ts"> 2 + import { 3 + CoreRichTextEditor, 4 + MentionNode, 5 + MentionMenu, 6 + HashtagDecoration, 7 + type MentionItem, 8 + type JSONContent 9 + } from '@foxui/text'; 10 + import type { Editor } from '@foxui/text'; 11 + import { cn, Avatar } from '@foxui/core'; 12 + import { searchActorsTypeahead } from '../atproto-handle-popup/helper'; 13 + import { editorJsonToBlueskyPost, type BlueskyPostContent } from './facets'; 14 + import type { Readable } from 'svelte/store'; 15 + 16 + let { 17 + class: className, 18 + placeholder = "What's on your mind?", 19 + content = $bindable<BlueskyPostContent>({ text: '', facets: [] }), 20 + maxLength = 300, 21 + onupdate, 22 + editor = $bindable<Readable<Editor>>(), 23 + ref = $bindable<HTMLDivElement | null>(null), 24 + host 25 + }: { 26 + class?: string; 27 + placeholder?: string; 28 + content?: BlueskyPostContent; 29 + maxLength?: number; 30 + onupdate?: (content: BlueskyPostContent) => void; 31 + editor?: Readable<Editor>; 32 + ref?: HTMLDivElement | null; 33 + host?: string; 34 + } = $props(); 35 + 36 + let charCount = $derived(content.text.length); 37 + let remaining = $derived(maxLength - charCount); 38 + 39 + async function searchMentions(query: string): Promise<MentionItem[]> { 40 + const result = await searchActorsTypeahead(query, 8, host); 41 + return result.actors.map((actor) => ({ 42 + id: actor.did, 43 + label: actor.handle, 44 + avatar: actor.avatar, 45 + data: { displayName: actor.displayName } 46 + })); 47 + } 48 + </script> 49 + 50 + <div class={cn('relative', className)}> 51 + <CoreRichTextEditor 52 + bind:editor 53 + bind:ref 54 + {placeholder} 55 + extraExtensions={[ 56 + MentionNode.configure({ 57 + HTMLAttributes: { 58 + class: 'text-accent-600 dark:text-accent-400 font-medium' 59 + } 60 + }), 61 + HashtagDecoration 62 + ]} 63 + onupdate={(c) => { 64 + content = editorJsonToBlueskyPost(c as JSONContent); 65 + onupdate?.(content); 66 + }} 67 + > 68 + {#if $editor} 69 + <MentionMenu editor={$editor} items={searchMentions}> 70 + {#snippet item({ item: mentionItem, isActive, select })} 71 + <button 72 + onclick={select} 73 + class={cn( 74 + 'text-base-900 dark:text-base-200 flex min-w-48 w-full cursor-pointer items-center gap-3 px-3 py-2 font-medium', 75 + isActive 76 + ? 'text-accent-700 dark:text-accent-400 bg-accent-500/10' 77 + : 'hover:bg-accent-500/10' 78 + )} 79 + > 80 + <Avatar src={mentionItem.avatar} alt="" class="size-6 rounded-full" /> 81 + <span class="flex flex-col items-start"> 82 + <span>{mentionItem.label}</span> 83 + {#if mentionItem.data?.displayName} 84 + <span class="text-base-500 text-xs">{mentionItem.data.displayName}</span> 85 + {/if} 86 + </span> 87 + </button> 88 + {/snippet} 89 + </MentionMenu> 90 + {/if} 91 + </CoreRichTextEditor> 92 + 93 + {#if maxLength} 94 + <div 95 + class={cn( 96 + 'mt-1 text-right text-xs tabular-nums', 97 + remaining < 0 98 + ? 'text-red-500' 99 + : remaining < 20 100 + ? 'text-amber-500' 101 + : 'text-base-400' 102 + )} 103 + > 104 + {remaining} 105 + </div> 106 + {/if} 107 + </div> 108 + 109 + <style> 110 + :global(.tiptap .hashtag) { 111 + color: var(--color-accent-600); 112 + } 113 + 114 + :global(.dark .tiptap .hashtag) { 115 + color: var(--color-accent-400); 116 + } 117 + </style>
+103
packages/social/src/lib/components/bluesky-post-creator/facets.ts
··· 1 + import type { JSONContent } from '@foxui/text'; 2 + 3 + interface MentionFeature { 4 + $type: 'app.bsky.richtext.facet#mention'; 5 + did: string; 6 + } 7 + 8 + interface LinkFeature { 9 + $type: 'app.bsky.richtext.facet#link'; 10 + uri: string; 11 + } 12 + 13 + interface TagFeature { 14 + $type: 'app.bsky.richtext.facet#tag'; 15 + tag: string; 16 + } 17 + 18 + type Feature = MentionFeature | LinkFeature | TagFeature; 19 + 20 + interface FacetIndex { 21 + byteStart: number; 22 + byteEnd: number; 23 + } 24 + 25 + export interface BlueskyFacet { 26 + index: FacetIndex; 27 + features: Feature[]; 28 + } 29 + 30 + export interface BlueskyPostContent { 31 + text: string; 32 + facets: BlueskyFacet[]; 33 + } 34 + 35 + const encoder = new TextEncoder(); 36 + 37 + function byteLength(str: string): number { 38 + return encoder.encode(str).byteLength; 39 + } 40 + 41 + export function editorJsonToBlueskyPost(json: JSONContent): BlueskyPostContent { 42 + let text = ''; 43 + const facets: BlueskyFacet[] = []; 44 + 45 + function processNode(node: JSONContent) { 46 + if (node.type === 'doc') { 47 + for (const child of node.content ?? []) { 48 + processNode(child); 49 + } 50 + } else if (node.type === 'paragraph') { 51 + if (text.length > 0) text += '\n'; 52 + for (const child of node.content ?? []) { 53 + processInline(child); 54 + } 55 + } 56 + } 57 + 58 + function processInline(node: JSONContent) { 59 + if (node.type === 'mention') { 60 + const label = `@${node.attrs?.label ?? node.attrs?.id}`; 61 + const byteStart = byteLength(text); 62 + text += label; 63 + const byteEnd = byteLength(text); 64 + facets.push({ 65 + index: { byteStart, byteEnd }, 66 + features: [{ $type: 'app.bsky.richtext.facet#mention', did: node.attrs?.id }] 67 + }); 68 + } else if (node.type === 'text') { 69 + const nodeText = node.text ?? ''; 70 + const linkMark = node.marks?.find((m) => m.type === 'link'); 71 + if (linkMark) { 72 + const byteStart = byteLength(text); 73 + text += nodeText; 74 + const byteEnd = byteLength(text); 75 + facets.push({ 76 + index: { byteStart, byteEnd }, 77 + features: [{ $type: 'app.bsky.richtext.facet#link', uri: linkMark.attrs?.href }] 78 + }); 79 + } else { 80 + text += nodeText; 81 + } 82 + } 83 + } 84 + 85 + processNode(json); 86 + 87 + // Detect hashtags in the final text 88 + const hashtagRegex = /(?<=^|\s)#([a-zA-Z\u00C0-\u024F\u1E00-\u1EFF][\w\u00C0-\u024F\u1E00-\u1EFF]*)/g; 89 + let match; 90 + while ((match = hashtagRegex.exec(text)) !== null) { 91 + const fullMatch = match[0]; 92 + const tag = match[1]; 93 + const charStart = match.index; 94 + const byteStart = byteLength(text.slice(0, charStart)); 95 + const byteEnd = byteStart + byteLength(fullMatch); 96 + facets.push({ 97 + index: { byteStart, byteEnd }, 98 + features: [{ $type: 'app.bsky.richtext.facet#tag', tag }] 99 + }); 100 + } 101 + 102 + return { text, facets }; 103 + }
+3
packages/social/src/lib/components/bluesky-post-creator/index.ts
··· 1 + export { default as BlueskyPostCreator } from './BlueskyPostCreator.svelte'; 2 + export { editorJsonToBlueskyPost } from './facets'; 3 + export type { BlueskyPostContent, BlueskyFacet } from './facets';
+6 -6
packages/social/src/lib/components/user-profile/UserProfile.svelte
··· 1 1 <script lang="ts"> 2 - import { Avatar, Button, cn } from '@foxui/core'; 2 + import { Avatar, Button, cn, sanitize } from '@foxui/core'; 3 3 4 4 let { profile, class: className }: { profile: { 5 5 banner?: string; ··· 15 15 <div> 16 16 {#if profile.banner} 17 17 <img 18 - class="aspect-[3/1] w-full border-b border-base-800 object-cover sm:rounded-xl sm:border" 18 + class="aspect-3/1 w-full border-b border-base-800 object-cover sm:rounded-xl sm:border" 19 19 src={profile.banner} 20 20 alt="" 21 21 /> 22 22 {:else} 23 - <div class="aspect-[8/1] w-full"></div> 23 + <div class="aspect-8/1 w-full"></div> 24 24 {/if} 25 25 </div> 26 26 <div ··· 29 29 'flex max-w-full items-end space-x-5 px-4 sm:-mt-16 sm:px-6 lg:px-8' 30 30 )} 31 31 > 32 - <Avatar src={profile.avatar} class="border-base-50 dark:border-base-950 border-2 size-24 sm:size-32" /> 32 + <Avatar src={profile.avatar} class="border-base-50 dark:border-base-800 border-2 size-24 sm:size-32" /> 33 33 <div 34 34 class="flex min-w-0 flex-1 flex-row sm:flex-row sm:items-center sm:justify-end sm:space-x-6 sm:pb-1" 35 35 > 36 36 <div 37 37 class={cn( 38 - profile.banner ? 'mt-4 sm:mt-0' : '-mt-[4.5rem] sm:-mt-[6.5rem]', 38 + profile.banner ? 'mt-4 sm:mt-0' : '-mt-18 sm:-mt-26', 39 39 'flex min-w-0 max-w-full flex-1 flex-col items-baseline' 40 40 )} 41 41 > ··· 54 54 </div> 55 55 56 56 <div class="px-4 sm:px-6 lg:px-8 py-4 text-xs sm:text-sm text-base-800 dark:text-base-200"> 57 - {@html profile.description?.replaceAll('\n', '<br/>')} 57 + {@html sanitize(profile.description?.replaceAll('\n', '<br/>') ?? '')} 58 58 </div> 59 59 </div> 60 60 {/if}
+126
packages/text/src/lib/components/block-type-menu/BlockTypeMenu.svelte
··· 1 + <script lang="ts" module> 2 + import type { Editor } from 'svelte-tiptap'; 3 + 4 + export type BlockTypeItem = { 5 + id: string; 6 + label: string; 7 + isActive: (editor: Editor) => boolean; 8 + command: (editor: Editor) => void; 9 + }; 10 + </script> 11 + 12 + <script lang="ts"> 13 + import { cn } from '@foxui/core'; 14 + import type { Readable } from 'svelte/store'; 15 + 16 + const allItems: (BlockTypeItem & { node: string })[] = [ 17 + { 18 + id: 'paragraph', 19 + label: 'Paragraph', 20 + node: 'paragraph', 21 + isActive: (e) => e.isActive('paragraph'), 22 + command: (e) => e.chain().focus().setParagraph().run() 23 + }, 24 + { 25 + id: 'heading-1', 26 + label: 'Heading 1', 27 + node: 'heading', 28 + isActive: (e) => e.isActive('heading', { level: 1 }), 29 + command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() 30 + }, 31 + { 32 + id: 'heading-2', 33 + label: 'Heading 2', 34 + node: 'heading', 35 + isActive: (e) => e.isActive('heading', { level: 2 }), 36 + command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() 37 + }, 38 + { 39 + id: 'heading-3', 40 + label: 'Heading 3', 41 + node: 'heading', 42 + isActive: (e) => e.isActive('heading', { level: 3 }), 43 + command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() 44 + }, 45 + { 46 + id: 'blockquote', 47 + label: 'Blockquote', 48 + node: 'blockquote', 49 + isActive: (e) => e.isActive('blockquote'), 50 + command: (e) => e.chain().focus().toggleBlockquote().run() 51 + }, 52 + { 53 + id: 'code-block', 54 + label: 'Code Block', 55 + node: 'codeBlock', 56 + isActive: (e) => e.isActive('codeBlock'), 57 + command: (e) => e.chain().focus().toggleCodeBlock().run() 58 + }, 59 + { 60 + id: 'bullet-list', 61 + label: 'Bullet List', 62 + node: 'bulletList', 63 + isActive: (e) => e.isActive('bulletList'), 64 + command: (e) => e.chain().focus().toggleBulletList().run() 65 + }, 66 + { 67 + id: 'ordered-list', 68 + label: 'Ordered List', 69 + node: 'orderedList', 70 + isActive: (e) => e.isActive('orderedList'), 71 + command: (e) => e.chain().focus().toggleOrderedList().run() 72 + } 73 + ]; 74 + 75 + let { 76 + editor = $bindable(), 77 + items: itemsProp, 78 + class: className 79 + }: { 80 + editor: Readable<Editor>; 81 + items?: BlockTypeItem[]; 82 + class?: string; 83 + } = $props(); 84 + 85 + let items = $derived( 86 + itemsProp ?? allItems.filter((item) => $editor && !!$editor.schema.nodes[item.node]) 87 + ); 88 + 89 + let activeIds = $state(new Set<string>()); 90 + 91 + function updateState() { 92 + const next = new Set<string>(); 93 + for (const item of items) { 94 + if (item.isActive($editor)) { 95 + next.add(item.id); 96 + } 97 + } 98 + activeIds = next; 99 + } 100 + 101 + $effect(() => { 102 + if (!$editor) return; 103 + $editor.on('transaction', updateState); 104 + updateState(); 105 + return () => { 106 + $editor.off('transaction', updateState); 107 + }; 108 + }); 109 + </script> 110 + 111 + <div class={cn('flex flex-wrap gap-1', className)}> 112 + {#each items as item (item.id)} 113 + <button 114 + type="button" 115 + class={cn( 116 + 'rounded-lg px-2 py-1 text-xs font-medium transition-colors', 117 + activeIds.has(item.id) && (item.id !== 'paragraph' || activeIds.size === 1) 118 + ? 'bg-accent-500/15 text-accent-700 dark:text-accent-400' 119 + : 'text-base-500 hover:bg-base-500/10 hover:text-base-700 dark:hover:text-base-300' 120 + )} 121 + onclick={() => item.command($editor)} 122 + > 123 + {item.label} 124 + </button> 125 + {/each} 126 + </div>
+2
packages/text/src/lib/components/block-type-menu/index.ts
··· 1 + export { default as BlockTypeMenu } from './BlockTypeMenu.svelte'; 2 + export type { BlockTypeItem } from './BlockTypeMenu.svelte';
+35
packages/text/src/lib/components/code-block/CodeBlockExtension.ts
··· 1 + import { Extension } from '@tiptap/core'; 2 + import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; 3 + import { SvelteNodeViewRenderer } from 'svelte-tiptap'; 4 + import type { CodeBlockExtensionOptions } from './helpers'; 5 + import CodeBlockView from './CodeBlockView.svelte'; 6 + 7 + export const CodeBlockExtension = Extension.create<CodeBlockExtensionOptions>({ 8 + name: 'codeBlockExtension', 9 + 10 + addOptions() { 11 + return { 12 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 + lowlight: undefined as any, 14 + defaultLanguage: null, 15 + codeBlockComponent: undefined, 16 + HTMLAttributes: {} 17 + }; 18 + }, 19 + 20 + addExtensions() { 21 + const component = this.options.codeBlockComponent ?? CodeBlockView; 22 + 23 + const codeBlockLowlight = CodeBlockLowlight.extend({ 24 + addNodeView() { 25 + return SvelteNodeViewRenderer(component); 26 + } 27 + }).configure({ 28 + lowlight: this.options.lowlight, 29 + defaultLanguage: this.options.defaultLanguage ?? null, 30 + HTMLAttributes: this.options.HTMLAttributes ?? {} 31 + }); 32 + 33 + return [codeBlockLowlight]; 34 + } 35 + });
+69
packages/text/src/lib/components/code-block/CodeBlockView.svelte
··· 1 + <script lang="ts"> 2 + import type { NodeViewProps } from '@tiptap/core'; 3 + import { NodeViewWrapper, NodeViewContent } from 'svelte-tiptap'; 4 + 5 + let props: NodeViewProps = $props(); 6 + 7 + // Copy 8 + let copied = $state(false); 9 + 10 + async function copyCode() { 11 + const text = props.node.textContent; 12 + await navigator.clipboard.writeText(text); 13 + copied = true; 14 + setTimeout(() => { 15 + copied = false; 16 + }, 2000); 17 + } 18 + </script> 19 + 20 + <NodeViewWrapper 21 + as="pre" 22 + class="code-block group/code-block relative my-4 overflow-hidden rounded-2xl font-mono text-sm {props.selected 23 + ? 'ring-2 ring-accent-500' 24 + : ''}" 25 + > 26 + <!-- Toolbar --> 27 + <div 28 + class="absolute right-2 top-2 z-10 flex items-center gap-1 opacity-0 transition-opacity group-hover/code-block:opacity-100" 29 + contenteditable="false" 30 + > 31 + <!-- Copy button --> 32 + <button 33 + type="button" 34 + class="cursor-pointer rounded-lg border border-base-300 bg-base-100 p-1 text-base-600 transition-colors hover:bg-base-200 dark:border-base-600 dark:bg-base-800 dark:text-base-400 dark:hover:bg-base-700" 35 + onclick={copyCode} 36 + > 37 + {#if copied} 38 + <svg 39 + xmlns="http://www.w3.org/2000/svg" 40 + fill="none" 41 + viewBox="0 0 24 24" 42 + stroke-width="1.5" 43 + stroke="currentColor" 44 + class="size-3.5 text-green-500" 45 + > 46 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 47 + </svg> 48 + {:else} 49 + <svg 50 + xmlns="http://www.w3.org/2000/svg" 51 + fill="none" 52 + viewBox="0 0 24 24" 53 + stroke-width="1.5" 54 + stroke="currentColor" 55 + class="size-3.5" 56 + > 57 + <path 58 + stroke-linecap="round" 59 + stroke-linejoin="round" 60 + d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" 61 + /> 62 + </svg> 63 + {/if} 64 + </button> 65 + </div> 66 + 67 + <!-- Code content --> 68 + <NodeViewContent as="code" class="block overflow-x-auto p-4 pt-10" /> 69 + </NodeViewWrapper>
+194
packages/text/src/lib/components/code-block/code.css
··· 1 + /* Code block structural styles */ 2 + .tiptap pre { 3 + background: #ffffff; 4 + color: #24292e; 5 + border: 1px solid #d0d7de; 6 + } 7 + 8 + .tiptap pre code { 9 + background: none; 10 + color: inherit; 11 + padding: 0; 12 + } 13 + 14 + .dark .tiptap pre { 15 + background: #0d1117; 16 + color: #c9d1d9; 17 + border: 1px solid #30363d; 18 + } 19 + 20 + /* GitHub Light syntax highlighting */ 21 + .tiptap pre .hljs-doctag, 22 + .tiptap pre .hljs-keyword, 23 + .tiptap pre .hljs-meta .hljs-keyword, 24 + .tiptap pre .hljs-template-tag, 25 + .tiptap pre .hljs-template-variable, 26 + .tiptap pre .hljs-type, 27 + .tiptap pre .hljs-variable.language_ { 28 + color: #d73a49; 29 + } 30 + 31 + .tiptap pre .hljs-title, 32 + .tiptap pre .hljs-title.class_, 33 + .tiptap pre .hljs-title.class_.inherited__, 34 + .tiptap pre .hljs-title.function_ { 35 + color: #6f42c1; 36 + } 37 + 38 + .tiptap pre .hljs-attr, 39 + .tiptap pre .hljs-attribute, 40 + .tiptap pre .hljs-literal, 41 + .tiptap pre .hljs-meta, 42 + .tiptap pre .hljs-number, 43 + .tiptap pre .hljs-operator, 44 + .tiptap pre .hljs-selector-attr, 45 + .tiptap pre .hljs-selector-class, 46 + .tiptap pre .hljs-selector-id, 47 + .tiptap pre .hljs-variable { 48 + color: #005cc5; 49 + } 50 + 51 + .tiptap pre .hljs-meta .hljs-string, 52 + .tiptap pre .hljs-regexp, 53 + .tiptap pre .hljs-string { 54 + color: #032f62; 55 + } 56 + 57 + .tiptap pre .hljs-built_in, 58 + .tiptap pre .hljs-symbol { 59 + color: #e36209; 60 + } 61 + 62 + .tiptap pre .hljs-code, 63 + .tiptap pre .hljs-comment, 64 + .tiptap pre .hljs-formula { 65 + color: #6a737d; 66 + } 67 + 68 + .tiptap pre .hljs-name, 69 + .tiptap pre .hljs-quote, 70 + .tiptap pre .hljs-selector-pseudo, 71 + .tiptap pre .hljs-selector-tag { 72 + color: #22863a; 73 + } 74 + 75 + .tiptap pre .hljs-subst { 76 + color: #24292e; 77 + } 78 + 79 + .tiptap pre .hljs-section { 80 + color: #005cc5; 81 + font-weight: bold; 82 + } 83 + 84 + .tiptap pre .hljs-bullet { 85 + color: #735c0f; 86 + } 87 + 88 + .tiptap pre .hljs-emphasis { 89 + color: #24292e; 90 + font-style: italic; 91 + } 92 + 93 + .tiptap pre .hljs-strong { 94 + color: #24292e; 95 + font-weight: bold; 96 + } 97 + 98 + .tiptap pre .hljs-addition { 99 + color: #22863a; 100 + background-color: #f0fff4; 101 + } 102 + 103 + .tiptap pre .hljs-deletion { 104 + color: #b31d28; 105 + background-color: #ffeef0; 106 + } 107 + 108 + /* GitHub Dark syntax highlighting */ 109 + .dark .tiptap pre .hljs-doctag, 110 + .dark .tiptap pre .hljs-keyword, 111 + .dark .tiptap pre .hljs-meta .hljs-keyword, 112 + .dark .tiptap pre .hljs-template-tag, 113 + .dark .tiptap pre .hljs-template-variable, 114 + .dark .tiptap pre .hljs-type, 115 + .dark .tiptap pre .hljs-variable.language_ { 116 + color: #ff7b72; 117 + } 118 + 119 + .dark .tiptap pre .hljs-title, 120 + .dark .tiptap pre .hljs-title.class_, 121 + .dark .tiptap pre .hljs-title.class_.inherited__, 122 + .dark .tiptap pre .hljs-title.function_ { 123 + color: #d2a8ff; 124 + } 125 + 126 + .dark .tiptap pre .hljs-attr, 127 + .dark .tiptap pre .hljs-attribute, 128 + .dark .tiptap pre .hljs-literal, 129 + .dark .tiptap pre .hljs-meta, 130 + .dark .tiptap pre .hljs-number, 131 + .dark .tiptap pre .hljs-operator, 132 + .dark .tiptap pre .hljs-selector-attr, 133 + .dark .tiptap pre .hljs-selector-class, 134 + .dark .tiptap pre .hljs-selector-id, 135 + .dark .tiptap pre .hljs-variable { 136 + color: #79c0ff; 137 + } 138 + 139 + .dark .tiptap pre .hljs-meta .hljs-string, 140 + .dark .tiptap pre .hljs-regexp, 141 + .dark .tiptap pre .hljs-string { 142 + color: #a5d6ff; 143 + } 144 + 145 + .dark .tiptap pre .hljs-built_in, 146 + .dark .tiptap pre .hljs-symbol { 147 + color: #ffa657; 148 + } 149 + 150 + .dark .tiptap pre .hljs-code, 151 + .dark .tiptap pre .hljs-comment, 152 + .dark .tiptap pre .hljs-formula { 153 + color: #8b949e; 154 + } 155 + 156 + .dark .tiptap pre .hljs-name, 157 + .dark .tiptap pre .hljs-quote, 158 + .dark .tiptap pre .hljs-selector-pseudo, 159 + .dark .tiptap pre .hljs-selector-tag { 160 + color: #7ee787; 161 + } 162 + 163 + .dark .tiptap pre .hljs-subst { 164 + color: #c9d1d9; 165 + } 166 + 167 + .dark .tiptap pre .hljs-section { 168 + color: #79c0ff; 169 + font-weight: bold; 170 + } 171 + 172 + .dark .tiptap pre .hljs-bullet { 173 + color: #f2cc60; 174 + } 175 + 176 + .dark .tiptap pre .hljs-emphasis { 177 + color: #c9d1d9; 178 + font-style: italic; 179 + } 180 + 181 + .dark .tiptap pre .hljs-strong { 182 + color: #c9d1d9; 183 + font-weight: bold; 184 + } 185 + 186 + .dark .tiptap pre .hljs-addition { 187 + color: #aff5b4; 188 + background-color: #033a16; 189 + } 190 + 191 + .dark .tiptap pre .hljs-deletion { 192 + color: #ffdcd7; 193 + background-color: #67060c; 194 + }
+14
packages/text/src/lib/components/code-block/helpers.ts
··· 1 + import type { Component } from 'svelte'; 2 + 3 + export interface CodeBlockExtensionOptions { 4 + /** The lowlight instance (created via `createLowlight`). Required. */ 5 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 6 + lowlight: any; 7 + /** Default language for new code blocks. @default null */ 8 + defaultLanguage?: string | null; 9 + /** Custom Svelte component for rendering code blocks. Receives NodeViewProps from tiptap. 10 + * If not provided, the built-in CodeBlockView is used. */ 11 + codeBlockComponent?: Component; 12 + /** HTML attributes applied to code block elements. */ 13 + HTMLAttributes?: Record<string, string>; 14 + }
+5
packages/text/src/lib/components/code-block/index.ts
··· 1 + export { CodeBlockExtension } from './CodeBlockExtension'; 2 + export { default as CodeBlockView } from './CodeBlockView.svelte'; 3 + export type { CodeBlockExtensionOptions } from './helpers'; 4 + 5 + import './code.css';
+83
packages/text/src/lib/components/core-rich-text-editor/CoreRichTextEditor.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { createEditor, EditorContent } from 'svelte-tiptap'; 4 + import StarterKit from '@tiptap/starter-kit'; 5 + import Placeholder from '@tiptap/extension-placeholder'; 6 + import { Markdown } from '@tiptap/markdown'; 7 + import type { BaseEditorProps } from './types'; 8 + import { LinkExtension } from '../link'; 9 + import { all, createLowlight } from 'lowlight'; 10 + import { CodeBlockExtension } from '..'; 11 + 12 + let { 13 + content = $bindable(), 14 + editor = $bindable(), 15 + ref = $bindable(null), 16 + class: className, 17 + placeholder = '', 18 + markdown = false, 19 + onupdate, 20 + ontransaction, 21 + onlinkadded, 22 + extensions, 23 + extraExtensions, 24 + children 25 + }: BaseEditorProps = $props(); 26 + 27 + onMount(() => { 28 + const exts = extensions ?? [ 29 + StarterKit.configure({ link: false, codeBlock: false }), 30 + LinkExtension.configure({ onlinkadded }), 31 + CodeBlockExtension.configure({ 32 + lowlight: createLowlight(all) 33 + }) 34 + ]; 35 + 36 + const allExtensions = [ 37 + ...exts, 38 + ...(extraExtensions ?? []), 39 + ...(placeholder ? [Placeholder.configure({ placeholder })] : []), 40 + ...(markdown ? [Markdown] : []) 41 + ]; 42 + 43 + editor = createEditor({ 44 + extensions: allExtensions, 45 + content, 46 + ...(markdown ? { contentType: 'markdown' } : {}), 47 + 48 + editorProps: { 49 + attributes: { 50 + class: 'outline-none' 51 + } 52 + }, 53 + onUpdate: (ctx) => { 54 + if (markdown && typeof ctx.editor.getMarkdown === 'function') { 55 + content = ctx.editor.getMarkdown(); 56 + } else { 57 + content = ctx.editor.getJSON(); 58 + } 59 + onupdate?.(content); 60 + }, 61 + onTransaction: () => { 62 + ontransaction?.(); 63 + } 64 + }); 65 + }); 66 + </script> 67 + 68 + <div bind:this={ref} class={className} role="region"> 69 + {#if $editor} 70 + <EditorContent editor={$editor} /> 71 + {@render children?.()} 72 + {/if} 73 + </div> 74 + 75 + <style> 76 + :global(.tiptap p.is-editor-empty:first-child::before) { 77 + color: var(--color-base-500); 78 + content: attr(data-placeholder); 79 + float: left; 80 + height: 0; 81 + pointer-events: none; 82 + } 83 + </style>
+3
packages/text/src/lib/components/core-rich-text-editor/index.ts
··· 1 + export { default as CoreRichTextEditor } from './CoreRichTextEditor.svelte'; 2 + 3 + export type { BaseEditorProps } from './types';
+23
packages/text/src/lib/components/core-rich-text-editor/types.ts
··· 1 + import type { Snippet } from 'svelte'; 2 + import type { Readable } from 'svelte/store'; 3 + import type { Editor } from 'svelte-tiptap'; 4 + import type { Content, Extensions } from '@tiptap/core'; 5 + import type { LinkAddedEvent } from '../link/helpers'; 6 + 7 + export interface BaseEditorProps { 8 + content?: Content; 9 + editor?: Readable<Editor>; 10 + ref?: HTMLDivElement | null; 11 + class?: string; 12 + /** Placeholder text shown when the editor is empty. */ 13 + placeholder?: string; 14 + /** Enable Markdown support: parse Markdown input and serialize content back to Markdown. */ 15 + markdown?: boolean; 16 + onupdate?: (content: Content | string) => void; 17 + ontransaction?: () => void; 18 + /** Called whenever a link is added to the editor. Only applies when using default extensions. */ 19 + onlinkadded?: (event: LinkAddedEvent) => void; 20 + extensions?: Extensions; 21 + extraExtensions?: Extensions; 22 + children?: Snippet; 23 + }
+217
packages/text/src/lib/components/embed/EmbedExtension.ts
··· 1 + import { Extension, Node, mergeAttributes } from '@tiptap/core'; 2 + import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'; 3 + import { SvelteNodeViewRenderer } from 'svelte-tiptap'; 4 + import type { EmbedDefinition, EmbedExtensionOptions } from './helpers'; 5 + import { isUrl } from './helpers'; 6 + import EmbedView from './EmbedView.svelte'; 7 + 8 + const embedPluginKey = new PluginKey('embedExtension'); 9 + 10 + const EmbedNode = Node.create({ 11 + name: 'embed', 12 + group: 'block', 13 + atom: true, 14 + draggable: true, 15 + selectable: true, 16 + inline: false, 17 + 18 + addAttributes() { 19 + return { 20 + url: { default: null }, 21 + embedName: { default: null }, 22 + embedData: { default: {} } 23 + }; 24 + }, 25 + 26 + parseHTML() { 27 + return [{ tag: 'div[data-type="embed"]' }]; 28 + }, 29 + 30 + renderHTML({ HTMLAttributes }) { 31 + return ['div', mergeAttributes({ 'data-type': 'embed' }, HTMLAttributes)]; 32 + }, 33 + 34 + addNodeView() { 35 + return SvelteNodeViewRenderer(EmbedView); 36 + } 37 + }); 38 + 39 + /** 40 + * Try to match a URL against an array of embed definitions. 41 + * Returns the first match or null. 42 + */ 43 + function tryMatch( 44 + url: string, 45 + embeds: EmbedDefinition[] 46 + ): { embed: EmbedDefinition; data: Record<string, any> } | null { 47 + for (const embed of embeds) { 48 + const data = embed.match(url); 49 + if (data && typeof data === 'object') { 50 + return { embed, data }; 51 + } 52 + } 53 + return null; 54 + } 55 + 56 + /** 57 + * Extract a URL from a paragraph node if it consists of only a link. 58 + * Returns the URL string or null. 59 + */ 60 + function getUrlFromParagraph(node: any): string | null { 61 + if (node.type.name !== 'paragraph') return null; 62 + if (node.childCount !== 1) return null; 63 + 64 + const child = node.firstChild; 65 + if (!child?.isText) return null; 66 + 67 + // Check for a link mark 68 + const linkMark = child.marks.find((m: any) => m.type.name === 'link'); 69 + if (!linkMark) return null; 70 + 71 + const href = linkMark.attrs.href; 72 + const text = child.text?.trim(); 73 + 74 + // Only match if the paragraph text is the URL itself (not e.g. [text](url)) 75 + if (text === href || text === href.replace(/^https?:\/\//, '')) { 76 + return href; 77 + } 78 + 79 + return null; 80 + } 81 + 82 + export const EmbedExtension = Extension.create<EmbedExtensionOptions>({ 83 + name: 'embedExtension', 84 + 85 + addOptions() { 86 + return { 87 + embeds: [] 88 + }; 89 + }, 90 + 91 + addStorage() { 92 + return { 93 + embeds: [] as EmbedDefinition[], 94 + openModal: null as (() => void) | null 95 + }; 96 + }, 97 + 98 + onCreate() { 99 + this.storage.embeds = this.options.embeds; 100 + }, 101 + 102 + addExtensions() { 103 + return [EmbedNode]; 104 + }, 105 + 106 + addProseMirrorPlugins() { 107 + const embeds = this.options.embeds; 108 + 109 + return [ 110 + new Plugin({ 111 + key: embedPluginKey, 112 + 113 + props: { 114 + /** 115 + * When a URL is pasted as plain text, check if it matches 116 + * an embed and insert an embed node instead. 117 + */ 118 + handlePaste(view, event) { 119 + const text = event.clipboardData?.getData('text/plain')?.trim(); 120 + if (!text || !isUrl(text)) return false; 121 + 122 + // Don't intercept if there's also HTML content (rich paste) 123 + const html = event.clipboardData?.getData('text/html'); 124 + if (html) return false; 125 + 126 + const result = tryMatch(text, embeds); 127 + if (!result) return false; 128 + 129 + event.preventDefault(); 130 + 131 + const node = view.state.schema.nodes.embed.create({ 132 + url: text, 133 + embedName: result.embed.name, 134 + embedData: result.data 135 + }); 136 + 137 + const { tr } = view.state; 138 + tr.replaceSelectionWith(node); 139 + 140 + // Ensure there's a paragraph after the embed and place cursor there 141 + const afterEmbed = tr.selection.to; 142 + const $after = tr.doc.resolve(afterEmbed); 143 + const hasBlockAfter = 144 + $after.nodeAfter && $after.nodeAfter.isTextblock; 145 + 146 + if (!hasBlockAfter) { 147 + const paragraph = 148 + view.state.schema.nodes.paragraph.create(); 149 + tr.insert(afterEmbed, paragraph); 150 + } 151 + 152 + // Place cursor in the paragraph after the embed 153 + tr.setSelection( 154 + TextSelection.create(tr.doc, afterEmbed + 1) 155 + ); 156 + 157 + view.dispatch(tr); 158 + return true; 159 + } 160 + }, 161 + 162 + /** 163 + * After each document change, scan for paragraphs that are just 164 + * a link URL matching an embed. Only convert if the cursor is not 165 + * inside that paragraph (i.e. the user has moved on). 166 + */ 167 + appendTransaction(transactions, _oldState, newState) { 168 + if (!transactions.some((tr) => tr.docChanged)) return null; 169 + 170 + const { from } = newState.selection; 171 + const replacements: Array<{ 172 + pos: number; 173 + size: number; 174 + url: string; 175 + embed: EmbedDefinition; 176 + data: Record<string, any>; 177 + }> = []; 178 + 179 + newState.doc.descendants((node, pos) => { 180 + // Skip non-paragraph nodes 181 + const url = getUrlFromParagraph(node); 182 + if (!url) return; 183 + 184 + // Skip if cursor is inside this paragraph 185 + const nodeEnd = pos + node.nodeSize; 186 + if (from >= pos && from <= nodeEnd) return; 187 + 188 + const result = tryMatch(url, embeds); 189 + if (!result) return; 190 + 191 + replacements.push({ 192 + pos, 193 + size: node.nodeSize, 194 + url, 195 + embed: result.embed, 196 + data: result.data 197 + }); 198 + }); 199 + 200 + if (replacements.length === 0) return null; 201 + 202 + const tr = newState.tr; 203 + // Apply in reverse order to keep positions stable 204 + for (const r of replacements.reverse()) { 205 + const embedNode = newState.schema.nodes.embed.create({ 206 + url: r.url, 207 + embedName: r.embed.name, 208 + embedData: r.data 209 + }); 210 + tr.replaceWith(r.pos, r.pos + r.size, embedNode); 211 + } 212 + return tr; 213 + } 214 + }) 215 + ]; 216 + } 217 + });
+67
packages/text/src/lib/components/embed/EmbedModal.svelte
··· 1 + <script lang="ts"> 2 + import type { Editor } from '@tiptap/core'; 3 + import { Modal, Button, Input } from '@foxui/core'; 4 + import { insertEmbed } from './helpers'; 5 + 6 + let { editor, open = $bindable(false) }: { editor: Editor; open: boolean } = $props(); 7 + 8 + let url = $state(''); 9 + let error = $state(''); 10 + 11 + $effect(() => { 12 + if (!editor) return; 13 + const storage = editor.storage.embedExtension; 14 + if (storage) { 15 + storage.openModal = () => { 16 + open = true; 17 + }; 18 + return () => { 19 + storage.openModal = null; 20 + }; 21 + } 22 + }); 23 + 24 + $effect(() => { 25 + if (open) { 26 + url = ''; 27 + error = ''; 28 + } 29 + }); 30 + 31 + function submit() { 32 + const result = insertEmbed(editor, url); 33 + if (result === null) { 34 + close(); 35 + } else { 36 + error = result; 37 + } 38 + } 39 + 40 + function close() { 41 + url = ''; 42 + error = ''; 43 + open = false; 44 + } 45 + </script> 46 + 47 + <Modal bind:open> 48 + <h3 class="text-lg font-semibold">Add Embed</h3> 49 + <Input 50 + type="text" 51 + placeholder="Paste a URL..." 52 + bind:value={url} 53 + onkeydown={(e: KeyboardEvent) => { 54 + if (e.key === 'Enter') { 55 + e.preventDefault(); 56 + submit(); 57 + } 58 + }} 59 + /> 60 + {#if error} 61 + <p class="text-sm text-red-500">{error}</p> 62 + {/if} 63 + <div class="flex justify-end gap-2"> 64 + <Button variant="secondary" onclick={close}>Cancel</Button> 65 + <Button variant="primary" onclick={submit}>Add</Button> 66 + </div> 67 + </Modal>
+37
packages/text/src/lib/components/embed/EmbedView.svelte
··· 1 + <script lang="ts"> 2 + import type { NodeViewProps } from '@tiptap/core'; 3 + import { NodeViewWrapper } from 'svelte-tiptap'; 4 + import type { EmbedDefinition } from './helpers'; 5 + 6 + let props: NodeViewProps = $props(); 7 + 8 + let embed: EmbedDefinition | undefined = $derived( 9 + (props.editor.storage.embedExtension?.embeds as EmbedDefinition[] | undefined)?.find( 10 + (e: EmbedDefinition) => e.name === props.node.attrs.embedName 11 + ) 12 + ); 13 + 14 + let url: string = $derived(props.node.attrs.url); 15 + 16 + let embedData: Record<string, any> = $derived.by(() => { 17 + const raw = props.node.attrs.embedData; 18 + if (!raw) return {}; 19 + if (typeof raw === 'string') { 20 + try { 21 + return JSON.parse(raw); 22 + } catch { 23 + return {}; 24 + } 25 + } 26 + return raw; 27 + }); 28 + </script> 29 + 30 + <NodeViewWrapper> 31 + {#if embed} 32 + {@const EmbedComponent = embed.component} 33 + <EmbedComponent {url} data={embedData} selected={props.selected} /> 34 + {:else} 35 + <a href={url} target="_blank" rel="noopener noreferrer">{url}</a> 36 + {/if} 37 + </NodeViewWrapper>
+14
packages/text/src/lib/components/embed/YouTubeEmbed.svelte
··· 1 + <script lang="ts"> 2 + let { url, data, selected }: { url: string; data: Record<string, any>; selected: boolean } = 3 + $props(); 4 + </script> 5 + 6 + <div class="w-full rounded-xl {selected ? 'ring-2 ring-accent-500' : ''}"> 7 + <iframe 8 + src="https://www.youtube-nocookie.com/embed/{data.videoId}" 9 + title={url} 10 + class="aspect-video w-full rounded-xl border-0" 11 + allowfullscreen 12 + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" 13 + ></iframe> 14 + </div>
+113
packages/text/src/lib/components/embed/helpers.ts
··· 1 + import type { Editor } from '@tiptap/core'; 2 + import type { Component } from 'svelte'; 3 + import YouTubeEmbed from './YouTubeEmbed.svelte'; 4 + 5 + declare module '@tiptap/core' { 6 + interface Storage { 7 + embedExtension: { 8 + embeds: EmbedDefinition[]; 9 + openModal: (() => void) | null; 10 + }; 11 + } 12 + } 13 + 14 + /** 15 + * Defines an embed provider. Each embed has a name, a match function 16 + * that extracts data from a URL, and a Svelte component to render. 17 + */ 18 + export interface EmbedDefinition { 19 + /** Unique name for this embed type (e.g. 'youtube', 'spotify'). */ 20 + name: string; 21 + /** Return embed-specific data if the URL matches, or false/null to skip. */ 22 + match: (url: string) => Record<string, any> | false | null; 23 + /** Svelte component to render. Receives props: url, data, selected. */ 24 + component: Component; 25 + } 26 + 27 + export interface EmbedExtensionOptions { 28 + /** Array of embed definitions to check URLs against. */ 29 + embeds: EmbedDefinition[]; 30 + } 31 + 32 + /** Props passed to every embed component. */ 33 + export interface EmbedComponentProps { 34 + /** The original URL. */ 35 + url: string; 36 + /** Data returned by the embed's match function. */ 37 + data: Record<string, any>; 38 + /** Whether the node is currently selected in the editor. */ 39 + selected: boolean; 40 + } 41 + 42 + const youtubeRegex = 43 + /(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/; 44 + 45 + /** 46 + * Built-in YouTube embed. Matches youtube.com/watch, youtu.be, youtube.com/embed, and youtube.com/shorts URLs. 47 + */ 48 + export const youtubeEmbed: EmbedDefinition = { 49 + name: 'youtube', 50 + match: (url: string) => { 51 + const m = url.match(youtubeRegex); 52 + if (!m) return false; 53 + return { videoId: m[1] }; 54 + }, 55 + component: YouTubeEmbed as Component 56 + }; 57 + 58 + /** 59 + * Simple URL check — just verifies the string starts with http(s)://. 60 + */ 61 + export function isUrl(text: string): boolean { 62 + try { 63 + const url = new URL(text); 64 + return url.protocol === 'http:' || url.protocol === 'https:'; 65 + } catch { 66 + return false; 67 + } 68 + } 69 + 70 + /** 71 + * Try to insert an embed for the given URL. Returns an error string if no 72 + * embed matches, or null on success. 73 + */ 74 + export function insertEmbed(editor: Editor, url: string): string | null { 75 + const storage = editor.storage.embedExtension as 76 + | { embeds: EmbedDefinition[] } 77 + | undefined; 78 + if (!storage) return 'Embed extension is not registered'; 79 + 80 + const trimmed = url.trim(); 81 + if (!isUrl(trimmed)) return 'Please enter a valid URL'; 82 + 83 + for (const embed of storage.embeds) { 84 + const data = embed.match(trimmed); 85 + if (data && typeof data === 'object') { 86 + editor 87 + .chain() 88 + .focus() 89 + .insertContent({ 90 + type: 'embed', 91 + attrs: { 92 + url: trimmed, 93 + embedName: embed.name, 94 + embedData: data 95 + } 96 + }) 97 + .run(); 98 + return null; 99 + } 100 + } 101 + 102 + return 'No matching embed found for this URL'; 103 + } 104 + 105 + /** 106 + * Open the embed modal if an EmbedModal component is mounted. 107 + */ 108 + export function openEmbedModal(editor: Editor): void { 109 + const storage = editor.storage.embedExtension as 110 + | { openModal: (() => void) | null } 111 + | undefined; 112 + storage?.openModal?.(); 113 + }
+5
packages/text/src/lib/components/embed/index.ts
··· 1 + export { EmbedExtension } from './EmbedExtension'; 2 + export { default as EmbedModal } from './EmbedModal.svelte'; 3 + export { default as YouTubeEmbed } from './YouTubeEmbed.svelte'; 4 + export { youtubeEmbed, isUrl, insertEmbed, openEmbedModal } from './helpers'; 5 + export type { EmbedDefinition, EmbedExtensionOptions, EmbedComponentProps } from './helpers';
+43
packages/text/src/lib/components/formatting/BoldToggle.svelte
··· 1 + <script lang="ts"> 2 + import { Toggle } from '@foxui/core'; 3 + import type { Editor } from 'svelte-tiptap'; 4 + import type { Readable } from 'svelte/store'; 5 + 6 + let { 7 + editor = $bindable(), 8 + isBold = $bindable(false) 9 + }: { 10 + editor: Readable<Editor>; 11 + isBold?: boolean; 12 + } = $props(); 13 + 14 + function updateState() { 15 + isBold = $editor.isActive('bold'); 16 + } 17 + 18 + $effect(() => { 19 + if (!$editor) return; 20 + 21 + $editor.on('transaction', updateState); 22 + updateState(); 23 + 24 + return () => { 25 + $editor.off('transaction', updateState); 26 + }; 27 + }); 28 + </script> 29 + 30 + <Toggle 31 + size="sm" 32 + onclick={() => $editor.chain().focus().toggleBold().run()} 33 + bind:pressed={() => isBold, () => {}} 34 + > 35 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 36 + <path 37 + fill-rule="evenodd" 38 + d="M5.246 3.744a.75.75 0 0 1 .75-.75h7.125a4.875 4.875 0 0 1 3.346 8.422 5.25 5.25 0 0 1-2.97 9.58h-7.5a.75.75 0 0 1-.75-.75V3.744Zm7.125 6.75a2.625 2.625 0 0 0 0-5.25H8.246v5.25h4.125Zm-4.125 2.251v6h4.5a3 3 0 0 0 0-6h-4.5Z" 39 + clip-rule="evenodd" 40 + /> 41 + </svg> 42 + <span class="sr-only">Bold</span> 43 + </Toggle>
+43
packages/text/src/lib/components/formatting/ItalicToggle.svelte
··· 1 + <script lang="ts"> 2 + import { Toggle } from '@foxui/core'; 3 + import type { Editor } from 'svelte-tiptap'; 4 + import type { Readable } from 'svelte/store'; 5 + 6 + let { 7 + editor = $bindable(), 8 + isItalic = $bindable(false) 9 + }: { 10 + editor: Readable<Editor>; 11 + isItalic?: boolean; 12 + } = $props(); 13 + 14 + function updateState() { 15 + isItalic = $editor.isActive('italic'); 16 + } 17 + 18 + $effect(() => { 19 + if (!$editor) return; 20 + 21 + $editor.on('transaction', updateState); 22 + updateState(); 23 + 24 + return () => { 25 + $editor.off('transaction', updateState); 26 + }; 27 + }); 28 + </script> 29 + 30 + <Toggle 31 + size="sm" 32 + onclick={() => $editor.chain().focus().toggleItalic().run()} 33 + bind:pressed={() => isItalic, () => {}} 34 + > 35 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 36 + <path 37 + fill-rule="evenodd" 38 + d="M10.497 3.744a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-3.275l-5.357 15.002h2.632a.75.75 0 1 1 0 1.5h-7.5a.75.75 0 1 1 0-1.5h3.275l5.357-15.002h-2.632a.75.75 0 0 1-.75-.75Z" 39 + clip-rule="evenodd" 40 + /> 41 + </svg> 42 + <span class="sr-only">Italic</span> 43 + </Toggle>
+43
packages/text/src/lib/components/formatting/StrikethroughToggle.svelte
··· 1 + <script lang="ts"> 2 + import { Toggle } from '@foxui/core'; 3 + import type { Editor } from 'svelte-tiptap'; 4 + import type { Readable } from 'svelte/store'; 5 + 6 + let { 7 + editor = $bindable(), 8 + isStrikethrough = $bindable(false) 9 + }: { 10 + editor: Readable<Editor>; 11 + isStrikethrough?: boolean; 12 + } = $props(); 13 + 14 + function updateState() { 15 + isStrikethrough = $editor.isActive('strike'); 16 + } 17 + 18 + $effect(() => { 19 + if (!$editor) return; 20 + 21 + $editor.on('transaction', updateState); 22 + updateState(); 23 + 24 + return () => { 25 + $editor.off('transaction', updateState); 26 + }; 27 + }); 28 + </script> 29 + 30 + <Toggle 31 + size="sm" 32 + onclick={() => $editor.chain().focus().toggleStrike().run()} 33 + bind:pressed={() => isStrikethrough, () => {}} 34 + > 35 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 36 + <path 37 + fill-rule="evenodd" 38 + d="M9.657 4.728c-1.086.385-1.766 1.057-1.979 1.85-.214.8.046 1.733.81 2.616.746.862 1.93 1.612 3.388 2.003.07.019.14.037.21.053h8.163a.75.75 0 0 1 0 1.5h-8.24a.66.66 0 0 1-.02 0H3.75a.75.75 0 0 1 0-1.5h4.78a7.108 7.108 0 0 1-1.175-1.074C6.372 9.042 5.849 7.61 6.229 6.19c.377-1.408 1.528-2.38 2.927-2.876 1.402-.497 3.127-.55 4.855-.086A8.937 8.937 0 0 1 16.94 4.6a.75.75 0 0 1-.881 1.215 7.437 7.437 0 0 0-2.436-1.14c-1.473-.394-2.885-.331-3.966.052Zm6.533 9.632a.75.75 0 0 1 1.03.25c.592.974.846 2.094.55 3.2-.378 1.408-1.529 2.38-2.927 2.876-1.402.497-3.127.55-4.855.087-1.712-.46-3.168-1.354-4.134-2.47a.75.75 0 0 1 1.134-.982c.746.862 1.93 1.612 3.388 2.003 1.473.394 2.884.331 3.966-.052 1.085-.384 1.766-1.056 1.978-1.85.169-.628.046-1.33-.381-2.032a.75.75 0 0 1 .25-1.03Z" 39 + clip-rule="evenodd" 40 + /> 41 + </svg> 42 + <span class="sr-only">Strikethrough</span> 43 + </Toggle>
+43
packages/text/src/lib/components/formatting/UnderlineToggle.svelte
··· 1 + <script lang="ts"> 2 + import { Toggle } from '@foxui/core'; 3 + import type { Editor } from 'svelte-tiptap'; 4 + import type { Readable } from 'svelte/store'; 5 + 6 + let { 7 + editor = $bindable(), 8 + isUnderline = $bindable(false) 9 + }: { 10 + editor: Readable<Editor>; 11 + isUnderline?: boolean; 12 + } = $props(); 13 + 14 + function updateState() { 15 + isUnderline = $editor.isActive('underline'); 16 + } 17 + 18 + $effect(() => { 19 + if (!$editor) return; 20 + 21 + $editor.on('transaction', updateState); 22 + updateState(); 23 + 24 + return () => { 25 + $editor.off('transaction', updateState); 26 + }; 27 + }); 28 + </script> 29 + 30 + <Toggle 31 + size="sm" 32 + onclick={() => $editor.chain().focus().toggleUnderline().run()} 33 + bind:pressed={() => isUnderline, () => {}} 34 + > 35 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 36 + <path 37 + fill-rule="evenodd" 38 + d="M5.995 2.994a.75.75 0 0 1 .75.75v7.5a5.25 5.25 0 1 0 10.5 0v-7.5a.75.75 0 0 1 1.5 0v7.5a6.75 6.75 0 1 1-13.5 0v-7.5a.75.75 0 0 1 .75-.75Zm-3 17.252a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5h-16.5a.75.75 0 0 1-.75-.75Z" 39 + clip-rule="evenodd" 40 + /> 41 + </svg> 42 + <span class="sr-only">Underline</span> 43 + </Toggle>
+4
packages/text/src/lib/components/formatting/index.ts
··· 1 + export { default as BoldToggle } from './BoldToggle.svelte'; 2 + export { default as ItalicToggle } from './ItalicToggle.svelte'; 3 + export { default as UnderlineToggle } from './UnderlineToggle.svelte'; 4 + export { default as StrikethroughToggle } from './StrikethroughToggle.svelte';
+41
packages/text/src/lib/components/hashtag-decoration/HashtagDecoration.ts
··· 1 + import { Extension } from '@tiptap/core'; 2 + import { Plugin } from '@tiptap/pm/state'; 3 + import { Decoration, DecorationSet } from '@tiptap/pm/view'; 4 + 5 + export const HashtagDecoration = Extension.create({ 6 + name: 'hashtagDecoration', 7 + 8 + addProseMirrorPlugins() { 9 + return [ 10 + new Plugin({ 11 + props: { 12 + decorations(state) { 13 + const decorations: Decoration[] = []; 14 + const { doc } = state; 15 + 16 + doc.descendants((node, pos) => { 17 + if (!node.isText || !node.text) return; 18 + 19 + const regex = /(?:^|\s)#([a-zA-Z\u00C0-\u024F\u1E00-\u1EFF][\w\u00C0-\u024F\u1E00-\u1EFF]*)/g; 20 + let match; 21 + 22 + while ((match = regex.exec(node.text)) !== null) { 23 + const hashPos = match.index + match[0].indexOf('#'); 24 + const from = pos + hashPos; 25 + const to = pos + match.index + match[0].length; 26 + 27 + decorations.push( 28 + Decoration.inline(from, to, { 29 + class: 'hashtag' 30 + }) 31 + ); 32 + } 33 + }); 34 + 35 + return DecorationSet.create(doc, decorations); 36 + } 37 + } 38 + }) 39 + ]; 40 + } 41 + });
+1
packages/text/src/lib/components/hashtag-decoration/index.ts
··· 1 + export { HashtagDecoration } from './HashtagDecoration';
+45
packages/text/src/lib/components/image/EditableImage.svelte
··· 1 + <script lang="ts"> 2 + import type { NodeViewProps } from '@tiptap/core'; 3 + import { NodeViewWrapper } from 'svelte-tiptap'; 4 + import { Modal, Textarea, Button } from '@foxui/core'; 5 + 6 + let props: NodeViewProps = $props(); 7 + 8 + let modalOpen = $state(false); 9 + let altText = $state(props.node.attrs.alt ?? ''); 10 + 11 + function openModal() { 12 + altText = props.node.attrs.alt ?? ''; 13 + modalOpen = true; 14 + } 15 + 16 + function save() { 17 + props.updateAttributes({ alt: altText }); 18 + modalOpen = false; 19 + } 20 + 21 + function cancel() { 22 + modalOpen = false; 23 + } 24 + </script> 25 + 26 + <NodeViewWrapper class="group/image relative"> 27 + <img 28 + src={props.node.attrs.src} 29 + alt={props.node.attrs.alt} 30 + class="max-w-full rounded-xl {props.selected ? 'ring-accent-500 ring-2' : ''}" 31 + /> 32 + 33 + <div class="absolute right-2 bottom-2 opacity-0 transition-opacity group-hover/image:opacity-100"> 34 + <Button size="sm" variant="secondary" onclick={openModal}>ALT</Button> 35 + </div> 36 + 37 + <Modal bind:open={modalOpen}> 38 + <h3 class="text-lg font-semibold">Edit alt text</h3> 39 + <Textarea bind:value={altText} placeholder="Describe this image..." /> 40 + <div class="flex justify-end gap-2"> 41 + <Button variant="secondary" onclick={cancel}>Cancel</Button> 42 + <Button variant="primary" onclick={save}>Save</Button> 43 + </div> 44 + </Modal> 45 + </NodeViewWrapper>
+199
packages/text/src/lib/components/image/ImageExtension.ts
··· 1 + import { Extension, Node, mergeAttributes } from '@tiptap/core'; 2 + import { Plugin } from '@tiptap/pm/state'; 3 + import Image from '@tiptap/extension-image'; 4 + import { SvelteNodeViewRenderer } from 'svelte-tiptap'; 5 + import type { ImageExtensionOptions, UploadState } from './helpers'; 6 + import { insertImage } from './helpers'; 7 + import ImageUploadView from './ImageUploadView.svelte'; 8 + 9 + declare module '@tiptap/core' { 10 + interface Commands<ReturnType> { 11 + imageExtension: { 12 + insertImageUpload: (file: File) => ReturnType; 13 + }; 14 + } 15 + } 16 + 17 + const ImageUploadNode = Node.create({ 18 + name: 'imageUpload', 19 + group: 'block', 20 + atom: true, 21 + draggable: false, 22 + selectable: true, 23 + inline: false, 24 + 25 + addAttributes() { 26 + return { 27 + uploadId: { default: null }, 28 + preview: { default: null } 29 + }; 30 + }, 31 + 32 + parseHTML() { 33 + return [{ tag: 'div[data-type="image-upload"]' }]; 34 + }, 35 + 36 + renderHTML({ HTMLAttributes }) { 37 + return ['div', mergeAttributes({ 'data-type': 'image-upload' }, HTMLAttributes)]; 38 + }, 39 + 40 + addNodeView() { 41 + return SvelteNodeViewRenderer(ImageUploadView); 42 + } 43 + }); 44 + 45 + export const ImageExtension = Extension.create<ImageExtensionOptions>({ 46 + name: 'imageExtension', 47 + 48 + addOptions() { 49 + return { 50 + upload: () => Promise.reject(new Error('No upload function configured')), 51 + accept: 'image/*', 52 + maxFileSize: 0, 53 + onError: undefined, 54 + HTMLAttributes: {}, 55 + imageComponent: undefined 56 + }; 57 + }, 58 + 59 + addStorage() { 60 + return { 61 + uploads: new Map<string, UploadState>() 62 + }; 63 + }, 64 + 65 + addExtensions() { 66 + const imageComponent = this.options.imageComponent; 67 + const imageExtension = imageComponent 68 + ? Image.extend({ 69 + addNodeView() { 70 + return SvelteNodeViewRenderer(imageComponent); 71 + } 72 + }).configure({ 73 + HTMLAttributes: this.options.HTMLAttributes ?? {} 74 + }) 75 + : Image.configure({ 76 + HTMLAttributes: this.options.HTMLAttributes ?? {} 77 + }); 78 + 79 + return [imageExtension, ImageUploadNode]; 80 + }, 81 + 82 + addCommands() { 83 + return { 84 + insertImageUpload: 85 + (file: File) => 86 + ({ editor }) => { 87 + insertImage(editor, file); 88 + return true; 89 + } 90 + }; 91 + }, 92 + 93 + addProseMirrorPlugins() { 94 + const editor = this.editor; 95 + const extensionOptions = this.options; 96 + 97 + return [ 98 + new Plugin({ 99 + props: { 100 + handlePaste(view, event) { 101 + const items = event.clipboardData?.items; 102 + if (!items) return false; 103 + 104 + for (const item of Array.from(items)) { 105 + if (!item.type.startsWith('image/')) continue; 106 + const file = item.getAsFile(); 107 + if (!file) continue; 108 + 109 + event.preventDefault(); 110 + insertImage(editor, file); 111 + return true; 112 + } 113 + 114 + return false; 115 + }, 116 + 117 + handleDrop(view, event, _slice, moved) { 118 + if (moved) return false; 119 + 120 + const files = event.dataTransfer?.files; 121 + if (!files?.length) return false; 122 + 123 + const file = files[0]; 124 + if (!file?.type.startsWith('image/')) return false; 125 + 126 + event.preventDefault(); 127 + 128 + const coords = view.posAtCoords({ 129 + left: event.clientX, 130 + top: event.clientY 131 + }); 132 + 133 + if (!coords) return false; 134 + 135 + const storage = editor.storage.imageExtension as 136 + | { uploads: Map<string, UploadState> } 137 + | undefined; 138 + if (!storage) return false; 139 + 140 + // Validate file type 141 + if ( 142 + extensionOptions.accept && 143 + extensionOptions.accept !== 'image/*' 144 + ) { 145 + const accepted = extensionOptions.accept 146 + .split(',') 147 + .map((s) => s.trim()); 148 + const matches = accepted.some((pattern) => { 149 + if (pattern.startsWith('.')) { 150 + return file.name.toLowerCase().endsWith(pattern.toLowerCase()); 151 + } 152 + if (pattern.endsWith('/*')) { 153 + return file.type.startsWith(pattern.replace('/*', '/')); 154 + } 155 + return file.type === pattern; 156 + }); 157 + if (!matches) { 158 + extensionOptions.onError?.( 159 + new Error(`File type "${file.type}" is not accepted`) 160 + ); 161 + return true; 162 + } 163 + } 164 + 165 + // Validate file size 166 + if ( 167 + extensionOptions.maxFileSize && 168 + extensionOptions.maxFileSize > 0 && 169 + file.size > extensionOptions.maxFileSize 170 + ) { 171 + extensionOptions.onError?.( 172 + new Error( 173 + `File size ${file.size} exceeds maximum ${extensionOptions.maxFileSize} bytes` 174 + ) 175 + ); 176 + return true; 177 + } 178 + 179 + const uploadId = crypto.randomUUID(); 180 + const previewUrl = URL.createObjectURL(file); 181 + const abortController = new AbortController(); 182 + 183 + storage.uploads.set(uploadId, { file, previewUrl, abortController }); 184 + 185 + const { tr } = view.state; 186 + const node = view.state.schema.nodes.imageUpload.create({ 187 + uploadId, 188 + preview: previewUrl 189 + }); 190 + 191 + view.dispatch(tr.insert(coords.pos, node)); 192 + 193 + return true; 194 + } 195 + } 196 + }) 197 + ]; 198 + } 199 + });
+103
packages/text/src/lib/components/image/ImageUploadView.svelte
··· 1 + <script lang="ts"> 2 + import type { NodeViewProps } from '@tiptap/core'; 3 + import { onMount } from 'svelte'; 4 + import { NodeViewWrapper } from 'svelte-tiptap'; 5 + import type { ImageExtensionOptions, UploadState } from './helpers'; 6 + 7 + let props: NodeViewProps = $props(); 8 + 9 + let progress = $state(0); 10 + let error = $state<string | null>(null); 11 + let uploading = $state(true); 12 + 13 + onMount(() => { 14 + const { uploadId } = props.node.attrs; 15 + const storage = props.editor.storage.imageExtension as { 16 + uploads: Map<string, UploadState>; 17 + }; 18 + const uploadState = storage.uploads.get(uploadId); 19 + if (!uploadState) { 20 + error = 'Upload state not found'; 21 + uploading = false; 22 + return; 23 + } 24 + 25 + const { file, abortController } = uploadState; 26 + 27 + const options = props.editor.extensionManager.extensions.find( 28 + (ext) => ext.name === 'imageExtension' 29 + )?.options as ImageExtensionOptions | undefined; 30 + 31 + const uploadFn = options?.upload; 32 + if (!uploadFn) { 33 + error = 'No upload function configured'; 34 + uploading = false; 35 + return; 36 + } 37 + 38 + let cancelled = false; 39 + 40 + uploadFn( 41 + file, 42 + (p) => { 43 + if (!cancelled) progress = p; 44 + }, 45 + abortController.signal 46 + ) 47 + .then((returnedUrl) => { 48 + if (cancelled) return; 49 + uploading = false; 50 + 51 + const pos = props.getPos(); 52 + props.deleteNode(); 53 + props.editor 54 + .chain() 55 + .focus() 56 + .insertContentAt(pos, { 57 + type: 'image', 58 + attrs: { src: returnedUrl } 59 + }) 60 + .run(); 61 + }) 62 + .catch((err) => { 63 + if (cancelled) return; 64 + uploading = false; 65 + const message = err instanceof Error ? err.message : 'Upload failed'; 66 + error = message; 67 + options?.onError?.(err instanceof Error ? err : new Error(message)); 68 + }); 69 + 70 + return () => { 71 + cancelled = true; 72 + if (uploading) { 73 + abortController.abort(); 74 + } 75 + const state = storage.uploads.get(uploadId); 76 + if (state) { 77 + URL.revokeObjectURL(state.previewUrl); 78 + storage.uploads.delete(uploadId); 79 + } 80 + }; 81 + }); 82 + </script> 83 + 84 + <NodeViewWrapper class="relative"> 85 + <img src={props.node.attrs.preview} alt="Upload preview" class="max-w-full rounded" /> 86 + 87 + {#if uploading} 88 + <div class="absolute bottom-0 left-0 right-0 h-1 bg-accent-500/30"> 89 + <div 90 + class="h-full bg-accent-500 transition-all duration-300" 91 + style="width: {progress * 100}%" 92 + ></div> 93 + </div> 94 + {/if} 95 + 96 + {#if error} 97 + <div 98 + class="absolute inset-0 flex items-center justify-center rounded bg-red-500/20 text-sm text-red-600" 99 + > 100 + {error} 101 + </div> 102 + {/if} 103 + </NodeViewWrapper>
+120
packages/text/src/lib/components/image/helpers.ts
··· 1 + import type { Editor } from '@tiptap/core'; 2 + import type { Component } from 'svelte'; 3 + 4 + declare module '@tiptap/core' { 5 + interface Storage { 6 + imageExtension: { 7 + uploads: Map<string, UploadState>; 8 + }; 9 + } 10 + } 11 + 12 + /** 13 + * Upload function signature for image uploads. 14 + * @param file - The image file to upload 15 + * @param onProgress - Progress callback (0..1) 16 + * @param abortSignal - Signal to abort the upload 17 + * @returns The final URL of the uploaded image 18 + */ 19 + export type ImageUploadFn = ( 20 + file: File, 21 + onProgress: (progress: number) => void, 22 + abortSignal: AbortSignal 23 + ) => Promise<string>; 24 + 25 + export interface ImageExtensionOptions { 26 + upload: ImageUploadFn; 27 + /** Accepted file types. @default 'image/*' */ 28 + accept?: string; 29 + /** Maximum file size in bytes. 0 = unlimited. @default 0 */ 30 + maxFileSize?: number; 31 + /** Error handler for upload failures */ 32 + onError?: (error: Error) => void; 33 + /** HTML attributes applied to final <img> elements */ 34 + HTMLAttributes?: Record<string, string>; 35 + /** Custom Svelte component for rendering final images. Receives NodeViewProps from tiptap. */ 36 + imageComponent?: Component; 37 + } 38 + 39 + export interface UploadState { 40 + file: File; 41 + previewUrl: string; 42 + abortController: AbortController; 43 + } 44 + 45 + /** 46 + * Insert a file as an uploading image into the editor. 47 + */ 48 + export function insertImage(editor: Editor, file: File): void { 49 + const storage = editor.storage.imageExtension as 50 + | { uploads: Map<string, UploadState> } 51 + | undefined; 52 + if (!storage) return; 53 + 54 + const options = editor.extensionManager.extensions.find( 55 + (ext) => ext.name === 'imageExtension' 56 + )?.options as ImageExtensionOptions | undefined; 57 + 58 + if (options?.accept && options.accept !== 'image/*') { 59 + const accepted = options.accept.split(',').map((s) => s.trim()); 60 + const matches = accepted.some((pattern) => { 61 + if (pattern.startsWith('.')) { 62 + return file.name.toLowerCase().endsWith(pattern.toLowerCase()); 63 + } 64 + if (pattern.endsWith('/*')) { 65 + return file.type.startsWith(pattern.replace('/*', '/')); 66 + } 67 + return file.type === pattern; 68 + }); 69 + if (!matches) { 70 + options.onError?.(new Error(`File type "${file.type}" is not accepted`)); 71 + return; 72 + } 73 + } 74 + 75 + if (options?.maxFileSize && options.maxFileSize > 0 && file.size > options.maxFileSize) { 76 + options.onError?.( 77 + new Error( 78 + `File size ${file.size} exceeds maximum ${options.maxFileSize} bytes` 79 + ) 80 + ); 81 + return; 82 + } 83 + 84 + const uploadId = crypto.randomUUID(); 85 + const previewUrl = URL.createObjectURL(file); 86 + const abortController = new AbortController(); 87 + 88 + storage.uploads.set(uploadId, { file, previewUrl, abortController }); 89 + 90 + editor 91 + .chain() 92 + .focus() 93 + .insertContent({ 94 + type: 'imageUpload', 95 + attrs: { uploadId, preview: previewUrl } 96 + }) 97 + .run(); 98 + } 99 + 100 + /** 101 + * Open a native file picker and insert the selected image into the editor. 102 + */ 103 + export function pickAndInsertImage(editor: Editor): void { 104 + const options = editor.extensionManager.extensions.find( 105 + (ext) => ext.name === 'imageExtension' 106 + )?.options as ImageExtensionOptions | undefined; 107 + 108 + const input = document.createElement('input'); 109 + input.type = 'file'; 110 + input.accept = options?.accept ?? 'image/*'; 111 + 112 + input.addEventListener('change', () => { 113 + const file = input.files?.[0]; 114 + if (file) { 115 + insertImage(editor, file); 116 + } 117 + }); 118 + 119 + input.click(); 120 + }
+4
packages/text/src/lib/components/image/index.ts
··· 1 + export { ImageExtension } from './ImageExtension'; 2 + export { default as EditableImage } from './EditableImage.svelte'; 3 + export { insertImage, pickAndInsertImage } from './helpers'; 4 + export type { ImageUploadFn, ImageExtensionOptions } from './helpers';
+133
packages/text/src/lib/components/link/LinkExtension.ts
··· 1 + import { Extension, InputRule, markInputRule, markPasteRule, PasteRule } from '@tiptap/core'; 2 + import { Link } from '@tiptap/extension-link'; 3 + import { AddMarkStep } from '@tiptap/pm/transform'; 4 + import type { LinkExtensionOptions } from './helpers'; 5 + 6 + /** 7 + * Markdown link input regex: [text](url "title") 8 + */ 9 + const inputRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: [""\u201C](.+)[""\u201D])?\)$/i; 10 + 11 + /** 12 + * Markdown link paste regex: [text](url "title") 13 + */ 14 + const pasteRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: [""\u201C](.+)[""\u201D])?\)/gi; 15 + 16 + function linkInputRule(config: Parameters<typeof markInputRule>[0]) { 17 + const defaultMarkInputRule = markInputRule(config); 18 + return new InputRule({ 19 + find: config.find, 20 + handler(props) { 21 + const { tr } = props.state; 22 + defaultMarkInputRule.handler(props); 23 + tr.setMeta('preventAutolink', true); 24 + } 25 + }); 26 + } 27 + 28 + function linkPasteRule(config: Parameters<typeof markPasteRule>[0]) { 29 + const defaultMarkPasteRule = markPasteRule(config); 30 + return new PasteRule({ 31 + find: config.find, 32 + handler(props) { 33 + const { tr } = props.state; 34 + defaultMarkPasteRule.handler(props); 35 + tr.setMeta('preventAutolink', true); 36 + } 37 + }); 38 + } 39 + 40 + const defaultLinkClasses = 'text-accent-600 dark:text-accent-400'; 41 + 42 + export const LinkExtension = Extension.create<LinkExtensionOptions>({ 43 + name: 'linkExtension', 44 + 45 + addOptions() { 46 + return { 47 + openOnClick: false, 48 + autolink: true, 49 + defaultProtocol: 'https', 50 + markdown: true, 51 + HTMLAttributes: {} 52 + }; 53 + }, 54 + 55 + onTransaction({ transaction }) { 56 + const onlinkadded = this.options.onlinkadded; 57 + if (!onlinkadded || !transaction.docChanged) return; 58 + 59 + for (const step of transaction.steps) { 60 + if (step instanceof AddMarkStep && step.mark.type.name === 'link') { 61 + const text = transaction.doc.textBetween(step.from, step.to); 62 + onlinkadded({ href: step.mark.attrs.href, text, editor: this.editor }); 63 + } 64 + } 65 + }, 66 + 67 + addExtensions() { 68 + const opts = this.options; 69 + const htmlAttrs = { 70 + class: defaultLinkClasses, 71 + ...opts.HTMLAttributes 72 + }; 73 + const base = Link.configure({ 74 + openOnClick: opts.openOnClick ?? 'whenNotEditable', 75 + autolink: opts.autolink ?? true, 76 + defaultProtocol: opts.defaultProtocol ?? 'https', 77 + HTMLAttributes: htmlAttrs 78 + }); 79 + 80 + if (!opts.markdown) return [base]; 81 + 82 + const extended = Link.extend({ 83 + inclusive: false, 84 + addOptions() { 85 + return { 86 + ...this.parent?.(), 87 + openOnClick: opts.openOnClick ?? ('whenNotEditable' as const) 88 + } as ReturnType<NonNullable<typeof this.parent>>; 89 + }, 90 + addAttributes() { 91 + return { 92 + ...this.parent?.(), 93 + title: { default: null } 94 + }; 95 + }, 96 + addInputRules() { 97 + return [ 98 + linkInputRule({ 99 + find: inputRegex, 100 + type: this.type, 101 + getAttributes(match) { 102 + return { 103 + title: match.pop()?.trim(), 104 + href: match.pop()?.trim() 105 + }; 106 + } 107 + }) 108 + ]; 109 + }, 110 + addPasteRules() { 111 + return [ 112 + linkPasteRule({ 113 + find: pasteRegex, 114 + type: this.type, 115 + getAttributes(match) { 116 + return { 117 + title: match.pop()?.trim(), 118 + href: match.pop()?.trim() 119 + }; 120 + } 121 + }) 122 + ]; 123 + } 124 + }).configure({ 125 + openOnClick: opts.openOnClick ?? 'whenNotEditable', 126 + autolink: opts.autolink ?? true, 127 + defaultProtocol: opts.defaultProtocol ?? 'https', 128 + HTMLAttributes: htmlAttrs 129 + }); 130 + 131 + return [extended]; 132 + } 133 + });
+95
packages/text/src/lib/components/link/LinkPopover.svelte
··· 1 + <script lang="ts"> 2 + import { Toggle, Popover, Input, Button } from '@foxui/core'; 3 + import type { Editor } from 'svelte-tiptap'; 4 + import type { Readable } from 'svelte/store'; 5 + 6 + let { 7 + editor = $bindable(), 8 + class: className 9 + }: { 10 + editor: Readable<Editor>; 11 + class?: string; 12 + } = $props(); 13 + 14 + let isLink = $state(false); 15 + let popoverOpen = $state(false); 16 + let url = $state(''); 17 + 18 + function updateState() { 19 + isLink = $editor.isActive('link'); 20 + } 21 + 22 + $effect(() => { 23 + if (!$editor) return; 24 + 25 + $editor.on('transaction', updateState); 26 + updateState(); 27 + 28 + return () => { 29 + $editor.off('transaction', updateState); 30 + }; 31 + }); 32 + 33 + function handleToggleClick() { 34 + if (isLink) { 35 + url = $editor.getAttributes('link').href ?? ''; 36 + } else { 37 + url = ''; 38 + } 39 + popoverOpen = true; 40 + } 41 + 42 + function save() { 43 + if (url.trim()) { 44 + $editor.chain().focus().setLink({ href: url.trim() }).run(); 45 + } else { 46 + $editor.chain().focus().unsetLink().run(); 47 + } 48 + popoverOpen = false; 49 + } 50 + 51 + function remove() { 52 + $editor.chain().focus().unsetLink().run(); 53 + popoverOpen = false; 54 + } 55 + </script> 56 + 57 + <Popover bind:open={popoverOpen} side="top" sideOffset={8} class={className}> 58 + {#snippet child({ props })} 59 + <Toggle {...props} size="sm" pressed={isLink} onclick={handleToggleClick}> 60 + <svg 61 + xmlns="http://www.w3.org/2000/svg" 62 + viewBox="0 0 24 24" 63 + fill="currentColor" 64 + class="size-5" 65 + > 66 + <path 67 + fill-rule="evenodd" 68 + d="M19.902 4.098a3.75 3.75 0 0 0-5.304 0l-4.5 4.5a3.75 3.75 0 0 0 1.035 6.037.75.75 0 0 1-.646 1.353 5.25 5.25 0 0 1-1.449-8.45l4.5-4.5a5.25 5.25 0 1 1 7.424 7.424l-1.757 1.757a.75.75 0 1 1-1.06-1.06l1.757-1.758a3.75 3.75 0 0 0 0-5.304Zm-7.389 4.267a.75.75 0 0 1 1.04-.208 5.25 5.25 0 0 1 1.449 8.45l-4.5 4.5a5.25 5.25 0 1 1-7.424-7.424l1.757-1.757a.75.75 0 0 1 1.06 1.06l-1.757 1.757a3.75 3.75 0 1 0 5.304 5.304l4.5-4.5a3.75 3.75 0 0 0-1.035-6.037.75.75 0 0 1-.208-1.04Z" 69 + clip-rule="evenodd" 70 + /> 71 + </svg> 72 + <span class="sr-only">Link</span> 73 + </Toggle> 74 + {/snippet} 75 + 76 + <div class="flex flex-col gap-2"> 77 + <Input 78 + placeholder="https://..." 79 + bind:value={url} 80 + variant="secondary" 81 + sizeVariant="sm" 82 + onkeydown={(e: KeyboardEvent) => { 83 + if (e.key === 'Enter') { 84 + save(); 85 + } 86 + }} 87 + /> 88 + <div class="flex items-center gap-2"> 89 + {#if isLink} 90 + <Button variant="secondary" size="sm" onclick={remove}>Remove</Button> 91 + {/if} 92 + <Button variant="primary" size="sm" onclick={save}>Save</Button> 93 + </div> 94 + </div> 95 + </Popover>
+43
packages/text/src/lib/components/link/helpers.ts
··· 1 + import type { Editor } from '@tiptap/core'; 2 + 3 + export interface LinkAddedEvent { 4 + /** The link URL. */ 5 + href: string; 6 + /** The link text. */ 7 + text: string; 8 + /** The editor instance. */ 9 + editor: Editor; 10 + } 11 + 12 + export interface LinkExtensionOptions { 13 + /** Open links on click. @default 'whenNotEditable' */ 14 + openOnClick?: boolean | 'whenNotEditable'; 15 + /** Automatically detect and linkify URLs as you type. @default true */ 16 + autolink?: boolean; 17 + /** Default protocol for URLs without one. @default 'https' */ 18 + defaultProtocol?: string; 19 + /** Enable markdown link syntax [text](url). @default true */ 20 + markdown?: boolean; 21 + /** HTML attributes applied to link elements */ 22 + HTMLAttributes?: Record<string, string>; 23 + /** Called whenever a link is added to the editor (via typing, pasting, autolink, or UI). */ 24 + onlinkadded?: (event: LinkAddedEvent) => void; 25 + } 26 + 27 + /** 28 + * Set a link on the current selection. 29 + */ 30 + export function setLink(editor: Editor, url: string): void { 31 + if (!url) { 32 + editor.chain().focus().unsetLink().run(); 33 + return; 34 + } 35 + editor.chain().focus().setLink({ href: url }).run(); 36 + } 37 + 38 + /** 39 + * Remove the link from the current selection. 40 + */ 41 + export function removeLink(editor: Editor): void { 42 + editor.chain().focus().unsetLink().run(); 43 + }
+4
packages/text/src/lib/components/link/index.ts
··· 1 + export { LinkExtension } from './LinkExtension'; 2 + export { default as LinkPopover } from './LinkPopover.svelte'; 3 + export { setLink, removeLink } from './helpers'; 4 + export type { LinkExtensionOptions, LinkAddedEvent } from './helpers';
+133
packages/text/src/lib/components/mention/MentionMenu.svelte
··· 1 + <script lang="ts" module> 2 + export type MentionItem = { 3 + id: string; 4 + label: string; 5 + avatar?: string; 6 + data?: Record<string, unknown>; 7 + }; 8 + </script> 9 + 10 + <script lang="ts"> 11 + import { onMount, mount, unmount } from 'svelte'; 12 + import type { Editor } from '@tiptap/core'; 13 + import Suggestion, { type SuggestionKeyDownProps, type SuggestionProps } from '@tiptap/suggestion'; 14 + import { PluginKey } from '@tiptap/pm/state'; 15 + import { computePosition, flip, shift, offset } from '@floating-ui/dom'; 16 + import type { Snippet } from 'svelte'; 17 + import MentionMenuPopup from './MentionMenuPopup.svelte'; 18 + 19 + let { 20 + editor, 21 + items: itemsFn, 22 + char = '@', 23 + minQueryLength = 2, 24 + pluginKey = 'mentionMenu', 25 + class: className, 26 + item: itemSnippet 27 + }: { 28 + editor: Editor; 29 + items: (query: string) => MentionItem[] | Promise<MentionItem[]>; 30 + char?: string; 31 + minQueryLength?: number; 32 + pluginKey?: string; 33 + class?: string; 34 + item?: Snippet<[{ item: MentionItem; isActive: boolean; select: () => void }]>; 35 + } = $props(); 36 + 37 + onMount(() => { 38 + const key = new PluginKey(pluginKey); 39 + let floatingEl: HTMLElement; 40 + let component: Record<string, any>; 41 + 42 + function updatePosition(clientRect: (() => DOMRect | null) | null | undefined) { 43 + if (!clientRect || !floatingEl) return; 44 + const rect = clientRect(); 45 + if (!rect) return; 46 + 47 + const virtualRef = { getBoundingClientRect: () => rect }; 48 + 49 + computePosition(virtualRef as Element, floatingEl, { 50 + placement: 'bottom-start', 51 + middleware: [offset(8), flip(), shift({ padding: 8 })] 52 + }).then(({ x, y }) => { 53 + Object.assign(floatingEl.style, { 54 + left: `${x}px`, 55 + top: `${y}px` 56 + }); 57 + }); 58 + } 59 + 60 + const plugin = Suggestion({ 61 + editor, 62 + char, 63 + pluginKey: key, 64 + 65 + items: async ({ query }) => { 66 + if (!query || query.length < minQueryLength) return []; 67 + return await itemsFn(query); 68 + }, 69 + 70 + command: ({ editor: ed, range, props }) => { 71 + ed.chain() 72 + .focus() 73 + .insertContentAt(range, [ 74 + { 75 + type: 'mention', 76 + attrs: { id: props.id, label: props.label, data: props.data ?? null } 77 + }, 78 + { type: 'text', text: ' ' } 79 + ]) 80 + .run(); 81 + }, 82 + 83 + render: () => ({ 84 + onStart: (props: SuggestionProps) => { 85 + floatingEl = document.createElement('div'); 86 + floatingEl.style.position = 'absolute'; 87 + floatingEl.style.zIndex = '50'; 88 + document.body.appendChild(floatingEl); 89 + 90 + component = mount(MentionMenuPopup, { 91 + target: floatingEl, 92 + props: { 93 + items: props.items as MentionItem[], 94 + command: props.command as (p: MentionItem) => void, 95 + class: className, 96 + item: itemSnippet 97 + } 98 + }); 99 + 100 + updatePosition(props.clientRect); 101 + }, 102 + onUpdate: (props: SuggestionProps) => { 103 + component.setItems(props.items as MentionItem[]); 104 + component.setCommand(props.command as (p: MentionItem) => void); 105 + updatePosition(props.clientRect); 106 + }, 107 + onKeyDown: (props: SuggestionKeyDownProps) => { 108 + if (props.event.key === 'Escape') { 109 + floatingEl.style.display = 'none'; 110 + return true; 111 + } 112 + return component.onKeyDown(props.event); 113 + }, 114 + onExit: () => { 115 + unmount(component); 116 + floatingEl.remove(); 117 + } 118 + }) 119 + }); 120 + 121 + // Insert at the BEGINNING of the plugin list so the suggestion's 122 + // handleKeyDown runs before the base keymap (which handles Enter). 123 + // editor.registerPlugin() appends to the end, which is too late. 124 + const { state } = editor.view; 125 + editor.view.updateState( 126 + state.reconfigure({ plugins: [plugin, ...state.plugins] }) 127 + ); 128 + 129 + return () => { 130 + editor.unregisterPlugin(key); 131 + }; 132 + }); 133 + </script>
+90
packages/text/src/lib/components/mention/MentionMenuPopup.svelte
··· 1 + <script lang="ts"> 2 + import type { MentionItem } from './MentionMenu.svelte'; 3 + import type { Snippet } from 'svelte'; 4 + import { cn } from '@foxui/core'; 5 + 6 + let { 7 + items, 8 + command, 9 + class: className, 10 + item: itemSnippet 11 + }: { 12 + items: MentionItem[]; 13 + command: (props: MentionItem) => void; 14 + class?: string; 15 + item?: Snippet<[{ item: MentionItem; isActive: boolean; select: () => void }]>; 16 + } = $props(); 17 + 18 + let activeIndex = $state(0); 19 + 20 + export function setItems(value: MentionItem[]) { 21 + items = value; 22 + activeIndex = 0; 23 + } 24 + 25 + export function setCommand(value: (props: MentionItem) => void) { 26 + command = value; 27 + } 28 + 29 + export function onKeyDown(event: KeyboardEvent): boolean { 30 + if (items.length === 0) return false; 31 + 32 + switch (event.key) { 33 + case 'ArrowUp': { 34 + activeIndex = (activeIndex - 1 + items.length) % items.length; 35 + return true; 36 + } 37 + case 'ArrowDown': { 38 + activeIndex = (activeIndex + 1) % items.length; 39 + return true; 40 + } 41 + case 'Enter': { 42 + const selected = items[activeIndex]; 43 + if (selected) { 44 + command(selected); 45 + return true; 46 + } 47 + } 48 + } 49 + 50 + return false; 51 + } 52 + </script> 53 + 54 + {#if items.length > 0} 55 + <div 56 + class={cn( 57 + 'bg-base-100 dark:bg-base-800 border-base-200 dark:border-base-700 text-base-900 dark:text-base-200 z-50 w-fit rounded-2xl border p-1 shadow-md outline-none', 58 + className 59 + )} 60 + > 61 + {#each items as mentionItem, index (mentionItem.id)} 62 + {#if itemSnippet} 63 + {@render itemSnippet({ 64 + item: mentionItem, 65 + isActive: activeIndex === index, 66 + select: () => command(mentionItem) 67 + })} 68 + {:else} 69 + <button 70 + onclick={() => command(mentionItem)} 71 + class={cn( 72 + 'flex w-full cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-sm', 73 + activeIndex === index 74 + ? 'bg-accent-500/10 text-accent-700 dark:text-accent-400' 75 + : 'hover:bg-accent-500/10' 76 + )} 77 + > 78 + {#if mentionItem.avatar} 79 + <img 80 + src={mentionItem.avatar} 81 + alt="" 82 + class="size-6 rounded-full" 83 + /> 84 + {/if} 85 + <span>{mentionItem.label}</span> 86 + </button> 87 + {/if} 88 + {/each} 89 + </div> 90 + {/if}
+76
packages/text/src/lib/components/mention/MentionNode.ts
··· 1 + import { mergeAttributes, Node } from '@tiptap/core'; 2 + import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; 3 + 4 + export interface MentionNodeOptions { 5 + HTMLAttributes: Record<string, unknown>; 6 + renderLabel: (props: { node: ProseMirrorNode }) => string; 7 + } 8 + 9 + export const MentionNode = Node.create<MentionNodeOptions>({ 10 + name: 'mention', 11 + 12 + addOptions() { 13 + return { 14 + HTMLAttributes: {}, 15 + renderLabel({ node }) { 16 + return `@${node.attrs.label ?? node.attrs.id}`; 17 + } 18 + }; 19 + }, 20 + 21 + group: 'inline', 22 + inline: true, 23 + atom: true, 24 + selectable: false, 25 + 26 + addAttributes() { 27 + return { 28 + id: { 29 + default: null, 30 + parseHTML: (element) => element.getAttribute('data-id'), 31 + renderHTML: (attributes) => ({ 32 + 'data-id': attributes.id 33 + }) 34 + }, 35 + label: { 36 + default: null, 37 + parseHTML: (element) => element.getAttribute('data-label'), 38 + renderHTML: (attributes) => ({ 39 + 'data-label': attributes.label 40 + }) 41 + }, 42 + data: { 43 + default: null, 44 + parseHTML: (element) => { 45 + const raw = element.getAttribute('data-mention-data'); 46 + if (!raw) return null; 47 + try { 48 + return JSON.parse(raw); 49 + } catch { 50 + return null; 51 + } 52 + }, 53 + renderHTML: (attributes) => { 54 + if (!attributes.data) return {}; 55 + return { 'data-mention-data': JSON.stringify(attributes.data) }; 56 + } 57 + } 58 + }; 59 + }, 60 + 61 + parseHTML() { 62 + return [{ tag: 'span[data-type="mention"]' }]; 63 + }, 64 + 65 + renderHTML({ node, HTMLAttributes }) { 66 + return [ 67 + 'span', 68 + mergeAttributes({ 'data-type': 'mention' }, this.options.HTMLAttributes, HTMLAttributes), 69 + this.options.renderLabel({ node }) 70 + ]; 71 + }, 72 + 73 + renderText({ node }) { 74 + return this.options.renderLabel({ node }); 75 + } 76 + });
+4
packages/text/src/lib/components/mention/index.ts
··· 1 + export { MentionNode } from './MentionNode'; 2 + export type { MentionNodeOptions } from './MentionNode'; 3 + export { default as MentionMenu } from './MentionMenu.svelte'; 4 + export type { MentionItem } from './MentionMenu.svelte';
+37 -117
packages/text/src/lib/components/plain-text-editor/PlainTextEditor.svelte
··· 1 1 <script lang="ts"> 2 - import { onDestroy, onMount } from 'svelte'; 3 - import { Editor, type Extensions, mergeAttributes, Node } from '@tiptap/core'; 4 - import Placeholder from '@tiptap/extension-placeholder'; 5 - import { type ParagraphOptions } from '@tiptap/extension-paragraph'; 2 + import { Node, mergeAttributes } from '@tiptap/core'; 6 3 import Text from '@tiptap/extension-text'; 7 4 import History from '@tiptap/extension-history'; 8 5 import { cn } from '@foxui/core'; 6 + import { CoreRichTextEditor } from '..'; 7 + import type { BaseEditorProps } from '../core-rich-text-editor/types'; 9 8 10 9 let { 11 - class: className, 12 - placeholder = '', 13 - value = $bindable(''), 10 + content = $bindable(), 11 + editor = $bindable(), 14 12 ref = $bindable(null), 15 - editor = $bindable(null), 16 - allowMultiline = false, 17 - onupdate, 18 - ontransaction, 19 - ...props 20 - }: { 21 - class?: string; 22 - placeholder?: string; 23 - value?: string; 24 - ref?: HTMLElement | null; 25 - editor?: Editor | null; 13 + class: className, 14 + allowMultiline = true, 15 + extensions, 16 + ...restProps 17 + }: BaseEditorProps & { 18 + /** Allow multiple lines/blocks. When false, only a single block is allowed. @default true */ 26 19 allowMultiline?: boolean; 27 - onupdate?: (value: string) => void; 28 - ontransaction?: () => void; 29 20 } = $props(); 30 21 31 - onMount(async () => { 32 - if (!ref || editor) return; 22 + const Paragraph = Node.create({ 23 + name: 'paragraph', 24 + group: 'block', 25 + content: 'inline*', 26 + parseHTML() { 27 + return [{ tag: 'p' }]; 28 + }, 29 + renderHTML({ HTMLAttributes }) { 30 + return ['p', mergeAttributes(HTMLAttributes), 0]; 31 + } 32 + }); 33 33 34 - const Document = Node.create({ 34 + let simpleExtensions = $derived([ 35 + Node.create({ 35 36 name: 'doc', 36 37 topNode: true, 37 38 content: allowMultiline ? 'block+' : 'block' 38 - }); 39 - 40 - const Paragraph = Node.create<ParagraphOptions>({ 41 - name: 'paragraph', 42 - 43 - priority: 1000, 44 - 45 - addOptions() { 46 - return { 47 - HTMLAttributes: {} 48 - }; 49 - }, 50 - 51 - group: 'block', 52 - 53 - content: 'text*', 54 - 55 - parseHTML() { 56 - return [{ tag: 'p' }]; 57 - }, 58 - 59 - renderHTML({ HTMLAttributes }) { 60 - return ['p', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; 61 - }, 62 - 63 - addCommands() { 64 - return { 65 - setParagraph: 66 - () => 67 - ({ commands }) => { 68 - return commands.setNode(this.name); 69 - } 70 - }; 71 - }, 72 - 73 - addKeyboardShortcuts() { 74 - return { 75 - 'Mod-Alt-0': () => this.editor.commands.setParagraph() 76 - }; 77 - } 78 - }); 79 - 80 - let extensions: Extensions = [Document.configure(), Paragraph.configure(), Text.configure(), History.configure()]; 81 - 82 - if (placeholder) { 83 - extensions.push( 84 - Placeholder.configure({ 85 - placeholder: placeholder 86 - }) 87 - ); 88 - } 89 - 90 - editor = new Editor({ 91 - element: ref, 92 - extensions: extensions, 93 - onUpdate: () => { 94 - onupdate?.(editor?.getText() ?? ''); 95 - 96 - value = editor?.getText() ?? ''; 97 - }, 98 - onTransaction: () => { 99 - ontransaction?.(); 100 - }, 101 - 102 - content: value, 103 - 104 - editorProps: { 105 - attributes: { 106 - class: 'outline-none pointer-events-auto' 107 - } 108 - } 109 - }); 110 - }); 111 - 112 - $effect(() => { 113 - if (!editor || editor.getText() === value) return; 114 - 115 - editor.commands.setContent(value); 116 - }); 117 - 118 - onDestroy(() => { 119 - editor?.destroy(); 120 - }); 39 + }), 40 + Paragraph, 41 + Text, 42 + History 43 + ]); 121 44 </script> 122 45 123 - <div class={cn('min-h-[1em]', className)} bind:this={ref} {...props}></div> 124 - 125 - <style> 126 - :global(.tiptap p.is-editor-empty:first-child::before) { 127 - color: var(--color-base-500); 128 - content: attr(data-placeholder); 129 - float: left; 130 - height: 0; 131 - pointer-events: none; 132 - } 133 - </style> 46 + <CoreRichTextEditor 47 + bind:content 48 + bind:editor 49 + bind:ref 50 + class={cn('min-h-[1em]', className)} 51 + extensions={extensions ?? simpleExtensions} 52 + {...restProps} 53 + />
+31
packages/text/src/lib/components/rich-text-bubble-menu/BubbleMenu.svelte
··· 1 + <script lang="ts"> 2 + import { cn } from '@foxui/core'; 3 + import type { Snippet } from 'svelte'; 4 + import { BubbleMenu } from 'svelte-tiptap'; 5 + import type { BubbleMenuPluginProps } from '@tiptap/extension-bubble-menu'; 6 + 7 + import type { Editor } from 'svelte-tiptap'; 8 + import type { Readable } from 'svelte/store'; 9 + 10 + let { 11 + editor = $bindable(), 12 + class: className, 13 + children, 14 + ...rest 15 + }: Partial<Omit<BubbleMenuPluginProps, 'editor' | 'element'>> & { 16 + editor: Readable<Editor>; 17 + class?: string; 18 + children?: Snippet; 19 + } = $props(); 20 + </script> 21 + 22 + <BubbleMenu editor={$editor} {...rest}> 23 + <div 24 + class={cn( 25 + 'bg-base-100 dark:bg-base-800 border-base-200 dark:border-base-700 text-base-900 dark:text-base-200 z-50 w-fit rounded-2xl border p-1 shadow-md outline-none', 26 + className 27 + )} 28 + > 29 + {@render children?.()} 30 + </div> 31 + </BubbleMenu>
+39
packages/text/src/lib/components/rich-text-bubble-menu/FormattingBubbleMenu.svelte
··· 1 + <script lang="ts"> 2 + import type { Editor } from 'svelte-tiptap'; 3 + import type { Readable } from 'svelte/store'; 4 + import { BubbleMenu } from '.'; 5 + 6 + import { BoldToggle, ItalicToggle, StrikethroughToggle, UnderlineToggle } from '../formatting'; 7 + import { LinkPopover } from '../link'; 8 + import type { Snippet } from 'svelte'; 9 + 10 + let { 11 + editor = $bindable(), 12 + class: className, 13 + children 14 + }: { 15 + editor: Readable<Editor>; 16 + class?: string; 17 + children?: Snippet; 18 + } = $props(); 19 + 20 + let hasLink = $derived( 21 + $editor?.extensionManager.extensions.some((ext) => ext.name === 'link') ?? false 22 + ); 23 + </script> 24 + 25 + <BubbleMenu 26 + {editor} 27 + class={className} 28 + shouldShow={({ editor, from, to }) => from !== to && !editor.isActive('codeBlock')} 29 + > 30 + <BoldToggle {editor} /> 31 + <ItalicToggle {editor} /> 32 + <UnderlineToggle {editor} /> 33 + <StrikethroughToggle {editor} /> 34 + {#if hasLink} 35 + <LinkPopover {editor} /> 36 + {/if} 37 + 38 + {@render children?.()} 39 + </BubbleMenu>
+2
packages/text/src/lib/components/rich-text-bubble-menu/index.ts
··· 1 + export { default as BubbleMenu } from './BubbleMenu.svelte'; 2 + export { default as FormattingBubbleMenu } from './FormattingBubbleMenu.svelte';
+120
packages/text/src/lib/components/rich-text-editor-old/Icon.svelte
··· 1 + <script lang="ts"> 2 + import type { RichTextTypes } from '.'; 3 + 4 + let { 5 + name 6 + }: { 7 + name: RichTextTypes; 8 + } = $props(); 9 + </script> 10 + 11 + {#if name === 'paragraph'} 12 + <svg 13 + xmlns="http://www.w3.org/2000/svg" 14 + viewBox="0 0 24 24" 15 + fill="none" 16 + stroke="currentColor" 17 + stroke-width="2" 18 + stroke-linecap="round" 19 + stroke-linejoin="round" 20 + ><path d="M13 4v16" /><path d="M17 4v16" /><path d="M19 4H9.5a4.5 4.5 0 0 0 0 9H13" /></svg 21 + > 22 + {:else if name === 'heading-1'} 23 + <svg 24 + xmlns="http://www.w3.org/2000/svg" 25 + viewBox="0 0 24 24" 26 + fill="none" 27 + stroke="currentColor" 28 + stroke-width="2" 29 + stroke-linecap="round" 30 + stroke-linejoin="round" 31 + ><path d="M4 12h8" /><path d="M4 18V6" /><path d="M12 18V6" /><path d="m17 12 3-2v8" /></svg 32 + > 33 + {:else if name === 'heading-2'} 34 + <svg 35 + xmlns="http://www.w3.org/2000/svg" 36 + viewBox="0 0 24 24" 37 + fill="none" 38 + stroke="currentColor" 39 + stroke-width="2" 40 + stroke-linecap="round" 41 + stroke-linejoin="round" 42 + ><path d="M4 12h8" /><path d="M4 18V6" /><path d="M12 18V6" /><path 43 + d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1" 44 + /></svg 45 + > 46 + {:else if name === 'heading-3'} 47 + <svg 48 + xmlns="http://www.w3.org/2000/svg" 49 + viewBox="0 0 24 24" 50 + fill="none" 51 + stroke="currentColor" 52 + stroke-width="2" 53 + stroke-linecap="round" 54 + stroke-linejoin="round" 55 + ><path d="M4 12h8" /><path d="M4 18V6" /><path d="M12 18V6" /><path 56 + d="M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2" 57 + /><path d="M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2" /></svg 58 + > 59 + {:else if name === 'blockquote'} 60 + <svg 61 + xmlns="http://www.w3.org/2000/svg" 62 + viewBox="0 0 24 24" 63 + fill="none" 64 + stroke="currentColor" 65 + stroke-width="2" 66 + stroke-linecap="round" 67 + stroke-linejoin="round" 68 + ><path d="M17 6H3" /><path d="M21 12H8" /><path d="M21 18H8" /><path d="M3 12v6" /></svg 69 + > 70 + {:else if name === 'code'} 71 + <svg 72 + xmlns="http://www.w3.org/2000/svg" 73 + viewBox="0 0 24 24" 74 + fill="none" 75 + stroke="currentColor" 76 + stroke-width="2" 77 + stroke-linecap="round" 78 + stroke-linejoin="round" 79 + ><polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" /></svg 80 + > 81 + {:else if name === 'bullet-list'} 82 + <svg 83 + xmlns="http://www.w3.org/2000/svg" 84 + viewBox="0 0 24 24" 85 + fill="none" 86 + stroke="currentColor" 87 + stroke-width="2" 88 + stroke-linecap="round" 89 + stroke-linejoin="round" 90 + ><path d="M3 12h.01" /><path d="M3 18h.01" /><path d="M3 6h.01" /><path d="M8 12h13" /><path 91 + d="M8 18h13" 92 + /><path d="M8 6h13" /></svg 93 + > 94 + {:else if name === 'ordered-list'} 95 + <svg 96 + xmlns="http://www.w3.org/2000/svg" 97 + viewBox="0 0 24 24" 98 + fill="none" 99 + stroke="currentColor" 100 + stroke-width="2" 101 + stroke-linecap="round" 102 + stroke-linejoin="round" 103 + ><path d="M10 12h11" /><path d="M10 18h11" /><path d="M10 6h11" /><path d="M4 10h2" /><path 104 + d="M4 6h1v4" 105 + /><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" /></svg 106 + > 107 + {:else if name === 'image'} 108 + <svg 109 + xmlns="http://www.w3.org/2000/svg" 110 + viewBox="0 0 24 24" 111 + fill="none" 112 + stroke="currentColor" 113 + stroke-width="2" 114 + stroke-linecap="round" 115 + stroke-linejoin="round" 116 + ><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /><circle cx="9" cy="9" r="2" /><path 117 + d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" 118 + /></svg 119 + > 120 + {/if}
+292
packages/text/src/lib/components/rich-text-editor-old/RichTextEditor.svelte
··· 1 + <script lang="ts"> 2 + import { onDestroy, onMount } from 'svelte'; 3 + import { type Editor as CoreEditor, mergeAttributes, type Content } from '@tiptap/core'; 4 + import { type Editor, createEditor, EditorContent } from 'svelte-tiptap'; 5 + import StarterKit from '@tiptap/starter-kit'; 6 + import Placeholder from '@tiptap/extension-placeholder'; 7 + import Image from '@tiptap/extension-image'; 8 + import { all, createLowlight } from 'lowlight'; 9 + import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; 10 + import Underline from '@tiptap/extension-underline'; 11 + import type { RichTextTypes } from '.'; 12 + import Slash, { suggestion } from './slash-menu'; 13 + import Typography from '@tiptap/extension-typography'; 14 + import { RichTextLink } from './RichTextLink'; 15 + import { Markdown } from '@tiptap/markdown'; 16 + 17 + import { cn } from '@foxui/core'; 18 + import { ImageUploadNode } from './image-upload/ImageUploadNode'; 19 + import { FormattingBubbleMenu } from './formatting-bubble-menu'; 20 + import type { Transaction } from '@tiptap/pm/state'; 21 + import type { Readable } from 'svelte/store'; 22 + 23 + let { 24 + content = $bindable({}), 25 + placeholder = 'Write or press / for commands', 26 + editor = $bindable(), 27 + ref = $bindable(null), 28 + class: className, 29 + onupdate, 30 + ontransaction 31 + }: { 32 + content?: Content; 33 + placeholder?: string; 34 + editor?: Readable<Editor>; 35 + ref?: HTMLDivElement | null; 36 + class?: string; 37 + onupdate?: (content: Content, context: { editor: CoreEditor; transaction: Transaction }) => void; 38 + ontransaction?: () => void; 39 + } = $props(); 40 + 41 + const lowlight = createLowlight(all); 42 + 43 + let hasFocus = true; 44 + 45 + const CustomImage = Image.extend({ 46 + renderHTML({ HTMLAttributes }) { 47 + const attrs = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 48 + uploadImageHandler: undefined 49 + }); 50 + return ['img', attrs]; 51 + } 52 + }); 53 + 54 + // Flag to track whether a file is being dragged over the drop area 55 + let isDragOver = $state(false); 56 + 57 + // Store local image files for later upload 58 + let localImages: Map<string, File> = $state(new Map()); 59 + 60 + // Track which image URLs in the editor are local previews 61 + let localImageUrls: Set<string> = $state(new Set()); 62 + 63 + // Process image file to create a local preview 64 + async function processImageFile(file: File) { 65 + if (!$editor) { 66 + console.warn('Tiptap editor not initialized'); 67 + return; 68 + } 69 + 70 + try { 71 + const localUrl = URL.createObjectURL(file); 72 + 73 + localImages.set(localUrl, file); 74 + localImageUrls.add(localUrl); 75 + 76 + $editor 77 + .chain() 78 + .focus() 79 + .setImageUploadNode({ 80 + preview: localUrl 81 + }) 82 + .run(); 83 + } catch (error) { 84 + console.error('Error creating image preview:', error); 85 + } 86 + } 87 + 88 + function switchTo(value: RichTextTypes) { 89 + $editor?.chain().focus().setParagraph().run(); 90 + 91 + if (value === 'heading-1') { 92 + $editor?.chain().focus().setNode('heading', { level: 1 }).run(); 93 + } else if (value === 'heading-2') { 94 + $editor?.chain().focus().setNode('heading', { level: 2 }).run(); 95 + } else if (value === 'heading-3') { 96 + $editor?.chain().focus().setNode('heading', { level: 3 }).run(); 97 + } else if (value === 'blockquote') { 98 + $editor?.chain().focus().setBlockquote().run(); 99 + } else if (value === 'code') { 100 + $editor?.chain().focus().setCodeBlock().run(); 101 + } else if (value === 'bullet-list') { 102 + $editor?.chain().focus().toggleBulletList().run(); 103 + } else if (value === 'ordered-list') { 104 + $editor?.chain().focus().toggleOrderedList().run(); 105 + } 106 + } 107 + 108 + onMount(() => { 109 + editor = createEditor({ 110 + extensions: [ 111 + StarterKit.configure({ 112 + dropcursor: { 113 + class: 'text-accent-500/30 rounded-2xl', 114 + width: 2 115 + }, 116 + codeBlock: false, 117 + heading: { 118 + levels: [1, 2, 3] 119 + } 120 + }), 121 + Placeholder.configure({ 122 + placeholder: ({ node }) => { 123 + if (node.type.name === 'paragraph' || node.type.name === 'heading') { 124 + return placeholder; 125 + } 126 + return ''; 127 + } 128 + }), 129 + CustomImage.configure({ 130 + HTMLAttributes: { 131 + class: 'max-w-full object-contain relative rounded-2xl' 132 + }, 133 + allowBase64: true 134 + }), 135 + CodeBlockLowlight.configure({ 136 + lowlight, 137 + defaultLanguage: 'js' 138 + }), 139 + Underline.configure({}), 140 + RichTextLink.configure({ 141 + openOnClick: false, 142 + autolink: true, 143 + defaultProtocol: 'https' 144 + }), 145 + Slash.configure({ 146 + suggestion: suggestion({ 147 + char: '/', 148 + pluginKey: 'slash', 149 + switchTo, 150 + processImageFile 151 + }) 152 + }), 153 + Typography.configure(), 154 + Markdown.configure(), 155 + ImageUploadNode.configure({ 156 + upload: async (file, onProgress, abortSignal) => { 157 + console.log('uploading image', file); 158 + for (let i = 0; i < 10; i++) { 159 + await new Promise((resolve) => setTimeout(resolve, 200)); 160 + onProgress?.({ progress: i / 10 }); 161 + } 162 + 163 + return 'https://picsum.photos/200/300'; 164 + } 165 + }) 166 + ], 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: () => { 183 + ontransaction?.(); 184 + }, 185 + content 186 + }); 187 + }); 188 + 189 + const handlePaste = (event: ClipboardEvent) => { 190 + const items = event.clipboardData?.items; 191 + if (!items) return; 192 + for (const item of Array.from(items)) { 193 + if (!item.type.startsWith('image/')) continue; 194 + const file = item.getAsFile(); 195 + if (!file) continue; 196 + event.preventDefault(); 197 + processImageFile(file); 198 + return; 199 + } 200 + }; 201 + 202 + function handleDragOver(event: DragEvent) { 203 + event.preventDefault(); 204 + event.stopPropagation(); 205 + isDragOver = true; 206 + } 207 + function handleDragLeave(event: DragEvent) { 208 + event.preventDefault(); 209 + event.stopPropagation(); 210 + isDragOver = false; 211 + } 212 + function handleDrop(event: DragEvent) { 213 + event.preventDefault(); 214 + event.stopPropagation(); 215 + isDragOver = false; 216 + if (!event.dataTransfer?.files?.length) return; 217 + const file = event.dataTransfer.files[0]; 218 + if (file?.type.startsWith('image/')) { 219 + processImageFile(file); 220 + } 221 + } 222 + 223 + onDestroy(() => { 224 + for (const localUrl of localImageUrls) { 225 + URL.revokeObjectURL(localUrl); 226 + } 227 + }); 228 + 229 + </script> 230 + 231 + <div 232 + bind:this={ref} 233 + class={cn('relative flex-1', className)} 234 + onpaste={handlePaste} 235 + ondragover={handleDragOver} 236 + ondragleave={handleDragLeave} 237 + ondrop={handleDrop} 238 + role="region" 239 + > 240 + {#if $editor} 241 + <EditorContent editor={$editor} /> 242 + {/if} 243 + </div> 244 + 245 + {#if $editor} 246 + <FormattingBubbleMenu editor={$editor} /> 247 + {/if} 248 + 249 + <style> 250 + :global(.tiptap) { 251 + :first-child { 252 + margin-top: 0; 253 + } 254 + 255 + :global(img) { 256 + display: block; 257 + height: auto; 258 + margin: 1.5rem 0; 259 + max-width: 100%; 260 + 261 + &.ProseMirror-selectednode { 262 + outline: 3px solid var(--color-accent-500); 263 + } 264 + } 265 + 266 + :global(div[data-type='image-upload']) { 267 + &.ProseMirror-selectednode { 268 + outline: 3px solid var(--color-accent-500); 269 + } 270 + } 271 + 272 + :global(blockquote p:first-of-type::before) { 273 + content: none; 274 + } 275 + 276 + :global(blockquote p:last-of-type::after) { 277 + content: none; 278 + } 279 + 280 + :global(blockquote p) { 281 + font-style: normal; 282 + } 283 + } 284 + 285 + :global(.tiptap .is-empty::before) { 286 + color: var(--color-base-500); 287 + content: attr(data-placeholder); 288 + float: left; 289 + height: 0; 290 + pointer-events: none; 291 + } 292 + </style>
+126
packages/text/src/lib/components/rich-text-editor-old/RichTextLink.ts
··· 1 + // from https://github.com/Doist/typist/blob/main/src/extensions/rich-text/rich-text-link.ts 2 + import { InputRule, markInputRule, markPasteRule, PasteRule } from '@tiptap/core'; 3 + import { Link } from '@tiptap/extension-link'; 4 + 5 + import type { LinkOptions } from '@tiptap/extension-link'; 6 + 7 + /** 8 + * The input regex for Markdown links with title support, and multiple quotation marks (required 9 + * in case the `Typography` extension is being included). 10 + */ 11 + const inputRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)$/i; 12 + 13 + /** 14 + * The paste regex for Markdown links with title support, and multiple quotation marks (required 15 + * in case the `Typography` extension is being included). 16 + */ 17 + const pasteRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)/gi; 18 + 19 + /** 20 + * Input rule built specifically for the `Link` extension, which ignores the auto-linked URL in 21 + * parentheses (e.g., `(https://doist.dev)`). 22 + * 23 + * @see https://github.com/ueberdosis/tiptap/discussions/1865 24 + */ 25 + function linkInputRule(config: Parameters<typeof markInputRule>[0]) { 26 + const defaultMarkInputRule = markInputRule(config); 27 + 28 + return new InputRule({ 29 + find: config.find, 30 + handler(props) { 31 + const { tr } = props.state; 32 + 33 + defaultMarkInputRule.handler(props); 34 + tr.setMeta('preventAutolink', true); 35 + } 36 + }); 37 + } 38 + 39 + /** 40 + * Paste rule built specifically for the `Link` extension, which ignores the auto-linked URL in 41 + * parentheses (e.g., `(https://doist.dev)`). This extension was inspired from the multiple 42 + * implementations found in a Tiptap discussion at GitHub. 43 + * 44 + * @see https://github.com/ueberdosis/tiptap/discussions/1865 45 + */ 46 + function linkPasteRule(config: Parameters<typeof markPasteRule>[0]) { 47 + const defaultMarkPasteRule = markPasteRule(config); 48 + 49 + return new PasteRule({ 50 + find: config.find, 51 + handler(props) { 52 + const { tr } = props.state; 53 + 54 + defaultMarkPasteRule.handler(props); 55 + tr.setMeta('preventAutolink', true); 56 + } 57 + }); 58 + } 59 + 60 + /** 61 + * The options available to customize the `RichTextLink` extension. 62 + */ 63 + type RichTextLinkOptions = LinkOptions; 64 + 65 + /** 66 + * Custom extension that extends the built-in `Link` extension to add additional input/paste rules 67 + * for converting the Markdown link syntax (i.e. `[Doist](https://doist.com)`) into links, and also 68 + * adds support for the `title` attribute. 69 + */ 70 + const RichTextLink = Link.extend<RichTextLinkOptions>({ 71 + inclusive: false, 72 + addOptions() { 73 + return { 74 + ...this.parent?.(), 75 + openOnClick: 'whenNotEditable' 76 + }; 77 + }, 78 + addAttributes() { 79 + return { 80 + ...this.parent?.(), 81 + title: { 82 + default: null 83 + } 84 + }; 85 + }, 86 + addInputRules() { 87 + return [ 88 + linkInputRule({ 89 + find: inputRegex, 90 + type: this.type, 91 + 92 + // We need to use `pop()` to remove the last capture groups from the match to 93 + // satisfy Tiptap's `markPasteRule` expectation of having the content as the last 94 + // capture group in the match (this makes the attribute order important) 95 + getAttributes(match) { 96 + return { 97 + title: match.pop()?.trim(), 98 + href: match.pop()?.trim() 99 + }; 100 + } 101 + }) 102 + ]; 103 + }, 104 + addPasteRules() { 105 + return [ 106 + linkPasteRule({ 107 + find: pasteRegex, 108 + type: this.type, 109 + 110 + // We need to use `pop()` to remove the last capture groups from the match to 111 + // satisfy Tiptap's `markInputRule` expectation of having the content as the last 112 + // capture group in the match (this makes the attribute order important) 113 + getAttributes(match) { 114 + return { 115 + title: match.pop()?.trim(), 116 + href: match.pop()?.trim() 117 + }; 118 + } 119 + }) 120 + ]; 121 + } 122 + }); 123 + 124 + export { RichTextLink }; 125 + 126 + export type { RichTextLinkOptions };
+208
packages/text/src/lib/components/rich-text-editor-old/code.css
··· 1 + /* Structural styles for code blocks */ 2 + .tiptap { 3 + :first-child { 4 + margin-top: 0; 5 + } 6 + 7 + pre { 8 + border-radius: 1rem; 9 + font-family: 'JetBrainsMono', monospace; 10 + margin: 1.5rem 0; 11 + padding: 0.75rem 1rem; 12 + 13 + code { 14 + background: none; 15 + color: inherit; 16 + font-size: 0.8rem; 17 + padding: 0; 18 + } 19 + } 20 + } 21 + 22 + /* GitHub Light theme (default) */ 23 + .tiptap pre { 24 + background: #ffffff; 25 + color: #24292e; 26 + border: 1px solid #d0d7de; 27 + } 28 + 29 + .tiptap pre .hljs-doctag, 30 + .tiptap pre .hljs-keyword, 31 + .tiptap pre .hljs-meta .hljs-keyword, 32 + .tiptap pre .hljs-template-tag, 33 + .tiptap pre .hljs-template-variable, 34 + .tiptap pre .hljs-type, 35 + .tiptap pre .hljs-variable.language_ { 36 + color: #d73a49; 37 + } 38 + 39 + .tiptap pre .hljs-title, 40 + .tiptap pre .hljs-title.class_, 41 + .tiptap pre .hljs-title.class_.inherited__, 42 + .tiptap pre .hljs-title.function_ { 43 + color: #6f42c1; 44 + } 45 + 46 + .tiptap pre .hljs-attr, 47 + .tiptap pre .hljs-attribute, 48 + .tiptap pre .hljs-literal, 49 + .tiptap pre .hljs-meta, 50 + .tiptap pre .hljs-number, 51 + .tiptap pre .hljs-operator, 52 + .tiptap pre .hljs-selector-attr, 53 + .tiptap pre .hljs-selector-class, 54 + .tiptap pre .hljs-selector-id, 55 + .tiptap pre .hljs-variable { 56 + color: #005cc5; 57 + } 58 + 59 + .tiptap pre .hljs-meta .hljs-string, 60 + .tiptap pre .hljs-regexp, 61 + .tiptap pre .hljs-string { 62 + color: #032f62; 63 + } 64 + 65 + .tiptap pre .hljs-built_in, 66 + .tiptap pre .hljs-symbol { 67 + color: #e36209; 68 + } 69 + 70 + .tiptap pre .hljs-code, 71 + .tiptap pre .hljs-comment, 72 + .tiptap pre .hljs-formula { 73 + color: #6a737d; 74 + } 75 + 76 + .tiptap pre .hljs-name, 77 + .tiptap pre .hljs-quote, 78 + .tiptap pre .hljs-selector-pseudo, 79 + .tiptap pre .hljs-selector-tag { 80 + color: #22863a; 81 + } 82 + 83 + .tiptap pre .hljs-subst { 84 + color: #24292e; 85 + } 86 + 87 + .tiptap pre .hljs-section { 88 + color: #005cc5; 89 + font-weight: bold; 90 + } 91 + 92 + .tiptap pre .hljs-bullet { 93 + color: #735c0f; 94 + } 95 + 96 + .tiptap pre .hljs-emphasis { 97 + color: #24292e; 98 + font-style: italic; 99 + } 100 + 101 + .tiptap pre .hljs-strong { 102 + color: #24292e; 103 + font-weight: bold; 104 + } 105 + 106 + .tiptap pre .hljs-addition { 107 + color: #22863a; 108 + background-color: #f0fff4; 109 + } 110 + 111 + .tiptap pre .hljs-deletion { 112 + color: #b31d28; 113 + background-color: #ffeef0; 114 + } 115 + 116 + /* GitHub Dark theme */ 117 + .dark .tiptap pre { 118 + background: #0d1117; 119 + color: #c9d1d9; 120 + border: 1px solid #30363d; 121 + } 122 + 123 + .dark .tiptap pre .hljs-doctag, 124 + .dark .tiptap pre .hljs-keyword, 125 + .dark .tiptap pre .hljs-meta .hljs-keyword, 126 + .dark .tiptap pre .hljs-template-tag, 127 + .dark .tiptap pre .hljs-template-variable, 128 + .dark .tiptap pre .hljs-type, 129 + .dark .tiptap pre .hljs-variable.language_ { 130 + color: #ff7b72; 131 + } 132 + 133 + .dark .tiptap pre .hljs-title, 134 + .dark .tiptap pre .hljs-title.class_, 135 + .dark .tiptap pre .hljs-title.class_.inherited__, 136 + .dark .tiptap pre .hljs-title.function_ { 137 + color: #d2a8ff; 138 + } 139 + 140 + .dark .tiptap pre .hljs-attr, 141 + .dark .tiptap pre .hljs-attribute, 142 + .dark .tiptap pre .hljs-literal, 143 + .dark .tiptap pre .hljs-meta, 144 + .dark .tiptap pre .hljs-number, 145 + .dark .tiptap pre .hljs-operator, 146 + .dark .tiptap pre .hljs-selector-attr, 147 + .dark .tiptap pre .hljs-selector-class, 148 + .dark .tiptap pre .hljs-selector-id, 149 + .dark .tiptap pre .hljs-variable { 150 + color: #79c0ff; 151 + } 152 + 153 + .dark .tiptap pre .hljs-meta .hljs-string, 154 + .dark .tiptap pre .hljs-regexp, 155 + .dark .tiptap pre .hljs-string { 156 + color: #a5d6ff; 157 + } 158 + 159 + .dark .tiptap pre .hljs-built_in, 160 + .dark .tiptap pre .hljs-symbol { 161 + color: #ffa657; 162 + } 163 + 164 + .dark .tiptap pre .hljs-code, 165 + .dark .tiptap pre .hljs-comment, 166 + .dark .tiptap pre .hljs-formula { 167 + color: #8b949e; 168 + } 169 + 170 + .dark .tiptap pre .hljs-name, 171 + .dark .tiptap pre .hljs-quote, 172 + .dark .tiptap pre .hljs-selector-pseudo, 173 + .dark .tiptap pre .hljs-selector-tag { 174 + color: #7ee787; 175 + } 176 + 177 + .dark .tiptap pre .hljs-subst { 178 + color: #c9d1d9; 179 + } 180 + 181 + .dark .tiptap pre .hljs-section { 182 + color: #79c0ff; 183 + font-weight: bold; 184 + } 185 + 186 + .dark .tiptap pre .hljs-bullet { 187 + color: #f2cc60; 188 + } 189 + 190 + .dark .tiptap pre .hljs-emphasis { 191 + color: #c9d1d9; 192 + font-style: italic; 193 + } 194 + 195 + .dark .tiptap pre .hljs-strong { 196 + color: #c9d1d9; 197 + font-weight: bold; 198 + } 199 + 200 + .dark .tiptap pre .hljs-addition { 201 + color: #aff5b4; 202 + background-color: #033a16; 203 + } 204 + 205 + .dark .tiptap pre .hljs-deletion { 206 + color: #ffdcd7; 207 + background-color: #67060c; 208 + }
+38
packages/text/src/lib/components/rich-text-editor-old/index.ts
··· 1 + // Main component (all-in-one convenience) 2 + export { default as RichTextEditor } from './RichTextEditor.svelte'; 3 + 4 + // Standalone composable components 5 + export { FormattingBubbleMenu } from './formatting-bubble-menu'; 6 + 7 + // Re-export svelte-tiptap primitives for convenience 8 + export { 9 + createEditor, 10 + EditorContent, 11 + BubbleMenu, 12 + FloatingMenu, 13 + SvelteNodeViewRenderer, 14 + NodeViewWrapper, 15 + NodeViewContent 16 + } from 'svelte-tiptap'; 17 + 18 + // Extensions 19 + export { RichTextLink } from './RichTextLink'; 20 + export { ImageUploadNode } from './image-upload/ImageUploadNode'; 21 + export { default as Slash, suggestion } from './slash-menu'; 22 + 23 + // Types 24 + export type RichTextTypes = 25 + | 'paragraph' 26 + | 'heading-1' 27 + | 'heading-2' 28 + | 'heading-3' 29 + | 'blockquote' 30 + | 'code' 31 + | 'bullet-list' 32 + | 'ordered-list'; 33 + 34 + export type { RichTextLinkOptions } from './RichTextLink'; 35 + export type { UploadFunction, ImageUploadNodeOptions } from './image-upload/ImageUploadNode'; 36 + 37 + // Code theme CSS (side-effect import for consumers who want it) 38 + import './code.css';
-120
packages/text/src/lib/components/rich-text-editor/Icon.svelte
··· 1 - <script lang="ts"> 2 - import type { RichTextTypes } from '.'; 3 - 4 - let { 5 - name 6 - }: { 7 - name: RichTextTypes; 8 - } = $props(); 9 - </script> 10 - 11 - {#if name === 'paragraph'} 12 - <svg 13 - xmlns="http://www.w3.org/2000/svg" 14 - viewBox="0 0 24 24" 15 - fill="none" 16 - stroke="currentColor" 17 - stroke-width="2" 18 - stroke-linecap="round" 19 - stroke-linejoin="round" 20 - ><path d="M13 4v16" /><path d="M17 4v16" /><path d="M19 4H9.5a4.5 4.5 0 0 0 0 9H13" /></svg 21 - > 22 - {:else if name === 'heading-1'} 23 - <svg 24 - xmlns="http://www.w3.org/2000/svg" 25 - viewBox="0 0 24 24" 26 - fill="none" 27 - stroke="currentColor" 28 - stroke-width="2" 29 - stroke-linecap="round" 30 - stroke-linejoin="round" 31 - ><path d="M4 12h8" /><path d="M4 18V6" /><path d="M12 18V6" /><path d="m17 12 3-2v8" /></svg 32 - > 33 - {:else if name === 'heading-2'} 34 - <svg 35 - xmlns="http://www.w3.org/2000/svg" 36 - viewBox="0 0 24 24" 37 - fill="none" 38 - stroke="currentColor" 39 - stroke-width="2" 40 - stroke-linecap="round" 41 - stroke-linejoin="round" 42 - ><path d="M4 12h8" /><path d="M4 18V6" /><path d="M12 18V6" /><path 43 - d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1" 44 - /></svg 45 - > 46 - {:else if name === 'heading-3'} 47 - <svg 48 - xmlns="http://www.w3.org/2000/svg" 49 - viewBox="0 0 24 24" 50 - fill="none" 51 - stroke="currentColor" 52 - stroke-width="2" 53 - stroke-linecap="round" 54 - stroke-linejoin="round" 55 - ><path d="M4 12h8" /><path d="M4 18V6" /><path d="M12 18V6" /><path 56 - d="M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2" 57 - /><path d="M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2" /></svg 58 - > 59 - {:else if name === 'blockquote'} 60 - <svg 61 - xmlns="http://www.w3.org/2000/svg" 62 - viewBox="0 0 24 24" 63 - fill="none" 64 - stroke="currentColor" 65 - stroke-width="2" 66 - stroke-linecap="round" 67 - stroke-linejoin="round" 68 - ><path d="M17 6H3" /><path d="M21 12H8" /><path d="M21 18H8" /><path d="M3 12v6" /></svg 69 - > 70 - {:else if name === 'code'} 71 - <svg 72 - xmlns="http://www.w3.org/2000/svg" 73 - viewBox="0 0 24 24" 74 - fill="none" 75 - stroke="currentColor" 76 - stroke-width="2" 77 - stroke-linecap="round" 78 - stroke-linejoin="round" 79 - ><polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" /></svg 80 - > 81 - {:else if name === 'bullet-list'} 82 - <svg 83 - xmlns="http://www.w3.org/2000/svg" 84 - viewBox="0 0 24 24" 85 - fill="none" 86 - stroke="currentColor" 87 - stroke-width="2" 88 - stroke-linecap="round" 89 - stroke-linejoin="round" 90 - ><path d="M3 12h.01" /><path d="M3 18h.01" /><path d="M3 6h.01" /><path d="M8 12h13" /><path 91 - d="M8 18h13" 92 - /><path d="M8 6h13" /></svg 93 - > 94 - {:else if name === 'ordered-list'} 95 - <svg 96 - xmlns="http://www.w3.org/2000/svg" 97 - viewBox="0 0 24 24" 98 - fill="none" 99 - stroke="currentColor" 100 - stroke-width="2" 101 - stroke-linecap="round" 102 - stroke-linejoin="round" 103 - ><path d="M10 12h11" /><path d="M10 18h11" /><path d="M10 6h11" /><path d="M4 10h2" /><path 104 - d="M4 6h1v4" 105 - /><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" /></svg 106 - > 107 - {:else if name === 'image'} 108 - <svg 109 - xmlns="http://www.w3.org/2000/svg" 110 - viewBox="0 0 24 24" 111 - fill="none" 112 - stroke="currentColor" 113 - stroke-width="2" 114 - stroke-linecap="round" 115 - stroke-linejoin="round" 116 - ><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /><circle cx="9" cy="9" r="2" /><path 117 - d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" 118 - /></svg 119 - > 120 - {/if}
+60 -278
packages/text/src/lib/components/rich-text-editor/RichTextEditor.svelte
··· 1 1 <script lang="ts"> 2 - import { onDestroy, onMount } from 'svelte'; 3 - import { type Editor as CoreEditor, mergeAttributes, type Content } from '@tiptap/core'; 4 - import { type Editor, createEditor, EditorContent } from 'svelte-tiptap'; 5 - import StarterKit from '@tiptap/starter-kit'; 6 - import Placeholder from '@tiptap/extension-placeholder'; 7 - import Image from '@tiptap/extension-image'; 8 - import { all, createLowlight } from 'lowlight'; 9 - import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; 10 - import Underline from '@tiptap/extension-underline'; 11 - import type { RichTextTypes } from '.'; 12 - import Slash, { suggestion } from './slash-menu'; 13 - import Typography from '@tiptap/extension-typography'; 14 - import { RichTextLink } from './RichTextLink'; 15 - import { Markdown } from '@tiptap/markdown'; 16 - 17 - import { cn } from '@foxui/core'; 18 - import { ImageUploadNode } from './image-upload/ImageUploadNode'; 19 - import { FormattingBubbleMenu } from './formatting-bubble-menu'; 20 - import type { Transaction } from '@tiptap/pm/state'; 21 - import type { Readable } from 'svelte/store'; 2 + import { CoreRichTextEditor } from '../core-rich-text-editor/'; 3 + import type { BaseEditorProps } from '../core-rich-text-editor/types'; 4 + import FormattingBubbleMenu from '../rich-text-bubble-menu/FormattingBubbleMenu.svelte'; 5 + import { SlashMenu } from '../slash-menu'; 6 + import { ImageExtension, EditableImage } from '../image'; 7 + import type { ImageUploadFn } from '../image'; 8 + import { EmbedExtension } from '../embed/EmbedExtension'; 9 + import EmbedModal from '../embed/EmbedModal.svelte'; 10 + import { youtubeEmbed } from '../embed/helpers'; 11 + import type { EmbedDefinition } from '../embed/helpers'; 22 12 23 13 let { 24 - content = $bindable({}), 25 - placeholder = 'Write or press / for commands', 14 + content = $bindable(), 26 15 editor = $bindable(), 27 16 ref = $bindable(null), 28 - class: className, 29 - onupdate, 30 - ontransaction 31 - }: { 32 - content?: Content; 33 - placeholder?: string; 34 - editor?: Readable<Editor>; 35 - ref?: HTMLDivElement | null; 36 - class?: string; 37 - onupdate?: (content: Content, context: { editor: CoreEditor; transaction: Transaction }) => void; 38 - ontransaction?: () => void; 17 + bubbleMenu = true, 18 + slashMenu = true, 19 + image = undefined, 20 + embeds = undefined, 21 + extraExtensions, 22 + ...restProps 23 + }: Omit<BaseEditorProps, 'extraExtensions'> & { 24 + /** Show the formatting bubble menu on text selection. @default true */ 25 + bubbleMenu?: boolean; 26 + /** Show the slash command menu when typing "/". @default true */ 27 + slashMenu?: boolean; 28 + /** Enable image uploads. Pass an upload function, or `false` to disable. When provided, ImageExtension is added with EditableImage. */ 29 + image?: ImageUploadFn | false; 30 + /** Enable embeds. Pass an array of EmbedDefinitions, `true` for YouTube only, or `false` to disable. @default undefined */ 31 + embeds?: EmbedDefinition[] | boolean; 32 + extraExtensions?: BaseEditorProps['extraExtensions']; 39 33 } = $props(); 40 34 41 - const lowlight = createLowlight(all); 35 + let imageExtensions = $derived( 36 + image 37 + ? [ImageExtension.configure({ upload: image, imageComponent: EditableImage })] 38 + : [] 39 + ); 42 40 43 - let hasFocus = true; 41 + let embedDefinitions = $derived( 42 + embeds === true ? [youtubeEmbed] : Array.isArray(embeds) ? embeds : [] 43 + ); 44 44 45 - const CustomImage = Image.extend({ 46 - renderHTML({ HTMLAttributes }) { 47 - const attrs = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 48 - uploadImageHandler: undefined 49 - }); 50 - return ['img', attrs]; 51 - } 52 - }); 45 + let embedExtensions = $derived( 46 + embedDefinitions.length > 0 47 + ? [EmbedExtension.configure({ embeds: embedDefinitions })] 48 + : [] 49 + ); 53 50 54 - // Flag to track whether a file is being dragged over the drop area 55 - let isDragOver = $state(false); 56 - 57 - // Store local image files for later upload 58 - let localImages: Map<string, File> = $state(new Map()); 59 - 60 - // Track which image URLs in the editor are local previews 61 - let localImageUrls: Set<string> = $state(new Set()); 62 - 63 - // Process image file to create a local preview 64 - async function processImageFile(file: File) { 65 - if (!$editor) { 66 - console.warn('Tiptap editor not initialized'); 67 - return; 68 - } 69 - 70 - try { 71 - const localUrl = URL.createObjectURL(file); 72 - 73 - localImages.set(localUrl, file); 74 - localImageUrls.add(localUrl); 75 - 76 - $editor 77 - .chain() 78 - .focus() 79 - .setImageUploadNode({ 80 - preview: localUrl 81 - }) 82 - .run(); 83 - } catch (error) { 84 - console.error('Error creating image preview:', error); 85 - } 86 - } 87 - 88 - function switchTo(value: RichTextTypes) { 89 - $editor?.chain().focus().setParagraph().run(); 90 - 91 - if (value === 'heading-1') { 92 - $editor?.chain().focus().setNode('heading', { level: 1 }).run(); 93 - } else if (value === 'heading-2') { 94 - $editor?.chain().focus().setNode('heading', { level: 2 }).run(); 95 - } else if (value === 'heading-3') { 96 - $editor?.chain().focus().setNode('heading', { level: 3 }).run(); 97 - } else if (value === 'blockquote') { 98 - $editor?.chain().focus().setBlockquote().run(); 99 - } else if (value === 'code') { 100 - $editor?.chain().focus().setCodeBlock().run(); 101 - } else if (value === 'bullet-list') { 102 - $editor?.chain().focus().toggleBulletList().run(); 103 - } else if (value === 'ordered-list') { 104 - $editor?.chain().focus().toggleOrderedList().run(); 105 - } 106 - } 107 - 108 - onMount(() => { 109 - editor = createEditor({ 110 - extensions: [ 111 - StarterKit.configure({ 112 - dropcursor: { 113 - class: 'text-accent-500/30 rounded-2xl', 114 - width: 2 115 - }, 116 - codeBlock: false, 117 - heading: { 118 - levels: [1, 2, 3] 119 - } 120 - }), 121 - Placeholder.configure({ 122 - placeholder: ({ node }) => { 123 - if (node.type.name === 'paragraph' || node.type.name === 'heading') { 124 - return placeholder; 125 - } 126 - return ''; 127 - } 128 - }), 129 - CustomImage.configure({ 130 - HTMLAttributes: { 131 - class: 'max-w-full object-contain relative rounded-2xl' 132 - }, 133 - allowBase64: true 134 - }), 135 - CodeBlockLowlight.configure({ 136 - lowlight, 137 - defaultLanguage: 'js' 138 - }), 139 - Underline.configure({}), 140 - RichTextLink.configure({ 141 - openOnClick: false, 142 - autolink: true, 143 - defaultProtocol: 'https' 144 - }), 145 - Slash.configure({ 146 - suggestion: suggestion({ 147 - char: '/', 148 - pluginKey: 'slash', 149 - switchTo, 150 - processImageFile 151 - }) 152 - }), 153 - Typography.configure(), 154 - Markdown.configure(), 155 - ImageUploadNode.configure({ 156 - upload: async (file, onProgress, abortSignal) => { 157 - console.log('uploading image', file); 158 - for (let i = 0; i < 10; i++) { 159 - await new Promise((resolve) => setTimeout(resolve, 200)); 160 - onProgress?.({ progress: i / 10 }); 161 - } 162 - 163 - return 'https://picsum.photos/200/300'; 164 - } 165 - }) 166 - ], 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: () => { 183 - ontransaction?.(); 184 - }, 185 - content 186 - }); 187 - }); 188 - 189 - const handlePaste = (event: ClipboardEvent) => { 190 - const items = event.clipboardData?.items; 191 - if (!items) return; 192 - for (const item of Array.from(items)) { 193 - if (!item.type.startsWith('image/')) continue; 194 - const file = item.getAsFile(); 195 - if (!file) continue; 196 - event.preventDefault(); 197 - processImageFile(file); 198 - return; 199 - } 200 - }; 201 - 202 - function handleDragOver(event: DragEvent) { 203 - event.preventDefault(); 204 - event.stopPropagation(); 205 - isDragOver = true; 206 - } 207 - function handleDragLeave(event: DragEvent) { 208 - event.preventDefault(); 209 - event.stopPropagation(); 210 - isDragOver = false; 211 - } 212 - function handleDrop(event: DragEvent) { 213 - event.preventDefault(); 214 - event.stopPropagation(); 215 - isDragOver = false; 216 - if (!event.dataTransfer?.files?.length) return; 217 - const file = event.dataTransfer.files[0]; 218 - if (file?.type.startsWith('image/')) { 219 - processImageFile(file); 220 - } 221 - } 222 - 223 - onDestroy(() => { 224 - for (const localUrl of localImageUrls) { 225 - URL.revokeObjectURL(localUrl); 226 - } 227 - }); 228 - 51 + let allExtraExtensions = $derived([ 52 + ...imageExtensions, 53 + ...embedExtensions, 54 + ...(extraExtensions ?? []) 55 + ]); 229 56 </script> 230 57 231 - <div 232 - bind:this={ref} 233 - class={cn('relative flex-1', className)} 234 - onpaste={handlePaste} 235 - ondragover={handleDragOver} 236 - ondragleave={handleDragLeave} 237 - ondrop={handleDrop} 238 - role="region" 58 + <CoreRichTextEditor 59 + bind:content 60 + bind:editor 61 + bind:ref 62 + extraExtensions={allExtraExtensions} 63 + {...restProps} 239 64 > 240 - {#if $editor} 241 - <EditorContent editor={$editor} /> 65 + {#if bubbleMenu} 66 + <FormattingBubbleMenu {editor} /> 242 67 {/if} 243 - </div> 244 - 245 - {#if $editor} 246 - <FormattingBubbleMenu editor={$editor} /> 247 - {/if} 248 - 249 - <style> 250 - :global(.tiptap) { 251 - :first-child { 252 - margin-top: 0; 253 - } 254 - 255 - :global(img) { 256 - display: block; 257 - height: auto; 258 - margin: 1.5rem 0; 259 - max-width: 100%; 260 - 261 - &.ProseMirror-selectednode { 262 - outline: 3px solid var(--color-accent-500); 263 - } 264 - } 265 - 266 - :global(div[data-type='image-upload']) { 267 - &.ProseMirror-selectednode { 268 - outline: 3px solid var(--color-accent-500); 269 - } 270 - } 271 - 272 - :global(blockquote p:first-of-type::before) { 273 - content: none; 274 - } 275 - 276 - :global(blockquote p:last-of-type::after) { 277 - content: none; 278 - } 279 - 280 - :global(blockquote p) { 281 - font-style: normal; 282 - } 283 - } 284 - 285 - :global(.tiptap .is-empty::before) { 286 - color: var(--color-base-500); 287 - content: attr(data-placeholder); 288 - float: left; 289 - height: 0; 290 - pointer-events: none; 291 - } 292 - </style> 68 + {#if slashMenu} 69 + <SlashMenu editor={$editor} /> 70 + {/if} 71 + {#if embedDefinitions.length > 0} 72 + <EmbedModal editor={$editor} /> 73 + {/if} 74 + </CoreRichTextEditor>
-126
packages/text/src/lib/components/rich-text-editor/RichTextLink.ts
··· 1 - // from https://github.com/Doist/typist/blob/main/src/extensions/rich-text/rich-text-link.ts 2 - import { InputRule, markInputRule, markPasteRule, PasteRule } from '@tiptap/core'; 3 - import { Link } from '@tiptap/extension-link'; 4 - 5 - import type { LinkOptions } from '@tiptap/extension-link'; 6 - 7 - /** 8 - * The input regex for Markdown links with title support, and multiple quotation marks (required 9 - * in case the `Typography` extension is being included). 10 - */ 11 - const inputRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)$/i; 12 - 13 - /** 14 - * The paste regex for Markdown links with title support, and multiple quotation marks (required 15 - * in case the `Typography` extension is being included). 16 - */ 17 - const pasteRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)/gi; 18 - 19 - /** 20 - * Input rule built specifically for the `Link` extension, which ignores the auto-linked URL in 21 - * parentheses (e.g., `(https://doist.dev)`). 22 - * 23 - * @see https://github.com/ueberdosis/tiptap/discussions/1865 24 - */ 25 - function linkInputRule(config: Parameters<typeof markInputRule>[0]) { 26 - const defaultMarkInputRule = markInputRule(config); 27 - 28 - return new InputRule({ 29 - find: config.find, 30 - handler(props) { 31 - const { tr } = props.state; 32 - 33 - defaultMarkInputRule.handler(props); 34 - tr.setMeta('preventAutolink', true); 35 - } 36 - }); 37 - } 38 - 39 - /** 40 - * Paste rule built specifically for the `Link` extension, which ignores the auto-linked URL in 41 - * parentheses (e.g., `(https://doist.dev)`). This extension was inspired from the multiple 42 - * implementations found in a Tiptap discussion at GitHub. 43 - * 44 - * @see https://github.com/ueberdosis/tiptap/discussions/1865 45 - */ 46 - function linkPasteRule(config: Parameters<typeof markPasteRule>[0]) { 47 - const defaultMarkPasteRule = markPasteRule(config); 48 - 49 - return new PasteRule({ 50 - find: config.find, 51 - handler(props) { 52 - const { tr } = props.state; 53 - 54 - defaultMarkPasteRule.handler(props); 55 - tr.setMeta('preventAutolink', true); 56 - } 57 - }); 58 - } 59 - 60 - /** 61 - * The options available to customize the `RichTextLink` extension. 62 - */ 63 - type RichTextLinkOptions = LinkOptions; 64 - 65 - /** 66 - * Custom extension that extends the built-in `Link` extension to add additional input/paste rules 67 - * for converting the Markdown link syntax (i.e. `[Doist](https://doist.com)`) into links, and also 68 - * adds support for the `title` attribute. 69 - */ 70 - const RichTextLink = Link.extend<RichTextLinkOptions>({ 71 - inclusive: false, 72 - addOptions() { 73 - return { 74 - ...this.parent?.(), 75 - openOnClick: 'whenNotEditable' 76 - }; 77 - }, 78 - addAttributes() { 79 - return { 80 - ...this.parent?.(), 81 - title: { 82 - default: null 83 - } 84 - }; 85 - }, 86 - addInputRules() { 87 - return [ 88 - linkInputRule({ 89 - find: inputRegex, 90 - type: this.type, 91 - 92 - // We need to use `pop()` to remove the last capture groups from the match to 93 - // satisfy Tiptap's `markPasteRule` expectation of having the content as the last 94 - // capture group in the match (this makes the attribute order important) 95 - getAttributes(match) { 96 - return { 97 - title: match.pop()?.trim(), 98 - href: match.pop()?.trim() 99 - }; 100 - } 101 - }) 102 - ]; 103 - }, 104 - addPasteRules() { 105 - return [ 106 - linkPasteRule({ 107 - find: pasteRegex, 108 - type: this.type, 109 - 110 - // We need to use `pop()` to remove the last capture groups from the match to 111 - // satisfy Tiptap's `markInputRule` expectation of having the content as the last 112 - // capture group in the match (this makes the attribute order important) 113 - getAttributes(match) { 114 - return { 115 - title: match.pop()?.trim(), 116 - href: match.pop()?.trim() 117 - }; 118 - } 119 - }) 120 - ]; 121 - } 122 - }); 123 - 124 - export { RichTextLink }; 125 - 126 - export type { RichTextLinkOptions };
-208
packages/text/src/lib/components/rich-text-editor/code.css
··· 1 - /* Structural styles for code blocks */ 2 - .tiptap { 3 - :first-child { 4 - margin-top: 0; 5 - } 6 - 7 - pre { 8 - border-radius: 1rem; 9 - font-family: 'JetBrainsMono', monospace; 10 - margin: 1.5rem 0; 11 - padding: 0.75rem 1rem; 12 - 13 - code { 14 - background: none; 15 - color: inherit; 16 - font-size: 0.8rem; 17 - padding: 0; 18 - } 19 - } 20 - } 21 - 22 - /* GitHub Light theme (default) */ 23 - .tiptap pre { 24 - background: #ffffff; 25 - color: #24292e; 26 - border: 1px solid #d0d7de; 27 - } 28 - 29 - .tiptap pre .hljs-doctag, 30 - .tiptap pre .hljs-keyword, 31 - .tiptap pre .hljs-meta .hljs-keyword, 32 - .tiptap pre .hljs-template-tag, 33 - .tiptap pre .hljs-template-variable, 34 - .tiptap pre .hljs-type, 35 - .tiptap pre .hljs-variable.language_ { 36 - color: #d73a49; 37 - } 38 - 39 - .tiptap pre .hljs-title, 40 - .tiptap pre .hljs-title.class_, 41 - .tiptap pre .hljs-title.class_.inherited__, 42 - .tiptap pre .hljs-title.function_ { 43 - color: #6f42c1; 44 - } 45 - 46 - .tiptap pre .hljs-attr, 47 - .tiptap pre .hljs-attribute, 48 - .tiptap pre .hljs-literal, 49 - .tiptap pre .hljs-meta, 50 - .tiptap pre .hljs-number, 51 - .tiptap pre .hljs-operator, 52 - .tiptap pre .hljs-selector-attr, 53 - .tiptap pre .hljs-selector-class, 54 - .tiptap pre .hljs-selector-id, 55 - .tiptap pre .hljs-variable { 56 - color: #005cc5; 57 - } 58 - 59 - .tiptap pre .hljs-meta .hljs-string, 60 - .tiptap pre .hljs-regexp, 61 - .tiptap pre .hljs-string { 62 - color: #032f62; 63 - } 64 - 65 - .tiptap pre .hljs-built_in, 66 - .tiptap pre .hljs-symbol { 67 - color: #e36209; 68 - } 69 - 70 - .tiptap pre .hljs-code, 71 - .tiptap pre .hljs-comment, 72 - .tiptap pre .hljs-formula { 73 - color: #6a737d; 74 - } 75 - 76 - .tiptap pre .hljs-name, 77 - .tiptap pre .hljs-quote, 78 - .tiptap pre .hljs-selector-pseudo, 79 - .tiptap pre .hljs-selector-tag { 80 - color: #22863a; 81 - } 82 - 83 - .tiptap pre .hljs-subst { 84 - color: #24292e; 85 - } 86 - 87 - .tiptap pre .hljs-section { 88 - color: #005cc5; 89 - font-weight: bold; 90 - } 91 - 92 - .tiptap pre .hljs-bullet { 93 - color: #735c0f; 94 - } 95 - 96 - .tiptap pre .hljs-emphasis { 97 - color: #24292e; 98 - font-style: italic; 99 - } 100 - 101 - .tiptap pre .hljs-strong { 102 - color: #24292e; 103 - font-weight: bold; 104 - } 105 - 106 - .tiptap pre .hljs-addition { 107 - color: #22863a; 108 - background-color: #f0fff4; 109 - } 110 - 111 - .tiptap pre .hljs-deletion { 112 - color: #b31d28; 113 - background-color: #ffeef0; 114 - } 115 - 116 - /* GitHub Dark theme */ 117 - .dark .tiptap pre { 118 - background: #0d1117; 119 - color: #c9d1d9; 120 - border: 1px solid #30363d; 121 - } 122 - 123 - .dark .tiptap pre .hljs-doctag, 124 - .dark .tiptap pre .hljs-keyword, 125 - .dark .tiptap pre .hljs-meta .hljs-keyword, 126 - .dark .tiptap pre .hljs-template-tag, 127 - .dark .tiptap pre .hljs-template-variable, 128 - .dark .tiptap pre .hljs-type, 129 - .dark .tiptap pre .hljs-variable.language_ { 130 - color: #ff7b72; 131 - } 132 - 133 - .dark .tiptap pre .hljs-title, 134 - .dark .tiptap pre .hljs-title.class_, 135 - .dark .tiptap pre .hljs-title.class_.inherited__, 136 - .dark .tiptap pre .hljs-title.function_ { 137 - color: #d2a8ff; 138 - } 139 - 140 - .dark .tiptap pre .hljs-attr, 141 - .dark .tiptap pre .hljs-attribute, 142 - .dark .tiptap pre .hljs-literal, 143 - .dark .tiptap pre .hljs-meta, 144 - .dark .tiptap pre .hljs-number, 145 - .dark .tiptap pre .hljs-operator, 146 - .dark .tiptap pre .hljs-selector-attr, 147 - .dark .tiptap pre .hljs-selector-class, 148 - .dark .tiptap pre .hljs-selector-id, 149 - .dark .tiptap pre .hljs-variable { 150 - color: #79c0ff; 151 - } 152 - 153 - .dark .tiptap pre .hljs-meta .hljs-string, 154 - .dark .tiptap pre .hljs-regexp, 155 - .dark .tiptap pre .hljs-string { 156 - color: #a5d6ff; 157 - } 158 - 159 - .dark .tiptap pre .hljs-built_in, 160 - .dark .tiptap pre .hljs-symbol { 161 - color: #ffa657; 162 - } 163 - 164 - .dark .tiptap pre .hljs-code, 165 - .dark .tiptap pre .hljs-comment, 166 - .dark .tiptap pre .hljs-formula { 167 - color: #8b949e; 168 - } 169 - 170 - .dark .tiptap pre .hljs-name, 171 - .dark .tiptap pre .hljs-quote, 172 - .dark .tiptap pre .hljs-selector-pseudo, 173 - .dark .tiptap pre .hljs-selector-tag { 174 - color: #7ee787; 175 - } 176 - 177 - .dark .tiptap pre .hljs-subst { 178 - color: #c9d1d9; 179 - } 180 - 181 - .dark .tiptap pre .hljs-section { 182 - color: #79c0ff; 183 - font-weight: bold; 184 - } 185 - 186 - .dark .tiptap pre .hljs-bullet { 187 - color: #f2cc60; 188 - } 189 - 190 - .dark .tiptap pre .hljs-emphasis { 191 - color: #c9d1d9; 192 - font-style: italic; 193 - } 194 - 195 - .dark .tiptap pre .hljs-strong { 196 - color: #c9d1d9; 197 - font-weight: bold; 198 - } 199 - 200 - .dark .tiptap pre .hljs-addition { 201 - color: #aff5b4; 202 - background-color: #033a16; 203 - } 204 - 205 - .dark .tiptap pre .hljs-deletion { 206 - color: #ffdcd7; 207 - background-color: #67060c; 208 - }
-37
packages/text/src/lib/components/rich-text-editor/index.ts
··· 1 - // Main component (all-in-one convenience) 2 1 export { default as RichTextEditor } from './RichTextEditor.svelte'; 3 - 4 - // Standalone composable components 5 - export { FormattingBubbleMenu } from './formatting-bubble-menu'; 6 - 7 - // Re-export svelte-tiptap primitives for convenience 8 - export { 9 - createEditor, 10 - EditorContent, 11 - BubbleMenu, 12 - FloatingMenu, 13 - SvelteNodeViewRenderer, 14 - NodeViewWrapper, 15 - NodeViewContent 16 - } from 'svelte-tiptap'; 17 - 18 - // Extensions 19 - export { RichTextLink } from './RichTextLink'; 20 - export { ImageUploadNode } from './image-upload/ImageUploadNode'; 21 - export { default as Slash, suggestion } from './slash-menu'; 22 - 23 - // Types 24 - export type RichTextTypes = 25 - | 'paragraph' 26 - | 'heading-1' 27 - | 'heading-2' 28 - | 'heading-3' 29 - | 'blockquote' 30 - | 'code' 31 - | 'bullet-list' 32 - | 'ordered-list'; 33 - 34 - export type { RichTextLinkOptions } from './RichTextLink'; 35 - export type { UploadFunction, ImageUploadNodeOptions } from './image-upload/ImageUploadNode'; 36 - 37 - // Code theme CSS (side-effect import for consumers who want it) 38 - import './code.css';
+134
packages/text/src/lib/components/slash-menu/SlashMenu.svelte
··· 1 + <script lang="ts" module> 2 + import type { Editor, Range } from '@tiptap/core'; 3 + 4 + export type SlashMenuItem = { 5 + id: string; 6 + label: string; 7 + description?: string; 8 + icon?: string; 9 + command: (props: { editor: Editor; range: Range }) => void; 10 + }; 11 + </script> 12 + 13 + <script lang="ts"> 14 + import { onMount, mount, unmount } from 'svelte'; 15 + import Suggestion, { type SuggestionKeyDownProps, type SuggestionProps } from '@tiptap/suggestion'; 16 + import { PluginKey } from '@tiptap/pm/state'; 17 + import { computePosition, flip, shift, offset } from '@floating-ui/dom'; 18 + import type { Snippet } from 'svelte'; 19 + import SlashMenuPopup from './SlashMenuPopup.svelte'; 20 + import { getSlashMenuItems } from './items'; 21 + 22 + let { 23 + editor, 24 + items: itemsProp, 25 + char = '/', 26 + pluginKey = 'slashMenu', 27 + class: className, 28 + item: itemSnippet 29 + }: { 30 + editor: Editor; 31 + items?: SlashMenuItem[] | ((query: string) => SlashMenuItem[] | Promise<SlashMenuItem[]>); 32 + char?: string; 33 + pluginKey?: string; 34 + class?: string; 35 + item?: Snippet<[{ item: SlashMenuItem; isActive: boolean; select: () => void }]>; 36 + } = $props(); 37 + 38 + onMount(() => { 39 + const key = new PluginKey(pluginKey); 40 + let floatingEl: HTMLElement; 41 + let component: Record<string, any>; 42 + 43 + function updatePosition(clientRect: (() => DOMRect | null) | null | undefined) { 44 + if (!clientRect || !floatingEl) return; 45 + const rect = clientRect(); 46 + if (!rect) return; 47 + 48 + const virtualRef = { getBoundingClientRect: () => rect }; 49 + 50 + computePosition(virtualRef as Element, floatingEl, { 51 + placement: 'bottom-start', 52 + middleware: [offset(8), flip(), shift({ padding: 8 })] 53 + }).then(({ x, y }) => { 54 + Object.assign(floatingEl.style, { 55 + left: `${x}px`, 56 + top: `${y}px` 57 + }); 58 + }); 59 + } 60 + 61 + const plugin = Suggestion({ 62 + editor, 63 + char, 64 + pluginKey: key, 65 + 66 + command: ({ editor: ed, range, props }) => { 67 + props.command({ editor: ed, range }); 68 + }, 69 + 70 + items: async ({ query }) => { 71 + let resolved: SlashMenuItem[]; 72 + 73 + if (typeof itemsProp === 'function') { 74 + resolved = await itemsProp(query); 75 + } else { 76 + resolved = itemsProp ?? getSlashMenuItems(editor); 77 + } 78 + 79 + if (!query) return resolved; 80 + return resolved.filter((item) => 81 + item.label.toLowerCase().includes(query.toLowerCase()) 82 + ); 83 + }, 84 + 85 + render: () => ({ 86 + onStart: (props: SuggestionProps) => { 87 + floatingEl = document.createElement('div'); 88 + floatingEl.style.position = 'absolute'; 89 + floatingEl.style.zIndex = '50'; 90 + document.body.appendChild(floatingEl); 91 + 92 + component = mount(SlashMenuPopup, { 93 + target: floatingEl, 94 + props: { 95 + items: props.items as SlashMenuItem[], 96 + editor: props.editor, 97 + range: props.range, 98 + class: className, 99 + item: itemSnippet 100 + } 101 + }); 102 + 103 + updatePosition(props.clientRect); 104 + }, 105 + onUpdate: (props: SuggestionProps) => { 106 + component.setItems(props.items as SlashMenuItem[]); 107 + component.setRange(props.range); 108 + updatePosition(props.clientRect); 109 + }, 110 + onKeyDown: (props: SuggestionKeyDownProps) => { 111 + if (props.event.key === 'Escape') { 112 + floatingEl.style.display = 'none'; 113 + return true; 114 + } 115 + return component.onKeyDown(props.event); 116 + }, 117 + onExit: () => { 118 + unmount(component); 119 + floatingEl.remove(); 120 + } 121 + }) 122 + }); 123 + 124 + // Insert at the beginning so handleKeyDown runs before the base keymap 125 + const { state } = editor.view; 126 + editor.view.updateState( 127 + state.reconfigure({ plugins: [plugin, ...state.plugins] }) 128 + ); 129 + 130 + return () => { 131 + editor.unregisterPlugin(key); 132 + }; 133 + }); 134 + </script>
+86
packages/text/src/lib/components/slash-menu/SlashMenuPopup.svelte
··· 1 + <script lang="ts"> 2 + import type { SlashMenuItem } from './SlashMenu.svelte'; 3 + import type { Snippet } from 'svelte'; 4 + import type { Editor, Range } from '@tiptap/core'; 5 + import { cn } from '@foxui/core'; 6 + 7 + let { 8 + items, 9 + editor, 10 + range, 11 + class: className, 12 + item: itemSnippet 13 + }: { 14 + items: SlashMenuItem[]; 15 + editor: Editor; 16 + range: Range; 17 + class?: string; 18 + item?: Snippet<[{ item: SlashMenuItem; isActive: boolean; select: () => void }]>; 19 + } = $props(); 20 + 21 + let activeIndex = $state(0); 22 + 23 + export function setItems(value: SlashMenuItem[]) { 24 + items = value; 25 + activeIndex = 0; 26 + } 27 + 28 + export function setRange(value: Range) { 29 + range = value; 30 + } 31 + 32 + export function onKeyDown(event: KeyboardEvent): boolean { 33 + if (items.length === 0) return false; 34 + 35 + switch (event.key) { 36 + case 'ArrowUp': { 37 + activeIndex = (activeIndex - 1 + items.length) % items.length; 38 + return true; 39 + } 40 + case 'ArrowDown': { 41 + activeIndex = (activeIndex + 1) % items.length; 42 + return true; 43 + } 44 + case 'Enter': { 45 + const selected = items[activeIndex]; 46 + if (selected) { 47 + selected.command({ editor, range }); 48 + return true; 49 + } 50 + } 51 + } 52 + 53 + return false; 54 + } 55 + </script> 56 + 57 + {#if items.length > 0} 58 + <div 59 + class={cn( 60 + 'bg-base-100 dark:bg-base-800 border-base-200 dark:border-base-700 text-base-900 dark:text-base-200 z-50 flex w-fit flex-col rounded-2xl border p-1 shadow-md outline-none', 61 + className 62 + )} 63 + > 64 + {#each items as slashItem, index (slashItem.id)} 65 + {#if itemSnippet} 66 + {@render itemSnippet({ 67 + item: slashItem, 68 + isActive: activeIndex === index, 69 + select: () => slashItem.command({ editor, range }) 70 + })} 71 + {:else} 72 + <button 73 + onclick={() => slashItem.command({ editor, range })} 74 + class={cn( 75 + 'flex w-full cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-sm', 76 + activeIndex === index 77 + ? 'bg-accent-500/10 text-accent-700 dark:text-accent-400' 78 + : 'hover:bg-accent-500/10' 79 + )} 80 + > 81 + <span>{slashItem.label}</span> 82 + </button> 83 + {/if} 84 + {/each} 85 + </div> 86 + {/if}
+3
packages/text/src/lib/components/slash-menu/index.ts
··· 1 + export { default as SlashMenu } from './SlashMenu.svelte'; 2 + export type { SlashMenuItem } from './SlashMenu.svelte'; 3 + export { getSlashMenuItems, getBlockTypeItems, getImageItem } from './items';
+140
packages/text/src/lib/components/slash-menu/items.ts
··· 1 + import type { Editor, Range } from '@tiptap/core'; 2 + import { pickAndInsertImage } from '../image/helpers'; 3 + import { openEmbedModal } from '../embed/helpers'; 4 + 5 + type SlashMenuItem = { 6 + id: string; 7 + label: string; 8 + description?: string; 9 + icon?: string; 10 + command: (props: { editor: Editor; range: Range }) => void; 11 + }; 12 + 13 + type SlashItemDef = SlashMenuItem & { 14 + /** Check if the required extension is available in the editor */ 15 + available: (editor: Editor) => boolean; 16 + }; 17 + 18 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 19 + const chain = (editor: Editor, range: Range) => editor.chain().focus().deleteRange(range) as any; 20 + 21 + const hasNode = (editor: Editor, name: string) => !!editor.schema.nodes[name]; 22 + const hasExtension = (editor: Editor, name: string) => 23 + editor.extensionManager.extensions.some((ext) => ext.name === name); 24 + 25 + const allItems: SlashItemDef[] = [ 26 + { 27 + id: 'paragraph', 28 + label: 'Paragraph', 29 + description: 'Plain text', 30 + available: (e) => hasNode(e, 'paragraph'), 31 + command: ({ editor, range }) => chain(editor, range).setParagraph().run() 32 + }, 33 + { 34 + id: 'heading-1', 35 + label: 'Heading 1', 36 + description: 'Large heading', 37 + available: (e) => hasNode(e, 'heading'), 38 + command: ({ editor, range }) => chain(editor, range).toggleHeading({ level: 1 }).run() 39 + }, 40 + { 41 + id: 'heading-2', 42 + label: 'Heading 2', 43 + description: 'Medium heading', 44 + available: (e) => hasNode(e, 'heading'), 45 + command: ({ editor, range }) => chain(editor, range).toggleHeading({ level: 2 }).run() 46 + }, 47 + { 48 + id: 'heading-3', 49 + label: 'Heading 3', 50 + description: 'Small heading', 51 + available: (e) => hasNode(e, 'heading'), 52 + command: ({ editor, range }) => chain(editor, range).toggleHeading({ level: 3 }).run() 53 + }, 54 + { 55 + id: 'blockquote', 56 + label: 'Blockquote', 57 + description: 'Quoted text', 58 + available: (e) => hasNode(e, 'blockquote'), 59 + command: ({ editor, range }) => chain(editor, range).toggleBlockquote().run() 60 + }, 61 + { 62 + id: 'code-block', 63 + label: 'Code Block', 64 + description: 'Code with syntax highlighting', 65 + available: (e) => hasNode(e, 'codeBlock'), 66 + command: ({ editor, range }) => chain(editor, range).toggleCodeBlock().run() 67 + }, 68 + { 69 + id: 'bullet-list', 70 + label: 'Bullet List', 71 + description: 'Unordered list', 72 + available: (e) => hasNode(e, 'bulletList'), 73 + command: ({ editor, range }) => chain(editor, range).toggleBulletList().run() 74 + }, 75 + { 76 + id: 'ordered-list', 77 + label: 'Ordered List', 78 + description: 'Numbered list', 79 + available: (e) => hasNode(e, 'orderedList'), 80 + command: ({ editor, range }) => chain(editor, range).toggleOrderedList().run() 81 + }, 82 + { 83 + id: 'image', 84 + label: 'Image', 85 + description: 'Upload an image', 86 + available: (e) => hasExtension(e, 'imageExtension'), 87 + command: ({ editor, range }) => { 88 + editor.chain().focus().deleteRange(range).run(); 89 + pickAndInsertImage(editor); 90 + } 91 + }, 92 + { 93 + id: 'embed', 94 + label: 'Embed', 95 + description: 'Embed a URL', 96 + available: (e) => hasExtension(e, 'embedExtension'), 97 + command: ({ editor, range }) => { 98 + editor.chain().focus().deleteRange(range).run(); 99 + openEmbedModal(editor); 100 + } 101 + } 102 + ]; 103 + 104 + /** 105 + * Returns slash menu items filtered to only those whose extensions 106 + * are actually registered in the editor. Safe to call with any editor config. 107 + * 108 + * @example 109 + * ```svelte 110 + * <SlashMenu editor={$editor} items={getSlashMenuItems($editor)} /> 111 + * ``` 112 + * 113 + * @example Filter or extend: 114 + * ```ts 115 + * const items = [ 116 + * ...getSlashMenuItems(editor).filter(i => i.id !== 'code-block'), 117 + * { id: 'divider', label: 'Divider', command: ... } 118 + * ]; 119 + * ``` 120 + */ 121 + export function getSlashMenuItems(editor: Editor): SlashMenuItem[] { 122 + return allItems.filter((item) => item.available(editor)); 123 + } 124 + 125 + /** 126 + * All block type items (paragraph, headings, blockquote, code, lists). 127 + * Filtered at runtime — only items with registered extensions are returned. 128 + */ 129 + export function getBlockTypeItems(editor: Editor): SlashMenuItem[] { 130 + return allItems 131 + .filter((item) => item.id !== 'image' && item.available(editor)); 132 + } 133 + 134 + /** 135 + * Image slash menu item, or undefined if ImageExtension is not registered. 136 + */ 137 + export function getImageItem(editor: Editor): SlashMenuItem | undefined { 138 + const item = allItems.find((i) => i.id === 'image'); 139 + return item && item.available(editor) ? item : undefined; 140 + }
+7
apps/docs/src/lib/docs/social/bluesky-post-creator/Card.svelte
··· 1 + <div class="flex h-full w-full items-center justify-center p-4"> 2 + <div class="border-base-300 dark:border-base-700 w-full rounded-xl border p-3"> 3 + <div class="bg-base-200 dark:bg-base-700 h-3 w-3/4 rounded"></div> 4 + <div class="bg-base-200 dark:bg-base-700 mt-1.5 h-3 w-1/2 rounded"></div> 5 + <div class="text-base-400 mt-2 text-right text-[10px]">280</div> 6 + </div> 7 + </div>
+35
apps/docs/src/lib/docs/social/bluesky-post-creator/Documentation.md
··· 1 + ## Usage 2 + 3 + ```svelte 4 + <script lang="ts"> 5 + import { BlueskyPostCreator, type BlueskyPostContent } from '@foxui/social'; 6 + 7 + let content: BlueskyPostContent = $state({ text: '', facets: [] }); 8 + </script> 9 + 10 + <BlueskyPostCreator 11 + bind:content 12 + placeholder="What's on your mind?" 13 + maxLength={300} 14 + /> 15 + ``` 16 + 17 + ## Features 18 + 19 + - **@mentions** - Type `@` followed by at least 2 characters to search for Bluesky handles. Select from the popup to insert a mention with the user's DID. 20 + - **Links** - URLs are automatically detected and linked. 21 + - **#hashtags** - Type `#tag` and hashtags are automatically detected in the output facets. 22 + - **Character count** - Shows remaining characters with color indicators (amber < 20, red when over limit). 23 + 24 + ## Output 25 + 26 + The `content` bindable returns a `BlueskyPostContent` object: 27 + 28 + ```typescript 29 + { 30 + text: string; // Plain text of the post 31 + facets: BlueskyFacet[]; // Byte-indexed facets for mentions, links, and tags 32 + } 33 + ``` 34 + 35 + Each facet has the Bluesky AT Protocol format with `index.byteStart`, `index.byteEnd`, and `features` array containing the facet type (`#mention`, `#link`, or `#tag`).
+19
apps/docs/src/lib/docs/social/bluesky-post-creator/Example.svelte
··· 1 + <script lang="ts"> 2 + import { BlueskyPostCreator, type BlueskyPostContent } from '@foxui/all'; 3 + 4 + let content: BlueskyPostContent = $state({ text: '', facets: [] }); 5 + </script> 6 + 7 + <div class="not-prose flex w-full flex-col gap-4"> 8 + <BlueskyPostCreator 9 + bind:content 10 + class="border-base-300 dark:border-base-700 rounded-2xl border p-4" 11 + /> 12 + 13 + {#if content.text} 14 + <details class="text-base-500 text-xs"> 15 + <summary class="cursor-pointer">Output</summary> 16 + <pre class="bg-base-100 dark:bg-base-800 mt-2 overflow-x-auto rounded-xl p-3">{JSON.stringify(content, null, 2)}</pre> 17 + </details> 18 + {/if} 19 + </div>
+61
apps/docs/src/lib/docs/social/bluesky-post-creator/api.ts
··· 1 + import type { APISchema } from '$lib/types/schema'; 2 + 3 + export default [ 4 + { 5 + title: 'BlueskyPostCreator', 6 + description: 7 + 'A Bluesky post composition editor with support for @mentions (with handle search), auto-linked URLs, and #hashtags. Outputs Bluesky-compatible text + facets.', 8 + props: { 9 + content: { 10 + type: { 11 + type: 'object', 12 + definition: '{ text: string; facets: BlueskyFacet[] }' 13 + }, 14 + description: 15 + 'The post content with plain text and byte-indexed facets for mentions, links, and tags.', 16 + bindable: true 17 + }, 18 + placeholder: { 19 + type: 'string', 20 + description: 'Placeholder text shown when the editor is empty.', 21 + default: "\"What's on your mind?\"" 22 + }, 23 + maxLength: { 24 + type: 'number', 25 + description: 26 + 'Maximum character count. A counter is displayed that changes color as the limit approaches.', 27 + default: '300' 28 + }, 29 + onupdate: { 30 + type: { 31 + type: 'function', 32 + definition: '(content: BlueskyPostContent) => void' 33 + }, 34 + description: 'Callback invoked whenever the post content changes.' 35 + }, 36 + host: { 37 + type: 'string', 38 + description: 39 + 'Custom AT Protocol API host for handle search. Defaults to the public Bluesky API.', 40 + default: "'https://public.api.bsky.app'" 41 + }, 42 + editor: { 43 + type: { 44 + type: 'object', 45 + definition: 'Readable<Editor>' 46 + }, 47 + description: 'The underlying tiptap editor instance (svelte-tiptap store).', 48 + bindable: true 49 + }, 50 + ref: { 51 + type: 'HTMLDivElement', 52 + description: 'Reference to the container element.', 53 + bindable: true 54 + }, 55 + class: { 56 + type: 'string', 57 + description: 'Additional CSS classes for the container.' 58 + } 59 + } 60 + } 61 + ] satisfies APISchema[];
+13
apps/docs/src/lib/docs/social/bluesky-post-creator/index.ts
··· 1 + import Docs from './Documentation.md'; 2 + import Example from './Example.svelte'; 3 + import Card from './Card.svelte'; 4 + import api from './api'; 5 + 6 + export default { 7 + slug: 'bluesky-post-creator', 8 + title: 'Bluesky Post Creator', 9 + docs: Docs, 10 + example: Example, 11 + card: Card, 12 + api 13 + };
+24
apps/docs/src/lib/docs/text/block-type-menu/Card.svelte
··· 1 + <div class="flex h-full w-full items-center justify-center p-4"> 2 + <div class="flex items-center gap-1"> 3 + <div 4 + class="bg-accent-500/15 text-accent-700 dark:text-accent-400 rounded-lg px-2 py-1 text-[10px] font-medium" 5 + > 6 + P 7 + </div> 8 + <div 9 + class="text-base-500 hover:bg-base-200 dark:hover:bg-base-700 rounded-lg px-2 py-1 text-[10px] font-medium" 10 + > 11 + H1 12 + </div> 13 + <div 14 + class="text-base-500 hover:bg-base-200 dark:hover:bg-base-700 rounded-lg px-2 py-1 text-[10px] font-medium" 15 + > 16 + H2 17 + </div> 18 + <div 19 + class="text-base-500 hover:bg-base-200 dark:hover:bg-base-700 rounded-lg px-2 py-1 text-[10px] font-medium" 20 + > 21 + H3 22 + </div> 23 + </div> 24 + </div>
+50
apps/docs/src/lib/docs/text/block-type-menu/Documentation.md
··· 1 + ## Usage 2 + 3 + `BlockTypeMenu` renders a row of block type buttons that reflect the current selection. Click a button to toggle between paragraph, headings, lists, and more. 4 + 5 + ```svelte 6 + <script lang="ts"> 7 + import { CoreRichTextEditor, BlockTypeMenu, type SvelteTiptap } from '@foxui/text'; 8 + import type { Readable } from 'svelte/store'; 9 + 10 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 11 + </script> 12 + 13 + <CoreRichTextEditor bind:editor /> 14 + {#if $editor} 15 + <BlockTypeMenu {editor} /> 16 + {/if} 17 + ``` 18 + 19 + Default items include Paragraph, Heading 1–3, Blockquote, Code Block, Bullet List, and Ordered List. Only items with registered extensions are shown. 20 + 21 + ## Custom items 22 + 23 + Pass `items` to customize the available block types. 24 + 25 + ```svelte 26 + <script lang="ts"> 27 + import { BlockTypeMenu, type BlockTypeItem } from '@foxui/text'; 28 + 29 + const items: BlockTypeItem[] = [ 30 + { 31 + id: 'paragraph', 32 + label: 'P', 33 + isActive: (e) => e.isActive('paragraph'), 34 + command: (e) => e.chain().focus().setParagraph().run() 35 + }, 36 + { 37 + id: 'heading-1', 38 + label: 'H1', 39 + isActive: (e) => e.isActive('heading', { level: 1 }), 40 + command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() 41 + } 42 + ]; 43 + </script> 44 + 45 + <BlockTypeMenu {editor} {items} /> 46 + ``` 47 + 48 + ## How it works 49 + 50 + `BlockTypeMenu` listens to editor transactions and tracks which items are active. The active item is highlighted with an accent color. Paragraph is only highlighted when it's the only active item — so it doesn't show as active alongside a heading or list.
+13
apps/docs/src/lib/docs/text/block-type-menu/Example.svelte
··· 1 + <script lang="ts"> 2 + import { CoreRichTextEditor, BlockTypeMenu, Box, type SvelteTiptap } from '@foxui/all'; 3 + import type { Readable } from 'svelte/store'; 4 + 5 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 6 + </script> 7 + 8 + <Box class="not-prose space-y-2"> 9 + {#if $editor} 10 + <BlockTypeMenu {editor} /> 11 + {/if} 12 + <CoreRichTextEditor bind:editor placeholder="Write something, then use the buttons above..." class="min-h-24" /> 13 + </Box>
+58
apps/docs/src/lib/docs/text/block-type-menu/api.ts
··· 1 + import type { APISchema } from '$lib/types/schema'; 2 + 3 + export default [ 4 + { 5 + title: 'BlockTypeMenu', 6 + description: 7 + 'A row of block type buttons that reflect the current selection. Only shows items for registered extensions.', 8 + props: { 9 + editor: { 10 + type: 'Readable<Editor>', 11 + description: 'The editor store.', 12 + required: true, 13 + bindable: true 14 + }, 15 + items: { 16 + type: { type: 'object', definition: 'BlockTypeItem[]' }, 17 + description: 18 + 'Custom block type items. Defaults to Paragraph, Heading 1–3, Blockquote, Code Block, Bullet List, Ordered List.' 19 + }, 20 + class: { 21 + type: 'string', 22 + description: 'CSS classes for the container.' 23 + } 24 + } 25 + }, 26 + { 27 + title: 'BlockTypeItem', 28 + description: 'The data shape for block type items.', 29 + props: { 30 + id: { 31 + type: 'string', 32 + description: 'Unique identifier.', 33 + required: true 34 + }, 35 + label: { 36 + type: 'string', 37 + description: 'Display label for the button.', 38 + required: true 39 + }, 40 + isActive: { 41 + type: { 42 + type: 'function', 43 + definition: '(editor: Editor) => boolean' 44 + }, 45 + description: 'Returns whether this block type is currently active.', 46 + required: true 47 + }, 48 + command: { 49 + type: { 50 + type: 'function', 51 + definition: '(editor: Editor) => void' 52 + }, 53 + description: 'Called when the button is clicked.', 54 + required: true 55 + } 56 + } 57 + } 58 + ] satisfies APISchema[];
+13
apps/docs/src/lib/docs/text/block-type-menu/index.ts
··· 1 + import Docs from './Documentation.md'; 2 + import Example from './Example.svelte'; 3 + import Card from './Card.svelte'; 4 + import api from './api'; 5 + 6 + export default { 7 + slug: 'block-type-menu', 8 + title: 'Block Type Menu', 9 + docs: Docs, 10 + example: Example, 11 + card: Card, 12 + api 13 + };
+17
apps/docs/src/lib/docs/text/bubble-menu/Card.svelte
··· 1 + <div class="flex h-full w-full items-center justify-center p-4"> 2 + <div class="space-y-2"> 3 + <div 4 + class="bg-base-200 dark:bg-base-700 flex items-center gap-1 rounded-xl px-2 py-1.5 shadow-sm" 5 + > 6 + <div class="bg-base-400 dark:bg-base-500 h-4 w-4 rounded"></div> 7 + <div class="bg-base-400 dark:bg-base-500 h-4 w-4 rounded"></div> 8 + <div class="bg-base-400 dark:bg-base-500 h-4 w-4 rounded"></div> 9 + <div class="bg-base-400 dark:bg-base-500 h-4 w-4 rounded"></div> 10 + </div> 11 + <div class="flex items-center gap-1"> 12 + <div class="bg-base-200 dark:bg-base-700 h-3 w-8 rounded"></div> 13 + <div class="bg-accent-500/30 h-3 w-16 rounded"></div> 14 + <div class="bg-base-200 dark:bg-base-700 h-3 w-10 rounded"></div> 15 + </div> 16 + </div> 17 + </div>
+63
apps/docs/src/lib/docs/text/bubble-menu/Documentation.md
··· 1 + ## Usage 2 + 3 + `FormattingBubbleMenu` provides a ready-to-use formatting toolbar that appears when text is selected. It includes bold, italic, underline, strikethrough, and link controls. 4 + 5 + ```svelte 6 + <script lang="ts"> 7 + import { CoreRichTextEditor, FormattingBubbleMenu, type SvelteTiptap } from '@foxui/text'; 8 + import type { Readable } from 'svelte/store'; 9 + 10 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 11 + </script> 12 + 13 + <CoreRichTextEditor bind:editor> 14 + <FormattingBubbleMenu {editor} /> 15 + </CoreRichTextEditor> 16 + ``` 17 + 18 + ## Adding extra controls 19 + 20 + Use the `children` snippet to add custom controls after the default formatting toggles. 21 + 22 + ```svelte 23 + <FormattingBubbleMenu {editor}> 24 + <MyCustomButton {editor} /> 25 + </FormattingBubbleMenu> 26 + ``` 27 + 28 + ## Custom bubble menu 29 + 30 + Use `BubbleMenu` directly for full control over the menu contents. It provides the styled container and positioning — you supply the controls. 31 + 32 + ```svelte 33 + <script lang="ts"> 34 + import { CoreRichTextEditor, BubbleMenu, BoldToggle, ItalicToggle, type SvelteTiptap } from '@foxui/text'; 35 + import type { Readable } from 'svelte/store'; 36 + 37 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 38 + </script> 39 + 40 + <CoreRichTextEditor bind:editor> 41 + <BubbleMenu {editor}> 42 + <BoldToggle {editor} /> 43 + <ItalicToggle {editor} /> 44 + </BubbleMenu> 45 + </CoreRichTextEditor> 46 + ``` 47 + 48 + ## Controlling visibility 49 + 50 + Pass `shouldShow` to control when the menu appears. By default, `FormattingBubbleMenu` hides when there's no selection or when inside a code block. 51 + 52 + ```svelte 53 + <BubbleMenu 54 + {editor} 55 + shouldShow={({ editor, from, to }) => from !== to && editor.isActive('paragraph')} 56 + > 57 + <!-- controls --> 58 + </BubbleMenu> 59 + ``` 60 + 61 + ## How it works 62 + 63 + Both components wrap svelte-tiptap's `BubbleMenu` with a styled container that supports dark mode. `FormattingBubbleMenu` automatically detects available extensions — for example, `LinkPopover` only appears if the link extension is registered.
+25
apps/docs/src/lib/docs/text/bubble-menu/Example.svelte
··· 1 + <script lang="ts"> 2 + import { CoreRichTextEditor, FormattingBubbleMenu, Box, type SvelteTiptap } from '@foxui/all'; 3 + import type { Readable } from 'svelte/store'; 4 + 5 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 6 + </script> 7 + 8 + <Box class="not-prose"> 9 + <CoreRichTextEditor 10 + bind:editor 11 + placeholder="Select some text to see the bubble menu..." 12 + class="min-h-24" 13 + content={{ 14 + type: 'doc', 15 + content: [ 16 + { 17 + type: 'paragraph', 18 + content: [{ type: 'text', text: 'Select this text to see the formatting toolbar.' }] 19 + } 20 + ] 21 + }} 22 + > 23 + <FormattingBubbleMenu {editor} /> 24 + </CoreRichTextEditor> 25 + </Box>
+54
apps/docs/src/lib/docs/text/bubble-menu/api.ts
··· 1 + import type { APISchema } from '$lib/types/schema'; 2 + 3 + export default [ 4 + { 5 + title: 'FormattingBubbleMenu', 6 + description: 7 + 'A pre-configured bubble menu with bold, italic, underline, strikethrough toggles, and an optional link editor. Appears on text selection.', 8 + props: { 9 + editor: { 10 + type: 'Readable<Editor>', 11 + description: 'The editor store.', 12 + required: true, 13 + bindable: true 14 + }, 15 + class: { 16 + type: 'string', 17 + description: 'CSS classes for the menu container.' 18 + }, 19 + children: { 20 + type: 'Snippet', 21 + description: 'Additional controls rendered after the default toggles.' 22 + } 23 + } 24 + }, 25 + { 26 + title: 'BubbleMenu', 27 + description: 28 + 'A styled bubble menu container that appears on text selection. Wraps svelte-tiptap BubbleMenu with dark mode styling. Use this for fully custom menus.', 29 + props: { 30 + editor: { 31 + type: 'Readable<Editor>', 32 + description: 'The editor store.', 33 + required: true, 34 + bindable: true 35 + }, 36 + shouldShow: { 37 + type: { 38 + type: 'function', 39 + definition: 40 + '(props: { editor: Editor; from: number; to: number }) => boolean' 41 + }, 42 + description: 'Function to control when the menu is visible.' 43 + }, 44 + class: { 45 + type: 'string', 46 + description: 'CSS classes for the menu container.' 47 + }, 48 + children: { 49 + type: 'Snippet', 50 + description: 'Menu content (buttons, toggles, etc.).' 51 + } 52 + } 53 + } 54 + ] satisfies APISchema[];
+19
apps/docs/src/lib/docs/text/bubble-menu/index.ts
··· 1 + import Docs from './Documentation.md'; 2 + import Example from './Example.svelte'; 3 + import Card from './Card.svelte'; 4 + import api from './api'; 5 + 6 + export default { 7 + slug: 'bubble-menu', 8 + title: 'Bubble Menu', 9 + docs: Docs, 10 + example: Example, 11 + card: Card, 12 + api, 13 + sources: [ 14 + { 15 + href: 'https://tiptap.dev/docs/editor/extensions/functionality/bubble-menu', 16 + label: 'TipTap' 17 + } 18 + ] 19 + };
+16
apps/docs/src/lib/docs/text/code-block/Card.svelte
··· 1 + <div class="flex h-full w-full items-center justify-center p-4"> 2 + <div class="bg-base-200 dark:bg-base-700 w-full space-y-1 rounded-lg p-2.5"> 3 + <div class="flex items-center gap-1"> 4 + <span class="text-accent-500 text-[9px] font-medium">const</span> 5 + <div class="bg-base-300 dark:bg-base-600 h-2 w-8 rounded"></div> 6 + <span class="text-base-400 text-[9px]">=</span> 7 + <div class="bg-base-300 dark:bg-base-600 h-2 w-12 rounded"></div> 8 + </div> 9 + <div class="flex items-center gap-1 pl-3"> 10 + <div class="bg-base-300 dark:bg-base-600 h-2 w-14 rounded"></div> 11 + </div> 12 + <div class="flex items-center gap-1"> 13 + <div class="bg-base-300 dark:bg-base-600 h-2 w-3 rounded"></div> 14 + </div> 15 + </div> 16 + </div>
+30
apps/docs/src/lib/docs/text/code-block/Documentation.md
··· 1 + ## Usage 2 + 3 + `CoreRichTextEditor` includes `CodeBlockExtension` by default — code blocks work out of the box with syntax highlighting and a copy button. 4 + 5 + ```svelte 6 + <script lang="ts"> 7 + import { CoreRichTextEditor } from '@foxui/text'; 8 + </script> 9 + 10 + <CoreRichTextEditor placeholder="Type ``` to create a code block..." /> 11 + ``` 12 + 13 + ## Copy button 14 + 15 + `CodeBlockView` includes a copy-to-clipboard button that appears on hover. It shows a checkmark for 2 seconds after copying. 16 + 17 + ## Custom code block component 18 + 19 + Pass `codeBlockComponent` to replace the default rendering with your own Svelte component. Your component receives tiptap's `NodeViewProps`. 20 + 21 + ```svelte 22 + <CoreRichTextEditor 23 + extraExtensions={[ 24 + CodeBlockExtension.configure({ 25 + lowlight: createLowlight(all), 26 + codeBlockComponent: MyCustomCodeBlock 27 + }) 28 + ]} 29 + /> 30 + ```
+38
apps/docs/src/lib/docs/text/code-block/Example.svelte
··· 1 + <script lang="ts"> 2 + import { CoreRichTextEditor, Box, type SvelteTiptap } from '@foxui/all'; 3 + import type { Readable } from 'svelte/store'; 4 + 5 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 6 + </script> 7 + 8 + <Box> 9 + <CoreRichTextEditor 10 + bind:editor 11 + placeholder="Try typing ``` to create a code block..." 12 + class="min-h-46" 13 + content={{ 14 + type: 'doc', 15 + content: [ 16 + { 17 + type: 'paragraph', 18 + content: [ 19 + { 20 + type: 'text', 21 + text: 'This is an example of a code block. You can create one by typing three backticks (```) followed by the language name.' 22 + } 23 + ] 24 + }, 25 + { 26 + type: 'codeBlock', 27 + attrs: { language: 'javascript' }, 28 + content: [ 29 + { 30 + type: 'text', 31 + text: 'function greet(name) {\n return `Hello, ${name}!`;\n}' 32 + } 33 + ] 34 + } 35 + ] 36 + }} 37 + /> 38 + </Box>
+32
apps/docs/src/lib/docs/text/code-block/api.ts
··· 1 + import type { APISchema } from '$lib/types/schema'; 2 + 3 + export default [ 4 + { 5 + title: 'CodeBlockExtension', 6 + description: 7 + 'A tiptap Extension that wraps CodeBlockLowlight with a Svelte NodeView featuring a copy button. Add via extensions or extraExtensions.', 8 + props: { 9 + lowlight: { 10 + type: { type: 'object', definition: 'Lowlight' }, 11 + description: 12 + 'The lowlight instance for syntax highlighting. Create with `createLowlight(all)` from the lowlight package.', 13 + required: true 14 + }, 15 + defaultLanguage: { 16 + type: 'string', 17 + description: 'Default language for new code blocks.', 18 + default: 'null' 19 + }, 20 + codeBlockComponent: { 21 + type: { type: 'object', definition: 'Component' }, 22 + description: 23 + 'Custom Svelte component for rendering code blocks. Receives NodeViewProps from tiptap.' 24 + }, 25 + HTMLAttributes: { 26 + type: { type: 'object', definition: 'Record<string, string>' }, 27 + description: 'HTML attributes applied to code block elements.', 28 + default: '{}' 29 + } 30 + } 31 + } 32 + ] satisfies APISchema[];
+19
apps/docs/src/lib/docs/text/code-block/index.ts
··· 1 + import Docs from './Documentation.md'; 2 + import Example from './Example.svelte'; 3 + import Card from './Card.svelte'; 4 + import api from './api'; 5 + 6 + export default { 7 + slug: 'code-block', 8 + title: 'Code Block Extension', 9 + docs: Docs, 10 + example: Example, 11 + card: Card, 12 + api, 13 + sources: [ 14 + { 15 + href: 'https://tiptap.dev/docs/editor/extensions/nodes/code-block-lowlight', 16 + label: 'TipTap' 17 + } 18 + ] 19 + };
+30
apps/docs/src/lib/docs/text/core-rich-text-editor/Card.svelte
··· 1 + <script> 2 + import { Prose } from '@foxui/all'; 3 + </script> 4 + 5 + <div class="w-full"> 6 + <Prose> 7 + <h3>Core Editor</h3> 8 + <p class="relative w-fit"> 9 + The foundation for rich text 10 + <span 11 + class="bg-accent-600 dark:bg-accent-500 cursor absolute -right-1 bottom-0 top-0 h-full w-[calc(var(--spacing)*0.5)]" 12 + ></span> 13 + </p> 14 + </Prose> 15 + </div> 16 + 17 + <style> 18 + @keyframes blink { 19 + 0%, 20 + 100% { 21 + opacity: 0; 22 + } 23 + 50% { 24 + opacity: 1; 25 + } 26 + } 27 + .cursor { 28 + animation: blink 1s step-end infinite; 29 + } 30 + </style>
+74
apps/docs/src/lib/docs/text/core-rich-text-editor/Documentation.md
··· 1 + ## Usage 2 + 3 + ```svelte 4 + <script lang="ts"> 5 + import { CoreRichTextEditor } from '@foxui/text'; 6 + </script> 7 + 8 + <CoreRichTextEditor placeholder="Start writing..." /> 9 + ``` 10 + 11 + ## Binding the editor 12 + 13 + Bind the `editor` store to access the tiptap Editor instance for programmatic control or to pass to child components like `FormattingBubbleMenu`. 14 + 15 + ```svelte 16 + <script lang="ts"> 17 + import { CoreRichTextEditor, FormattingBubbleMenu, type SvelteTiptap } from '@foxui/text'; 18 + import type { Readable } from 'svelte/store'; 19 + 20 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 21 + </script> 22 + 23 + <CoreRichTextEditor bind:editor placeholder="Write something..."> 24 + <FormattingBubbleMenu {editor} /> 25 + </CoreRichTextEditor> 26 + ``` 27 + 28 + ## Binding content 29 + 30 + Use `bind:content` to read or control the editor content. Content is a tiptap `Content` type (JSON by default). 31 + 32 + ```svelte 33 + <script lang="ts"> 34 + import { CoreRichTextEditor } from '@foxui/text'; 35 + 36 + let content = $state(); 37 + </script> 38 + 39 + <CoreRichTextEditor bind:content placeholder="Write here..." /> 40 + ``` 41 + 42 + ## Markdown mode 43 + 44 + Enable `markdown` to parse Markdown input and serialize content back to Markdown strings instead of JSON. 45 + 46 + ```svelte 47 + <CoreRichTextEditor markdown bind:content placeholder="Write Markdown..." /> 48 + ``` 49 + 50 + ## Extra extensions 51 + 52 + Use `extraExtensions` to add extensions alongside the defaults (StarterKit, LinkExtension, CodeBlockExtension). Use `extensions` to replace defaults entirely. 53 + 54 + ```svelte 55 + <script lang="ts"> 56 + import { CoreRichTextEditor, ImageExtension, MentionNode } from '@foxui/text'; 57 + </script> 58 + 59 + <CoreRichTextEditor 60 + extraExtensions={[ 61 + ImageExtension.configure({ upload: myUpload }), 62 + MentionNode 63 + ]} 64 + /> 65 + ``` 66 + 67 + ## Default extensions 68 + 69 + `CoreRichTextEditor` includes these extensions by default: 70 + 71 + - **StarterKit** — paragraphs, headings, lists, blockquote, code, bold, italic, strike, etc. 72 + - **LinkExtension** — autolink, paste detection, Markdown link syntax 73 + - **CodeBlockExtension** — code blocks with syntax highlighting and copy button 74 + - **Placeholder** — shown when editor is empty (if `placeholder` prop is set)
+11
apps/docs/src/lib/docs/text/core-rich-text-editor/Example.svelte
··· 1 + <script lang="ts"> 2 + import { CoreRichTextEditor, Box, type SvelteTiptap } from '@foxui/all'; 3 + import type { Readable } from 'svelte/store'; 4 + 5 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 6 + </script> 7 + 8 + <Box class="not-prose"> 9 + <CoreRichTextEditor bind:editor placeholder="Write something..." class="min-h-24" 10 + ></CoreRichTextEditor> 11 + </Box>
+76
apps/docs/src/lib/docs/text/core-rich-text-editor/api.ts
··· 1 + import type { APISchema } from '$lib/types/schema'; 2 + 3 + export default [ 4 + { 5 + title: 'CoreRichTextEditor', 6 + description: 7 + 'The base rich text editor component. Wraps svelte-tiptap with sensible defaults (StarterKit, LinkExtension, CodeBlockLowlight). Use directly for full control, or use RichTextEditor for a batteries-included experience.', 8 + props: { 9 + content: { 10 + type: 'Content', 11 + description: 'The editor content (TipTap Content type - can be HTML string, JSON, etc.).', 12 + default: '{}', 13 + bindable: true 14 + }, 15 + editor: { 16 + type: 'Readable<Editor>', 17 + description: 'A Svelte readable store containing the TipTap Editor instance.', 18 + bindable: true 19 + }, 20 + placeholder: { 21 + type: 'string', 22 + description: 'Placeholder text shown when the editor is empty.', 23 + default: "''" 24 + }, 25 + markdown: { 26 + type: 'boolean', 27 + description: 28 + 'Enable Markdown mode. Parses Markdown input and serializes content back to Markdown strings.', 29 + default: 'false' 30 + }, 31 + extensions: { 32 + type: 'Extensions', 33 + description: 34 + 'Custom extension array. Replaces all default extensions when provided.' 35 + }, 36 + extraExtensions: { 37 + type: 'Extensions', 38 + description: 39 + 'Additional extensions merged with the defaults. Use this to add features without losing StarterKit, LinkExtension, etc.' 40 + }, 41 + onupdate: { 42 + type: { 43 + type: 'function', 44 + definition: '(content: Content | string) => void' 45 + }, 46 + description: 'Callback invoked when the editor content changes.' 47 + }, 48 + ontransaction: { 49 + type: { type: 'function', definition: '() => void' }, 50 + description: 'Callback invoked on every TipTap transaction.' 51 + }, 52 + onlinkadded: { 53 + type: { 54 + type: 'function', 55 + definition: '(event: LinkAddedEvent) => void' 56 + }, 57 + description: 58 + 'Called whenever a link is added (typing, pasting, autolink, or UI). Only applies when using default extensions.' 59 + }, 60 + children: { 61 + type: 'Snippet', 62 + description: 63 + 'Child content rendered inside the editor wrapper (e.g. BubbleMenu, SlashMenu). Only rendered when the editor is ready.' 64 + }, 65 + class: { 66 + type: 'string', 67 + description: 'Additional CSS classes to apply to the wrapper div.' 68 + }, 69 + ref: { 70 + type: 'HTMLDivElement', 71 + description: 'The underlying DOM element.', 72 + bindable: true 73 + } 74 + } 75 + } 76 + ] satisfies APISchema[];
+19
apps/docs/src/lib/docs/text/core-rich-text-editor/index.ts
··· 1 + import Docs from './Documentation.md'; 2 + import Example from './Example.svelte'; 3 + import Card from './Card.svelte'; 4 + import api from './api'; 5 + 6 + export default { 7 + slug: 'core-rich-text-editor', 8 + title: 'Core Rich Text Editor', 9 + docs: Docs, 10 + example: Example, 11 + card: Card, 12 + api, 13 + sources: [ 14 + { 15 + href: 'https://tiptap.dev/', 16 + label: 'TipTap' 17 + } 18 + ] 19 + };
+33
apps/docs/src/lib/docs/text/embed/Card.svelte
··· 1 + <div class="flex h-full w-full items-center justify-center p-4"> 2 + <div class="w-full space-y-2"> 3 + <div 4 + class="bg-base-200 dark:bg-base-700 flex h-20 w-full items-center justify-center rounded-lg" 5 + > 6 + <svg 7 + class="text-base-400 h-8 w-8" 8 + fill="currentColor" 9 + viewBox="0 0 24 24" 10 + > 11 + <path d="M8 5v14l11-7z" /> 12 + </svg> 13 + </div> 14 + <div 15 + class="bg-base-200 dark:bg-base-700 flex h-5 w-full items-center gap-1.5 rounded-full px-2.5" 16 + > 17 + <svg 18 + class="text-base-400 h-2.5 w-2.5 shrink-0" 19 + fill="none" 20 + stroke="currentColor" 21 + stroke-width="2" 22 + viewBox="0 0 24 24" 23 + > 24 + <path 25 + stroke-linecap="round" 26 + stroke-linejoin="round" 27 + d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 28 + /> 29 + </svg> 30 + <div class="bg-base-300 dark:bg-base-600 h-1.5 w-full rounded-full"></div> 31 + </div> 32 + </div> 33 + </div>
+128
apps/docs/src/lib/docs/text/embed/Documentation.md
··· 1 + ## Usage 2 + 3 + Add `EmbedExtension` via `extraExtensions` with an array of embed definitions. URLs pasted or typed on their own line will automatically convert to rich embeds. 4 + 5 + ```svelte 6 + <script lang="ts"> 7 + import { CoreRichTextEditor, EmbedExtension, youtubeEmbed } from '@foxui/text'; 8 + </script> 9 + 10 + <CoreRichTextEditor 11 + extraExtensions={[ 12 + EmbedExtension.configure({ 13 + embeds: [youtubeEmbed] 14 + }) 15 + ]} 16 + /> 17 + ``` 18 + 19 + That's it — paste a YouTube URL and it becomes an embedded player. 20 + 21 + ## How detection works 22 + 23 + 1. **Paste** — When a URL is pasted as plain text, it's checked against all embed matchers. If one matches, an embed node is inserted immediately. 24 + 2. **Typed URLs** — When the user types a URL and moves to the next line (e.g. presses Enter), the link-only paragraph is detected and converted. 25 + 26 + ## Built-in YouTube embed 27 + 28 + The `youtubeEmbed` definition matches common YouTube URL formats: 29 + 30 + - `https://www.youtube.com/watch?v=VIDEO_ID` 31 + - `https://youtu.be/VIDEO_ID` 32 + - `https://www.youtube.com/embed/VIDEO_ID` 33 + - `https://www.youtube.com/shorts/VIDEO_ID` 34 + 35 + ## Embed modal 36 + 37 + Add `EmbedModal` as a child of the editor to let users manually insert embeds via a URL input dialog. It validates the URL against all registered embeds and shows an error if none match. 38 + 39 + ```svelte 40 + <script lang="ts"> 41 + import { CoreRichTextEditor, EmbedExtension, EmbedModal, youtubeEmbed, openEmbedModal } from '@foxui/text'; 42 + </script> 43 + 44 + <CoreRichTextEditor bind:editor extraExtensions={[EmbedExtension.configure({ embeds: [youtubeEmbed] })]}> 45 + {#if $editor} 46 + <EmbedModal editor={$editor} /> 47 + {/if} 48 + </CoreRichTextEditor> 49 + 50 + <!-- Trigger from a button --> 51 + <button onclick={() => openEmbedModal($editor)}>Add Embed</button> 52 + ``` 53 + 54 + The modal is also available from the slash menu — type `/embed` to open it. 55 + 56 + You can also control the modal directly with a bindable `open` prop: 57 + 58 + ```svelte 59 + <EmbedModal editor={$editor} bind:open={embedModalOpen} /> 60 + <button onclick={() => embedModalOpen = true}>Add Embed</button> 61 + ``` 62 + 63 + ## Programmatic insertion 64 + 65 + Use `insertEmbed` to insert an embed from a URL without the modal. Returns `null` on success or an error string. 66 + 67 + ```typescript 68 + import { insertEmbed } from '@foxui/text'; 69 + 70 + const error = insertEmbed($editor, 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'); 71 + if (error) console.error(error); 72 + ``` 73 + 74 + ## Custom embeds 75 + 76 + Create your own embed by defining a `name`, a `match` function, and a Svelte `component`: 77 + 78 + ```typescript 79 + import type { EmbedDefinition } from '@foxui/text'; 80 + import SpotifyPlayer from './SpotifyPlayer.svelte'; 81 + 82 + const spotifyEmbed: EmbedDefinition = { 83 + name: 'spotify', 84 + match: (url) => { 85 + const m = url.match(/open\.spotify\.com\/track\/(\w+)/); 86 + return m ? { trackId: m[1] } : false; 87 + }, 88 + component: SpotifyPlayer 89 + }; 90 + ``` 91 + 92 + Your component receives three props: 93 + 94 + ```svelte 95 + <!-- SpotifyPlayer.svelte --> 96 + <script lang="ts"> 97 + let { url, data, selected }: { url: string; data: Record<string, any>; selected: boolean } = 98 + $props(); 99 + </script> 100 + 101 + <iframe 102 + src="https://open.spotify.com/embed/track/{data.trackId}" 103 + class="h-20 w-full rounded-lg {selected ? 'ring-2 ring-accent-500' : ''}" 104 + allow="encrypted-media" 105 + ></iframe> 106 + ``` 107 + 108 + Then add it alongside the built-in embeds: 109 + 110 + ```svelte 111 + <CoreRichTextEditor 112 + extraExtensions={[ 113 + EmbedExtension.configure({ 114 + embeds: [youtubeEmbed, spotifyEmbed] 115 + }) 116 + ]} 117 + /> 118 + ``` 119 + 120 + ## Multiple embed providers 121 + 122 + Pass as many embed definitions as you like. The first match wins — order matters. 123 + 124 + ```typescript 125 + EmbedExtension.configure({ 126 + embeds: [youtubeEmbed, spotifyEmbed, vimeoEmbed, twitterEmbed] 127 + }) 128 + ```
+41
apps/docs/src/lib/docs/text/embed/Example.svelte
··· 1 + <script lang="ts"> 2 + import { 3 + CoreRichTextEditor, 4 + EmbedExtension, 5 + EmbedModal, 6 + youtubeEmbed, 7 + Box, 8 + Button, 9 + openEmbedModal, 10 + type SvelteTiptap 11 + } from '@foxui/all'; 12 + import type { Readable } from 'svelte/store'; 13 + 14 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 15 + </script> 16 + 17 + <Box class="not-prose"> 18 + <CoreRichTextEditor 19 + bind:editor 20 + placeholder="Paste a YouTube URL to see it embed..." 21 + extraExtensions={[ 22 + EmbedExtension.configure({ 23 + embeds: [youtubeEmbed] 24 + }) 25 + ]} 26 + class="min-h-24" 27 + > 28 + {#if $editor} 29 + <EmbedModal editor={$editor} /> 30 + {/if} 31 + </CoreRichTextEditor> 32 + <div class="mt-2"> 33 + <Button 34 + onclick={() => { 35 + if ($editor) openEmbedModal($editor); 36 + }} 37 + > 38 + Add Embed 39 + </Button> 40 + </div> 41 + </Box>
+98
apps/docs/src/lib/docs/text/embed/api.ts
··· 1 + import type { APISchema } from '$lib/types/schema'; 2 + 3 + export default [ 4 + { 5 + title: 'EmbedExtension', 6 + description: 7 + 'A tiptap Extension that auto-detects URLs in paragraphs and replaces them with rich embeds. Add via extraExtensions.', 8 + props: { 9 + embeds: { 10 + type: { 11 + type: 'object', 12 + definition: 'EmbedDefinition[]' 13 + }, 14 + description: 15 + 'Array of embed definitions. Each definition has a name, match function, and component.', 16 + default: '[]' 17 + } 18 + } 19 + }, 20 + { 21 + title: 'EmbedDefinition', 22 + description: 'Defines an embed provider with URL matching and a rendering component.', 23 + props: { 24 + name: { 25 + type: 'string', 26 + description: 'Unique name for this embed type (e.g. "youtube", "spotify").', 27 + required: true 28 + }, 29 + match: { 30 + type: { 31 + type: 'function', 32 + definition: '(url: string) => Record<string, any> | false | null' 33 + }, 34 + description: 35 + 'URL matcher. Return an object with embed-specific data if the URL matches, or false/null to skip.', 36 + required: true 37 + }, 38 + component: { 39 + type: { type: 'object', definition: 'Component' }, 40 + description: 41 + 'Svelte component to render the embed. Receives props: url (string), data (object from match), selected (boolean).', 42 + required: true 43 + } 44 + } 45 + }, 46 + { 47 + title: 'EmbedModal', 48 + description: 49 + 'Modal dialog for manually inserting an embed by URL. Add as a child of the editor. Automatically registers with the slash menu.', 50 + props: { 51 + editor: { 52 + type: { type: 'object', definition: 'Editor' }, 53 + description: 'The tiptap editor instance.', 54 + required: true 55 + }, 56 + open: { 57 + type: 'boolean', 58 + description: 'Bindable. Controls modal visibility.', 59 + default: 'false' 60 + } 61 + } 62 + }, 63 + { 64 + title: 'insertEmbed', 65 + description: 66 + 'Programmatically insert an embed for a URL. Returns null on success or an error string.', 67 + props: { 68 + editor: { 69 + type: { type: 'object', definition: 'Editor' }, 70 + description: 'The tiptap editor instance.', 71 + required: true 72 + }, 73 + url: { 74 + type: 'string', 75 + description: 'The URL to embed.', 76 + required: true 77 + } 78 + } 79 + }, 80 + { 81 + title: 'openEmbedModal', 82 + description: 83 + 'Opens the EmbedModal if one is mounted. Used by the slash menu and can be called from custom buttons.', 84 + props: { 85 + editor: { 86 + type: { type: 'object', definition: 'Editor' }, 87 + description: 'The tiptap editor instance.', 88 + required: true 89 + } 90 + } 91 + }, 92 + { 93 + title: 'youtubeEmbed', 94 + description: 95 + 'Built-in YouTube embed definition. Matches youtube.com/watch, youtu.be, youtube.com/embed, and youtube.com/shorts URLs.', 96 + props: {} 97 + } 98 + ] satisfies APISchema[];
+16
apps/docs/src/lib/docs/text/embed/index.ts
··· 1 + import Docs from './Documentation.md'; 2 + import Example from './Example.svelte'; 3 + import Card from './Card.svelte'; 4 + import api from './api'; 5 + 6 + export default { 7 + slug: 'embed', 8 + title: 'Embed Extension', 9 + docs: Docs, 10 + example: Example, 11 + card: Card, 12 + api, 13 + sources: [ 14 + { href: 'https://tiptap.dev/docs/editor/extensions/nodes', label: 'TipTap' } 15 + ] 16 + };
+22
apps/docs/src/lib/docs/text/formatting-toggles/Card.svelte
··· 1 + <div class="flex h-full w-full items-center justify-center gap-2 p-4"> 2 + <div 3 + class="bg-base-200 dark:bg-base-700 flex size-8 items-center justify-center rounded-lg text-sm font-bold" 4 + > 5 + B 6 + </div> 7 + <div 8 + class="bg-base-200 dark:bg-base-700 flex size-8 items-center justify-center rounded-lg text-sm italic" 9 + > 10 + I 11 + </div> 12 + <div 13 + class="bg-base-200 dark:bg-base-700 flex size-8 items-center justify-center rounded-lg text-sm underline" 14 + > 15 + U 16 + </div> 17 + <div 18 + class="bg-base-200 dark:bg-base-700 flex size-8 items-center justify-center rounded-lg text-sm line-through" 19 + > 20 + S 21 + </div> 22 + </div>
+50
apps/docs/src/lib/docs/text/formatting-toggles/Documentation.md
··· 1 + ## Usage 2 + 3 + Individual formatting toggle buttons for bold, italic, underline, and strikethrough. Drop them into any toolbar or bubble menu. 4 + 5 + ```svelte 6 + <script lang="ts"> 7 + import { CoreRichTextEditor, BoldToggle, ItalicToggle, UnderlineToggle, StrikethroughToggle, type SvelteTiptap } from '@foxui/text'; 8 + import type { Readable } from 'svelte/store'; 9 + 10 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 11 + </script> 12 + 13 + <CoreRichTextEditor bind:editor /> 14 + {#if $editor} 15 + <div class="flex gap-1"> 16 + <BoldToggle {editor} /> 17 + <ItalicToggle {editor} /> 18 + <UnderlineToggle {editor} /> 19 + <StrikethroughToggle {editor} /> 20 + </div> 21 + {/if} 22 + ``` 23 + 24 + ## Reading state 25 + 26 + Each toggle exposes a bindable state prop (`isBold`, `isItalic`, `isUnderline`, `isStrikethrough`) that reactively tracks whether the mark is active at the current selection. 27 + 28 + ```svelte 29 + <script lang="ts"> 30 + let isBold = $state(false); 31 + </script> 32 + 33 + <BoldToggle {editor} bind:isBold /> 34 + <p>Bold is {isBold ? 'on' : 'off'}</p> 35 + ``` 36 + 37 + ## In a bubble menu 38 + 39 + These toggles are what `FormattingBubbleMenu` uses internally. You can also use them inside a custom `BubbleMenu`. 40 + 41 + ```svelte 42 + <BubbleMenu {editor}> 43 + <BoldToggle {editor} /> 44 + <ItalicToggle {editor} /> 45 + </BubbleMenu> 46 + ``` 47 + 48 + ## How it works 49 + 50 + Each toggle uses `@foxui/core`'s `Toggle` component and listens to editor transactions to stay in sync. Clicking a toggle calls the corresponding tiptap chain command (e.g. `toggleBold()`).
+39
apps/docs/src/lib/docs/text/formatting-toggles/Example.svelte
··· 1 + <script lang="ts"> 2 + import { 3 + CoreRichTextEditor, 4 + BoldToggle, 5 + ItalicToggle, 6 + UnderlineToggle, 7 + StrikethroughToggle, 8 + Box, 9 + type SvelteTiptap 10 + } from '@foxui/all'; 11 + import type { Readable } from 'svelte/store'; 12 + 13 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 14 + </script> 15 + 16 + <Box class="not-prose space-y-2"> 17 + {#if $editor} 18 + <div class="flex gap-1"> 19 + <BoldToggle {editor} /> 20 + <ItalicToggle {editor} /> 21 + <UnderlineToggle {editor} /> 22 + <StrikethroughToggle {editor} /> 23 + </div> 24 + {/if} 25 + <CoreRichTextEditor 26 + bind:editor 27 + placeholder="Select text and use the toggles above..." 28 + class="min-h-24" 29 + content={{ 30 + type: 'doc', 31 + content: [ 32 + { 33 + type: 'paragraph', 34 + content: [{ type: 'text', text: 'Try selecting this text and toggling formatting.' }] 35 + } 36 + ] 37 + }} 38 + /> 39 + </Box>
+79
apps/docs/src/lib/docs/text/formatting-toggles/api.ts
··· 1 + import type { APISchema } from '$lib/types/schema'; 2 + 3 + export default [ 4 + { 5 + title: 'BoldToggle', 6 + description: 'A toggle button for bold formatting. Syncs with the current editor selection.', 7 + props: { 8 + editor: { 9 + type: 'Readable<Editor>', 10 + description: 'The editor store.', 11 + required: true, 12 + bindable: true 13 + }, 14 + isBold: { 15 + type: 'boolean', 16 + description: 'Whether bold is active at the current selection.', 17 + default: 'false', 18 + bindable: true 19 + } 20 + } 21 + }, 22 + { 23 + title: 'ItalicToggle', 24 + description: 25 + 'A toggle button for italic formatting. Syncs with the current editor selection.', 26 + props: { 27 + editor: { 28 + type: 'Readable<Editor>', 29 + description: 'The editor store.', 30 + required: true, 31 + bindable: true 32 + }, 33 + isItalic: { 34 + type: 'boolean', 35 + description: 'Whether italic is active at the current selection.', 36 + default: 'false', 37 + bindable: true 38 + } 39 + } 40 + }, 41 + { 42 + title: 'UnderlineToggle', 43 + description: 44 + 'A toggle button for underline formatting. Syncs with the current editor selection.', 45 + props: { 46 + editor: { 47 + type: 'Readable<Editor>', 48 + description: 'The editor store.', 49 + required: true, 50 + bindable: true 51 + }, 52 + isUnderline: { 53 + type: 'boolean', 54 + description: 'Whether underline is active at the current selection.', 55 + default: 'false', 56 + bindable: true 57 + } 58 + } 59 + }, 60 + { 61 + title: 'StrikethroughToggle', 62 + description: 63 + 'A toggle button for strikethrough formatting. Syncs with the current editor selection.', 64 + props: { 65 + editor: { 66 + type: 'Readable<Editor>', 67 + description: 'The editor store.', 68 + required: true, 69 + bindable: true 70 + }, 71 + isStrikethrough: { 72 + type: 'boolean', 73 + description: 'Whether strikethrough is active at the current selection.', 74 + default: 'false', 75 + bindable: true 76 + } 77 + } 78 + } 79 + ] satisfies APISchema[];
+13
apps/docs/src/lib/docs/text/formatting-toggles/index.ts
··· 1 + import Docs from './Documentation.md'; 2 + import Example from './Example.svelte'; 3 + import Card from './Card.svelte'; 4 + import api from './api'; 5 + 6 + export default { 7 + slug: 'formatting-toggles', 8 + title: 'Formatting Toggles', 9 + docs: Docs, 10 + example: Example, 11 + card: Card, 12 + api 13 + };
+10
apps/docs/src/lib/docs/text/hashtag-decoration/Card.svelte
··· 1 + <div class="flex h-full w-full items-center justify-center p-4"> 2 + <div class="w-full space-y-1.5"> 3 + <div class="flex items-center gap-1"> 4 + <div class="bg-base-200 dark:bg-base-700 h-3 w-1/4 rounded"></div> 5 + <span class="text-accent-500 text-[10px] font-medium">#hashtag</span> 6 + <div class="bg-base-200 dark:bg-base-700 h-3 w-1/5 rounded"></div> 7 + </div> 8 + <div class="bg-base-200 dark:bg-base-700 h-3 w-2/3 rounded"></div> 9 + </div> 10 + </div>
+50
apps/docs/src/lib/docs/text/hashtag-decoration/Documentation.md
··· 1 + ## Usage 2 + 3 + `HashtagDecoration` highlights `#hashtags` in the editor with a CSS class. Add it via `extraExtensions` and style the `.hashtag` class. 4 + 5 + ```svelte 6 + <script lang="ts"> 7 + import { CoreRichTextEditor, HashtagDecoration } from '@foxui/text'; 8 + </script> 9 + 10 + <CoreRichTextEditor 11 + extraExtensions={[HashtagDecoration]} 12 + placeholder="Try typing #hello..." 13 + /> 14 + 15 + <style> 16 + :global(.hashtag) { 17 + color: var(--color-accent-600); 18 + font-weight: 500; 19 + } 20 + </style> 21 + ``` 22 + 23 + ## How it works 24 + 25 + `HashtagDecoration` is a tiptap Extension that creates ProseMirror decorations for hashtag patterns. It scans text nodes for `#word` patterns and applies `class="hashtag"` as an inline decoration. 26 + 27 + The regex supports: 28 + - Standard ASCII letters and digits: `#hello`, `#tag123` 29 + - International characters: `#café`, `#über`, `#naïve` 30 + 31 + Hashtags must start with a letter after the `#` — `#123` won't match. 32 + 33 + ## Styling 34 + 35 + The extension only adds the `hashtag` CSS class — you control the visual style. Some examples: 36 + 37 + ```css 38 + /* Accent color */ 39 + :global(.hashtag) { 40 + color: var(--color-accent-600); 41 + } 42 + 43 + /* Blue with background */ 44 + :global(.hashtag) { 45 + color: #2563eb; 46 + background: #2563eb15; 47 + border-radius: 4px; 48 + padding: 0 2px; 49 + } 50 + ```
+39
apps/docs/src/lib/docs/text/hashtag-decoration/Example.svelte
··· 1 + <script lang="ts"> 2 + import { CoreRichTextEditor, HashtagDecoration, Box, type SvelteTiptap } from '@foxui/all'; 3 + import type { Readable } from 'svelte/store'; 4 + 5 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 6 + </script> 7 + 8 + <Box class="not-prose"> 9 + <CoreRichTextEditor 10 + bind:editor 11 + extraExtensions={[HashtagDecoration]} 12 + placeholder="Try typing #hello or #svelte..." 13 + class="min-h-24" 14 + content={{ 15 + type: 'doc', 16 + content: [ 17 + { 18 + type: 'paragraph', 19 + content: [ 20 + { 21 + type: 'text', 22 + text: 'Check out #svelte and #tiptap for building rich text editors!' 23 + } 24 + ] 25 + } 26 + ] 27 + }} 28 + /> 29 + </Box> 30 + 31 + <style> 32 + :global(.hashtag) { 33 + color: var(--color-accent-600); 34 + font-weight: 500; 35 + } 36 + :global(.dark .hashtag) { 37 + color: var(--color-accent-400); 38 + } 39 + </style>
+10
apps/docs/src/lib/docs/text/hashtag-decoration/api.ts
··· 1 + import type { APISchema } from '$lib/types/schema'; 2 + 3 + export default [ 4 + { 5 + title: 'HashtagDecoration', 6 + description: 7 + 'A tiptap Extension that decorates #hashtags with a CSS class. Add via extraExtensions and style the .hashtag class.', 8 + props: {} 9 + } 10 + ] satisfies APISchema[];
+13
apps/docs/src/lib/docs/text/hashtag-decoration/index.ts
··· 1 + import Docs from './Documentation.md'; 2 + import Example from './Example.svelte'; 3 + import Card from './Card.svelte'; 4 + import api from './api'; 5 + 6 + export default { 7 + slug: 'hashtag-decoration', 8 + title: 'Hashtag Decoration', 9 + docs: Docs, 10 + example: Example, 11 + card: Card, 12 + api 13 + };
+24
apps/docs/src/lib/docs/text/image/Card.svelte
··· 1 + <div class="flex h-full w-full items-center justify-center p-4"> 2 + <div class="w-full space-y-2"> 3 + <div 4 + class="bg-base-200 dark:bg-base-700 flex h-20 w-full items-center justify-center rounded-lg" 5 + > 6 + <svg 7 + class="text-base-400 h-8 w-8" 8 + fill="none" 9 + stroke="currentColor" 10 + stroke-width="1.5" 11 + viewBox="0 0 24 24" 12 + > 13 + <path 14 + stroke-linecap="round" 15 + stroke-linejoin="round" 16 + d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Zm14.25-12a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z" 17 + /> 18 + </svg> 19 + </div> 20 + <div class="bg-base-200 dark:bg-base-700 h-1.5 w-full rounded-full"> 21 + <div class="bg-accent-500 h-1.5 w-3/5 rounded-full"></div> 22 + </div> 23 + </div> 24 + </div>
+137
apps/docs/src/lib/docs/text/image/Documentation.md
··· 1 + ## Usage 2 + 3 + Add `ImageExtension` via `extraExtensions` with an `upload` function. Images can then be pasted, dropped, or inserted programmatically. 4 + 5 + ```svelte 6 + <script lang="ts"> 7 + import { CoreRichTextEditor, ImageExtension, type SvelteTiptap } from '@foxui/text'; 8 + import type { Readable } from 'svelte/store'; 9 + 10 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 11 + 12 + async function upload(file: File, onProgress: (p: number) => void, signal: AbortSignal) { 13 + const res = await fetch('/api/upload', { method: 'POST', body: file, signal }); 14 + return (await res.json()).url; 15 + } 16 + </script> 17 + 18 + <CoreRichTextEditor 19 + bind:editor 20 + extraExtensions={[ImageExtension.configure({ upload })]} 21 + /> 22 + ``` 23 + 24 + That's it — paste or drag-drop an image and it will upload automatically. 25 + 26 + ## Programmatic insertion 27 + 28 + Use `pickAndInsertImage` to open a file picker, or `insertImage` to insert a file directly. Great for toolbar buttons or slash menu items. 29 + 30 + ```svelte 31 + <script lang="ts"> 32 + import { pickAndInsertImage, insertImage } from '@foxui/text'; 33 + </script> 34 + 35 + <!-- File picker button --> 36 + <button onclick={() => pickAndInsertImage($editor)}>Add Image</button> 37 + 38 + <!-- Insert a file you already have --> 39 + <button onclick={() => insertImage($editor, myFile)}>Insert</button> 40 + ``` 41 + 42 + ## Slash menu integration 43 + 44 + Add an image item to `SlashMenu` for a `/image` command. 45 + 46 + ```svelte 47 + <SlashMenu 48 + editor={$editor} 49 + items={[ 50 + { 51 + id: 'image', 52 + label: 'Image', 53 + command: ({ editor, range }) => { 54 + editor.chain().focus().deleteRange(range).run(); 55 + pickAndInsertImage(editor); 56 + } 57 + } 58 + ]} 59 + /> 60 + ``` 61 + 62 + ## Editable alt text 63 + 64 + Use the built-in `EditableImage` component to let users edit alt text. It shows a pencil button on hover that opens a modal with a textarea. 65 + 66 + ```svelte 67 + <script lang="ts"> 68 + import { ImageExtension, EditableImage } from '@foxui/text'; 69 + </script> 70 + 71 + <CoreRichTextEditor 72 + extraExtensions={[ 73 + ImageExtension.configure({ 74 + upload: myUpload, 75 + imageComponent: EditableImage 76 + }) 77 + ]} 78 + /> 79 + ``` 80 + 81 + ## Custom image component 82 + 83 + Pass `imageComponent` to replace the default `<img>` rendering with your own Svelte component. Your component receives tiptap's `NodeViewProps` — use `props.node.attrs.src` for the image URL and `props.updateAttributes()` to update node data. 84 + 85 + ```svelte 86 + <!-- CustomImage.svelte --> 87 + <script lang="ts"> 88 + import type { NodeViewProps } from '@tiptap/core'; 89 + import { NodeViewWrapper } from 'svelte-tiptap'; 90 + 91 + let props: NodeViewProps = $props(); 92 + </script> 93 + 94 + <NodeViewWrapper> 95 + <figure class="my-4"> 96 + <img 97 + src={props.node.attrs.src} 98 + alt={props.node.attrs.alt} 99 + class="rounded-lg shadow-md" 100 + /> 101 + {#if props.node.attrs.alt} 102 + <figcaption class="mt-1 text-sm text-gray-500">{props.node.attrs.alt}</figcaption> 103 + {/if} 104 + </figure> 105 + </NodeViewWrapper> 106 + ``` 107 + 108 + ```svelte 109 + <script lang="ts"> 110 + import { ImageExtension } from '@foxui/text'; 111 + import CustomImage from './CustomImage.svelte'; 112 + </script> 113 + 114 + <CoreRichTextEditor 115 + extraExtensions={[ 116 + ImageExtension.configure({ 117 + upload: myUpload, 118 + imageComponent: CustomImage 119 + }) 120 + ]} 121 + /> 122 + ``` 123 + 124 + ## How it works 125 + 126 + `ImageExtension` is a single tiptap Extension that bundles everything: 127 + 128 + - **`@tiptap/extension-image`** for rendering final `<img>` elements 129 + - **Upload placeholder node** with a Svelte NodeView showing a preview and progress bar 130 + - **ProseMirror plugins** for `handlePaste` and `handleDrop` — no external DOM handlers needed 131 + - **Extension storage** with a runtime `Map` so `File` objects reach the upload function (node attributes stay serializable) 132 + 133 + When an image is inserted, a blob preview is shown immediately. The `upload` function runs in the background. Once it resolves, the placeholder is replaced with a final image using the **returned URL**. 134 + 135 + ## Works with any editor 136 + 137 + `ImageExtension` works with `CoreRichTextEditor`, `RichTextEditor`, or any tiptap editor that supports `extraExtensions`.
+79
apps/docs/src/lib/docs/text/image/Example.svelte
··· 1 + <script lang="ts"> 2 + import { 3 + CoreRichTextEditor, 4 + ImageExtension, 5 + EditableImage, 6 + pickAndInsertImage, 7 + Box, 8 + Button, 9 + type SvelteTiptap 10 + } from '@foxui/all'; 11 + import type { Readable } from 'svelte/store'; 12 + 13 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 14 + let errorMessage = $state(''); 15 + 16 + async function fakeUpload( 17 + file: File, 18 + onProgress: (p: number) => void, 19 + signal: AbortSignal 20 + ): Promise<string> { 21 + // Read the actual file as a data URL so the same image is shown after "upload" 22 + const dataUrl = await new Promise<string>((resolve, reject) => { 23 + const reader = new FileReader(); 24 + reader.onload = () => resolve(reader.result as string); 25 + reader.onerror = () => reject(new Error('Failed to read file')); 26 + reader.readAsDataURL(file); 27 + }); 28 + 29 + // Simulate upload progress 30 + return new Promise((resolve, reject) => { 31 + let progress = 0; 32 + const interval = setInterval(() => { 33 + progress += 0.1; 34 + onProgress(Math.min(progress, 1)); 35 + if (progress >= 1) { 36 + clearInterval(interval); 37 + resolve(dataUrl); 38 + } 39 + }, 200); 40 + signal.addEventListener('abort', () => { 41 + clearInterval(interval); 42 + reject(new Error('Upload cancelled')); 43 + }); 44 + }); 45 + } 46 + </script> 47 + 48 + <Box class="not-prose"> 49 + <CoreRichTextEditor 50 + bind:editor 51 + placeholder="Paste or drop an image..." 52 + extraExtensions={[ 53 + ImageExtension.configure({ 54 + upload: fakeUpload, 55 + imageComponent: EditableImage, 56 + onError: (err) => { 57 + errorMessage = err.message; 58 + } 59 + }) 60 + ]} 61 + class="min-h-24" 62 + /> 63 + 64 + <div class="mt-2 flex items-center gap-2"> 65 + <Button 66 + onclick={() => { 67 + if ($editor) { 68 + pickAndInsertImage($editor); 69 + } 70 + }} 71 + > 72 + Add Image 73 + </Button> 74 + 75 + {#if errorMessage} 76 + <span class="text-sm text-red-500">{errorMessage}</span> 77 + {/if} 78 + </div> 79 + </Box>
+105
apps/docs/src/lib/docs/text/image/api.ts
··· 1 + import type { APISchema } from '$lib/types/schema'; 2 + 3 + export default [ 4 + { 5 + title: 'ImageExtension', 6 + description: 7 + 'A tiptap Extension that bundles image display, upload placeholders, and paste/drop handling. Add via extraExtensions.', 8 + props: { 9 + upload: { 10 + type: { 11 + type: 'function', 12 + definition: '(file: File, onProgress: (p: number) => void, signal: AbortSignal) => Promise<string>' 13 + }, 14 + description: 'Upload function. Receives the file, a progress callback (0–1), and an AbortSignal. Must return the final image URL.', 15 + required: true 16 + }, 17 + accept: { 18 + type: 'string', 19 + description: 'Accepted file types for the file picker and validation.', 20 + default: "'image/*'" 21 + }, 22 + maxFileSize: { 23 + type: 'number', 24 + description: 'Maximum file size in bytes. 0 means unlimited.', 25 + default: '0' 26 + }, 27 + onError: { 28 + type: { 29 + type: 'function', 30 + definition: '(error: Error) => void' 31 + }, 32 + description: 'Called when an upload fails or a file is rejected.' 33 + }, 34 + HTMLAttributes: { 35 + type: { type: 'object', definition: 'Record<string, string>' }, 36 + description: 'HTML attributes applied to final <img> elements.', 37 + default: '{}' 38 + }, 39 + imageComponent: { 40 + type: { type: 'object', definition: 'Component' }, 41 + description: 42 + 'Custom Svelte component for rendering final images. Receives NodeViewProps from tiptap (node.attrs.src, node.attrs.alt, etc.).' 43 + } 44 + } 45 + }, 46 + { 47 + title: 'EditableImage', 48 + description: 49 + 'A pre-built image component with alt text editing. Shows a pencil button on hover that opens a modal with a textarea. Use as imageComponent option.', 50 + props: {} 51 + }, 52 + { 53 + title: 'pickAndInsertImage', 54 + description: 55 + 'Helper function that opens a native file picker and inserts the selected image into the editor.', 56 + props: { 57 + editor: { 58 + type: { type: 'object', definition: 'Editor' }, 59 + description: 'The tiptap editor instance.', 60 + required: true 61 + } 62 + } 63 + }, 64 + { 65 + title: 'insertImage', 66 + description: 67 + 'Helper function that inserts a file directly as an uploading image into the editor.', 68 + props: { 69 + editor: { 70 + type: { type: 'object', definition: 'Editor' }, 71 + description: 'The tiptap editor instance.', 72 + required: true 73 + }, 74 + file: { 75 + type: { type: 'object', definition: 'File' }, 76 + description: 'The image file to upload.', 77 + required: true 78 + } 79 + } 80 + }, 81 + { 82 + title: 'ImageUploadFn', 83 + description: 'Type signature for the upload function.', 84 + props: { 85 + file: { 86 + type: { type: 'object', definition: 'File' }, 87 + description: 'The image file to upload.', 88 + required: true 89 + }, 90 + onProgress: { 91 + type: { 92 + type: 'function', 93 + definition: '(progress: number) => void' 94 + }, 95 + description: 'Progress callback. Values range from 0 to 1.', 96 + required: true 97 + }, 98 + abortSignal: { 99 + type: { type: 'object', definition: 'AbortSignal' }, 100 + description: 'Signal to abort the upload when the placeholder is deleted.', 101 + required: true 102 + } 103 + } 104 + } 105 + ] satisfies APISchema[];
+14
apps/docs/src/lib/docs/text/image/index.ts
··· 1 + import Docs from './Documentation.md'; 2 + import Example from './Example.svelte'; 3 + import Card from './Card.svelte'; 4 + import api from './api'; 5 + 6 + export default { 7 + slug: 'image', 8 + title: 'Image Extension', 9 + docs: Docs, 10 + example: Example, 11 + card: Card, 12 + api, 13 + sources: [{ href: 'https://tiptap.dev/docs/editor/extensions/nodes/image', label: 'TipTap' }] 14 + };
+10
apps/docs/src/lib/docs/text/link/Card.svelte
··· 1 + <div class="flex h-full w-full items-center justify-center p-4"> 2 + <div class="w-full space-y-1.5"> 3 + <div class="flex items-center gap-1"> 4 + <div class="bg-base-200 dark:bg-base-700 h-3 w-1/4 rounded"></div> 5 + <span class="text-accent-500 text-[10px] font-medium underline">example.com</span> 6 + <div class="bg-base-200 dark:bg-base-700 h-3 w-1/5 rounded"></div> 7 + </div> 8 + <div class="bg-base-200 dark:bg-base-700 h-3 w-2/3 rounded"></div> 9 + </div> 10 + </div>
+74
apps/docs/src/lib/docs/text/link/Documentation.md
··· 1 + ## Usage 2 + 3 + `CoreRichTextEditor` includes `LinkExtension` by default. Pasted and typed URLs are automatically converted to links, and Markdown syntax `[text](url)` is supported out of the box. 4 + 5 + ```svelte 6 + <CoreRichTextEditor /> 7 + ``` 8 + 9 + `FormattingBubbleMenu` automatically includes a `LinkPopover` when the link extension is detected — no extra setup needed. 10 + 11 + ```svelte 12 + <CoreRichTextEditor bind:editor> 13 + <FormattingBubbleMenu {editor} /> 14 + </CoreRichTextEditor> 15 + ``` 16 + 17 + ## Standalone link popover 18 + 19 + `LinkPopover` is a toggle button with a popover for editing URLs. It follows the same pattern as `BoldToggle` / `ItalicToggle` — drop it into any toolbar. 20 + 21 + ```svelte 22 + <LinkPopover {editor} /> 23 + ``` 24 + 25 + ## Reacting to new links 26 + 27 + Use the `onlinkadded` callback to run logic whenever a link is added — whether by typing, pasting, autolink, or the link popover. 28 + 29 + Via `CoreRichTextEditor` (uses default extensions): 30 + 31 + ```svelte 32 + <CoreRichTextEditor 33 + onlinkadded={({ href, text }) => { 34 + console.log(`Link added: ${text} → ${href}`); 35 + }} 36 + /> 37 + ``` 38 + 39 + Or configure it directly on the extension: 40 + 41 + ```svelte 42 + <CoreRichTextEditor 43 + extensions={[ 44 + StarterKit.configure({ link: false }), 45 + LinkExtension.configure({ 46 + onlinkadded: ({ href, text, editor }) => { 47 + // fetch preview, validate, transform, etc. 48 + } 49 + }) 50 + ]} 51 + /> 52 + ``` 53 + 54 + ## Programmatic usage 55 + 56 + ```ts 57 + import { setLink, removeLink } from '@foxui/text'; 58 + 59 + // Set a link on the current selection 60 + setLink(editor, 'https://example.com'); 61 + 62 + // Remove link from the current selection 63 + removeLink(editor); 64 + ``` 65 + 66 + ## How it works 67 + 68 + `LinkExtension` wraps `@tiptap/extension-link` with: 69 + 70 + - **Autolink** — URLs typed or pasted are automatically detected and linked 71 + - **Markdown syntax** — `[text](url "title")` input and paste rules, with `title` attribute support 72 + - **Configurable** — toggle autolink, markdown, open-on-click behavior, default protocol 73 + 74 + `LinkPopover` tracks the current selection via editor transactions. When the selection is inside a link, the toggle is pressed and clicking it opens the popover pre-filled with the current URL.
+14
apps/docs/src/lib/docs/text/link/Example.svelte
··· 1 + <script lang="ts"> 2 + import { CoreRichTextEditor, FormattingBubbleMenu, Box, type SvelteTiptap } from '@foxui/all'; 3 + import type { Readable } from 'svelte/store'; 4 + 5 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 6 + </script> 7 + 8 + <Box class="not-prose"> 9 + <CoreRichTextEditor bind:editor class="min-h-24"> 10 + {#if $editor} 11 + <FormattingBubbleMenu {editor} /> 12 + {/if} 13 + </CoreRichTextEditor> 14 + </Box>
+84
apps/docs/src/lib/docs/text/link/api.ts
··· 1 + import type { APISchema } from '$lib/types/schema'; 2 + 3 + export default [ 4 + { 5 + title: 'LinkExtension', 6 + description: 7 + 'A tiptap Extension that bundles link detection, autolink, and optional Markdown syntax. Add via extraExtensions.', 8 + props: { 9 + openOnClick: { 10 + type: "boolean | 'whenNotEditable'", 11 + description: 'Whether clicking a link opens it.', 12 + default: "'whenNotEditable'" 13 + }, 14 + autolink: { 15 + type: 'boolean', 16 + description: 'Automatically detect and linkify URLs as you type.', 17 + default: 'true' 18 + }, 19 + defaultProtocol: { 20 + type: 'string', 21 + description: 'Protocol added to URLs without one.', 22 + default: "'https'" 23 + }, 24 + markdown: { 25 + type: 'boolean', 26 + description: 'Enable Markdown link syntax [text](url "title").', 27 + default: 'true' 28 + }, 29 + HTMLAttributes: { 30 + type: { type: 'object', definition: 'Record<string, string>' }, 31 + description: 'HTML attributes applied to link elements.', 32 + default: '{}' 33 + }, 34 + onlinkadded: { 35 + type: { type: 'function', definition: '(event: LinkAddedEvent) => void' }, 36 + description: 37 + 'Called whenever a link is added to the editor (via typing, pasting, autolink, or UI). Receives { href, text, editor }.' 38 + } 39 + } 40 + }, 41 + { 42 + title: 'LinkPopover', 43 + description: 44 + 'A toggle button with a popover for setting, editing, or removing links on the current selection. Works standalone, in a bubble menu, or in any toolbar.', 45 + props: { 46 + editor: { 47 + type: { type: 'object', definition: 'Readable<Editor>' }, 48 + description: 'The editor store.', 49 + required: true 50 + }, 51 + class: { 52 + type: 'string', 53 + description: 'CSS classes for the popover content.' 54 + } 55 + } 56 + }, 57 + { 58 + title: 'setLink', 59 + description: 'Helper function to set a link on the current selection. Unsets the link if URL is empty.', 60 + props: { 61 + editor: { 62 + type: { type: 'object', definition: 'Editor' }, 63 + description: 'The tiptap editor instance.', 64 + required: true 65 + }, 66 + url: { 67 + type: 'string', 68 + description: 'The URL to set.', 69 + required: true 70 + } 71 + } 72 + }, 73 + { 74 + title: 'removeLink', 75 + description: 'Helper function to remove the link from the current selection.', 76 + props: { 77 + editor: { 78 + type: { type: 'object', definition: 'Editor' }, 79 + description: 'The tiptap editor instance.', 80 + required: true 81 + } 82 + } 83 + } 84 + ] satisfies APISchema[];
+14
apps/docs/src/lib/docs/text/link/index.ts
··· 1 + import Docs from './Documentation.md'; 2 + import Example from './Example.svelte'; 3 + import Card from './Card.svelte'; 4 + import api from './api'; 5 + 6 + export default { 7 + slug: 'link', 8 + title: 'Link Extension', 9 + docs: Docs, 10 + example: Example, 11 + card: Card, 12 + api, 13 + sources: [{ href: 'https://tiptap.dev/docs/editor/extensions/marks/link', label: 'TipTap' }] 14 + };
+10
apps/docs/src/lib/docs/text/mention/Card.svelte
··· 1 + <div class="flex h-full w-full items-center justify-center p-4"> 2 + <div class="w-full space-y-1.5"> 3 + <div class="bg-base-200 dark:bg-base-700 h-3 w-1/3 rounded"></div> 4 + <div class="flex items-center gap-1"> 5 + <div class="bg-base-200 dark:bg-base-700 h-3 w-1/4 rounded"></div> 6 + <span class="text-accent-500 text-[10px] font-medium">@mention</span> 7 + <div class="bg-base-200 dark:bg-base-700 h-3 w-1/5 rounded"></div> 8 + </div> 9 + </div> 10 + </div>
+59
apps/docs/src/lib/docs/text/mention/Documentation.md
··· 1 + ## Usage 2 + 3 + Add `MentionNode` via `extraExtensions`, then render `MentionMenu` as a child — similar to how `BubbleMenu` and `FloatingMenu` work in svelte-tiptap. 4 + 5 + ```svelte 6 + <script lang="ts"> 7 + import { 8 + CoreRichTextEditor, 9 + MentionNode, 10 + MentionMenu, 11 + type MentionItem 12 + type SvelteTiptap 13 + } from '@foxui/text'; 14 + import type { Readable } from 'svelte/store'; 15 + 16 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 17 + 18 + async function searchUsers(query: string): Promise<MentionItem[]> { 19 + const res = await fetch(`/api/users?q=${query}`); 20 + const users = await res.json(); 21 + return users.map((u) => ({ 22 + id: u.id, 23 + label: u.name, 24 + avatar: u.avatarUrl 25 + })); 26 + } 27 + </script> 28 + 29 + <CoreRichTextEditor 30 + bind:editor 31 + extraExtensions={[MentionNode]} 32 + > 33 + {#if $editor} 34 + <MentionMenu editor={$editor} items={searchUsers}> 35 + {#snippet item({ item: mentionItem, isActive, select })} 36 + <button onclick={select}> 37 + {mentionItem.label} 38 + </button> 39 + {/snippet} 40 + </MentionMenu> 41 + {/if} 42 + </CoreRichTextEditor> 43 + ``` 44 + 45 + ## Custom rendering 46 + 47 + The `item` snippet receives `{ item, isActive, select }`. Use this to fully customize how each suggestion looks — add avatars, secondary text, keyboard highlight styling, etc. 48 + 49 + If no `item` snippet is provided, a minimal default rendering is used. 50 + 51 + ## How it works 52 + 53 + `MentionNode` is a tiptap Node extension that defines an inline, non-editable mention node with `id`, `label`, and optional `data` attributes. 54 + 55 + `MentionMenu` follows the same pattern as svelte-tiptap's `BubbleMenu` — it takes an `editor` prop and registers a suggestion plugin on mount. The popup is positioned using `@floating-ui/dom` and supports keyboard navigation (Arrow keys + Enter + Escape). 56 + 57 + ## Works with any editor 58 + 59 + Both `CoreRichTextEditor` and `RichTextEditor` support `extraExtensions` to add extensions without replacing defaults. You can also use `MentionNode` + `MentionMenu` with any tiptap editor setup.
+66
apps/docs/src/lib/docs/text/mention/Example.svelte
··· 1 + <script lang="ts"> 2 + import { 3 + CoreRichTextEditor, 4 + MentionNode, 5 + MentionMenu, 6 + type MentionItem, 7 + type SvelteTiptap 8 + } from '@foxui/all'; 9 + import { Avatar, cn, Box } from '@foxui/all'; 10 + import type { Readable } from 'svelte/store'; 11 + 12 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 13 + 14 + async function searchBlueskyUsers(query: string): Promise<MentionItem[]> { 15 + const res = await fetch( 16 + `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${query}&limit=5` 17 + ); 18 + if (!res.ok) return []; 19 + const data = await res.json(); 20 + return data.actors.map( 21 + (actor: { did: string; handle: string; avatar?: string; displayName?: string }) => ({ 22 + id: actor.did, 23 + label: actor.handle, 24 + avatar: actor.avatar, 25 + data: { displayName: actor.displayName } 26 + }) 27 + ); 28 + } 29 + </script> 30 + 31 + <Box class="not-prose"> 32 + <CoreRichTextEditor 33 + bind:editor 34 + placeholder="Type @ to mention someone..." 35 + extraExtensions={[ 36 + MentionNode.configure({ 37 + HTMLAttributes: { class: 'text-accent-600 dark:text-accent-400 font-medium' } 38 + }) 39 + ]} 40 + class="min-h-24" 41 + > 42 + {#if $editor} 43 + <MentionMenu editor={$editor} items={searchBlueskyUsers}> 44 + {#snippet item({ item: mentionItem, isActive, select })} 45 + <button 46 + onclick={select} 47 + class={cn( 48 + 'text-base-900 dark:text-base-200 flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-sm', 49 + isActive 50 + ? 'text-accent-700 dark:text-accent-400 bg-accent-500/10' 51 + : 'hover:bg-accent-500/10' 52 + )} 53 + > 54 + <Avatar src={mentionItem.avatar} alt="" class="size-6 rounded-full" /> 55 + <span class="flex flex-col items-start"> 56 + <span class="font-medium">{mentionItem.label}</span> 57 + {#if mentionItem.data?.displayName} 58 + <span class="text-base-500 text-xs">{mentionItem.data.displayName}</span> 59 + {/if} 60 + </span> 61 + </button> 62 + {/snippet} 63 + </MentionMenu> 64 + {/if} 65 + </CoreRichTextEditor> 66 + </Box>
+95
apps/docs/src/lib/docs/text/mention/api.ts
··· 1 + import type { APISchema } from '$lib/types/schema'; 2 + 3 + export default [ 4 + { 5 + title: 'MentionMenu', 6 + description: 7 + 'A suggestion popup component for @mentions. Registers a tiptap suggestion plugin on mount, like BubbleMenu/FloatingMenu.', 8 + props: { 9 + editor: { 10 + type: { type: 'object', definition: 'Editor' }, 11 + description: 'The tiptap editor instance.', 12 + required: true 13 + }, 14 + items: { 15 + type: { 16 + type: 'function', 17 + definition: '(query: string) => MentionItem[] | Promise<MentionItem[]>' 18 + }, 19 + description: 'Function that returns mention suggestions for a search query.', 20 + required: true 21 + }, 22 + char: { 23 + type: 'string', 24 + description: 'Trigger character for the mention popup.', 25 + default: "'@'" 26 + }, 27 + minQueryLength: { 28 + type: 'number', 29 + description: 'Minimum characters after trigger before searching.', 30 + default: '2' 31 + }, 32 + pluginKey: { 33 + type: 'string', 34 + description: 'Unique key for the suggestion plugin.', 35 + default: "'mentionMenu'" 36 + }, 37 + item: { 38 + type: { 39 + type: 'function', 40 + definition: 41 + 'Snippet<[{ item: MentionItem; isActive: boolean; select: () => void }]>' 42 + }, 43 + description: 'Snippet for custom rendering of each suggestion item.' 44 + }, 45 + class: { 46 + type: 'string', 47 + description: 'CSS classes for the popup container.' 48 + } 49 + } 50 + }, 51 + { 52 + title: 'MentionNode', 53 + description: 54 + 'A tiptap Node extension for inline mention nodes. Add to editor extensions to enable mentions.', 55 + props: { 56 + HTMLAttributes: { 57 + type: { type: 'object', definition: 'Record<string, unknown>' }, 58 + description: 'HTML attributes applied to mention nodes.', 59 + default: '{}' 60 + }, 61 + renderLabel: { 62 + type: { 63 + type: 'function', 64 + definition: '({ node }) => string' 65 + }, 66 + description: 'Custom function to render the mention label text.', 67 + default: "'({ node }) => `@${node.attrs.label}`'" 68 + } 69 + } 70 + }, 71 + { 72 + title: 'MentionItem', 73 + description: 'The data shape for mention suggestions.', 74 + props: { 75 + id: { 76 + type: 'string', 77 + description: 'Unique identifier for the mention (e.g., user ID, DID).', 78 + required: true 79 + }, 80 + label: { 81 + type: 'string', 82 + description: 'Display label for the mention (e.g., username, handle).', 83 + required: true 84 + }, 85 + avatar: { 86 + type: 'string', 87 + description: 'Optional avatar URL.' 88 + }, 89 + data: { 90 + type: { type: 'object', definition: 'Record<string, unknown>' }, 91 + description: 'Optional extra data stored on the mention node.' 92 + } 93 + } 94 + } 95 + ] satisfies APISchema[];
+13
apps/docs/src/lib/docs/text/mention/index.ts
··· 1 + import Docs from './Documentation.md'; 2 + import Example from './Example.svelte'; 3 + import Card from './Card.svelte'; 4 + import api from './api'; 5 + 6 + export default { 7 + slug: 'mention', 8 + title: 'Mention Menu', 9 + docs: Docs, 10 + example: Example, 11 + card: Card, 12 + api 13 + };
+1 -1
apps/docs/src/lib/docs/text/plain-text-editor/Documentation.md
··· 7 7 let content = $state(''); 8 8 </script> 9 9 10 - <PlainTextEditor bind:value={content} placeholder="Write something..." /> 10 + <PlainTextEditor bind:content placeholder="Write something..." /> 11 11 ```
+1 -1
apps/docs/src/lib/docs/text/plain-text-editor/Example.svelte
··· 6 6 </script> 7 7 8 8 <Box class="not-prose text-lg font-semibold"> 9 - <PlainTextEditor bind:value={content} placeholder="Write something..." /> 9 + <PlainTextEditor bind:content placeholder="Write something..." /> 10 10 </Box> 11 11
+3 -3
apps/docs/src/lib/docs/text/plain-text-editor/api.ts
··· 5 5 title: 'PlainTextEditor', 6 6 description: 'A plain text editor powered by TipTap, with optional single-line mode.', 7 7 props: { 8 - value: { 9 - type: 'string', 10 - description: 'The current text content.', 8 + content: { 9 + type: 'Content', 10 + description: 'The editor content (TipTap Content type).', 11 11 default: "''", 12 12 bindable: true 13 13 },
+57 -1
apps/docs/src/lib/docs/text/rich-text-editor/Documentation.md
··· 7 7 let content = $state(''); 8 8 </script> 9 9 10 - <RichTextEditor bind:content={content} placeholder="Write something..." /> 10 + <RichTextEditor bind:content placeholder="Write something..." /> 11 + ``` 12 + 13 + This gives you a fully featured editor with a formatting bubble menu and `/` slash commands out of the box. 14 + 15 + ## Image uploads 16 + 17 + Pass an upload function to `image` to enable image paste, drop, and the `/image` slash command. Images use `EditableImage` (with alt text editing) by default. 18 + 19 + ```svelte 20 + <script lang="ts"> 21 + import { RichTextEditor } from '@foxui/text'; 22 + 23 + async function upload(file: File, onProgress: (p: number) => void, signal: AbortSignal) { 24 + const res = await fetch('/api/upload', { method: 'POST', body: file, signal }); 25 + return (await res.json()).url; 26 + } 27 + </script> 28 + 29 + <RichTextEditor image={upload} /> 30 + ``` 31 + 32 + ## Embeds 33 + 34 + Pass `embeds={true}` for built-in YouTube embeds, or pass an array of custom `EmbedDefinition`s. An embed modal is automatically included, and `/embed` appears in the slash menu. 35 + 36 + ```svelte 37 + <!-- YouTube embeds --> 38 + <RichTextEditor embeds={true} /> 39 + 40 + <!-- Custom embed providers --> 41 + <RichTextEditor embeds={[youtubeEmbed, spotifyEmbed]} /> 42 + ``` 43 + 44 + ## Disabling features 45 + 46 + Turn off individual features with boolean props. 47 + 48 + ```svelte 49 + <!-- No bubble menu --> 50 + <RichTextEditor bubbleMenu={false} /> 51 + 52 + <!-- No slash menu --> 53 + <RichTextEditor slashMenu={false} /> 54 + ``` 55 + 56 + ## All CoreRichTextEditor props 57 + 58 + `RichTextEditor` passes all props through to `CoreRichTextEditor`, so you can use `extraExtensions`, `markdown`, `onlinkadded`, etc. 59 + 60 + ```svelte 61 + <RichTextEditor 62 + image={upload} 63 + embeds={true} 64 + markdown 65 + placeholder="Write Markdown..." 66 + /> 11 67 ```
+17 -9
apps/docs/src/lib/docs/text/rich-text-editor/Example.svelte
··· 1 1 <script lang="ts"> 2 - import { Prose } from '@foxui/all'; 3 - import { RichTextEditor } from '@foxui/all'; 2 + import { Box, RichTextEditor } from '@foxui/all'; 4 3 5 4 let content = $state(''); 5 + 6 + async function uploadImage(file: File, onProgress: (progress: number) => void) { 7 + for (let i = 0; i <= 10; i++) { 8 + await new Promise((r) => setTimeout(r, 150)); 9 + onProgress(i / 10); 10 + } 11 + return URL.createObjectURL(file); 12 + } 6 13 </script> 7 14 8 - <div 9 - class="border-base-300/50 bg-base-300/20 dark:border-base-800/50 dark:bg-base-900/30 text-base-900 dark:text-base-100 relative isolate min-h-24 w-full rounded-2xl border p-4 backdrop-blur-2xl" 10 - > 11 - <Prose size="md"> 12 - <RichTextEditor bind:content /> 13 - </Prose> 14 - </div> 15 + <Box> 16 + <RichTextEditor 17 + bind:content 18 + placeholder="Write something or press / for commands..." 19 + image={uploadImage} 20 + embeds={true} 21 + /> 22 + </Box>
+63 -8
apps/docs/src/lib/docs/text/rich-text-editor/api.ts
··· 3 3 export default [ 4 4 { 5 5 title: 'RichTextEditor', 6 - description: 'A rich text editor powered by TipTap with a formatting toolbar, slash commands, and markdown support.', 6 + description: 7 + 'A batteries-included rich text editor with formatting bubble menu, slash commands, image uploads, and embeds. Wraps CoreRichTextEditor with all UI features.', 7 8 props: { 8 9 content: { 9 10 type: 'Content', 10 - description: 'The editor content (TipTap Content type - can be HTML string, JSON, etc.).', 11 + description: 12 + 'The editor content (TipTap Content type - can be HTML string, JSON, etc.).', 11 13 default: '{}', 12 14 bindable: true 13 - }, 14 - placeholder: { 15 - type: 'string', 16 - description: 'Placeholder text.', 17 - default: "'Write or press / for commands'" 18 15 }, 19 16 editor: { 20 17 type: 'Readable<Editor>', 21 18 description: 'A Svelte readable store containing the TipTap Editor instance.', 22 19 bindable: true 23 20 }, 21 + bubbleMenu: { 22 + type: 'boolean', 23 + description: 24 + 'Show the formatting bubble menu (bold, italic, underline, strikethrough, link) on text selection.', 25 + default: 'true' 26 + }, 27 + slashMenu: { 28 + type: 'boolean', 29 + description: 'Show the slash command menu when typing "/".', 30 + default: 'true' 31 + }, 32 + image: { 33 + type: { 34 + type: 'union', 35 + definition: 'ImageUploadFn | false' 36 + }, 37 + description: 38 + 'Enable image uploads. Pass an upload function to enable paste, drop, and /image slash command. Images use EditableImage with alt text editing.' 39 + }, 40 + embeds: { 41 + type: { 42 + type: 'union', 43 + definition: 'EmbedDefinition[] | boolean' 44 + }, 45 + description: 46 + 'Enable embeds. Pass `true` for built-in YouTube embeds, or an array of EmbedDefinitions for custom providers. Adds /embed to the slash menu and includes an embed modal.' 47 + }, 48 + placeholder: { 49 + type: 'string', 50 + description: 'Placeholder text.', 51 + default: "''" 52 + }, 53 + markdown: { 54 + type: 'boolean', 55 + description: 56 + 'Enable Markdown mode. Parses Markdown input and serializes content back to Markdown strings.', 57 + default: 'false' 58 + }, 59 + extensions: { 60 + type: 'Extensions', 61 + description: 'Custom extension array. Replaces all default extensions when provided.' 62 + }, 63 + extraExtensions: { 64 + type: 'Extensions', 65 + description: 66 + 'Additional extensions merged with the defaults. Added after image/embed extensions.' 67 + }, 24 68 onupdate: { 25 - type: { type: 'function', definition: '(content: Content, context: { editor: Editor; transaction: Transaction }) => void' }, 69 + type: { 70 + type: 'function', 71 + definition: '(content: Content | string) => void' 72 + }, 26 73 description: 'Callback invoked when the editor content changes.' 27 74 }, 28 75 ontransaction: { 29 76 type: { type: 'function', definition: '() => void' }, 30 77 description: 'Callback invoked on every TipTap transaction.' 78 + }, 79 + onlinkadded: { 80 + type: { 81 + type: 'function', 82 + definition: '(event: LinkAddedEvent) => void' 83 + }, 84 + description: 85 + 'Called whenever a link is added (typing, pasting, autolink, or UI). Only applies when using default extensions.' 31 86 }, 32 87 class: { 33 88 type: 'string',
+19
apps/docs/src/lib/docs/text/slash-menu/Card.svelte
··· 1 + <div class="flex h-full w-full items-center justify-center p-4"> 2 + <div class="w-full space-y-1"> 3 + <div class="bg-base-200 dark:bg-base-700 flex items-center gap-1.5 rounded-lg px-2 py-1.5"> 4 + <span class="text-base-400 text-xs">/</span> 5 + <div class="bg-base-300 dark:bg-base-600 h-2.5 w-16 rounded"></div> 6 + </div> 7 + <div class="bg-base-200 dark:bg-base-700 space-y-0.5 rounded-lg p-1"> 8 + <div class="bg-accent-500/15 rounded px-2 py-1"> 9 + <div class="bg-accent-500/40 h-2 w-16 rounded"></div> 10 + </div> 11 + <div class="rounded px-2 py-1"> 12 + <div class="bg-base-300 dark:bg-base-600 h-2 w-14 rounded"></div> 13 + </div> 14 + <div class="rounded px-2 py-1"> 15 + <div class="bg-base-300 dark:bg-base-600 h-2 w-18 rounded"></div> 16 + </div> 17 + </div> 18 + </div> 19 + </div>
+92
apps/docs/src/lib/docs/text/slash-menu/Documentation.md
··· 1 + ## Usage 2 + 3 + `SlashMenu` adds a `/` command menu to the editor. Type `/` to open it, then search and select a block type. 4 + 5 + ```svelte 6 + <script lang="ts"> 7 + import { CoreRichTextEditor, SlashMenu, type SvelteTiptap } from '@foxui/text'; 8 + import type { Readable } from 'svelte/store'; 9 + 10 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 11 + </script> 12 + 13 + <CoreRichTextEditor bind:editor> 14 + <SlashMenu editor={$editor} /> 15 + </CoreRichTextEditor> 16 + ``` 17 + 18 + Default items include Paragraph, Heading 1–3, Blockquote, Code Block, Bullet List, and Ordered List. Image and Embed items appear automatically when those extensions are registered. 19 + 20 + ## Custom items 21 + 22 + Pass `items` as an array or a function for dynamic/async items. 23 + 24 + ```svelte 25 + <SlashMenu 26 + editor={$editor} 27 + items={[ 28 + { 29 + id: 'heading-1', 30 + label: 'Heading 1', 31 + description: 'Large heading', 32 + command: ({ editor, range }) => { 33 + editor.chain().focus().deleteRange(range).toggleHeading({ level: 1 }).run(); 34 + } 35 + }, 36 + { 37 + id: 'divider', 38 + label: 'Divider', 39 + description: 'Horizontal rule', 40 + command: ({ editor, range }) => { 41 + editor.chain().focus().deleteRange(range).setHorizontalRule().run(); 42 + } 43 + } 44 + ]} 45 + /> 46 + ``` 47 + 48 + ## Async items 49 + 50 + Pass a function to load items dynamically. The function receives the search query. 51 + 52 + ```svelte 53 + <SlashMenu 54 + editor={$editor} 55 + items={async (query) => { 56 + const items = getSlashMenuItems($editor); 57 + return items.filter((item) => item.label.toLowerCase().includes(query.toLowerCase())); 58 + }} 59 + /> 60 + ``` 61 + 62 + ## Custom item rendering 63 + 64 + Use the `item` snippet to customize how each menu item looks. 65 + 66 + ```svelte 67 + <SlashMenu editor={$editor}> 68 + {#snippet item({ item: menuItem, isActive, select })} 69 + <button onclick={select} class:active={isActive}> 70 + {menuItem.label} 71 + {#if menuItem.description} 72 + <span class="text-sm text-gray-500">{menuItem.description}</span> 73 + {/if} 74 + </button> 75 + {/snippet} 76 + </SlashMenu> 77 + ``` 78 + 79 + ## Helper functions 80 + 81 + ```ts 82 + import { getSlashMenuItems, getBlockTypeItems, getImageItem } from '@foxui/text'; 83 + 84 + // All available items for the editor 85 + const items = getSlashMenuItems(editor); 86 + 87 + // Block type items only (no image/embed) 88 + const blockItems = getBlockTypeItems(editor); 89 + 90 + // Image item (or undefined if ImageExtension not registered) 91 + const imageItem = getImageItem(editor); 92 + ```
+12
apps/docs/src/lib/docs/text/slash-menu/Example.svelte
··· 1 + <script lang="ts"> 2 + import { CoreRichTextEditor, SlashMenu, Box, type SvelteTiptap } from '@foxui/all'; 3 + import type { Readable } from 'svelte/store'; 4 + 5 + let editor = $state<Readable<SvelteTiptap.Editor>>(); 6 + </script> 7 + 8 + <Box> 9 + <CoreRichTextEditor bind:editor placeholder="Type / to open the command menu..." class="min-h-24"> 10 + <SlashMenu editor={$editor} /> 11 + </CoreRichTextEditor> 12 + </Box>
+103
apps/docs/src/lib/docs/text/slash-menu/api.ts
··· 1 + import type { APISchema } from '$lib/types/schema'; 2 + 3 + export default [ 4 + { 5 + title: 'SlashMenu', 6 + description: 7 + 'A "/" command menu for the editor. Type "/" to open, then search and select block types or actions. Registers as a ProseMirror plugin.', 8 + props: { 9 + editor: { 10 + type: { type: 'object', definition: 'Editor' }, 11 + description: 'The tiptap editor instance.', 12 + required: true 13 + }, 14 + items: { 15 + type: { 16 + type: 'union', 17 + definition: 18 + 'SlashMenuItem[] | ((query: string) => SlashMenuItem[] | Promise<SlashMenuItem[]>)' 19 + }, 20 + description: 21 + 'Menu items as a static array or async function. Defaults to all available block types.' 22 + }, 23 + char: { 24 + type: 'string', 25 + description: 'Trigger character for the menu.', 26 + default: "'/'" 27 + }, 28 + pluginKey: { 29 + type: 'string', 30 + description: 'Unique key for the suggestion plugin.', 31 + default: "'slashMenu'" 32 + }, 33 + item: { 34 + type: { 35 + type: 'function', 36 + definition: 37 + 'Snippet<[{ item: SlashMenuItem; isActive: boolean; select: () => void }]>' 38 + }, 39 + description: 'Snippet for custom rendering of each menu item.' 40 + }, 41 + class: { 42 + type: 'string', 43 + description: 'CSS classes for the popup container.' 44 + } 45 + } 46 + }, 47 + { 48 + title: 'SlashMenuItem', 49 + description: 'The data shape for slash menu items.', 50 + props: { 51 + id: { 52 + type: 'string', 53 + description: 'Unique identifier for the item.', 54 + required: true 55 + }, 56 + label: { 57 + type: 'string', 58 + description: 'Display label.', 59 + required: true 60 + }, 61 + description: { 62 + type: 'string', 63 + description: 'Optional description shown below the label.' 64 + }, 65 + icon: { 66 + type: 'string', 67 + description: 'Optional icon identifier.' 68 + }, 69 + command: { 70 + type: { 71 + type: 'function', 72 + definition: '(props: { editor: Editor; range: Range }) => void' 73 + }, 74 + description: 'Function called when the item is selected.', 75 + required: true 76 + } 77 + } 78 + }, 79 + { 80 + title: 'getSlashMenuItems', 81 + description: 82 + 'Returns default slash menu items filtered to only those with registered extensions. Includes block types, image, and embed.', 83 + props: { 84 + editor: { 85 + type: { type: 'object', definition: 'Editor' }, 86 + description: 'The tiptap editor instance.', 87 + required: true 88 + } 89 + } 90 + }, 91 + { 92 + title: 'getBlockTypeItems', 93 + description: 94 + 'Returns block type items only (no image/embed). Useful for block type selectors.', 95 + props: { 96 + editor: { 97 + type: { type: 'object', definition: 'Editor' }, 98 + description: 'The tiptap editor instance.', 99 + required: true 100 + } 101 + } 102 + } 103 + ] satisfies APISchema[];
+13
apps/docs/src/lib/docs/text/slash-menu/index.ts
··· 1 + import Docs from './Documentation.md'; 2 + import Example from './Example.svelte'; 3 + import Card from './Card.svelte'; 4 + import api from './api'; 5 + 6 + export default { 7 + slug: 'slash-menu', 8 + title: 'Slash Menu', 9 + docs: Docs, 10 + example: Example, 11 + card: Card, 12 + api 13 + };
+156
packages/text/src/lib/components/rich-text-editor-old/formatting-bubble-menu/FormattingBubbleMenu.svelte
··· 1 + <script lang="ts"> 2 + import { BubbleMenu, type Editor } from 'svelte-tiptap'; 3 + import FormattingToolbar from './FormattingToolbar.svelte'; 4 + import LinkEditor from './LinkEditor.svelte'; 5 + 6 + type RichTextTypes = 7 + | 'paragraph' 8 + | 'heading-1' 9 + | 'heading-2' 10 + | 'heading-3' 11 + | 'blockquote' 12 + | 'code' 13 + | 'bullet-list' 14 + | 'ordered-list'; 15 + 16 + let { 17 + editor, 18 + bold = true, 19 + italic = true, 20 + underline = true, 21 + strikethrough = true, 22 + link = true, 23 + blockType = true 24 + }: { 25 + editor: Editor; 26 + bold?: boolean; 27 + italic?: boolean; 28 + underline?: boolean; 29 + strikethrough?: boolean; 30 + link?: boolean; 31 + blockType?: boolean; 32 + } = $props(); 33 + 34 + let isBold = $state(false); 35 + let isItalic = $state(false); 36 + let isUnderline = $state(false); 37 + let isStrikethrough = $state(false); 38 + let isLink = $state(false); 39 + let selectedType: RichTextTypes = $state('paragraph'); 40 + 41 + function updateState() { 42 + isBold = editor.isActive('bold'); 43 + isItalic = editor.isActive('italic'); 44 + isUnderline = editor.isActive('underline'); 45 + isStrikethrough = editor.isActive('strike'); 46 + isLink = editor.isActive('link'); 47 + 48 + if (editor.isActive('heading', { level: 1 })) { 49 + selectedType = 'heading-1'; 50 + } else if (editor.isActive('heading', { level: 2 })) { 51 + selectedType = 'heading-2'; 52 + } else if (editor.isActive('heading', { level: 3 })) { 53 + selectedType = 'heading-3'; 54 + } else if (editor.isActive('blockquote')) { 55 + selectedType = 'blockquote'; 56 + } else if (editor.isActive('code')) { 57 + selectedType = 'code'; 58 + } else if (editor.isActive('bulletList')) { 59 + selectedType = 'bullet-list'; 60 + } else if (editor.isActive('orderedList')) { 61 + selectedType = 'ordered-list'; 62 + } else { 63 + selectedType = 'paragraph'; 64 + } 65 + } 66 + 67 + function switchTo(value: RichTextTypes) { 68 + editor.chain().focus().setParagraph().run(); 69 + 70 + if (value === 'heading-1') { 71 + editor.chain().focus().setNode('heading', { level: 1 }).run(); 72 + } else if (value === 'heading-2') { 73 + editor.chain().focus().setNode('heading', { level: 2 }).run(); 74 + } else if (value === 'heading-3') { 75 + editor.chain().focus().setNode('heading', { level: 3 }).run(); 76 + } else if (value === 'blockquote') { 77 + editor.chain().focus().setBlockquote().run(); 78 + } else if (value === 'code') { 79 + editor.chain().focus().setCodeBlock().run(); 80 + } else if (value === 'bullet-list') { 81 + editor.chain().focus().toggleBulletList().run(); 82 + } else if (value === 'ordered-list') { 83 + editor.chain().focus().toggleOrderedList().run(); 84 + } 85 + } 86 + 87 + function handleLinkClick() { 88 + if (isLink) { 89 + // Already a link — the link BubbleMenu will handle editing 90 + return; 91 + } 92 + // Set an empty link mark — this triggers isLink=true, which shows LinkEditor 93 + editor.chain().focus().setLink({ href: '' }).run(); 94 + } 95 + 96 + $effect(() => { 97 + editor.on('transaction', updateState); 98 + updateState(); 99 + 100 + return () => { 101 + editor.off('transaction', updateState); 102 + }; 103 + }); 104 + </script> 105 + 106 + <BubbleMenu 107 + {editor} 108 + shouldShow={({ editor: e }) => { 109 + return ( 110 + !e.isActive('image') && 111 + !e.view.state.selection.empty && 112 + !e.isActive('codeBlock') && 113 + !e.isActive('link') && 114 + !e.isActive('imageUpload') 115 + ); 116 + }} 117 + pluginKey="formatting-bubble-menu" 118 + > 119 + <div 120 + class="bg-base-50 dark:bg-base-900 border-base-500/20 dark:border-base-700/20 relative w-fit rounded-2xl border px-1 py-1 shadow-lg backdrop-blur-sm" 121 + > 122 + <FormattingToolbar 123 + {editor} 124 + {isBold} 125 + {isItalic} 126 + {isUnderline} 127 + {isStrikethrough} 128 + {isLink} 129 + bind:selectedType 130 + {switchTo} 131 + onLinkClick={handleLinkClick} 132 + showBold={bold} 133 + showItalic={italic} 134 + showUnderline={underline} 135 + showStrikethrough={strikethrough} 136 + showLink={link} 137 + showBlockType={blockType} 138 + /> 139 + </div> 140 + </BubbleMenu> 141 + 142 + {#if link} 143 + <BubbleMenu 144 + {editor} 145 + shouldShow={({ editor: e }) => { 146 + return e.isActive('link'); 147 + }} 148 + pluginKey="link-bubble-menu" 149 + > 150 + <div 151 + class="bg-base-50 dark:bg-base-900 border-base-500/20 dark:border-base-700/20 relative w-fit rounded-2xl border px-1 py-1 shadow-lg backdrop-blur-sm" 152 + > 153 + <LinkEditor {editor} /> 154 + </div> 155 + </BubbleMenu> 156 + {/if}
+156
packages/text/src/lib/components/rich-text-editor-old/formatting-bubble-menu/FormattingToolbar.svelte
··· 1 + <script lang="ts"> 2 + import { Toggle } from '@foxui/core'; 3 + import type { Editor } from 'svelte-tiptap'; 4 + import Select from './Select.svelte'; 5 + 6 + type RichTextTypes = 7 + | 'paragraph' 8 + | 'heading-1' 9 + | 'heading-2' 10 + | 'heading-3' 11 + | 'blockquote' 12 + | 'code' 13 + | 'bullet-list' 14 + | 'ordered-list'; 15 + 16 + let { 17 + editor, 18 + isBold, 19 + isItalic, 20 + isUnderline, 21 + isStrikethrough, 22 + isLink, 23 + selectedType = $bindable('paragraph'), 24 + switchTo, 25 + onLinkClick, 26 + showBold = true, 27 + showItalic = true, 28 + showUnderline = true, 29 + showStrikethrough = true, 30 + showLink = true, 31 + showBlockType = true 32 + }: { 33 + editor: Editor; 34 + isBold: boolean; 35 + isItalic: boolean; 36 + isUnderline: boolean; 37 + isStrikethrough: boolean; 38 + isLink: boolean; 39 + selectedType: RichTextTypes; 40 + switchTo: (value: RichTextTypes) => void; 41 + onLinkClick: () => void; 42 + showBold?: boolean; 43 + showItalic?: boolean; 44 + showUnderline?: boolean; 45 + showStrikethrough?: boolean; 46 + showLink?: boolean; 47 + showBlockType?: boolean; 48 + } = $props(); 49 + </script> 50 + 51 + <div class="flex items-center"> 52 + {#if showBlockType} 53 + <Select 54 + onValueChange={(value) => { 55 + switchTo(value as RichTextTypes); 56 + }} 57 + type="single" 58 + items={[ 59 + { value: 'paragraph', label: 'Text' }, 60 + { value: 'heading-1', label: 'Heading 1' }, 61 + { value: 'heading-2', label: 'Heading 2' }, 62 + { value: 'heading-3', label: 'Heading 3' }, 63 + { value: 'blockquote', label: 'Blockquote' }, 64 + { value: 'code', label: 'Code Block' }, 65 + { value: 'bullet-list', label: 'Bullet List' }, 66 + { value: 'ordered-list', label: 'Ordered List' } 67 + ]} 68 + bind:value={selectedType} 69 + /> 70 + {/if} 71 + 72 + {#if showBold} 73 + <Toggle 74 + size="sm" 75 + onclick={() => editor.chain().focus().toggleBold().run()} 76 + bind:pressed={() => isBold, (_bold) => {}} 77 + > 78 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 79 + <path 80 + fill-rule="evenodd" 81 + d="M5.246 3.744a.75.75 0 0 1 .75-.75h7.125a4.875 4.875 0 0 1 3.346 8.422 5.25 5.25 0 0 1-2.97 9.58h-7.5a.75.75 0 0 1-.75-.75V3.744Zm7.125 6.75a2.625 2.625 0 0 0 0-5.25H8.246v5.25h4.125Zm-4.125 2.251v6h4.5a3 3 0 0 0 0-6h-4.5Z" 82 + clip-rule="evenodd" 83 + /> 84 + </svg> 85 + <span class="sr-only">Bold</span> 86 + </Toggle> 87 + {/if} 88 + 89 + {#if showItalic} 90 + <Toggle 91 + size="sm" 92 + onclick={() => editor.chain().focus().toggleItalic().run()} 93 + bind:pressed={() => isItalic, (_italic) => {}} 94 + > 95 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 96 + <path 97 + fill-rule="evenodd" 98 + d="M10.497 3.744a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-3.275l-5.357 15.002h2.632a.75.75 0 1 1 0 1.5h-7.5a.75.75 0 1 1 0-1.5h3.275l5.357-15.002h-2.632a.75.75 0 0 1-.75-.75Z" 99 + clip-rule="evenodd" 100 + /> 101 + </svg> 102 + <span class="sr-only">Italic</span> 103 + </Toggle> 104 + {/if} 105 + 106 + {#if showUnderline} 107 + <Toggle 108 + size="sm" 109 + onclick={() => editor.chain().focus().toggleUnderline().run()} 110 + bind:pressed={() => isUnderline, (_underline) => {}} 111 + > 112 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 113 + <path 114 + fill-rule="evenodd" 115 + d="M5.995 2.994a.75.75 0 0 1 .75.75v7.5a5.25 5.25 0 1 0 10.5 0v-7.5a.75.75 0 0 1 1.5 0v7.5a6.75 6.75 0 1 1-13.5 0v-7.5a.75.75 0 0 1 .75-.75Zm-3 17.252a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5h-16.5a.75.75 0 0 1-.75-.75Z" 116 + clip-rule="evenodd" 117 + /> 118 + </svg> 119 + <span class="sr-only">Underline</span> 120 + </Toggle> 121 + {/if} 122 + 123 + {#if showStrikethrough} 124 + <Toggle 125 + size="sm" 126 + onclick={() => editor.chain().focus().toggleStrike().run()} 127 + bind:pressed={() => isStrikethrough, (_strikethrough) => {}} 128 + > 129 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 130 + <path 131 + fill-rule="evenodd" 132 + d="M9.657 4.728c-1.086.385-1.766 1.057-1.979 1.85-.214.8.046 1.733.81 2.616.746.862 1.93 1.612 3.388 2.003.07.019.14.037.21.053h8.163a.75.75 0 0 1 0 1.5h-8.24a.66.66 0 0 1-.02 0H3.75a.75.75 0 0 1 0-1.5h4.78a7.108 7.108 0 0 1-1.175-1.074C6.372 9.042 5.849 7.61 6.229 6.19c.377-1.408 1.528-2.38 2.927-2.876 1.402-.497 3.127-.55 4.855-.086A8.937 8.937 0 0 1 16.94 4.6a.75.75 0 0 1-.881 1.215 7.437 7.437 0 0 0-2.436-1.14c-1.473-.394-2.885-.331-3.966.052Zm6.533 9.632a.75.75 0 0 1 1.03.25c.592.974.846 2.094.55 3.2-.378 1.408-1.529 2.38-2.927 2.876-1.402.497-3.127.55-4.855.087-1.712-.46-3.168-1.354-4.134-2.47a.75.75 0 0 1 1.134-.982c.746.862 1.93 1.612 3.388 2.003 1.473.394 2.884.331 3.966-.052 1.085-.384 1.766-1.056 1.978-1.85.169-.628.046-1.33-.381-2.032a.75.75 0 0 1 .25-1.03Z" 133 + clip-rule="evenodd" 134 + /> 135 + </svg> 136 + <span class="sr-only">Strikethrough</span> 137 + </Toggle> 138 + {/if} 139 + 140 + {#if showLink} 141 + <Toggle 142 + size="sm" 143 + onclick={onLinkClick} 144 + bind:pressed={() => isLink, (_link) => {}} 145 + > 146 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 147 + <path 148 + fill-rule="evenodd" 149 + d="M19.902 4.098a3.75 3.75 0 0 0-5.304 0l-4.5 4.5a3.75 3.75 0 0 0 1.035 6.037.75.75 0 0 1-.646 1.353 5.25 5.25 0 0 1-1.449-8.45l4.5-4.5a5.25 5.25 0 1 1 7.424 7.424l-1.757 1.757a.75.75 0 1 1-1.06-1.06l1.757-1.757a3.75 3.75 0 0 0 0-5.304Zm-7.389 4.267a.75.75 0 0 1 1-.353 5.25 5.25 0 0 1 1.449 8.45l-4.5 4.5a5.25 5.25 0 1 1-7.424-7.424l1.757-1.757a.75.75 0 1 1 1.06 1.06l-1.757 1.757a3.75 3.75 0 1 0 5.304 5.304l4.5-4.5a3.75 3.75 0 0 0-1.035-6.037.75.75 0 0 1-.354-1Z" 150 + clip-rule="evenodd" 151 + /> 152 + </svg> 153 + <span class="sr-only">Link</span> 154 + </Toggle> 155 + {/if} 156 + </div>
+84
packages/text/src/lib/components/rich-text-editor-old/formatting-bubble-menu/LinkEditor.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Input } from '@foxui/core'; 3 + import type { Editor } from 'svelte-tiptap'; 4 + import { tick } from 'svelte'; 5 + 6 + let { 7 + editor 8 + }: { 9 + editor: Editor; 10 + } = $props(); 11 + 12 + let link = $state(''); 13 + let linkInput = $state<HTMLInputElement | null>(null); 14 + 15 + function processLink(value: string) { 16 + return value.includes(':') ? value : `http://${value}`; 17 + } 18 + 19 + function saveLink() { 20 + if (link === '') { 21 + editor.chain().focus().extendMarkRange('link').unsetLink().run(); 22 + } else { 23 + editor.chain().focus().extendMarkRange('link').setLink({ href: processLink(link) }).run(); 24 + } 25 + } 26 + 27 + function removeLink() { 28 + editor.chain().focus().extendMarkRange('link').unsetLink().run(); 29 + } 30 + 31 + // Read current link href and focus input 32 + $effect(() => { 33 + const href = editor.getAttributes('link').href; 34 + link = href ?? ''; 35 + tick().then(() => { 36 + linkInput?.focus(); 37 + }); 38 + }); 39 + </script> 40 + 41 + <div class="flex items-center gap-1"> 42 + <Input 43 + bind:ref={linkInput} 44 + sizeVariant="sm" 45 + bind:value={link} 46 + placeholder="Enter link" 47 + onblur={saveLink} 48 + onkeydown={(e: KeyboardEvent) => { 49 + if (e.key === 'Enter') { 50 + saveLink(); 51 + } 52 + }} 53 + /> 54 + <Button size="iconSm" onclick={saveLink}> 55 + <svg 56 + xmlns="http://www.w3.org/2000/svg" 57 + fill="none" 58 + viewBox="0 0 24 24" 59 + stroke-width="1.5" 60 + stroke="currentColor" 61 + class="size-6" 62 + > 63 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 64 + </svg> 65 + 66 + <span class="sr-only">save link</span> 67 + </Button> 68 + <Button size="iconSm" onclick={removeLink} variant="ghost"> 69 + <svg 70 + xmlns="http://www.w3.org/2000/svg" 71 + fill="none" 72 + viewBox="0 0 24 24" 73 + stroke-width="1.5" 74 + stroke="currentColor" 75 + > 76 + <path 77 + stroke-linecap="round" 78 + stroke-linejoin="round" 79 + d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" 80 + /> 81 + </svg> 82 + <span class="sr-only">remove link</span> 83 + </Button> 84 + </div>
+72
packages/text/src/lib/components/rich-text-editor-old/formatting-bubble-menu/Select.svelte
··· 1 + <script lang="ts"> 2 + import { Button, cn, toggleVariants } from '@foxui/core'; 3 + import { Select, type WithoutChildren } from 'bits-ui'; 4 + import Icon from '../Icon.svelte'; 5 + 6 + type Props = WithoutChildren<Select.RootProps> & { 7 + placeholder?: string; 8 + items: { value: string; label: string; disabled?: boolean }[]; 9 + contentProps?: WithoutChildren<Select.ContentProps>; 10 + }; 11 + 12 + let { value = $bindable(), items, contentProps, placeholder, ...restProps }: Props = $props(); 13 + </script> 14 + 15 + <Select.Root bind:value={value as never} {...restProps}> 16 + <Select.Trigger> 17 + {#snippet child({ props })} 18 + <button {...props} class={cn(toggleVariants({ size: 'sm' }), 'gap-1')}> 19 + {#if value} 20 + <Icon name={value as any} /> 21 + {:else} 22 + <span class="size-3.5">?</span> 23 + {/if} 24 + 25 + <svg 26 + xmlns="http://www.w3.org/2000/svg" 27 + fill="none" 28 + viewBox="0 0 24 24" 29 + stroke-width="1.5" 30 + stroke="currentColor" 31 + class="!size-2.5" 32 + > 33 + <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /> 34 + </svg> 35 + </button> 36 + {/snippet} 37 + </Select.Trigger> 38 + <Select.Portal> 39 + <Select.Content 40 + {...contentProps} 41 + class={cn( 42 + 'bg-base-50/50 border-base-500/20 overflow-hidden rounded-2xl border shadow-lg backdrop-blur-xl', 43 + 'dark:bg-base-900/50 dark:border-base-500/10', 44 + 'motion-safe:animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 45 + contentProps?.class 46 + )} 47 + sideOffset={6} 48 + > 49 + <Select.ScrollUpButton>up</Select.ScrollUpButton> 50 + <Select.Viewport class="divide-base-300/30 dark:divide-base-800 divide-y text-sm"> 51 + {#each items as { value, label, disabled } (value)} 52 + <Select.Item {value} {label} {disabled}> 53 + {#snippet children({ selected })} 54 + <div 55 + class={cn( 56 + 'text-base-900 dark:text-base-200 group relative isolate flex min-w-28 cursor-pointer items-center gap-3 px-3 py-2 font-medium [&_svg]:size-3.5', 57 + selected 58 + ? 'text-accent-700 dark:text-accent-400 bg-accent-500/10' 59 + : 'hover:bg-accent-500/10' 60 + )} 61 + > 62 + <Icon name={value as any} /> 63 + {label} 64 + </div> 65 + {/snippet} 66 + </Select.Item> 67 + {/each} 68 + </Select.Viewport> 69 + <Select.ScrollDownButton>down</Select.ScrollDownButton> 70 + </Select.Content> 71 + </Select.Portal> 72 + </Select.Root>
+1
packages/text/src/lib/components/rich-text-editor-old/formatting-bubble-menu/index.ts
··· 1 + export { default as FormattingBubbleMenu } from './FormattingBubbleMenu.svelte';
+47
packages/text/src/lib/components/rich-text-editor-old/image-upload/ImageUploadComponent.svelte
··· 1 + <script lang="ts"> 2 + import type { NodeViewProps } from '@tiptap/core'; 3 + import { onMount } from 'svelte'; 4 + import { NodeViewWrapper } from 'svelte-tiptap'; 5 + 6 + let props: NodeViewProps = $props(); 7 + 8 + let progress = $state(0); 9 + 10 + const handleUpload = async (files?: File[]) => { 11 + console.log(props.node, props.node.attrs); 12 + 13 + const url = await props.extension.options.upload(files, (event) => { 14 + console.log('progress', event); 15 + progress = event.progress; 16 + }); 17 + 18 + if (url) { 19 + const pos = props.getPos(); 20 + const filename = files?.[0]?.name.replace(/\.[^/.]+$/, '') || 'unknown'; 21 + 22 + props.deleteNode(); 23 + props.editor 24 + .chain() 25 + .focus() 26 + .insertContentAt(pos, [ 27 + { 28 + type: 'image', 29 + attrs: { src: props.node.attrs.preview, alt: filename, title: filename } 30 + } 31 + ]) 32 + .run(); 33 + } 34 + }; 35 + 36 + onMount(() => { 37 + console.log('node', props); 38 + handleUpload(); 39 + }); 40 + </script> 41 + 42 + <NodeViewWrapper class="relative"> 43 + <img src={props.node.attrs.preview} alt="Upload preview" class="" /> 44 + <div class="absolute left-0 right-0 bottom-0 h-1 bg-accent-500/30"> 45 + <div class="h-full bg-accent-500 transition-all duration-300" style="width: {progress * 100}%"></div> 46 + </div> 47 + </NodeViewWrapper>
+115
packages/text/src/lib/components/rich-text-editor-old/image-upload/ImageUploadNode.ts
··· 1 + import { Node, mergeAttributes } from '@tiptap/core'; 2 + import { SvelteNodeViewRenderer } from 'svelte-tiptap'; 3 + 4 + import ImageUploadComponent from './ImageUploadComponent.svelte'; 5 + 6 + export type UploadFunction = ( 7 + file: File, 8 + onProgress?: (event: { progress: number }) => void, 9 + abortSignal?: AbortSignal 10 + ) => Promise<string>; 11 + 12 + export interface ImageUploadNodeOptions { 13 + /** 14 + * Acceptable file types for upload. 15 + * @default 'image/*' 16 + */ 17 + accept?: string; 18 + /** 19 + * Maximum number of files that can be uploaded. 20 + * @default 1 21 + */ 22 + limit?: number; 23 + /** 24 + * Maximum file size in bytes (0 for unlimited). 25 + * @default 0 26 + */ 27 + maxSize?: number; 28 + 29 + /** 30 + * Preview image URL. 31 + */ 32 + preview?: string; 33 + /** 34 + * Function to handle the upload process. 35 + */ 36 + upload?: UploadFunction; 37 + /** 38 + * Callback for upload errors. 39 + */ 40 + onError?: (error: Error) => void; 41 + /** 42 + * Callback for successful uploads. 43 + */ 44 + onSuccess?: (url: string) => void; 45 + } 46 + 47 + declare module '@tiptap/core' { 48 + interface Commands<ReturnType> { 49 + imageUpload: { 50 + setImageUploadNode: (options?: ImageUploadNodeOptions) => ReturnType; 51 + }; 52 + } 53 + } 54 + 55 + export const ImageUploadNode = Node.create<ImageUploadNodeOptions>({ 56 + name: 'imageUpload', 57 + group: 'block', 58 + atom: true, 59 + draggable: true, 60 + selectable: false, 61 + inline: false, 62 + 63 + addAttributes() { 64 + return { 65 + accept: { 66 + default: this.options.accept 67 + }, 68 + limit: { 69 + default: this.options.limit 70 + }, 71 + maxSize: { 72 + default: this.options.maxSize 73 + }, 74 + preview: { 75 + default: this.options.preview 76 + } 77 + }; 78 + }, 79 + 80 + addOptions() { 81 + return { 82 + accept: 'image/*', 83 + limit: 1, 84 + maxSize: 0, 85 + upload: undefined, 86 + onError: undefined, 87 + onSuccess: undefined 88 + }; 89 + }, 90 + 91 + addCommands() { 92 + return { 93 + setImageUploadNode: 94 + (options = {}) => 95 + ({ commands }) => { 96 + return commands.insertContent({ 97 + type: this.name, 98 + attrs: options 99 + }); 100 + } 101 + }; 102 + }, 103 + 104 + parseHTML() { 105 + return [{ tag: 'div[data-type="image-upload"]' }]; 106 + }, 107 + 108 + renderHTML({ HTMLAttributes }) { 109 + return ['div', mergeAttributes({ 'data-type': 'image-upload' }, HTMLAttributes)]; 110 + }, 111 + 112 + addNodeView() { 113 + return SvelteNodeViewRenderer(ImageUploadComponent); 114 + } 115 + });
+88
packages/text/src/lib/components/rich-text-editor-old/slash-menu/SuggestionSelect.svelte
··· 1 + <script lang="ts"> 2 + import { cn } from '@foxui/core'; 3 + import type { Editor, Range } from '@tiptap/core'; 4 + import Icon from '../Icon.svelte'; 5 + import type { RichTextTypes } from '..'; 6 + 7 + type Props = { 8 + items: { 9 + value: RichTextTypes; 10 + label: string; 11 + command: ({ editor, range }: { editor: Editor; range: Range }) => void; 12 + }[]; 13 + range: Range; 14 + editor: Editor; 15 + active?: number; 16 + }; 17 + 18 + let { items, range, editor, active }: Props = $props(); 19 + 20 + let activeIndex = $state(active ?? 0); 21 + 22 + export function setItems(value: any[]) { 23 + items = value; 24 + } 25 + 26 + export function setRange(value: Range) { 27 + range = value; 28 + } 29 + 30 + export function onKeyDown(event: KeyboardEvent) { 31 + if (event.repeat) { 32 + return false; 33 + } 34 + switch (event.key) { 35 + case 'ArrowUp': { 36 + if (activeIndex <= 0) { 37 + activeIndex = items.length - 1; 38 + } else { 39 + activeIndex--; 40 + } 41 + return true; 42 + } 43 + case 'ArrowDown': { 44 + if (activeIndex >= items.length - 1) { 45 + activeIndex = 0; 46 + } else { 47 + activeIndex++; 48 + } 49 + return true; 50 + } 51 + case 'Enter': { 52 + const selected = items[activeIndex]; 53 + 54 + if (selected) { 55 + selected.command({ editor, range }); 56 + return true; 57 + } 58 + } 59 + } 60 + 61 + return false; 62 + } 63 + </script> 64 + 65 + <menu 66 + class={cn( 67 + 'bg-base-50/50 border-base-500/20 overflow-hidden rounded-2xl border shadow-lg backdrop-blur-xl', 68 + 'dark:bg-base-900/50 dark:border-base-500/10', 69 + 'motion-safe:animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 70 + "divide-base-300/30 dark:divide-base-800 divide-y text-sm" 71 + )} 72 + > 73 + 74 + {#each items as item, index} 75 + <button 76 + onclick={() => item.command({ editor, range })} 77 + class={cn( 78 + 'text-base-900 dark:text-base-200 group relative isolate flex min-w-28 w-full cursor-pointer items-center gap-3 px-3 py-2 font-medium [&_svg]:size-3.5', 79 + activeIndex === index 80 + ? 'text-accent-700 dark:text-accent-400 bg-accent-500/10' 81 + : 'hover:bg-accent-500/10' 82 + )} 83 + > 84 + <Icon name={item.value} /> 85 + {item.label} 86 + </button> 87 + {/each} 88 + </menu>
+198
packages/text/src/lib/components/rich-text-editor-old/slash-menu/index.ts
··· 1 + import { Extension } from '@tiptap/core'; 2 + import Suggestion from '@tiptap/suggestion'; 3 + import type { Editor, Range } from '@tiptap/core'; 4 + import { PluginKey } from '@tiptap/pm/state'; 5 + import type { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion'; 6 + import SuggestionSelect from './SuggestionSelect.svelte'; 7 + import { mount, unmount } from 'svelte'; 8 + import { computePosition, flip, shift, offset } from '@floating-ui/dom'; 9 + import type { RichTextTypes } from '..'; 10 + 11 + export default Extension.create({ 12 + name: 'slash', 13 + 14 + addOptions() { 15 + return { 16 + suggestion: { 17 + char: '/', 18 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 19 + command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { 20 + props.command({ editor, range }); 21 + } 22 + } 23 + }; 24 + }, 25 + 26 + addProseMirrorPlugins() { 27 + return [ 28 + Suggestion({ 29 + editor: this.editor, 30 + ...this.options.suggestion 31 + }) 32 + ]; 33 + } 34 + }); 35 + 36 + export function suggestion({ 37 + char, 38 + pluginKey, 39 + switchTo, 40 + processImageFile 41 + }: { 42 + char: string; 43 + pluginKey: string; 44 + switchTo: (value: RichTextTypes) => void; 45 + processImageFile: (file: File) => void; 46 + }) { 47 + return { 48 + char, 49 + pluginKey: new PluginKey(pluginKey), 50 + 51 + items: ({ query }: { query: string }) => { 52 + return [ 53 + { 54 + value: 'paragraph', 55 + label: 'Paragraph', 56 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 57 + editor.chain().focus().deleteRange(range).run(); 58 + switchTo('paragraph'); 59 + } 60 + }, 61 + { 62 + value: 'heading-1', 63 + label: 'Heading 1', 64 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 65 + editor.chain().focus().deleteRange(range).run(); 66 + switchTo('heading-1'); 67 + } 68 + }, 69 + { 70 + value: 'heading-2', 71 + label: 'Heading 2', 72 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 73 + editor.chain().focus().deleteRange(range).run(); 74 + switchTo('heading-2'); 75 + } 76 + }, 77 + { 78 + value: 'heading-3', 79 + label: 'Heading 3', 80 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 81 + editor.chain().focus().deleteRange(range).run(); 82 + switchTo('heading-3'); 83 + } 84 + }, 85 + { 86 + value: 'blockquote', 87 + label: 'Blockquote', 88 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 89 + editor.chain().focus().deleteRange(range).run(); 90 + switchTo('blockquote'); 91 + } 92 + }, 93 + { 94 + value: 'code', 95 + label: 'Code Block', 96 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 97 + editor.chain().focus().deleteRange(range).run(); 98 + switchTo('code'); 99 + } 100 + }, 101 + { 102 + value: 'bullet-list', 103 + label: 'Bullet List', 104 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 105 + editor.chain().focus().deleteRange(range).run(); 106 + switchTo('bullet-list'); 107 + } 108 + }, 109 + { 110 + value: 'ordered-list', 111 + label: 'Ordered List', 112 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 113 + editor.chain().focus().deleteRange(range).run(); 114 + switchTo('ordered-list'); 115 + } 116 + }, 117 + { 118 + value: 'image', 119 + label: 'Add Image', 120 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 121 + editor.chain().focus().deleteRange(range).run(); 122 + 123 + const fileInput = document.createElement('input'); 124 + fileInput.type = 'file'; 125 + fileInput.click(); 126 + fileInput.addEventListener('change', (event) => { 127 + const input = event.target as HTMLInputElement; 128 + if (!input.files?.length) return; 129 + const file = input.files[0]; 130 + if (!file?.type.startsWith('image/')) return; 131 + processImageFile(file); 132 + 133 + input.remove(); 134 + }); 135 + } 136 + } 137 + ].filter((item) => item.label.toLowerCase().includes(query.toLowerCase())); 138 + }, 139 + 140 + render: () => { 141 + let component: ReturnType<typeof SuggestionSelect>; 142 + let floatingEl: HTMLElement; 143 + 144 + function updatePosition(clientRect: (() => DOMRect | null) | null | undefined) { 145 + if (!clientRect || !floatingEl) return; 146 + const rect = clientRect(); 147 + if (!rect) return; 148 + 149 + const virtualRef = { 150 + getBoundingClientRect: () => rect 151 + }; 152 + 153 + computePosition(virtualRef as Element, floatingEl, { 154 + placement: 'bottom-start', 155 + middleware: [offset(8), flip(), shift({ padding: 8 })] 156 + }).then(({ x, y }) => { 157 + Object.assign(floatingEl.style, { 158 + left: `${x}px`, 159 + top: `${y}px` 160 + }); 161 + }); 162 + } 163 + 164 + return { 165 + onStart: (props: SuggestionProps) => { 166 + floatingEl = document.createElement('div'); 167 + floatingEl.style.position = 'absolute'; 168 + floatingEl.style.zIndex = '50'; 169 + document.body.appendChild(floatingEl); 170 + 171 + component = mount(SuggestionSelect, { 172 + target: floatingEl, 173 + props 174 + }); 175 + 176 + updatePosition(props.clientRect); 177 + }, 178 + onUpdate: (props: SuggestionProps) => { 179 + component.setItems(props.items); 180 + component.setRange(props.range); 181 + updatePosition(props.clientRect); 182 + }, 183 + onKeyDown: (props: SuggestionKeyDownProps) => { 184 + if (props.event.key === 'Escape') { 185 + floatingEl.style.display = 'none'; 186 + return true; 187 + } 188 + 189 + return component.onKeyDown(props.event); 190 + }, 191 + onExit: () => { 192 + unmount(component); 193 + floatingEl.remove(); 194 + } 195 + }; 196 + } 197 + }; 198 + }
-156
packages/text/src/lib/components/rich-text-editor/formatting-bubble-menu/FormattingBubbleMenu.svelte
··· 1 - <script lang="ts"> 2 - import { BubbleMenu, type Editor } from 'svelte-tiptap'; 3 - import FormattingToolbar from './FormattingToolbar.svelte'; 4 - import LinkEditor from './LinkEditor.svelte'; 5 - 6 - type RichTextTypes = 7 - | 'paragraph' 8 - | 'heading-1' 9 - | 'heading-2' 10 - | 'heading-3' 11 - | 'blockquote' 12 - | 'code' 13 - | 'bullet-list' 14 - | 'ordered-list'; 15 - 16 - let { 17 - editor, 18 - bold = true, 19 - italic = true, 20 - underline = true, 21 - strikethrough = true, 22 - link = true, 23 - blockType = true 24 - }: { 25 - editor: Editor; 26 - bold?: boolean; 27 - italic?: boolean; 28 - underline?: boolean; 29 - strikethrough?: boolean; 30 - link?: boolean; 31 - blockType?: boolean; 32 - } = $props(); 33 - 34 - let isBold = $state(false); 35 - let isItalic = $state(false); 36 - let isUnderline = $state(false); 37 - let isStrikethrough = $state(false); 38 - let isLink = $state(false); 39 - let selectedType: RichTextTypes = $state('paragraph'); 40 - 41 - function updateState() { 42 - isBold = editor.isActive('bold'); 43 - isItalic = editor.isActive('italic'); 44 - isUnderline = editor.isActive('underline'); 45 - isStrikethrough = editor.isActive('strike'); 46 - isLink = editor.isActive('link'); 47 - 48 - if (editor.isActive('heading', { level: 1 })) { 49 - selectedType = 'heading-1'; 50 - } else if (editor.isActive('heading', { level: 2 })) { 51 - selectedType = 'heading-2'; 52 - } else if (editor.isActive('heading', { level: 3 })) { 53 - selectedType = 'heading-3'; 54 - } else if (editor.isActive('blockquote')) { 55 - selectedType = 'blockquote'; 56 - } else if (editor.isActive('code')) { 57 - selectedType = 'code'; 58 - } else if (editor.isActive('bulletList')) { 59 - selectedType = 'bullet-list'; 60 - } else if (editor.isActive('orderedList')) { 61 - selectedType = 'ordered-list'; 62 - } else { 63 - selectedType = 'paragraph'; 64 - } 65 - } 66 - 67 - function switchTo(value: RichTextTypes) { 68 - editor.chain().focus().setParagraph().run(); 69 - 70 - if (value === 'heading-1') { 71 - editor.chain().focus().setNode('heading', { level: 1 }).run(); 72 - } else if (value === 'heading-2') { 73 - editor.chain().focus().setNode('heading', { level: 2 }).run(); 74 - } else if (value === 'heading-3') { 75 - editor.chain().focus().setNode('heading', { level: 3 }).run(); 76 - } else if (value === 'blockquote') { 77 - editor.chain().focus().setBlockquote().run(); 78 - } else if (value === 'code') { 79 - editor.chain().focus().setCodeBlock().run(); 80 - } else if (value === 'bullet-list') { 81 - editor.chain().focus().toggleBulletList().run(); 82 - } else if (value === 'ordered-list') { 83 - editor.chain().focus().toggleOrderedList().run(); 84 - } 85 - } 86 - 87 - function handleLinkClick() { 88 - if (isLink) { 89 - // Already a link — the link BubbleMenu will handle editing 90 - return; 91 - } 92 - // Set an empty link mark — this triggers isLink=true, which shows LinkEditor 93 - editor.chain().focus().setLink({ href: '' }).run(); 94 - } 95 - 96 - $effect(() => { 97 - editor.on('transaction', updateState); 98 - updateState(); 99 - 100 - return () => { 101 - editor.off('transaction', updateState); 102 - }; 103 - }); 104 - </script> 105 - 106 - <BubbleMenu 107 - {editor} 108 - shouldShow={({ editor: e }) => { 109 - return ( 110 - !e.isActive('image') && 111 - !e.view.state.selection.empty && 112 - !e.isActive('codeBlock') && 113 - !e.isActive('link') && 114 - !e.isActive('imageUpload') 115 - ); 116 - }} 117 - pluginKey="formatting-bubble-menu" 118 - > 119 - <div 120 - class="bg-base-50 dark:bg-base-900 border-base-500/20 dark:border-base-700/20 relative w-fit rounded-2xl border px-1 py-1 shadow-lg backdrop-blur-sm" 121 - > 122 - <FormattingToolbar 123 - {editor} 124 - {isBold} 125 - {isItalic} 126 - {isUnderline} 127 - {isStrikethrough} 128 - {isLink} 129 - bind:selectedType 130 - {switchTo} 131 - onLinkClick={handleLinkClick} 132 - showBold={bold} 133 - showItalic={italic} 134 - showUnderline={underline} 135 - showStrikethrough={strikethrough} 136 - showLink={link} 137 - showBlockType={blockType} 138 - /> 139 - </div> 140 - </BubbleMenu> 141 - 142 - {#if link} 143 - <BubbleMenu 144 - {editor} 145 - shouldShow={({ editor: e }) => { 146 - return e.isActive('link'); 147 - }} 148 - pluginKey="link-bubble-menu" 149 - > 150 - <div 151 - class="bg-base-50 dark:bg-base-900 border-base-500/20 dark:border-base-700/20 relative w-fit rounded-2xl border px-1 py-1 shadow-lg backdrop-blur-sm" 152 - > 153 - <LinkEditor {editor} /> 154 - </div> 155 - </BubbleMenu> 156 - {/if}
-156
packages/text/src/lib/components/rich-text-editor/formatting-bubble-menu/FormattingToolbar.svelte
··· 1 - <script lang="ts"> 2 - import { Toggle } from '@foxui/core'; 3 - import type { Editor } from 'svelte-tiptap'; 4 - import Select from './Select.svelte'; 5 - 6 - type RichTextTypes = 7 - | 'paragraph' 8 - | 'heading-1' 9 - | 'heading-2' 10 - | 'heading-3' 11 - | 'blockquote' 12 - | 'code' 13 - | 'bullet-list' 14 - | 'ordered-list'; 15 - 16 - let { 17 - editor, 18 - isBold, 19 - isItalic, 20 - isUnderline, 21 - isStrikethrough, 22 - isLink, 23 - selectedType = $bindable('paragraph'), 24 - switchTo, 25 - onLinkClick, 26 - showBold = true, 27 - showItalic = true, 28 - showUnderline = true, 29 - showStrikethrough = true, 30 - showLink = true, 31 - showBlockType = true 32 - }: { 33 - editor: Editor; 34 - isBold: boolean; 35 - isItalic: boolean; 36 - isUnderline: boolean; 37 - isStrikethrough: boolean; 38 - isLink: boolean; 39 - selectedType: RichTextTypes; 40 - switchTo: (value: RichTextTypes) => void; 41 - onLinkClick: () => void; 42 - showBold?: boolean; 43 - showItalic?: boolean; 44 - showUnderline?: boolean; 45 - showStrikethrough?: boolean; 46 - showLink?: boolean; 47 - showBlockType?: boolean; 48 - } = $props(); 49 - </script> 50 - 51 - <div class="flex items-center"> 52 - {#if showBlockType} 53 - <Select 54 - onValueChange={(value) => { 55 - switchTo(value as RichTextTypes); 56 - }} 57 - type="single" 58 - items={[ 59 - { value: 'paragraph', label: 'Text' }, 60 - { value: 'heading-1', label: 'Heading 1' }, 61 - { value: 'heading-2', label: 'Heading 2' }, 62 - { value: 'heading-3', label: 'Heading 3' }, 63 - { value: 'blockquote', label: 'Blockquote' }, 64 - { value: 'code', label: 'Code Block' }, 65 - { value: 'bullet-list', label: 'Bullet List' }, 66 - { value: 'ordered-list', label: 'Ordered List' } 67 - ]} 68 - bind:value={selectedType} 69 - /> 70 - {/if} 71 - 72 - {#if showBold} 73 - <Toggle 74 - size="sm" 75 - onclick={() => editor.chain().focus().toggleBold().run()} 76 - bind:pressed={() => isBold, (_bold) => {}} 77 - > 78 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 79 - <path 80 - fill-rule="evenodd" 81 - d="M5.246 3.744a.75.75 0 0 1 .75-.75h7.125a4.875 4.875 0 0 1 3.346 8.422 5.25 5.25 0 0 1-2.97 9.58h-7.5a.75.75 0 0 1-.75-.75V3.744Zm7.125 6.75a2.625 2.625 0 0 0 0-5.25H8.246v5.25h4.125Zm-4.125 2.251v6h4.5a3 3 0 0 0 0-6h-4.5Z" 82 - clip-rule="evenodd" 83 - /> 84 - </svg> 85 - <span class="sr-only">Bold</span> 86 - </Toggle> 87 - {/if} 88 - 89 - {#if showItalic} 90 - <Toggle 91 - size="sm" 92 - onclick={() => editor.chain().focus().toggleItalic().run()} 93 - bind:pressed={() => isItalic, (_italic) => {}} 94 - > 95 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 96 - <path 97 - fill-rule="evenodd" 98 - d="M10.497 3.744a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-3.275l-5.357 15.002h2.632a.75.75 0 1 1 0 1.5h-7.5a.75.75 0 1 1 0-1.5h3.275l5.357-15.002h-2.632a.75.75 0 0 1-.75-.75Z" 99 - clip-rule="evenodd" 100 - /> 101 - </svg> 102 - <span class="sr-only">Italic</span> 103 - </Toggle> 104 - {/if} 105 - 106 - {#if showUnderline} 107 - <Toggle 108 - size="sm" 109 - onclick={() => editor.chain().focus().toggleUnderline().run()} 110 - bind:pressed={() => isUnderline, (_underline) => {}} 111 - > 112 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 113 - <path 114 - fill-rule="evenodd" 115 - d="M5.995 2.994a.75.75 0 0 1 .75.75v7.5a5.25 5.25 0 1 0 10.5 0v-7.5a.75.75 0 0 1 1.5 0v7.5a6.75 6.75 0 1 1-13.5 0v-7.5a.75.75 0 0 1 .75-.75Zm-3 17.252a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5h-16.5a.75.75 0 0 1-.75-.75Z" 116 - clip-rule="evenodd" 117 - /> 118 - </svg> 119 - <span class="sr-only">Underline</span> 120 - </Toggle> 121 - {/if} 122 - 123 - {#if showStrikethrough} 124 - <Toggle 125 - size="sm" 126 - onclick={() => editor.chain().focus().toggleStrike().run()} 127 - bind:pressed={() => isStrikethrough, (_strikethrough) => {}} 128 - > 129 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 130 - <path 131 - fill-rule="evenodd" 132 - d="M9.657 4.728c-1.086.385-1.766 1.057-1.979 1.85-.214.8.046 1.733.81 2.616.746.862 1.93 1.612 3.388 2.003.07.019.14.037.21.053h8.163a.75.75 0 0 1 0 1.5h-8.24a.66.66 0 0 1-.02 0H3.75a.75.75 0 0 1 0-1.5h4.78a7.108 7.108 0 0 1-1.175-1.074C6.372 9.042 5.849 7.61 6.229 6.19c.377-1.408 1.528-2.38 2.927-2.876 1.402-.497 3.127-.55 4.855-.086A8.937 8.937 0 0 1 16.94 4.6a.75.75 0 0 1-.881 1.215 7.437 7.437 0 0 0-2.436-1.14c-1.473-.394-2.885-.331-3.966.052Zm6.533 9.632a.75.75 0 0 1 1.03.25c.592.974.846 2.094.55 3.2-.378 1.408-1.529 2.38-2.927 2.876-1.402.497-3.127.55-4.855.087-1.712-.46-3.168-1.354-4.134-2.47a.75.75 0 0 1 1.134-.982c.746.862 1.93 1.612 3.388 2.003 1.473.394 2.884.331 3.966-.052 1.085-.384 1.766-1.056 1.978-1.85.169-.628.046-1.33-.381-2.032a.75.75 0 0 1 .25-1.03Z" 133 - clip-rule="evenodd" 134 - /> 135 - </svg> 136 - <span class="sr-only">Strikethrough</span> 137 - </Toggle> 138 - {/if} 139 - 140 - {#if showLink} 141 - <Toggle 142 - size="sm" 143 - onclick={onLinkClick} 144 - bind:pressed={() => isLink, (_link) => {}} 145 - > 146 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 147 - <path 148 - fill-rule="evenodd" 149 - d="M19.902 4.098a3.75 3.75 0 0 0-5.304 0l-4.5 4.5a3.75 3.75 0 0 0 1.035 6.037.75.75 0 0 1-.646 1.353 5.25 5.25 0 0 1-1.449-8.45l4.5-4.5a5.25 5.25 0 1 1 7.424 7.424l-1.757 1.757a.75.75 0 1 1-1.06-1.06l1.757-1.757a3.75 3.75 0 0 0 0-5.304Zm-7.389 4.267a.75.75 0 0 1 1-.353 5.25 5.25 0 0 1 1.449 8.45l-4.5 4.5a5.25 5.25 0 1 1-7.424-7.424l1.757-1.757a.75.75 0 1 1 1.06 1.06l-1.757 1.757a3.75 3.75 0 1 0 5.304 5.304l4.5-4.5a3.75 3.75 0 0 0-1.035-6.037.75.75 0 0 1-.354-1Z" 150 - clip-rule="evenodd" 151 - /> 152 - </svg> 153 - <span class="sr-only">Link</span> 154 - </Toggle> 155 - {/if} 156 - </div>
-84
packages/text/src/lib/components/rich-text-editor/formatting-bubble-menu/LinkEditor.svelte
··· 1 - <script lang="ts"> 2 - import { Button, Input } from '@foxui/core'; 3 - import type { Editor } from 'svelte-tiptap'; 4 - import { tick } from 'svelte'; 5 - 6 - let { 7 - editor 8 - }: { 9 - editor: Editor; 10 - } = $props(); 11 - 12 - let link = $state(''); 13 - let linkInput = $state<HTMLInputElement | null>(null); 14 - 15 - function processLink(value: string) { 16 - return value.includes(':') ? value : `http://${value}`; 17 - } 18 - 19 - function saveLink() { 20 - if (link === '') { 21 - editor.chain().focus().extendMarkRange('link').unsetLink().run(); 22 - } else { 23 - editor.chain().focus().extendMarkRange('link').setLink({ href: processLink(link) }).run(); 24 - } 25 - } 26 - 27 - function removeLink() { 28 - editor.chain().focus().extendMarkRange('link').unsetLink().run(); 29 - } 30 - 31 - // Read current link href and focus input 32 - $effect(() => { 33 - const href = editor.getAttributes('link').href; 34 - link = href ?? ''; 35 - tick().then(() => { 36 - linkInput?.focus(); 37 - }); 38 - }); 39 - </script> 40 - 41 - <div class="flex items-center gap-1"> 42 - <Input 43 - bind:ref={linkInput} 44 - sizeVariant="sm" 45 - bind:value={link} 46 - placeholder="Enter link" 47 - onblur={saveLink} 48 - onkeydown={(e: KeyboardEvent) => { 49 - if (e.key === 'Enter') { 50 - saveLink(); 51 - } 52 - }} 53 - /> 54 - <Button size="iconSm" onclick={saveLink}> 55 - <svg 56 - xmlns="http://www.w3.org/2000/svg" 57 - fill="none" 58 - viewBox="0 0 24 24" 59 - stroke-width="1.5" 60 - stroke="currentColor" 61 - class="size-6" 62 - > 63 - <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 64 - </svg> 65 - 66 - <span class="sr-only">save link</span> 67 - </Button> 68 - <Button size="iconSm" onclick={removeLink} variant="ghost"> 69 - <svg 70 - xmlns="http://www.w3.org/2000/svg" 71 - fill="none" 72 - viewBox="0 0 24 24" 73 - stroke-width="1.5" 74 - stroke="currentColor" 75 - > 76 - <path 77 - stroke-linecap="round" 78 - stroke-linejoin="round" 79 - d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" 80 - /> 81 - </svg> 82 - <span class="sr-only">remove link</span> 83 - </Button> 84 - </div>
-72
packages/text/src/lib/components/rich-text-editor/formatting-bubble-menu/Select.svelte
··· 1 - <script lang="ts"> 2 - import { Button, cn, toggleVariants } from '@foxui/core'; 3 - import { Select, type WithoutChildren } from 'bits-ui'; 4 - import Icon from '../Icon.svelte'; 5 - 6 - type Props = WithoutChildren<Select.RootProps> & { 7 - placeholder?: string; 8 - items: { value: string; label: string; disabled?: boolean }[]; 9 - contentProps?: WithoutChildren<Select.ContentProps>; 10 - }; 11 - 12 - let { value = $bindable(), items, contentProps, placeholder, ...restProps }: Props = $props(); 13 - </script> 14 - 15 - <Select.Root bind:value={value as never} {...restProps}> 16 - <Select.Trigger> 17 - {#snippet child({ props })} 18 - <button {...props} class={cn(toggleVariants({ size: 'sm' }), 'gap-1')}> 19 - {#if value} 20 - <Icon name={value as any} /> 21 - {:else} 22 - <span class="size-3.5">?</span> 23 - {/if} 24 - 25 - <svg 26 - xmlns="http://www.w3.org/2000/svg" 27 - fill="none" 28 - viewBox="0 0 24 24" 29 - stroke-width="1.5" 30 - stroke="currentColor" 31 - class="!size-2.5" 32 - > 33 - <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /> 34 - </svg> 35 - </button> 36 - {/snippet} 37 - </Select.Trigger> 38 - <Select.Portal> 39 - <Select.Content 40 - {...contentProps} 41 - class={cn( 42 - 'bg-base-50/50 border-base-500/20 overflow-hidden rounded-2xl border shadow-lg backdrop-blur-xl', 43 - 'dark:bg-base-900/50 dark:border-base-500/10', 44 - 'motion-safe:animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 45 - contentProps?.class 46 - )} 47 - sideOffset={6} 48 - > 49 - <Select.ScrollUpButton>up</Select.ScrollUpButton> 50 - <Select.Viewport class="divide-base-300/30 dark:divide-base-800 divide-y text-sm"> 51 - {#each items as { value, label, disabled } (value)} 52 - <Select.Item {value} {label} {disabled}> 53 - {#snippet children({ selected })} 54 - <div 55 - class={cn( 56 - 'text-base-900 dark:text-base-200 group relative isolate flex min-w-28 cursor-pointer items-center gap-3 px-3 py-2 font-medium [&_svg]:size-3.5', 57 - selected 58 - ? 'text-accent-700 dark:text-accent-400 bg-accent-500/10' 59 - : 'hover:bg-accent-500/10' 60 - )} 61 - > 62 - <Icon name={value as any} /> 63 - {label} 64 - </div> 65 - {/snippet} 66 - </Select.Item> 67 - {/each} 68 - </Select.Viewport> 69 - <Select.ScrollDownButton>down</Select.ScrollDownButton> 70 - </Select.Content> 71 - </Select.Portal> 72 - </Select.Root>
-1
packages/text/src/lib/components/rich-text-editor/formatting-bubble-menu/index.ts
··· 1 - export { default as FormattingBubbleMenu } from './FormattingBubbleMenu.svelte';
-47
packages/text/src/lib/components/rich-text-editor/image-upload/ImageUploadComponent.svelte
··· 1 - <script lang="ts"> 2 - import type { NodeViewProps } from '@tiptap/core'; 3 - import { onMount } from 'svelte'; 4 - import { NodeViewWrapper } from 'svelte-tiptap'; 5 - 6 - let props: NodeViewProps = $props(); 7 - 8 - let progress = $state(0); 9 - 10 - const handleUpload = async (files?: File[]) => { 11 - console.log(props.node, props.node.attrs); 12 - 13 - const url = await props.extension.options.upload(files, (event) => { 14 - console.log('progress', event); 15 - progress = event.progress; 16 - }); 17 - 18 - if (url) { 19 - const pos = props.getPos(); 20 - const filename = files?.[0]?.name.replace(/\.[^/.]+$/, '') || 'unknown'; 21 - 22 - props.deleteNode(); 23 - props.editor 24 - .chain() 25 - .focus() 26 - .insertContentAt(pos, [ 27 - { 28 - type: 'image', 29 - attrs: { src: props.node.attrs.preview, alt: filename, title: filename } 30 - } 31 - ]) 32 - .run(); 33 - } 34 - }; 35 - 36 - onMount(() => { 37 - console.log('node', props); 38 - handleUpload(); 39 - }); 40 - </script> 41 - 42 - <NodeViewWrapper class="relative"> 43 - <img src={props.node.attrs.preview} alt="Upload preview" class="" /> 44 - <div class="absolute left-0 right-0 bottom-0 h-1 bg-accent-500/30"> 45 - <div class="h-full bg-accent-500 transition-all duration-300" style="width: {progress * 100}%"></div> 46 - </div> 47 - </NodeViewWrapper>
-115
packages/text/src/lib/components/rich-text-editor/image-upload/ImageUploadNode.ts
··· 1 - import { Node, mergeAttributes } from '@tiptap/core'; 2 - import { SvelteNodeViewRenderer } from 'svelte-tiptap'; 3 - 4 - import ImageUploadComponent from './ImageUploadComponent.svelte'; 5 - 6 - export type UploadFunction = ( 7 - file: File, 8 - onProgress?: (event: { progress: number }) => void, 9 - abortSignal?: AbortSignal 10 - ) => Promise<string>; 11 - 12 - export interface ImageUploadNodeOptions { 13 - /** 14 - * Acceptable file types for upload. 15 - * @default 'image/*' 16 - */ 17 - accept?: string; 18 - /** 19 - * Maximum number of files that can be uploaded. 20 - * @default 1 21 - */ 22 - limit?: number; 23 - /** 24 - * Maximum file size in bytes (0 for unlimited). 25 - * @default 0 26 - */ 27 - maxSize?: number; 28 - 29 - /** 30 - * Preview image URL. 31 - */ 32 - preview?: string; 33 - /** 34 - * Function to handle the upload process. 35 - */ 36 - upload?: UploadFunction; 37 - /** 38 - * Callback for upload errors. 39 - */ 40 - onError?: (error: Error) => void; 41 - /** 42 - * Callback for successful uploads. 43 - */ 44 - onSuccess?: (url: string) => void; 45 - } 46 - 47 - declare module '@tiptap/core' { 48 - interface Commands<ReturnType> { 49 - imageUpload: { 50 - setImageUploadNode: (options?: ImageUploadNodeOptions) => ReturnType; 51 - }; 52 - } 53 - } 54 - 55 - export const ImageUploadNode = Node.create<ImageUploadNodeOptions>({ 56 - name: 'imageUpload', 57 - group: 'block', 58 - atom: true, 59 - draggable: true, 60 - selectable: false, 61 - inline: false, 62 - 63 - addAttributes() { 64 - return { 65 - accept: { 66 - default: this.options.accept 67 - }, 68 - limit: { 69 - default: this.options.limit 70 - }, 71 - maxSize: { 72 - default: this.options.maxSize 73 - }, 74 - preview: { 75 - default: this.options.preview 76 - } 77 - }; 78 - }, 79 - 80 - addOptions() { 81 - return { 82 - accept: 'image/*', 83 - limit: 1, 84 - maxSize: 0, 85 - upload: undefined, 86 - onError: undefined, 87 - onSuccess: undefined 88 - }; 89 - }, 90 - 91 - addCommands() { 92 - return { 93 - setImageUploadNode: 94 - (options = {}) => 95 - ({ commands }) => { 96 - return commands.insertContent({ 97 - type: this.name, 98 - attrs: options 99 - }); 100 - } 101 - }; 102 - }, 103 - 104 - parseHTML() { 105 - return [{ tag: 'div[data-type="image-upload"]' }]; 106 - }, 107 - 108 - renderHTML({ HTMLAttributes }) { 109 - return ['div', mergeAttributes({ 'data-type': 'image-upload' }, HTMLAttributes)]; 110 - }, 111 - 112 - addNodeView() { 113 - return SvelteNodeViewRenderer(ImageUploadComponent); 114 - } 115 - });
-88
packages/text/src/lib/components/rich-text-editor/slash-menu/SuggestionSelect.svelte
··· 1 - <script lang="ts"> 2 - import { cn } from '@foxui/core'; 3 - import type { Editor, Range } from '@tiptap/core'; 4 - import Icon from '../Icon.svelte'; 5 - import type { RichTextTypes } from '..'; 6 - 7 - type Props = { 8 - items: { 9 - value: RichTextTypes; 10 - label: string; 11 - command: ({ editor, range }: { editor: Editor; range: Range }) => void; 12 - }[]; 13 - range: Range; 14 - editor: Editor; 15 - active?: number; 16 - }; 17 - 18 - let { items, range, editor, active }: Props = $props(); 19 - 20 - let activeIndex = $state(active ?? 0); 21 - 22 - export function setItems(value: any[]) { 23 - items = value; 24 - } 25 - 26 - export function setRange(value: Range) { 27 - range = value; 28 - } 29 - 30 - export function onKeyDown(event: KeyboardEvent) { 31 - if (event.repeat) { 32 - return false; 33 - } 34 - switch (event.key) { 35 - case 'ArrowUp': { 36 - if (activeIndex <= 0) { 37 - activeIndex = items.length - 1; 38 - } else { 39 - activeIndex--; 40 - } 41 - return true; 42 - } 43 - case 'ArrowDown': { 44 - if (activeIndex >= items.length - 1) { 45 - activeIndex = 0; 46 - } else { 47 - activeIndex++; 48 - } 49 - return true; 50 - } 51 - case 'Enter': { 52 - const selected = items[activeIndex]; 53 - 54 - if (selected) { 55 - selected.command({ editor, range }); 56 - return true; 57 - } 58 - } 59 - } 60 - 61 - return false; 62 - } 63 - </script> 64 - 65 - <menu 66 - class={cn( 67 - 'bg-base-50/50 border-base-500/20 overflow-hidden rounded-2xl border shadow-lg backdrop-blur-xl', 68 - 'dark:bg-base-900/50 dark:border-base-500/10', 69 - 'motion-safe:animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 70 - "divide-base-300/30 dark:divide-base-800 divide-y text-sm" 71 - )} 72 - > 73 - 74 - {#each items as item, index} 75 - <button 76 - onclick={() => item.command({ editor, range })} 77 - class={cn( 78 - 'text-base-900 dark:text-base-200 group relative isolate flex min-w-28 w-full cursor-pointer items-center gap-3 px-3 py-2 font-medium [&_svg]:size-3.5', 79 - activeIndex === index 80 - ? 'text-accent-700 dark:text-accent-400 bg-accent-500/10' 81 - : 'hover:bg-accent-500/10' 82 - )} 83 - > 84 - <Icon name={item.value} /> 85 - {item.label} 86 - </button> 87 - {/each} 88 - </menu>
-198
packages/text/src/lib/components/rich-text-editor/slash-menu/index.ts
··· 1 - import { Extension } from '@tiptap/core'; 2 - import Suggestion from '@tiptap/suggestion'; 3 - import type { Editor, Range } from '@tiptap/core'; 4 - import { PluginKey } from '@tiptap/pm/state'; 5 - import type { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion'; 6 - import SuggestionSelect from './SuggestionSelect.svelte'; 7 - import { mount, unmount } from 'svelte'; 8 - import { computePosition, flip, shift, offset } from '@floating-ui/dom'; 9 - import type { RichTextTypes } from '..'; 10 - 11 - export default Extension.create({ 12 - name: 'slash', 13 - 14 - addOptions() { 15 - return { 16 - suggestion: { 17 - char: '/', 18 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 19 - command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { 20 - props.command({ editor, range }); 21 - } 22 - } 23 - }; 24 - }, 25 - 26 - addProseMirrorPlugins() { 27 - return [ 28 - Suggestion({ 29 - editor: this.editor, 30 - ...this.options.suggestion 31 - }) 32 - ]; 33 - } 34 - }); 35 - 36 - export function suggestion({ 37 - char, 38 - pluginKey, 39 - switchTo, 40 - processImageFile 41 - }: { 42 - char: string; 43 - pluginKey: string; 44 - switchTo: (value: RichTextTypes) => void; 45 - processImageFile: (file: File) => void; 46 - }) { 47 - return { 48 - char, 49 - pluginKey: new PluginKey(pluginKey), 50 - 51 - items: ({ query }: { query: string }) => { 52 - return [ 53 - { 54 - value: 'paragraph', 55 - label: 'Paragraph', 56 - command: ({ editor, range }: { editor: Editor; range: Range }) => { 57 - editor.chain().focus().deleteRange(range).run(); 58 - switchTo('paragraph'); 59 - } 60 - }, 61 - { 62 - value: 'heading-1', 63 - label: 'Heading 1', 64 - command: ({ editor, range }: { editor: Editor; range: Range }) => { 65 - editor.chain().focus().deleteRange(range).run(); 66 - switchTo('heading-1'); 67 - } 68 - }, 69 - { 70 - value: 'heading-2', 71 - label: 'Heading 2', 72 - command: ({ editor, range }: { editor: Editor; range: Range }) => { 73 - editor.chain().focus().deleteRange(range).run(); 74 - switchTo('heading-2'); 75 - } 76 - }, 77 - { 78 - value: 'heading-3', 79 - label: 'Heading 3', 80 - command: ({ editor, range }: { editor: Editor; range: Range }) => { 81 - editor.chain().focus().deleteRange(range).run(); 82 - switchTo('heading-3'); 83 - } 84 - }, 85 - { 86 - value: 'blockquote', 87 - label: 'Blockquote', 88 - command: ({ editor, range }: { editor: Editor; range: Range }) => { 89 - editor.chain().focus().deleteRange(range).run(); 90 - switchTo('blockquote'); 91 - } 92 - }, 93 - { 94 - value: 'code', 95 - label: 'Code Block', 96 - command: ({ editor, range }: { editor: Editor; range: Range }) => { 97 - editor.chain().focus().deleteRange(range).run(); 98 - switchTo('code'); 99 - } 100 - }, 101 - { 102 - value: 'bullet-list', 103 - label: 'Bullet List', 104 - command: ({ editor, range }: { editor: Editor; range: Range }) => { 105 - editor.chain().focus().deleteRange(range).run(); 106 - switchTo('bullet-list'); 107 - } 108 - }, 109 - { 110 - value: 'ordered-list', 111 - label: 'Ordered List', 112 - command: ({ editor, range }: { editor: Editor; range: Range }) => { 113 - editor.chain().focus().deleteRange(range).run(); 114 - switchTo('ordered-list'); 115 - } 116 - }, 117 - { 118 - value: 'image', 119 - label: 'Add Image', 120 - command: ({ editor, range }: { editor: Editor; range: Range }) => { 121 - editor.chain().focus().deleteRange(range).run(); 122 - 123 - const fileInput = document.createElement('input'); 124 - fileInput.type = 'file'; 125 - fileInput.click(); 126 - fileInput.addEventListener('change', (event) => { 127 - const input = event.target as HTMLInputElement; 128 - if (!input.files?.length) return; 129 - const file = input.files[0]; 130 - if (!file?.type.startsWith('image/')) return; 131 - processImageFile(file); 132 - 133 - input.remove(); 134 - }); 135 - } 136 - } 137 - ].filter((item) => item.label.toLowerCase().includes(query.toLowerCase())); 138 - }, 139 - 140 - render: () => { 141 - let component: ReturnType<typeof SuggestionSelect>; 142 - let floatingEl: HTMLElement; 143 - 144 - function updatePosition(clientRect: (() => DOMRect | null) | null | undefined) { 145 - if (!clientRect || !floatingEl) return; 146 - const rect = clientRect(); 147 - if (!rect) return; 148 - 149 - const virtualRef = { 150 - getBoundingClientRect: () => rect 151 - }; 152 - 153 - computePosition(virtualRef as Element, floatingEl, { 154 - placement: 'bottom-start', 155 - middleware: [offset(8), flip(), shift({ padding: 8 })] 156 - }).then(({ x, y }) => { 157 - Object.assign(floatingEl.style, { 158 - left: `${x}px`, 159 - top: `${y}px` 160 - }); 161 - }); 162 - } 163 - 164 - return { 165 - onStart: (props: SuggestionProps) => { 166 - floatingEl = document.createElement('div'); 167 - floatingEl.style.position = 'absolute'; 168 - floatingEl.style.zIndex = '50'; 169 - document.body.appendChild(floatingEl); 170 - 171 - component = mount(SuggestionSelect, { 172 - target: floatingEl, 173 - props 174 - }); 175 - 176 - updatePosition(props.clientRect); 177 - }, 178 - onUpdate: (props: SuggestionProps) => { 179 - component.setItems(props.items); 180 - component.setRange(props.range); 181 - updatePosition(props.clientRect); 182 - }, 183 - onKeyDown: (props: SuggestionKeyDownProps) => { 184 - if (props.event.key === 'Escape') { 185 - floatingEl.style.display = 'none'; 186 - return true; 187 - } 188 - 189 - return component.onKeyDown(props.event); 190 - }, 191 - onExit: () => { 192 - unmount(component); 193 - floatingEl.remove(); 194 - } 195 - }; 196 - } 197 - }; 198 - }