[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.

improve color picker, rich text editor

Florian (May 19, 2025, 9:10 PM +0200) 67952805 27345065

+584 -70
+30
pnpm-lock.yaml
··· 562 562 state: 563 563 specifier: link:@tiptap/pm/state 564 564 version: link:@tiptap/pm/state 565 + svelte-tiptap: 566 + specifier: ^2.1.0 567 + version: 2.1.0(@tiptap/core@2.12.0)(@tiptap/extension-bubble-menu@2.12.0)(@tiptap/extension-floating-menu@2.12.0)(@tiptap/pm@2.12.0)(svelte@5.28.1) 565 568 tippy.js: 566 569 specifier: ^6.3.7 567 570 version: 6.3.7 ··· 2004 2007 dependencies: 2005 2008 '@tiptap/core': 2.12.0(@tiptap/pm@2.12.0) 2006 2009 '@tiptap/pm': 2.12.0 2010 + dev: false 2011 + 2012 + /@tiptap/extension-floating-menu@2.12.0(@tiptap/core@2.12.0)(@tiptap/pm@2.12.0): 2013 + resolution: {integrity: sha512-BYpyZx/56KCDksWuJJbhki/uNgt9sACuSSZFH5AN1yS1ISD+EzIxqf6Pzzv8QCoNJ+KcRNVaZsOlOFaJGoyzag==, tarball: https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.12.0.tgz} 2014 + peerDependencies: 2015 + '@tiptap/core': ^2.7.0 2016 + '@tiptap/pm': ^2.7.0 2017 + dependencies: 2018 + '@tiptap/core': 2.12.0(@tiptap/pm@2.12.0) 2019 + '@tiptap/pm': 2.12.0 2020 + tippy.js: 6.3.7 2007 2021 dev: false 2008 2022 2009 2023 /@tiptap/extension-gapcursor@2.12.0(@tiptap/core@2.12.0)(@tiptap/pm@2.12.0): ··· 4325 4339 peerDependencies: 4326 4340 svelte: ^3.0.0 || ^4.0.0 || ^5.0.0-next.1 4327 4341 dependencies: 4342 + svelte: 5.28.1 4343 + dev: false 4344 + 4345 + /svelte-tiptap@2.1.0(@tiptap/core@2.12.0)(@tiptap/extension-bubble-menu@2.12.0)(@tiptap/extension-floating-menu@2.12.0)(@tiptap/pm@2.12.0)(svelte@5.28.1): 4346 + resolution: {integrity: sha512-CtmBM4RzJ7dibuglG6g8YsiyEiaFWAu6qFuX73STuxKFJqoBuqXQWpJqQRttrUWVp/rrrDFjd08zyC1Zzrl/Qg==, tarball: https://registry.npmjs.org/svelte-tiptap/-/svelte-tiptap-2.1.0.tgz} 4347 + peerDependencies: 4348 + '@tiptap/core': ^2.0.3 4349 + '@tiptap/extension-bubble-menu': ^2.0.3 4350 + '@tiptap/extension-floating-menu': ^2.0.3 4351 + '@tiptap/pm': ^2.0.3 4352 + svelte: ^5.0.0 4353 + dependencies: 4354 + '@tiptap/core': 2.12.0(@tiptap/pm@2.12.0) 4355 + '@tiptap/extension-bubble-menu': 2.12.0(@tiptap/core@2.12.0)(@tiptap/pm@2.12.0) 4356 + '@tiptap/extension-floating-menu': 2.12.0(@tiptap/core@2.12.0)(@tiptap/pm@2.12.0) 4357 + '@tiptap/pm': 2.12.0 4328 4358 svelte: 5.28.1 4329 4359 dev: false 4330 4360
+5
.changeset/afraid-forks-brush.md
··· 1 + --- 2 + '@fuxui/colors': patch 3 + --- 4 + 5 + improve color picker
+5
.changeset/breezy-otters-cover.md
··· 1 + --- 2 + '@fuxui/text': patch 3 + --- 4 + 5 + improve rich text editor
+1
packages/text/package.json
··· 89 89 "bits-ui": "^1.4.3", 90 90 "lowlight": "^3.3.0", 91 91 "state": "link:@tiptap/pm/state", 92 + "svelte-tiptap": "^2.1.0", 92 93 "tippy.js": "^6.3.7" 93 94 }, 94 95 "peerDependencies": {
+1 -1
packages/colors/src/lib/components/color-select/ColorSelect.svelte
··· 56 56 class={cn( 57 57 'group relative flex cursor-pointer items-center justify-center rounded-full p-0.5 ring-current', 58 58 'has-focus-visible:outline-base-900 dark:has-focus-visible:outline-base-100 has-focus-visible:outline-2 has-focus-visible:outline-offset-0', 59 - 'has-checked:ring-3', 59 + 'has-checked:ring-3 hover:scale-105 active:scale-95 transition-all duration-100', 60 60 colorsClass, 61 61 getColorClass(color) 62 62 )}
+5
packages/text/src/lib/components/plain-text-editor/PlainTextEditor.svelte
··· 15 15 editor = $bindable(null), 16 16 allowMultiline = false, 17 17 onupdate, 18 + ontransaction, 18 19 ...props 19 20 }: { 20 21 class?: string; ··· 24 25 editor?: Editor | null; 25 26 allowMultiline?: boolean; 26 27 onupdate?: (value: string) => void; 28 + ontransaction?: () => void; 27 29 } = $props(); 28 30 29 31 onMount(async () => { ··· 92 94 onupdate?.(editor?.getText() ?? ''); 93 95 94 96 value = editor?.getText() ?? ''; 97 + }, 98 + onTransaction: () => { 99 + ontransaction?.(); 95 100 }, 96 101 97 102 content: value,
+104 -59
packages/text/src/lib/components/rich-text-editor/RichTextEditor.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount, onDestroy } from 'svelte'; 3 - import { Editor, type Content } from '@tiptap/core'; 3 + import { Editor, mergeAttributes, type Content } from '@tiptap/core'; 4 4 import StarterKit from '@tiptap/starter-kit'; 5 5 import Placeholder from '@tiptap/extension-placeholder'; 6 6 import Image from '@tiptap/extension-image'; ··· 8 8 import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; 9 9 import BubbleMenu from '@tiptap/extension-bubble-menu'; 10 10 import Underline from '@tiptap/extension-underline'; 11 - import Link from '@tiptap/extension-link'; 12 11 import RichTextEditorMenu from './RichTextEditorMenu.svelte'; 13 12 import type { RichTextTypes } from '.'; 14 13 import RichTextEditorLinkMenu from './RichTextEditorLinkMenu.svelte'; 15 14 import Slash, { suggestion } from './slash-menu'; 16 - import Typography from '@tiptap/extension-typography' 15 + import Typography from '@tiptap/extension-typography'; 16 + import { RichTextLink } from './RichTextLink'; 17 17 18 18 import './code.css'; 19 + import { cn } from '@fuxui/base'; 20 + import { ImageUploadNode } from './image-upload/ImageUploadNode'; 19 21 20 22 let { 21 23 content = $bindable({}), 22 24 placeholder = 'Write or press / for commands', 23 25 editor = $bindable(null), 24 - ref = $bindable(null) 26 + ref = $bindable(null), 27 + class: className, 28 + onupdate, 29 + ontransaction 25 30 }: { 26 - content: Content; 31 + content?: Content; 27 32 placeholder?: string; 28 33 editor?: Editor | null; 29 34 ref?: HTMLDivElement | null; 35 + class?: string; 36 + onupdate?: (content: Content) => void; 37 + ontransaction?: () => void; 30 38 } = $props(); 31 39 32 40 const lowlight = createLowlight(all); ··· 44 52 let isStrikethrough = $state(false); 45 53 let isLink = $state(false); 46 54 let isImage = $state(false); 55 + 56 + const CustomImage = Image.extend({ 57 + // addAttributes(this) { 58 + // return { 59 + // inline: true, 60 + // allowBase64: true, 61 + // HTMLAttributes: {}, 62 + // uploadImageHandler: { default: undefined } 63 + // }; 64 + // }, 65 + renderHTML({ HTMLAttributes }) { 66 + const attrs = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 67 + uploadImageHandler: undefined 68 + }); 69 + const isLocal = attrs['data-local'] === 'true'; 70 + 71 + // if (isLocal) { 72 + // // For local images, wrap in a container with a label 73 + // return [ 74 + // 'div', 75 + // { class: 'image-container' }, 76 + // ['img', { ...attrs, class: `${attrs.class || ''} local-image` }], 77 + // ['span', { class: 'local-image-label' }, 'Local preview'] 78 + // ]; 79 + // } 80 + 81 + console.log('attrs', attrs); 82 + 83 + // For regular images, just return the img tag 84 + return ['img', attrs]; 85 + } 86 + }); 47 87 48 88 onMount(() => { 49 89 if (!ref) return; ··· 68 108 return ''; 69 109 } 70 110 }), 71 - Image.configure({ 111 + CustomImage.configure({ 72 112 HTMLAttributes: { 73 113 class: 'max-w-full object-contain relative rounded-2xl' 74 - } 114 + }, 115 + allowBase64: true 75 116 }), 76 117 CodeBlockLowlight.configure({ 77 118 lowlight, ··· 85 126 !editor.isActive('image') && 86 127 !editor.view.state.selection.empty && 87 128 !editor.isActive('codeBlock') && 88 - !editor.isActive('link') 129 + !editor.isActive('link') && 130 + !editor.isActive('imageUpload') 89 131 ); 90 132 }, 91 133 pluginKey: 'bubble-menu-marks' ··· 99 141 pluginKey: 'bubble-menu-links' 100 142 }), 101 143 Underline.configure({}), 102 - Link.configure({ 144 + RichTextLink.configure({ 103 145 openOnClick: false, 104 146 autolink: true, 105 147 defaultProtocol: 'https' ··· 112 154 processImageFile 113 155 }) 114 156 }), 115 - Typography.configure() 157 + Typography.configure(), 158 + ImageUploadNode.configure({ 159 + upload: async (file, onProgress, abortSignal) => { 160 + console.log('uploading image', file); 161 + // wait 2 seconds 162 + for(let i = 0; i < 10; i++) { 163 + await new Promise((resolve) => setTimeout(resolve, 200)); 164 + onProgress?.({ progress: i / 10 }); 165 + } 166 + 167 + return 'https://picsum.photos/200/300'; 168 + } 169 + }) 116 170 ]; 117 171 118 172 editor = new Editor({ ··· 125 179 }, 126 180 onUpdate: (ctx) => { 127 181 content = ctx.editor.getJSON(); 182 + onupdate?.(content); 183 + console.log('content', content); 128 184 }, 129 185 onFocus: () => { 130 186 hasFocus = true; ··· 157 213 } else { 158 214 selectedType = 'paragraph'; 159 215 } 216 + ontransaction?.(); 160 217 }, 161 - content 218 + content: `` 162 219 }); 163 220 164 221 menu?.classList.remove('hidden'); 165 222 menuLink?.classList.remove('hidden'); 223 + 166 224 }); 167 225 168 226 // Flag to track whether a file is being dragged over the drop area ··· 175 233 let localImageUrls: Set<string> = $state(new Set()); 176 234 177 235 // Process image file to create a local preview 178 - function processImageFile(file: File, input?: HTMLInputElement) { 236 + async function processImageFile(file: File) { 179 237 if (!editor) { 180 238 console.warn('Tiptap editor not initialized'); 181 239 return; 182 240 } 183 241 184 242 try { 185 - // Create a local blob URL for the image 186 243 const localUrl = URL.createObjectURL(file); 187 244 188 - // Store the file for later upload 189 245 localImages.set(localUrl, file); 190 246 localImageUrls.add(localUrl); 191 247 192 - // Insert image into editor with the local URL 193 - editor 194 - .chain() 195 - .focus() 196 - .insertContent({ 197 - type: 'image', 198 - attrs: { 199 - src: localUrl, 200 - 'data-local': 'true' // Mark as local image 201 - } 202 - }) 203 - .run(); 248 + //editor.commands.setImageUploadNode(); 249 + editor.chain().focus().setImageUploadNode( 250 + { 251 + preview: localUrl 252 + } 253 + ).run(); 204 254 205 - // Update content state to ensure persistence 206 - content = editor.getJSON(); 255 + // wait 2 seconds 256 + // await new Promise((resolve) => setTimeout(resolve, 500)); 207 257 208 - // Clear the file input if provided 209 - if (input) input.value = ''; 258 + // content = editor.getJSON(); 259 + 260 + // console.log('replacing image url in content'); 261 + // replaceImageUrlInContent(content, localUrl, 'https://picsum.photos/200/300'); 262 + // editor.commands.setContent(content); 210 263 } catch (error) { 211 264 console.error('Error creating image preview:', error); 212 265 } 213 266 } 214 - export function handlePaste(node: HTMLElement) { 215 - const pasteHandler = (event: ClipboardEvent) => { 216 - const items = event.clipboardData?.items; 217 - if (!items) return; 218 - // Check for image data in clipboard 219 - for (const item of Array.from(items)) { 220 - if (item.type.startsWith('image/')) { 221 - // Get the image file from clipboard 222 - const file = item.getAsFile(); 223 - if (!file) continue; 224 - // Prevent default paste behavior 225 - event.preventDefault(); 226 - processImageFile(file); 227 - // Only process the first image found 228 - return; 229 - } 230 - } 231 - }; 232 267 233 - node.addEventListener('paste', pasteHandler); 268 + const handlePaste = (event: ClipboardEvent) => { 269 + const items = event.clipboardData?.items; 270 + if (!items) return; 271 + // Check for image data in clipboard 272 + for (const item of Array.from(items)) { 273 + if (!item.type.startsWith('image/')) continue; 274 + const file = item.getAsFile(); 275 + if (!file) continue; 276 + event.preventDefault(); 277 + processImageFile(file); 278 + return; 279 + } 280 + }; 234 281 235 - return { 236 - destroy() { 237 - node.removeEventListener('paste', pasteHandler); 238 - } 239 - }; 240 - } 241 - 242 - // --- Drag-and-drop handlers for image upload --- 243 282 function handleDragOver(event: DragEvent) { 244 283 event.preventDefault(); 245 284 event.stopPropagation(); ··· 316 355 317 356 <div 318 357 bind:this={ref} 319 - class="relative flex-1" 320 - use:handlePaste 358 + class={cn('relative flex-1', className)} 359 + onpaste={handlePaste} 321 360 ondragover={handleDragOver} 322 361 ondragleave={handleDragLeave} 323 362 ondrop={handleDrop} ··· 353 392 margin: 1.5rem 0; 354 393 max-width: 100%; 355 394 395 + &.ProseMirror-selectednode { 396 + outline: 3px solid var(--color-accent-500); 397 + } 398 + } 399 + 400 + :global(div[data-type='image-upload']) { 356 401 &.ProseMirror-selectednode { 357 402 outline: 3px solid var(--color-accent-500); 358 403 }
+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 };
+62 -3
packages/colors/src/lib/components/color-picker/base/ColorPicker.svelte
··· 8 8 okhsv_to_oklch, 9 9 rgb_to_hex, 10 10 oklab_to_okhsv, 11 - rgb_to_okhsv 11 + rgb_to_okhsv, 12 + oklab_to_rgb 12 13 } from './color'; 13 - 14 + 14 15 import { cn } from '@fuxui/base'; 15 16 16 17 let { ··· 18 19 oklab = $bindable(), 19 20 okhsv = $bindable(), 20 21 class: className, 21 - onchange 22 + onchange, 23 + quickSelects = $bindable([]) 22 24 }: { 23 25 rgb?: RGB; 24 26 oklab?: OKlab; 25 27 okhsv?: OKhsv; 26 28 class?: string; 27 29 onchange?: (color: { hex: string; rgb: RGB; oklab: OKlab; okhsv: OKhsv; oklch: OKlch }) => void; 30 + quickSelects?: { 31 + label: string; 32 + rgb?: RGB; 33 + oklab?: OKlab; 34 + okhsv?: OKhsv; 35 + }[]; 28 36 } = $props(); 29 37 30 38 const width = picker_size + slider_width + gap_size + border_size * 2; ··· 198 206 break; 199 207 } 200 208 } 209 + 210 + function convertToRgb( 211 + rgb: RGB | undefined, 212 + oklab: OKlab | undefined, 213 + okhsv: OKhsv | undefined 214 + ): RGB { 215 + if (okhsv) { 216 + return okhsv_to_rgb(okhsv); 217 + } 218 + 219 + if (oklab) { 220 + return oklab_to_rgb(oklab); 221 + } 222 + 223 + if (rgb) { 224 + return rgb; 225 + } 226 + 227 + throw 'rgb, oklab, or okhsv required'; 228 + } 229 + 230 + function getRgbString(rgb: RGB) { 231 + return `rgb(${rgb.r * 255}, ${rgb.g * 255}, ${rgb.b * 255})`; 232 + } 201 233 </script> 202 234 203 235 <!-- svelte-ignore a11y_no_noninteractive_tabindex --> ··· 251 283 </g> 252 284 </svg> 253 285 </div> 286 + 287 + {#if quickSelects.length > 0} 288 + <div class="grid grid-cols-7 gap-2 px-2 pt-2 pb-3" 289 + style:width="{width}px"> 290 + {#each quickSelects as quickSelect} 291 + <button 292 + class={cn( 293 + 'focus-visible:outline-base-900 dark:focus-visible:outline-base-100 cursor-pointer rounded-full focus-visible:outline-2 focus-visible:outline-offset-2', 294 + 'group' 295 + )} 296 + onclick={() => { 297 + color = convertToInternal(quickSelect.rgb, quickSelect.oklab, quickSelect.okhsv); 298 + 299 + update_input(); 300 + }} 301 + > 302 + <div 303 + class="border-base-300 dark:border-base-700 focus-visible:outline-accent-500 z-10 size-7 rounded-full border group-hover:scale-105 group-active:scale-95 transition-all duration-100" 304 + style="background-color: {getRgbString( 305 + convertToRgb(quickSelect.rgb, quickSelect.oklab, quickSelect.okhsv) 306 + )};" 307 + ></div> 308 + <span class="sr-only">Select {quickSelect.label}</span> 309 + </button> 310 + {/each} 311 + </div> 312 + {/if}
+13 -4
packages/colors/src/lib/components/color-picker/popover/PopoverColorPicker.svelte
··· 12 12 sideOffset = 10, 13 13 side = 'bottom', 14 14 class: className, 15 - onchange 15 + onchange, 16 + quickSelects = $bindable([]), 17 + ...restProps 16 18 }: { 17 19 rgb?: RGB; 18 20 oklab?: OKlab; ··· 21 23 sideOffset?: number; 22 24 side?: 'top' | 'right' | 'bottom' | 'left'; 23 25 onchange?: (color: { hex: string; rgb: RGB; oklab: OKlab; okhsv: OKhsv; oklch: OKlch }) => void; 26 + quickSelects?: { 27 + label: string; 28 + rgb?: RGB; 29 + oklab?: OKlab; 30 + okhsv?: OKhsv; 31 + }[]; 24 32 } = $props(); 25 33 26 34 let internalColor = $derived(convertToInternal(rgb, oklab, okhsv)); ··· 51 59 <button 52 60 {...props} 53 61 class={cn( 54 - 'focus-visible:outline-base-900 dark:focus-visible:outline-base-100 cursor-pointer rounded-full focus-visible:outline-2 focus-visible:outline-offset-2' 62 + 'focus-visible:outline-base-900 dark:focus-visible:outline-base-100 cursor-pointer rounded-full focus-visible:outline-2 focus-visible:outline-offset-2', 63 + 'group' 55 64 )} 56 65 > 57 66 <div 58 - class="border-base-300 dark:border-base-700 focus-visible:outline-accent-500 z-10 size-8 rounded-full border" 67 + class="border-base-300 dark:border-base-700 focus-visible:outline-accent-500 z-10 size-8 rounded-full border group-hover:scale-105 group-active:scale-95 transition-all duration-100" 59 68 style={`background-color: rgb(${internalColor.r * 255}, ${internalColor.g * 255}, ${internalColor.b * 255});`} 60 69 ></div> 61 70 <span class="sr-only">Pick a color</span> 62 71 </button> 63 72 {/snippet} 64 73 65 - <ColorPicker bind:rgb bind:oklab bind:okhsv class={className} {onchange} /> 74 + <ColorPicker bind:rgb bind:oklab bind:okhsv class={className} {onchange} {quickSelects} {...restProps} /> 66 75 </Popover>
+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 + });
+70 -3
apps/docs/src/routes/(main)/components/colors/color-picker/Example.svelte
··· 1 1 <script lang="ts"> 2 - import { ColorPicker, PopoverColorPicker, oklch_string_to_oklch, oklch_to_rgb } from '@fuxui/colors'; 2 + import { 3 + ColorPicker, 4 + PopoverColorPicker, 5 + oklch_string_to_oklch, 6 + oklch_to_rgb 7 + } from '@fuxui/colors'; 3 8 import { ThemeWatcher } from '$lib/helper/ThemeWatcher.svelte'; 4 9 import { onMount } from 'svelte'; 5 10 ··· 23 28 24 29 <h3>Inline Color Picker</h3> 25 30 26 - <ColorPicker bind:rgb /> 31 + <ColorPicker 32 + bind:rgb 33 + /> 27 34 28 35 <h3>Popover Color Picker</h3> 29 36 30 - <PopoverColorPicker bind:rgb /> 37 + <PopoverColorPicker 38 + bind:rgb 39 + quickSelects={[ 40 + { 41 + label: 'Red', 42 + rgb: { 43 + r: 1, 44 + g: 0, 45 + b: 0 46 + } 47 + }, 48 + { 49 + label: 'Green', 50 + rgb: { 51 + r: 0, 52 + g: 1, 53 + b: 0 54 + } 55 + }, 56 + { 57 + label: 'Blue', 58 + rgb: { 59 + r: 0, 60 + g: 0, 61 + b: 1 62 + } 63 + }, 64 + { 65 + label: 'Yellow', 66 + rgb: { 67 + r: 1, 68 + g: 1, 69 + b: 0 70 + } 71 + }, 72 + { 73 + label: 'Magenta', 74 + rgb: { 75 + r: 1, 76 + g: 0, 77 + b: 1 78 + } 79 + }, 80 + { 81 + label: 'Cyan', 82 + rgb: { 83 + r: 0, 84 + g: 1, 85 + b: 1 86 + } 87 + }, 88 + { 89 + label: 'White', 90 + rgb: { 91 + r: 1, 92 + g: 1, 93 + b: 1 94 + } 95 + } 96 + ]} 97 + />