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

atproto login and handle

Florian (Mar 1, 2026, 3:24 AM +0100) 791bd2dc 7e0dbc75

+1042 -2311
+2 -2
apps/docs/src/routes/+layout.svelte
··· 12 12 let images = ['test.jpg', 'gradient.jpg', 'mountains.jpg', 'dark_mountains.jpg']; 13 13 let currentImage = $state(0); 14 14 </script> 15 - 15 + <!-- 16 16 <svelte:body 17 17 onkeydown={(e) => { 18 18 if(!dev) return; ··· 25 25 currentImage = (currentImage - 1 + images.length) % images.length; 26 26 } 27 27 }} 28 - /> 28 + /> --> 29 29 30 30 {@render children()} 31 31
+1 -1
apps/docs/src/lib/site-components/Cards.svelte
··· 15 15 class="group relative flex flex-col items-start gap-3 transition-opacity duration-150 md:gap-4" 16 16 > 17 17 <div 18 - class="bg-base-50 dark:bg-base-950/50 shadow-lg backdrop-blur-xl shadow-base-900/10 inset-shadow-sm inset-shadow-base-800/5 dark:inset-shadow-black/50 border-base-200 dark:border-base-800 pointer-events-none relative h-44 w-full overflow-hidden rounded-2xl border" 18 + class="bg-base-200/40 dark:bg-base-950/50 inset-shadow-sm inset-shadow-base-800/7 dark:inset-shadow-black/50 border-base-300/70 dark:border-base-800 pointer-events-none relative h-44 w-full overflow-hidden rounded-2xl border" 19 19 > 20 20 <div 21 21 class={cn(
+6
apps/docs/src/lib/site-components/components_social.ts
··· 7 7 import CardEmojiPicker from '$lib/cards/social/CardEmojiPicker.svelte'; 8 8 import CardSocialPost from '$lib/cards/social/CardSocialPost.svelte'; 9 9 import CardLinkCard from '$lib/cards/social/CardLinkCard.svelte'; 10 + import CardAtprotoHandlePopup from '$lib/cards/social/CardAtprotoHandlePopup.svelte'; 10 11 11 12 export const socialComponents: ComponentCard[] = [ 12 13 { ··· 48 49 component: CardLinkCard, 49 50 label: 'Link Card', 50 51 href: 'link-card' 52 + }, 53 + { 54 + component: CardAtprotoHandlePopup, 55 + label: 'Atproto Handle Popup', 56 + href: 'atproto-handle-popup' 51 57 } 52 58 ].sort((a, b) => a.label.localeCompare(b.label));
+1
packages/social/src/lib/components/index.ts
··· 11 11 export * from './nested-comments'; 12 12 export * from './link-card'; 13 13 export * from './animated-emoji-picker'; 14 + export * from './atproto-handle-popup'; 14 15 15 16 export function numberToHumanReadable(number: number) { 16 17 if (number < 1000) {
apps/docs/src/lib/cards/social/CardAtprotoHandlePopup.svelte
+1 -1
packages/core/src/lib/components/avatar/Avatar.svelte
··· 35 35 36 36 <div 37 37 class={cn( 38 - 'border-base-300 bg-base-200 text-base-900 dark:border-base-800 dark:bg-base-900 dark:text-base-50 relative isolate flex size-10 shrink-0 overflow-hidden rounded-full border', 38 + 'not-prose border-base-300 bg-base-200 text-base-900 dark:border-base-800 dark:bg-base-900 dark:text-base-50 relative isolate flex size-10 shrink-0 overflow-hidden rounded-full border', 39 39 className 40 40 )} 41 41 {...restProps}
+1 -1
packages/core/src/lib/components/button/Button.svelte
··· 11 11 primary: 12 12 'backdrop-blur-md backdrop-brightness-105 shadow-lg transform-all shadow-accent-500/5 dark:shadow-accent-500/2 disabled:shadow-md active:shadow-md inset-shadow-sm inset-shadow-accent-700/5 dark:inset-shadow-accent-500/2 focus-visible:outline-accent-500 border border-accent-500/15 dark:border-accent-500/15 hover:bg-accent-200/60 dark:hover:bg-accent-950/25 bg-accent-200/50 dark:bg-accent-950/20 text-accent-950 dark:text-accent-400', 13 13 secondary: 14 - 'backdrop-blur-md backdrop-brightness-105 shadow-lg transform-all shadow-base-500/5 dark:shadow-base-500/2 active:shadow-md inset-shadow-sm inset-shadow-base-700/2 dark:inset-shadow-base-500/2 focus-visible:outline-base-800 dark:focus-visible:outline-base-200 bg-base-300/40 dark:bg-base-800/30 text-base-900 dark:text-base-50 hover:bg-base-300/45 dark:hover:bg-base-800/35 border border-base-300/50 dark:border-base-700/30', 14 + 'backdrop-blur-md backdrop-brightness-105 shadow-lg transform-all shadow-base-500/5 dark:shadow-base-500/2 active:shadow-md inset-shadow-sm inset-shadow-base-700/2 dark:inset-shadow-base-500/2 focus-visible:outline-base-800 dark:focus-visible:outline-base-200 bg-base-400/30 dark:bg-base-700/30 text-base-900 dark:text-base-50 hover:bg-base-400/40 dark:hover:bg-base-700/35 border border-base-400/50 dark:border-base-700/30', 15 15 link: 'focus-visible:outline-base-900 dark:focus-visible:outline-base-50 text-base-800 dark:text-base-200 font-semibold hover:text-accent-600 dark:hover:text-accent-400 data-[current=true]:text-accent-600 dark:data-[current=true]:text-accent-400', 16 16 ghost: 17 17 'focus-visible:outline-base-900 dark:focus-visible:outline-base-50 text-base-800 dark:text-base-200 font-semibold hover:text-accent-600 hover:bg-accent-400/5 data-[current=true]:bg-accent-500/5 dark:hover:text-accent-400 dark:hover:bg-accent-700/5 data-[current=true]:text-accent-600 dark:data-[current=true]:text-accent-400 dark:data-[current=true]:bg-accent-500/5',
+1 -1
packages/core/src/lib/components/navbar/Navbar.svelte
··· 13 13 14 14 <div 15 15 class={cn( 16 - 'border-base-400/30 dark:border-base-300/10 dark:bg-base-950/70 fixed top-1 right-0 left-0 z-50 mx-1 flex h-16 items-center justify-between overflow-hidden rounded-2xl border p-2 shadow-lg inset-shadow-black/50 backdrop-blur-lg dark:shadow-none dark:inset-shadow-sm', 16 + 'border-base-400/40 dark:border-base-300/10 bg-base-200/70 dark:bg-base-950/70 fixed top-1 right-0 left-0 z-50 mx-1 flex h-16 items-center justify-between overflow-hidden rounded-2xl border p-2 dark:inset-shadow-black/50 backdrop-blur-lg inset-shadow-sm inset-shadow-base-800/10', 17 17 hasSidebar ? 'lg:left-74' : '', 18 18 className 19 19 )}
+3 -2
packages/core/src/lib/components/sidebar/Sidebar.svelte
··· 35 35 {#if !mobileOnly} 36 36 <div 37 37 class={cn( 38 - 'border-base-200 bg-base-50/50 dark:border-base-300/10 dark:bg-base-950/50 backdrop-blur-xl fixed my-1 ml-0.5 overflow-y-hidden rounded-2xl', 39 - 'inset-auto left-1 top-0 z-50 block h-[calc(100dvh-0.5rem)] w-72 border shadow-lg lg:hidden', 38 + 'border-base-400/40 bg-base-200/70 dark:border-base-300/10 dark:bg-base-950/50 backdrop-blur-xl fixed my-1 ml-0.5 overflow-y-hidden rounded-2xl', 39 + 'inset-auto left-1 top-0 z-50 block h-[calc(100dvh-0.5rem)] w-72 border lg:hidden', 40 + 'inset-shadow-sm inset-shadow-base-800/10 dark:inset-shadow-black/50', 40 41 'hidden -translate-x-72 border lg:block lg:w-72 lg:translate-x-0', 41 42 className 42 43 )}
+68
packages/social/src/lib/components/atproto-handle-popup/AtprotoHandlePopup.svelte
··· 1 + <script lang="ts"> 2 + import { Command } from 'bits-ui'; 3 + import { searchActorsTypeahead, type Profile } from './helper'; 4 + import { Avatar, cn, inputVariants } from '@foxui/core'; 5 + 6 + let results: Profile[] = $state([]); 7 + let inputValue = $state(''); 8 + 9 + async function search(q: string) { 10 + if (!q || q.length < 2) { 11 + results = []; 12 + return; 13 + } 14 + results = (await searchActorsTypeahead(q, 5)).actors; 15 + } 16 + 17 + function select(actor: Profile) { 18 + results = []; 19 + inputValue = ''; 20 + value = actor.handle; 21 + onselected?.(actor); 22 + } 23 + 24 + let { 25 + value = $bindable(), 26 + onselected, 27 + ref = $bindable(null) 28 + }: { 29 + value?: string; 30 + onselected?: (actor: Profile) => void; 31 + ref?: HTMLInputElement | null; 32 + } = $props(); 33 + </script> 34 + 35 + <div class="relative w-full"> 36 + <Command.Root shouldFilter={false} class="w-full"> 37 + <Command.Input 38 + bind:ref 39 + bind:value={inputValue} 40 + oninput={() => search(inputValue)} 41 + onkeydown={(e) => { 42 + if (e.key === 'Enter' && results.length === 0 && inputValue) { 43 + e.preventDefault(); 44 + select({ handle: inputValue, displayName: '', avatar: '', did: '' }); 45 + } 46 + }} 47 + class={cn(inputVariants(), 'w-full')} 48 + placeholder="handle" 49 + aria-label="enter your handle" 50 + /> 51 + {#if results.length > 0} 52 + <Command.List 53 + class="border-base-300 bg-base-50 dark:bg-base-800 dark:border-base-700 absolute z-100 mt-2.5 max-h-[30dvh] w-full overflow-y-auto rounded-2xl border p-1 shadow-lg" 54 + > 55 + {#each results as actor (actor.did)} 56 + <Command.Item 57 + value={actor.handle} 58 + onSelect={() => select(actor)} 59 + class="data-selected:bg-accent-100 dark:data-selected:bg-accent-600/30 my-0.5 flex w-full cursor-pointer items-center gap-2 rounded-xl p-2 px-2" 60 + > 61 + <Avatar src={actor.avatar} alt="" class="size-6 rounded-full" /> 62 + {actor.handle} 63 + </Command.Item> 64 + {/each} 65 + </Command.List> 66 + {/if} 67 + </Command.Root> 68 + </div>
+31
packages/social/src/lib/components/atproto-handle-popup/helper.ts
··· 1 + export type Profile = { 2 + handle: string; 3 + displayName: string; 4 + avatar: string; 5 + did: string; 6 + }; 7 + 8 + /** 9 + * Searches for actors with typeahead/autocomplete functionality. 10 + */ 11 + export async function searchActorsTypeahead( 12 + q: string, 13 + limit: number = 10, 14 + host?: string 15 + ): Promise<{ 16 + actors: Profile[]; 17 + q: string; 18 + }> { 19 + host ??= 'https://public.api.bsky.app'; 20 + 21 + const response = await fetch( 22 + host + '/xrpc/app.bsky.actor.searchActorsTypeahead?q=' + q + '&limit=' + limit 23 + ); 24 + 25 + if (!response.ok) return { actors: [], q }; 26 + 27 + const data = await response.json(); 28 + console.log(data); 29 + 30 + return { actors: data.actors, q }; 31 + }
+1
packages/social/src/lib/components/atproto-handle-popup/index.ts
··· 1 + export { default as AtprotoHandlePopup } from './AtprotoHandlePopup.svelte';
-23
packages/social/src/lib/components/atproto-login/AtprotoLogin.svelte
··· 1 - <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - import { AtprotoLoginModal, atProtoLoginModalState, type ATProtoLoginProps } from '.'; 4 - 5 - let { login, formAction, formMethod }: ATProtoLoginProps = $props(); 6 - </script> 7 - 8 - <Button onclick={() => atProtoLoginModalState.show()}> 9 - <svg 10 - fill="currentColor" 11 - xmlns="http://www.w3.org/2000/svg" 12 - viewBox="-40 -40 680 620" 13 - version="1.1" 14 - aria-hidden="true" 15 - > 16 - <path 17 - d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" 18 - /> 19 - </svg> 20 - Login 21 - </Button> 22 - 23 - <AtprotoLoginModal {login} {formAction} {formMethod} />
+176 -63
packages/social/src/lib/components/atproto-login/AtprotoLoginModal.svelte
··· 7 7 </script> 8 8 9 9 <script lang="ts"> 10 - import { Button, Modal, Subheading, Input, Avatar } from '@foxui/core'; 11 - import type { ATProtoLoginProps } from '.'; 10 + import { Button, Modal, Subheading, Avatar } from '@foxui/core'; 11 + import { AtprotoHandlePopup } from '../atproto-handle-popup'; 12 + import type { Profile } from '../atproto-handle-popup/helper'; 13 + import { saveRecentLogin, type ATProtoLoginProps } from '.'; 14 + import { onMount, tick } from 'svelte'; 12 15 13 16 let value = $state(''); 14 17 let error: string | null = $state(null); 15 18 let loading = $state(false); 16 19 17 - let { login, signup, formAction, formMethod = 'get' }: ATProtoLoginProps = $props(); 20 + let { 21 + login, 22 + signup, 23 + formAction, 24 + formMethod = 'get', 25 + loginOnSelect = true 26 + }: ATProtoLoginProps & { loginOnSelect?: boolean } = $props(); 27 + 28 + let selectedActor: Profile | undefined = $state(); 29 + let recentLogins: Record<string, Profile> = $state({}); 30 + let recentLoginsView = $state(true); 31 + let input: HTMLInputElement | null = $state(null); 32 + let submitButton: HTMLButtonElement | null = $state(null); 33 + 34 + let showRecentLogins = $derived( 35 + Object.keys(recentLogins).length > 0 && !loading && !selectedActor && recentLoginsView 36 + ); 37 + 38 + onMount(() => { 39 + try { 40 + recentLogins = JSON.parse(localStorage.getItem('recent-logins') || '{}'); 41 + } catch { 42 + // ignore parse errors 43 + } 44 + }); 45 + 46 + $effect(() => { 47 + if (!atProtoLoginModalState.open) { 48 + error = null; 49 + value = ''; 50 + loading = false; 51 + selectedActor = undefined; 52 + recentLoginsView = true; 53 + } else { 54 + focusInput(); 55 + } 56 + }); 57 + 58 + function focusInput() { 59 + tick().then(() => input?.focus()); 60 + } 61 + 62 + function focusSubmit() { 63 + tick().then(() => submitButton?.focus()); 64 + } 65 + 66 + function removeRecentLogin(did: string) { 67 + const { [did]: _, ...rest } = recentLogins; 68 + recentLogins = rest; 69 + try { 70 + localStorage.setItem('recent-logins', JSON.stringify(recentLogins)); 71 + } catch { 72 + // ignore storage errors 73 + } 74 + } 18 75 19 76 async function onLogin(handle: string) { 20 77 if (loading || !login) return; 21 - 22 78 loading = true; 23 79 error = null; 24 - 25 80 try { 26 81 const hide = await login(handle); 27 - 28 82 if (hide) { 29 83 atProtoLoginModalState.hide(); 30 84 value = ''; ··· 36 90 } 37 91 } 38 92 39 - async function onSubmit(evt: Event) { 93 + async function onSubmit(evt?: Event) { 40 94 if (formAction || !login) return; 41 - evt.preventDefault(); 42 - 95 + evt?.preventDefault(); 43 96 await onLogin(value); 44 97 } 45 98 46 - let input: HTMLInputElement | null = $state(null); 99 + function handleActorSelected(actor: Profile) { 100 + selectedActor = actor; 101 + value = actor.handle; 47 102 48 - let lastLogin: { handle: string; avatar: string } | null = $state(null); 49 - 50 - $effect(() => { 51 - let lastLoginDid = localStorage.getItem('last-login'); 52 - 53 - if (lastLoginDid) { 54 - let profile = localStorage.getItem(`profile-${lastLoginDid}`); 55 - 56 - if (profile) { 57 - lastLogin = JSON.parse(profile); 58 - } 59 - } 60 - }); 103 + if (loginOnSelect) onSubmit(); 104 + else focusSubmit(); 105 + } 61 106 </script> 62 107 63 108 <Modal ··· 68 113 input?.focus(); 69 114 }} 70 115 > 71 - <form onsubmit={onSubmit} action={formAction} method={formMethod} class="flex flex-col gap-2"> 116 + <form 117 + onsubmit={(e) => onSubmit(e)} 118 + action={formAction} 119 + method={formMethod} 120 + class="flex flex-col gap-2" 121 + > 72 122 <Subheading class="inline-flex items-center gap-2 font-bold"> 73 - Login with your internet handle</Subheading 74 - > 123 + Login with your internet handle 124 + </Subheading> 75 125 <div class="mt-1 text-xs font-light text-neutral-800 dark:text-neutral-200"> 76 - e.g. your bluesky account 126 + like your bluesky account 77 127 </div> 78 128 79 - {#if lastLogin} 80 - <Button 81 - class="max-w-xs justify-start truncate overflow-x-hidden" 82 - variant="primary" 83 - onclick={() => onLogin(lastLogin?.handle ?? '')} 84 - disabled={loading} 85 - > 86 - <Avatar src={lastLogin.avatar} class="size-6" /> 129 + {#if showRecentLogins} 130 + <div class="mt-2 mb-2 text-sm font-medium">Recent logins</div> 87 131 88 - <div 89 - class="text-accent-600 dark:text-accent-400 text-md max-w-full truncate overflow-x-hidden font-semibold" 90 - > 91 - <p>{loading ? 'Loading...' : lastLogin.handle}</p> 92 - </div> 93 - </Button> 94 - {/if} 95 - 96 - <div class="mt-4 w-full"> 97 - <div class="mt-2"> 98 - <Input 99 - bind:ref={input} 100 - type="text" 101 - placeholder="you.bsky.social" 102 - class="w-full" 103 - bind:value 104 - /> 132 + <div class="flex flex-col gap-2"> 133 + {#each Object.values(recentLogins) 134 + .filter((l) => l.handle && l.handle !== 'handle.invalid') 135 + .slice(0, 4) as recentLogin (recentLogin.did)} 136 + <div 137 + class="bg-base-100 hover:bg-base-200 dark:bg-base-700 dark:hover:bg-base-600 border-base-300 dark:border-base-600 group relative flex h-10 w-full items-center justify-between gap-2 rounded-full border px-3 font-semibold transition-colors duration-100" 138 + > 139 + <div class="flex items-center gap-2"> 140 + <Avatar src={recentLogin.avatar} class="size-6" /> 141 + {recentLogin.handle} 142 + </div> 143 + <button 144 + type="button" 145 + class="absolute inset-0 z-0 cursor-pointer" 146 + onclick={() => handleActorSelected(recentLogin)} 147 + > 148 + <span class="sr-only">login as {recentLogin.handle}</span> 149 + </button> 150 + <button 151 + type="button" 152 + class="relative z-10 cursor-pointer rounded-full p-0.5" 153 + onclick={(e) => { 154 + e.stopPropagation(); 155 + removeRecentLogin(recentLogin.did); 156 + }} 157 + > 158 + <svg 159 + xmlns="http://www.w3.org/2000/svg" 160 + fill="none" 161 + viewBox="0 0 24 24" 162 + stroke-width="1.5" 163 + stroke="currentColor" 164 + class="size-3" 165 + > 166 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 167 + </svg> 168 + <span class="sr-only">remove</span> 169 + </button> 170 + </div> 171 + {/each} 105 172 </div> 106 - </div> 173 + {:else if !selectedActor} 174 + <div class="mt-4 w-full"> 175 + <AtprotoHandlePopup bind:value onselected={handleActorSelected} bind:ref={input} /> 176 + </div> 177 + {:else} 178 + <div 179 + class="bg-base-100 dark:bg-base-700 border-base-300 dark:border-base-600 mt-4 flex h-10 w-full items-center justify-between gap-2 rounded-full border px-3 font-semibold" 180 + > 181 + <div class="flex items-center gap-2"> 182 + <Avatar src={selectedActor.avatar} class="size-6" /> 183 + {selectedActor.handle} 184 + </div> 185 + <button 186 + type="button" 187 + class="cursor-pointer rounded-full p-0.5" 188 + onclick={() => { 189 + selectedActor = undefined; 190 + value = ''; 191 + focusInput(); 192 + }} 193 + > 194 + <svg 195 + xmlns="http://www.w3.org/2000/svg" 196 + fill="none" 197 + viewBox="0 0 24 24" 198 + stroke-width="1.5" 199 + stroke="currentColor" 200 + class="size-3" 201 + > 202 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 203 + </svg> 204 + <span class="sr-only">clear selection</span> 205 + </button> 206 + </div> 207 + {/if} 107 208 108 209 {#if error} 109 210 <p class="text-accent-500 mt-2 text-sm font-medium">{error}</p> 110 211 {/if} 111 212 112 - <Button type="submit" class="mt-2 ml-auto w-full" disabled={loading} 113 - >{loading ? 'Loading...' : 'Login'}</Button 114 - > 115 - {#if signup} 116 - <div 117 - class="border-base-200 dark:border-base-800 text-base-800 dark:text-base-200 mt-4 flex flex-col gap-3 border-t pt-4 text-sm leading-7" 118 - > 119 - <span>Don't have an account?</span> 213 + <div class="mt-2"> 214 + {#if showRecentLogins} 215 + <div class="mt-2 mb-4 text-sm font-medium">Or login with new handle</div> 216 + 120 217 <Button 121 218 onclick={() => { 122 - signup?.(); 219 + recentLoginsView = false; 220 + focusInput(); 123 221 }} 124 - variant="secondary">Sign Up</Button 222 + class="w-full" 125 223 > 224 + Login with new handle 225 + </Button> 226 + {:else} 227 + <Button bind:ref={submitButton} type="submit" class="w-full" disabled={loading}> 228 + {loading ? 'Loading...' : 'Login'} 229 + </Button> 230 + {/if} 231 + </div> 232 + 233 + {#if signup} 234 + <div 235 + class="border-base-200 dark:border-base-800 text-base-800 dark:text-base-200 mt-2 flex flex-col gap-3 border-t text-sm leading-7 pt-2" 236 + > 237 + <span class="text-sm font-medium">Don't have an account?</span> 238 + <Button onclick={() => signup?.()} variant="secondary">Sign Up</Button> 126 239 </div> 127 240 {/if} 128 241 </form>
+13 -1
packages/social/src/lib/components/atproto-login/index.ts
··· 1 1 export { default as AtprotoLoginModal } from './AtprotoLoginModal.svelte'; 2 2 export { atProtoLoginModalState } from './AtprotoLoginModal.svelte'; 3 - export { default as AtprotoLogin } from './AtprotoLogin.svelte'; 4 3 5 4 export type ATProtoLoginProps = { 6 5 login?: (handle: string) => Promise<boolean | undefined>; ··· 8 7 formAction?: string; 9 8 formMethod?: 'dialog' | 'get' | 'post' | 'DIALOG' | 'GET' | 'POST' | null; 10 9 }; 10 + 11 + export function saveRecentLogin(profile: { 12 + did: string; 13 + handle: string; 14 + avatar?: string; 15 + displayName?: string; 16 + }) { 17 + try { 18 + const existing = JSON.parse(localStorage.getItem('recent-logins') || '{}'); 19 + existing[profile.did] = profile; 20 + localStorage.setItem('recent-logins', JSON.stringify(existing)); 21 + } catch {} 22 + }
-120
packages/text/src/lib/components/rich-text-editor copy/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}
-415
packages/text/src/lib/components/rich-text-editor copy/RichTextEditor.svelte
··· 1 - <script lang="ts"> 2 - import { onMount, onDestroy } from 'svelte'; 3 - import { Editor, mergeAttributes, type Content } from '@tiptap/core'; 4 - import StarterKit from '@tiptap/starter-kit'; 5 - import Placeholder from '@tiptap/extension-placeholder'; 6 - import Image from '@tiptap/extension-image'; 7 - import { all, createLowlight } from 'lowlight'; 8 - import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; 9 - import BubbleMenu from '@tiptap/extension-bubble-menu'; 10 - import Underline from '@tiptap/extension-underline'; 11 - import RichTextEditorMenu from './RichTextEditorMenu.svelte'; 12 - import type { RichTextTypes } from '.'; 13 - import RichTextEditorLinkMenu from './RichTextEditorLinkMenu.svelte'; 14 - import Slash, { suggestion } from './slash-menu'; 15 - import Typography from '@tiptap/extension-typography'; 16 - import { Markdown } from '@tiptap/markdown'; 17 - import { RichTextLink } from './RichTextLink'; 18 - 19 - import './code.css'; 20 - import { cn } from '@foxui/core'; 21 - import { ImageUploadNode } from './image-upload/ImageUploadNode'; 22 - import { Transaction } from '@tiptap/pm/state'; 23 - 24 - let { 25 - content = $bindable({}), 26 - placeholder = 'Write or press / for commands', 27 - editor = $bindable(null), 28 - ref = $bindable(null), 29 - class: className, 30 - onupdate, 31 - ontransaction 32 - }: { 33 - content?: Content; 34 - placeholder?: string; 35 - editor?: Editor | null; 36 - ref?: HTMLDivElement | null; 37 - class?: string; 38 - onupdate?: (content: Content, context: { editor: Editor; transaction: Transaction }) => void; 39 - ontransaction?: () => void; 40 - } = $props(); 41 - 42 - const lowlight = createLowlight(all); 43 - 44 - let hasFocus = true; 45 - 46 - let menu: HTMLElement | null = $state(null); 47 - let menuLink: HTMLElement | null = $state(null); 48 - 49 - let selectedType: RichTextTypes = $state('paragraph'); 50 - 51 - let isBold = $state(false); 52 - let isItalic = $state(false); 53 - let isUnderline = $state(false); 54 - let isStrikethrough = $state(false); 55 - let isLink = $state(false); 56 - let isImage = $state(false); 57 - 58 - const CustomImage = Image.extend({ 59 - // addAttributes(this) { 60 - // return { 61 - // inline: true, 62 - // allowBase64: true, 63 - // HTMLAttributes: {}, 64 - // uploadImageHandler: { default: undefined } 65 - // }; 66 - // }, 67 - renderHTML({ HTMLAttributes }) { 68 - const attrs = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 69 - uploadImageHandler: undefined 70 - }); 71 - const isLocal = attrs['data-local'] === 'true'; 72 - 73 - // if (isLocal) { 74 - // // For local images, wrap in a container with a label 75 - // return [ 76 - // 'div', 77 - // { class: 'image-container' }, 78 - // ['img', { ...attrs, class: `${attrs.class || ''} local-image` }], 79 - // ['span', { class: 'local-image-label' }, 'Local preview'] 80 - // ]; 81 - // } 82 - 83 - console.log('attrs', attrs); 84 - 85 - // For regular images, just return the img tag 86 - return ['img', attrs]; 87 - } 88 - }); 89 - 90 - onMount(() => { 91 - if (!ref) return; 92 - 93 - let extensions = [ 94 - StarterKit.configure({ 95 - dropcursor: { 96 - class: 'text-accent-500/30 rounded-2xl', 97 - width: 2 98 - }, 99 - codeBlock: false, 100 - heading: { 101 - levels: [1, 2, 3] 102 - } 103 - }), 104 - Placeholder.configure({ 105 - placeholder: ({ node }) => { 106 - // only show in paragraphs 107 - if (node.type.name === 'paragraph' || node.type.name === 'heading') { 108 - return placeholder; 109 - } 110 - return ''; 111 - } 112 - }), 113 - CustomImage.configure({ 114 - HTMLAttributes: { 115 - class: 'max-w-full object-contain relative rounded-2xl' 116 - }, 117 - allowBase64: true 118 - }), 119 - CodeBlockLowlight.configure({ 120 - lowlight, 121 - defaultLanguage: 'js' 122 - }), 123 - BubbleMenu.configure({ 124 - element: menu, 125 - shouldShow: ({ editor }) => { 126 - // dont show if image selected or no selection or is code block 127 - return ( 128 - !editor.isActive('image') && 129 - !editor.view.state.selection.empty && 130 - !editor.isActive('codeBlock') && 131 - !editor.isActive('link') && 132 - !editor.isActive('imageUpload') 133 - ); 134 - }, 135 - pluginKey: 'bubble-menu-marks' 136 - }), 137 - BubbleMenu.configure({ 138 - element: menuLink, 139 - shouldShow: ({ editor }) => { 140 - // only show if link is selected 141 - return editor.isActive('link') && !editor.view.state.selection.empty; 142 - }, 143 - pluginKey: 'bubble-menu-links' 144 - }), 145 - Underline.configure({}), 146 - RichTextLink.configure({ 147 - openOnClick: false, 148 - autolink: true, 149 - defaultProtocol: 'https' 150 - }), 151 - Slash.configure({ 152 - suggestion: suggestion({ 153 - char: '/', 154 - pluginKey: 'slash', 155 - switchTo, 156 - processImageFile 157 - }) 158 - }), 159 - Typography.configure(), 160 - Markdown.configure(), 161 - ImageUploadNode.configure({}) 162 - ]; 163 - 164 - editor = new Editor({ 165 - element: ref, 166 - extensions, 167 - editorProps: { 168 - attributes: { 169 - class: 'outline-none' 170 - } 171 - }, 172 - onUpdate: (ctx) => { 173 - content = ctx.editor.getJSON(); 174 - onupdate?.(content, ctx); 175 - }, 176 - onFocus: () => { 177 - hasFocus = true; 178 - }, 179 - onBlur: () => { 180 - hasFocus = false; 181 - }, 182 - onTransaction: (ctx) => { 183 - isBold = ctx.editor.isActive('bold'); 184 - isItalic = ctx.editor.isActive('italic'); 185 - isUnderline = ctx.editor.isActive('underline'); 186 - isStrikethrough = ctx.editor.isActive('strike'); 187 - isLink = ctx.editor.isActive('link'); 188 - isImage = ctx.editor.isActive('image'); 189 - 190 - if (ctx.editor.isActive('heading', { level: 1 })) { 191 - selectedType = 'heading-1'; 192 - } else if (ctx.editor.isActive('heading', { level: 2 })) { 193 - selectedType = 'heading-2'; 194 - } else if (ctx.editor.isActive('heading', { level: 3 })) { 195 - selectedType = 'heading-3'; 196 - } else if (ctx.editor.isActive('blockquote')) { 197 - selectedType = 'blockquote'; 198 - } else if (ctx.editor.isActive('code')) { 199 - selectedType = 'code'; 200 - } else if (ctx.editor.isActive('bulletList')) { 201 - selectedType = 'bullet-list'; 202 - } else if (ctx.editor.isActive('orderedList')) { 203 - selectedType = 'ordered-list'; 204 - } else { 205 - selectedType = 'paragraph'; 206 - } 207 - ontransaction?.(); 208 - }, 209 - content 210 - }); 211 - }); 212 - 213 - // Flag to track whether a file is being dragged over the drop area 214 - let isDragOver = $state(false); 215 - 216 - // Store local image files for later upload 217 - let localImages: Map<string, File> = $state(new Map()); 218 - 219 - // Track which image URLs in the editor are local previews 220 - let localImageUrls: Set<string> = $state(new Set()); 221 - 222 - // Process image file to create a local preview 223 - async function processImageFile(file: File) { 224 - if (!editor) { 225 - console.warn('Tiptap editor not initialized'); 226 - return; 227 - } 228 - 229 - try { 230 - const localUrl = URL.createObjectURL(file); 231 - 232 - localImages.set(localUrl, file); 233 - localImageUrls.add(localUrl); 234 - 235 - //editor.commands.setImageUploadNode(); 236 - editor 237 - .chain() 238 - .focus() 239 - .setImageUploadNode({ 240 - preview: localUrl 241 - }) 242 - .run(); 243 - 244 - // wait 2 seconds 245 - // await new Promise((resolve) => setTimeout(resolve, 500)); 246 - 247 - // content = editor.getJSON(); 248 - 249 - // console.log('replacing image url in content'); 250 - // replaceImageUrlInContent(content, localUrl, 'https://picsum.photos/200/300'); 251 - // editor.commands.setContent(content); 252 - } catch (error) { 253 - console.error('Error creating image preview:', error); 254 - } 255 - } 256 - 257 - const handlePaste = (event: ClipboardEvent) => { 258 - const items = event.clipboardData?.items; 259 - if (!items) return; 260 - // Check for image data in clipboard 261 - for (const item of Array.from(items)) { 262 - if (!item.type.startsWith('image/')) continue; 263 - const file = item.getAsFile(); 264 - if (!file) continue; 265 - event.preventDefault(); 266 - processImageFile(file); 267 - return; 268 - } 269 - }; 270 - 271 - function handleDragOver(event: DragEvent) { 272 - event.preventDefault(); 273 - event.stopPropagation(); 274 - isDragOver = true; 275 - } 276 - function handleDragLeave(event: DragEvent) { 277 - event.preventDefault(); 278 - event.stopPropagation(); 279 - isDragOver = false; 280 - } 281 - function handleDrop(event: DragEvent) { 282 - event.preventDefault(); 283 - event.stopPropagation(); 284 - isDragOver = false; 285 - if (!event.dataTransfer?.files?.length) return; 286 - const file = event.dataTransfer.files[0]; 287 - if (file?.type.startsWith('image/')) { 288 - processImageFile(file); 289 - } 290 - } 291 - 292 - onDestroy(() => { 293 - for (const localUrl of localImageUrls) { 294 - URL.revokeObjectURL(localUrl); 295 - } 296 - 297 - editor?.destroy(); 298 - }); 299 - 300 - let link = $state(''); 301 - 302 - let linkInput: HTMLInputElement | null = $state(null); 303 - 304 - function clickedLink() { 305 - if (isLink) { 306 - //tiptap?.chain().focus().unsetLink().run(); 307 - // get current link 308 - link = editor?.getAttributes('link').href; 309 - 310 - setTimeout(() => { 311 - linkInput?.focus(); 312 - }, 100); 313 - } else { 314 - link = ''; 315 - // set link 316 - editor?.chain().focus().setLink({ href: link }).run(); 317 - 318 - setTimeout(() => { 319 - linkInput?.focus(); 320 - }, 100); 321 - } 322 - } 323 - 324 - function switchTo(value: RichTextTypes) { 325 - editor?.chain().focus().setParagraph().run(); 326 - 327 - if (value === 'heading-1') { 328 - editor?.chain().focus().setNode('heading', { level: 1 }).run(); 329 - } else if (value === 'heading-2') { 330 - editor?.chain().focus().setNode('heading', { level: 2 }).run(); 331 - } else if (value === 'heading-3') { 332 - editor?.chain().focus().setNode('heading', { level: 3 }).run(); 333 - } else if (value === 'blockquote') { 334 - editor?.chain().focus().setBlockquote().run(); 335 - } else if (value === 'code') { 336 - editor?.chain().focus().setCodeBlock().run(); 337 - } else if (value === 'bullet-list') { 338 - editor?.chain().focus().toggleBulletList().run(); 339 - } else if (value === 'ordered-list') { 340 - editor?.chain().focus().toggleOrderedList().run(); 341 - } 342 - } 343 - </script> 344 - 345 - <div 346 - bind:this={ref} 347 - class={cn('relative flex-1', className)} 348 - onpaste={handlePaste} 349 - ondragover={handleDragOver} 350 - ondragleave={handleDragLeave} 351 - ondrop={handleDrop} 352 - role="region" 353 - ></div> 354 - 355 - <RichTextEditorMenu 356 - bind:ref={menu} 357 - {editor} 358 - {isBold} 359 - {isItalic} 360 - {isUnderline} 361 - {isStrikethrough} 362 - {isLink} 363 - {isImage} 364 - {clickedLink} 365 - {processImageFile} 366 - {switchTo} 367 - bind:selectedType 368 - /> 369 - 370 - <RichTextEditorLinkMenu bind:ref={menuLink} {editor} bind:link bind:linkInput /> 371 - 372 - <style> 373 - :global(.tiptap) { 374 - :first-child { 375 - margin-top: 0; 376 - } 377 - 378 - :global(img) { 379 - display: block; 380 - height: auto; 381 - margin: 1.5rem 0; 382 - max-width: 100%; 383 - 384 - &.ProseMirror-selectednode { 385 - outline: 3px solid var(--color-accent-500); 386 - } 387 - } 388 - 389 - :global(div[data-type='image-upload']) { 390 - &.ProseMirror-selectednode { 391 - outline: 3px solid var(--color-accent-500); 392 - } 393 - } 394 - 395 - :global(blockquote p:first-of-type::before) { 396 - content: none; 397 - } 398 - 399 - :global(blockquote p:last-of-type::after) { 400 - content: none; 401 - } 402 - 403 - :global(blockquote p) { 404 - font-style: normal; 405 - } 406 - } 407 - 408 - :global(.tiptap .is-empty::before) { 409 - color: var(--color-base-500); 410 - content: attr(data-placeholder); 411 - float: left; 412 - height: 0; 413 - pointer-events: none; 414 - } 415 - </style>
-111
packages/text/src/lib/components/rich-text-editor copy/RichTextEditorLinkMenu.svelte
··· 1 - <script lang="ts"> 2 - import { Button, Input } from '@foxui/core'; 3 - import type { Editor } from '@tiptap/core'; 4 - 5 - let { 6 - editor, 7 - link = $bindable(''), 8 - ref = $bindable(null), 9 - linkInput = $bindable(null) 10 - }: { 11 - editor: Editor | null; 12 - link: string; 13 - ref: HTMLElement | null; 14 - linkInput: HTMLInputElement | null; 15 - } = $props(); 16 - 17 - function processLink(link: string) { 18 - return link.includes(':') ? link : `http://${link}`; 19 - } 20 - </script> 21 - 22 - <div 23 - bind:this={ref} 24 - style="visibility: hidden; opacity: 0;" 25 - class="menu bg-base-50 dark:bg-base-900 relative w-fit rounded-2xl px-1 py-1 shadow-lg backdrop-blur-sm" 26 - > 27 - <div class="flex items-center gap-1"> 28 - <Input 29 - bind:ref={linkInput} 30 - sizeVariant="sm" 31 - bind:value={link} 32 - placeholder="Enter link" 33 - onblur={() => { 34 - if (link === '') { 35 - editor?.chain().focus().extendMarkRange('link').unsetLink().run(); 36 - } else { 37 - editor 38 - ?.chain() 39 - .focus() 40 - .extendMarkRange('link') 41 - .setLink({ href: processLink(link) }) 42 - .run(); 43 - } 44 - }} 45 - onkeydown={(e: KeyboardEvent) => { 46 - if (e.key === 'Enter') { 47 - if (link === '') { 48 - editor?.chain().focus().extendMarkRange('link').unsetLink().run(); 49 - } else { 50 - editor 51 - ?.chain() 52 - .focus() 53 - .extendMarkRange('link') 54 - .setLink({ href: processLink(link) }) 55 - .run(); 56 - } 57 - } 58 - }} 59 - /> 60 - <Button 61 - size="iconSm" 62 - onclick={() => { 63 - if (link === '') { 64 - editor?.chain().focus().extendMarkRange('link').unsetLink().run(); 65 - } else { 66 - editor 67 - ?.chain() 68 - .focus() 69 - .extendMarkRange('link') 70 - .setLink({ href: processLink(link) }) 71 - .run(); 72 - } 73 - }} 74 - > 75 - <svg 76 - xmlns="http://www.w3.org/2000/svg" 77 - fill="none" 78 - viewBox="0 0 24 24" 79 - stroke-width="1.5" 80 - stroke="currentColor" 81 - class="size-6" 82 - > 83 - <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 84 - </svg> 85 - 86 - <span class="sr-only">save link</span> 87 - </Button> 88 - <Button 89 - size="iconSm" 90 - onclick={() => { 91 - editor?.chain().focus().extendMarkRange('link').unsetLink().run(); 92 - }} 93 - variant="ghost" 94 - > 95 - <svg 96 - xmlns="http://www.w3.org/2000/svg" 97 - fill="none" 98 - viewBox="0 0 24 24" 99 - stroke-width="1.5" 100 - stroke="currentColor" 101 - > 102 - <path 103 - stroke-linecap="round" 104 - stroke-linejoin="round" 105 - 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" 106 - /> 107 - </svg> 108 - <span class="sr-only">remove link</span> 109 - </Button> 110 - </div> 111 - </div>
-182
packages/text/src/lib/components/rich-text-editor copy/RichTextEditorMenu.svelte
··· 1 - <script lang="ts"> 2 - import { cn, Toggle, toggleVariants, Tooltip } from '@foxui/core'; 3 - import type { Editor } from '@tiptap/core'; 4 - import Select from './Select.svelte'; 5 - import type { RichTextTypes } from '.'; 6 - 7 - let { 8 - editor, 9 - isBold, 10 - isImage, 11 - isItalic, 12 - isUnderline, 13 - isStrikethrough, 14 - isLink, 15 - clickedLink, 16 - selectedType = $bindable('paragraph'), 17 - ref = $bindable(null), 18 - processImageFile, 19 - switchTo 20 - }: { 21 - editor: Editor | null; 22 - isBold: boolean; 23 - isImage: boolean; 24 - isItalic: boolean; 25 - isUnderline: boolean; 26 - isStrikethrough: boolean; 27 - isLink: boolean; 28 - clickedLink: () => void; 29 - selectedType: RichTextTypes; 30 - ref: HTMLElement | null; 31 - processImageFile: (file: File, input: HTMLInputElement) => void; 32 - switchTo: (value: RichTextTypes) => void; 33 - } = $props(); 34 - 35 - function handleFileProcess(event: Event) { 36 - const input = event.target as HTMLInputElement; 37 - if (!input.files?.length) return; 38 - const file = input.files[0]; 39 - if (!file || !file.type.startsWith('image/')) return; 40 - processImageFile(file, input); 41 - } 42 - 43 - let fileInput = $state<HTMLInputElement | null>(null); 44 - </script> 45 - 46 - <div 47 - bind:this={ref} 48 - style="visibility: hidden; opacity: 0;" 49 - 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" 50 - > 51 - <Select 52 - onValueChange={(value) => { 53 - switchTo(value as RichTextTypes); 54 - }} 55 - type="single" 56 - items={[ 57 - { value: 'paragraph', label: 'Text' }, 58 - { value: 'heading-1', label: 'Heading 1' }, 59 - { value: 'heading-2', label: 'Heading 2' }, 60 - { value: 'heading-3', label: 'Heading 3' }, 61 - { value: 'blockquote', label: 'Blockquote' }, 62 - { value: 'code', label: 'Code Block' }, 63 - { value: 'bullet-list', label: 'Bullet List' }, 64 - { value: 'ordered-list', label: 'Ordered List' } 65 - ]} 66 - bind:value={selectedType} 67 - /> 68 - <!-- <Tooltip withContext text="Bold" delayDuration={0}> 69 - {#snippet child({ props })} --> 70 - <Toggle 71 - size="sm" 72 - onclick={() => editor?.chain().focus().toggleBold().run()} 73 - bind:pressed={() => isBold, (bold) => {}} 74 - > 75 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 76 - <path 77 - fill-rule="evenodd" 78 - 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" 79 - clip-rule="evenodd" 80 - /> 81 - </svg> 82 - 83 - <span class="sr-only">Bold</span> 84 - </Toggle> 85 - <!-- {/snippet} 86 - </Tooltip> --> 87 - <Toggle 88 - size="sm" 89 - onclick={() => editor?.chain().focus().toggleItalic().run()} 90 - bind:pressed={() => isItalic, (italic) => {}} 91 - > 92 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 93 - <path 94 - fill-rule="evenodd" 95 - 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" 96 - clip-rule="evenodd" 97 - /> 98 - </svg> 99 - 100 - <span class="sr-only">Italic</span> 101 - </Toggle> 102 - 103 - <Toggle 104 - size="sm" 105 - onclick={() => editor?.chain().focus().toggleUnderline().run()} 106 - bind:pressed={() => isUnderline, (underline) => {}} 107 - > 108 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 109 - <path 110 - fill-rule="evenodd" 111 - 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" 112 - clip-rule="evenodd" 113 - /> 114 - </svg> 115 - 116 - <span class="sr-only">Underline</span> 117 - </Toggle> 118 - 119 - <Toggle 120 - size="sm" 121 - onclick={() => editor?.chain().focus().toggleStrike().run()} 122 - bind:pressed={() => isStrikethrough, (strikethrough) => {}} 123 - > 124 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 125 - <path 126 - fill-rule="evenodd" 127 - 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" 128 - clip-rule="evenodd" 129 - /> 130 - </svg> 131 - 132 - <span class="sr-only">Strikethrough</span> 133 - </Toggle> 134 - 135 - <Toggle 136 - size="sm" 137 - onclick={() => { 138 - clickedLink(); 139 - }} 140 - bind:pressed={() => isLink, (link) => {}} 141 - > 142 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 143 - <path 144 - fill-rule="evenodd" 145 - 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" 146 - clip-rule="evenodd" 147 - /> 148 - </svg> 149 - 150 - <span class="sr-only">Link</span> 151 - </Toggle> 152 - 153 - <!-- <Toggle 154 - size="sm" 155 - onclick={() => { 156 - fileInput?.click(); 157 - }} 158 - bind:pressed={() => isImage, (image) => {}} 159 - > 160 - <svg 161 - xmlns="http://www.w3.org/2000/svg" 162 - viewBox="0 0 24 24" 163 - fill="none" 164 - stroke="currentColor" 165 - stroke-width="2" 166 - stroke-linecap="round" 167 - stroke-linejoin="round" 168 - ><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /><circle cx="9" cy="9" r="2" /><path 169 - d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" 170 - /></svg 171 - > 172 - </Toggle> --> 173 - 174 - <input 175 - type="file" 176 - accept="image/*" 177 - class="hidden" 178 - onchange={handleFileProcess} 179 - tabindex="-1" 180 - bind:this={fileInput} 181 - /> 182 - </div>
-126
packages/text/src/lib/components/rich-text-editor copy/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' as const 76 - } as RichTextLinkOptions; 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 };
-72
packages/text/src/lib/components/rich-text-editor copy/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>
-175
packages/text/src/lib/components/rich-text-editor copy/code.css
··· 1 - /* GitHub Light theme for code blocks */ 2 - .tiptap { 3 - :first-child { 4 - margin-top: 0; 5 - } 6 - 7 - pre { 8 - background: #f6f8fa; 9 - border-radius: 1rem; 10 - color: #1f2328; 11 - font-family: 'JetBrainsMono', monospace; 12 - border: 1px solid #d1d9e0; 13 - margin: 1.5rem 0; 14 - padding: 0.75rem 1rem; 15 - 16 - code { 17 - background: none; 18 - color: inherit; 19 - font-size: 0.8rem; 20 - padding: 0; 21 - } 22 - 23 - .hljs-comment, 24 - .hljs-quote { 25 - color: #59636e; 26 - font-style: italic; 27 - } 28 - 29 - .hljs-keyword, 30 - .hljs-selector-tag { 31 - color: #cf222e; 32 - } 33 - 34 - .hljs-tag, 35 - .hljs-name { 36 - color: #116329; 37 - } 38 - 39 - .hljs-attribute { 40 - color: #0550ae; 41 - } 42 - 43 - .hljs-variable, 44 - .hljs-template-variable, 45 - .hljs-regexp, 46 - .hljs-link { 47 - color: #0550ae; 48 - } 49 - 50 - .hljs-number, 51 - .hljs-literal { 52 - color: #0550ae; 53 - } 54 - 55 - .hljs-string, 56 - .hljs-symbol, 57 - .hljs-bullet { 58 - color: #0a3069; 59 - } 60 - 61 - .hljs-title, 62 - .hljs-section { 63 - color: #0550ae; 64 - font-weight: 600; 65 - } 66 - 67 - .hljs-type, 68 - .hljs-built_in, 69 - .hljs-builtin-name, 70 - .hljs-params { 71 - color: #953800; 72 - } 73 - 74 - .hljs-meta { 75 - color: #0550ae; 76 - } 77 - 78 - .hljs-selector-id, 79 - .hljs-selector-class { 80 - color: #0550ae; 81 - } 82 - 83 - .hljs-emphasis { 84 - font-style: italic; 85 - } 86 - 87 - .hljs-strong { 88 - font-weight: 700; 89 - } 90 - } 91 - } 92 - 93 - /* GitHub Dark theme for code blocks */ 94 - .dark .tiptap { 95 - pre { 96 - background: #161b22; 97 - color: #e6edf3; 98 - border: 1px solid #30363d; 99 - 100 - code { 101 - background: none; 102 - color: inherit; 103 - font-size: 0.8rem; 104 - padding: 0; 105 - } 106 - 107 - .hljs-comment, 108 - .hljs-quote { 109 - color: #8b949e; 110 - font-style: italic; 111 - } 112 - 113 - .hljs-keyword, 114 - .hljs-selector-tag { 115 - color: #ff7b72; 116 - } 117 - 118 - .hljs-tag, 119 - .hljs-name { 120 - color: #7ee787; 121 - } 122 - 123 - .hljs-attribute { 124 - color: #79c0ff; 125 - } 126 - 127 - .hljs-variable, 128 - .hljs-template-variable, 129 - .hljs-regexp, 130 - .hljs-link { 131 - color: #79c0ff; 132 - } 133 - 134 - .hljs-number, 135 - .hljs-literal { 136 - color: #79c0ff; 137 - } 138 - 139 - .hljs-string, 140 - .hljs-symbol, 141 - .hljs-bullet { 142 - color: #a5d6ff; 143 - } 144 - 145 - .hljs-title, 146 - .hljs-section { 147 - color: #d2a8ff; 148 - font-weight: 600; 149 - } 150 - 151 - .hljs-type, 152 - .hljs-built_in, 153 - .hljs-builtin-name, 154 - .hljs-params { 155 - color: #ffa657; 156 - } 157 - 158 - .hljs-meta { 159 - color: #79c0ff; 160 - } 161 - 162 - .hljs-selector-id, 163 - .hljs-selector-class { 164 - color: #79c0ff; 165 - } 166 - 167 - .hljs-emphasis { 168 - font-style: italic; 169 - } 170 - 171 - .hljs-strong { 172 - font-weight: 700; 173 - } 174 - } 175 - }
-11
packages/text/src/lib/components/rich-text-editor copy/index.ts
··· 1 - export { default as RichTextEditor } from './RichTextEditor.svelte'; 2 - 3 - export type RichTextTypes = 4 - | 'paragraph' 5 - | 'heading-1' 6 - | 'heading-2' 7 - | 'heading-3' 8 - | 'blockquote' 9 - | 'code' 10 - | 'bullet-list' 11 - | 'ordered-list';
+4 -94
packages/text/src/lib/components/rich-text-editor/RichTextEditor.svelte
··· 1 1 <script lang="ts"> 2 2 import { onDestroy } from 'svelte'; 3 3 import { type Editor as CoreEditor, mergeAttributes, type Content } from '@tiptap/core'; 4 - import { type Editor, createEditor, EditorContent, BubbleMenu } from 'svelte-tiptap'; 4 + import { type Editor, createEditor, EditorContent } from 'svelte-tiptap'; 5 5 import StarterKit from '@tiptap/starter-kit'; 6 6 import Placeholder from '@tiptap/extension-placeholder'; 7 7 import Image from '@tiptap/extension-image'; 8 8 import { all, createLowlight } from 'lowlight'; 9 9 import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; 10 10 import Underline from '@tiptap/extension-underline'; 11 - import RichTextEditorMenu from './RichTextEditorMenu.svelte'; 12 11 import type { RichTextTypes } from '.'; 13 - import RichTextEditorLinkMenu from './RichTextEditorLinkMenu.svelte'; 14 12 import Slash, { suggestion } from './slash-menu'; 15 13 import Typography from '@tiptap/extension-typography'; 16 14 import { RichTextLink } from './RichTextLink'; 17 15 import { Markdown } from '@tiptap/markdown'; 18 16 19 - import './code.css'; 20 17 import { cn } from '@foxui/core'; 21 18 import { ImageUploadNode } from './image-upload/ImageUploadNode'; 19 + import { FormattingBubbleMenu } from './formatting-bubble-menu'; 22 20 import type { Transaction } from '@tiptap/pm/state'; 23 21 import type { Readable } from 'svelte/store'; 24 22 ··· 43 41 const lowlight = createLowlight(all); 44 42 45 43 let hasFocus = true; 46 - 47 - let selectedType: RichTextTypes = $state('paragraph'); 48 - 49 - let isBold = $state(false); 50 - let isItalic = $state(false); 51 - let isUnderline = $state(false); 52 - let isStrikethrough = $state(false); 53 - let isLink = $state(false); 54 - let isImage = $state(false); 55 44 56 45 const CustomImage = Image.extend({ 57 46 renderHTML({ HTMLAttributes }) { ··· 189 178 onBlur: () => { 190 179 hasFocus = false; 191 180 }, 192 - onTransaction: (ctx) => { 193 - isBold = ctx.editor.isActive('bold'); 194 - isItalic = ctx.editor.isActive('italic'); 195 - isUnderline = ctx.editor.isActive('underline'); 196 - isStrikethrough = ctx.editor.isActive('strike'); 197 - isLink = ctx.editor.isActive('link'); 198 - isImage = ctx.editor.isActive('image'); 199 - 200 - if (ctx.editor.isActive('heading', { level: 1 })) { 201 - selectedType = 'heading-1'; 202 - } else if (ctx.editor.isActive('heading', { level: 2 })) { 203 - selectedType = 'heading-2'; 204 - } else if (ctx.editor.isActive('heading', { level: 3 })) { 205 - selectedType = 'heading-3'; 206 - } else if (ctx.editor.isActive('blockquote')) { 207 - selectedType = 'blockquote'; 208 - } else if (ctx.editor.isActive('code')) { 209 - selectedType = 'code'; 210 - } else if (ctx.editor.isActive('bulletList')) { 211 - selectedType = 'bullet-list'; 212 - } else if (ctx.editor.isActive('orderedList')) { 213 - selectedType = 'ordered-list'; 214 - } else { 215 - selectedType = 'paragraph'; 216 - } 181 + onTransaction: () => { 217 182 ontransaction?.(); 218 183 }, 219 184 content ··· 259 224 } 260 225 }); 261 226 262 - let link = $state(''); 263 - 264 - let linkInput: HTMLInputElement | null = $state(null); 265 - 266 - function clickedLink() { 267 - if (isLink) { 268 - link = $editor?.getAttributes('link').href; 269 - 270 - setTimeout(() => { 271 - linkInput?.focus(); 272 - }, 100); 273 - } else { 274 - link = ''; 275 - $editor?.chain().focus().setLink({ href: link }).run(); 276 - 277 - setTimeout(() => { 278 - linkInput?.focus(); 279 - }, 100); 280 - } 281 - } 282 227 </script> 283 228 284 229 <div ··· 296 241 </div> 297 242 298 243 {#if $editor} 299 - <BubbleMenu 300 - editor={$editor} 301 - shouldShow={({ editor }) => { 302 - return ( 303 - !editor.isActive('image') && 304 - !editor.view.state.selection.empty && 305 - !editor.isActive('codeBlock') && 306 - !editor.isActive('link') && 307 - !editor.isActive('imageUpload') 308 - ); 309 - }} 310 - pluginKey="bubble-menu-marks" 311 - > 312 - <RichTextEditorMenu 313 - editor={$editor} 314 - {isBold} 315 - {isItalic} 316 - {isUnderline} 317 - {isStrikethrough} 318 - {isLink} 319 - {isImage} 320 - {clickedLink} 321 - {processImageFile} 322 - {switchTo} 323 - bind:selectedType 324 - /> 325 - </BubbleMenu> 326 - <BubbleMenu 327 - editor={$editor} 328 - shouldShow={({ editor }) => { 329 - return editor.isActive('link') && !editor.view.state.selection.empty; 330 - }} 331 - pluginKey="bubble-menu-links" 332 - > 333 - <RichTextEditorLinkMenu editor={$editor} bind:link bind:linkInput /> 334 - </BubbleMenu> 244 + <FormattingBubbleMenu editor={$editor} /> 335 245 {/if} 336 246 337 247 <style>
-92
packages/text/src/lib/components/rich-text-editor/RichTextEditorLinkMenu.svelte
··· 1 - <script lang="ts"> 2 - import { Button, Input } from "@foxui/core"; 3 - import type { Editor } from "@tiptap/core"; 4 - 5 - let { 6 - editor, 7 - link = $bindable(''), 8 - linkInput = $bindable(null) 9 - }: { 10 - editor: Editor | null; 11 - link: string; 12 - linkInput: HTMLInputElement | null; 13 - } = $props(); 14 - 15 - function processLink(link: string) { 16 - return link.includes(':') ? link : `http://${link}`; 17 - } 18 - </script> 19 - 20 - <div 21 - class="menu bg-base-50 dark:bg-base-900 relative w-fit rounded-2xl px-1 py-1 shadow-lg backdrop-blur-sm" 22 - > 23 - <div class="flex items-center gap-1"> 24 - <Input 25 - bind:ref={linkInput} 26 - sizeVariant="sm" 27 - bind:value={link} 28 - placeholder="Enter link" 29 - onblur={() => { 30 - if (link === '') { 31 - editor?.chain().focus().extendMarkRange('link').unsetLink().run(); 32 - } else { 33 - editor?.chain().focus().extendMarkRange('link').setLink({ href: processLink(link) }).run(); 34 - } 35 - }} 36 - onkeydown={(e: KeyboardEvent) => { 37 - if (e.key === 'Enter') { 38 - if (link === '') { 39 - editor?.chain().focus().extendMarkRange('link').unsetLink().run(); 40 - } else { 41 - editor?.chain().focus().extendMarkRange('link').setLink({ href: processLink(link) }).run(); 42 - } 43 - } 44 - }} 45 - /> 46 - <Button 47 - size="iconSm" 48 - onclick={() => { 49 - if (link === '') { 50 - editor?.chain().focus().extendMarkRange('link').unsetLink().run(); 51 - } else { 52 - editor?.chain().focus().extendMarkRange('link').setLink({ href: processLink(link) }).run(); 53 - } 54 - }} 55 - > 56 - <svg 57 - xmlns="http://www.w3.org/2000/svg" 58 - fill="none" 59 - viewBox="0 0 24 24" 60 - stroke-width="1.5" 61 - stroke="currentColor" 62 - class="size-6" 63 - > 64 - <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 65 - </svg> 66 - 67 - <span class="sr-only">save link</span> 68 - </Button> 69 - <Button 70 - size="iconSm" 71 - onclick={() => { 72 - editor?.chain().focus().extendMarkRange('link').unsetLink().run(); 73 - }} 74 - variant="ghost" 75 - > 76 - <svg 77 - xmlns="http://www.w3.org/2000/svg" 78 - fill="none" 79 - viewBox="0 0 24 24" 80 - stroke-width="1.5" 81 - stroke="currentColor" 82 - > 83 - <path 84 - stroke-linecap="round" 85 - stroke-linejoin="round" 86 - 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" 87 - /> 88 - </svg> 89 - <span class="sr-only">remove link</span> 90 - </Button> 91 - </div> 92 - </div>
-179
packages/text/src/lib/components/rich-text-editor/RichTextEditorMenu.svelte
··· 1 - <script lang="ts"> 2 - import { cn, Toggle, toggleVariants, Tooltip } from '@foxui/core'; 3 - import type { Editor } from '@tiptap/core'; 4 - import Select from './Select.svelte'; 5 - import type { RichTextTypes } from '.'; 6 - 7 - let { 8 - editor, 9 - isBold, 10 - isImage, 11 - isItalic, 12 - isUnderline, 13 - isStrikethrough, 14 - isLink, 15 - clickedLink, 16 - selectedType = $bindable('paragraph'), 17 - processImageFile, 18 - switchTo 19 - }: { 20 - editor: Editor | null; 21 - isBold: boolean; 22 - isImage: boolean; 23 - isItalic: boolean; 24 - isUnderline: boolean; 25 - isStrikethrough: boolean; 26 - isLink: boolean; 27 - clickedLink: () => void; 28 - selectedType: RichTextTypes; 29 - processImageFile: (file: File, input: HTMLInputElement) => void; 30 - switchTo: (value: RichTextTypes) => void; 31 - } = $props(); 32 - 33 - function handleFileProcess(event: Event) { 34 - const input = event.target as HTMLInputElement; 35 - if (!input.files?.length) return; 36 - const file = input.files[0]; 37 - if (!file || !file.type.startsWith('image/')) return; 38 - processImageFile(file, input); 39 - } 40 - 41 - let fileInput = $state<HTMLInputElement | null>(null); 42 - 43 - </script> 44 - 45 - <div 46 - 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" 47 - > 48 - <Select 49 - onValueChange={(value) => { 50 - switchTo(value as RichTextTypes); 51 - }} 52 - type="single" 53 - items={[ 54 - { value: 'paragraph', label: 'Text' }, 55 - { value: 'heading-1', label: 'Heading 1' }, 56 - { value: 'heading-2', label: 'Heading 2' }, 57 - { value: 'heading-3', label: 'Heading 3' }, 58 - { value: 'blockquote', label: 'Blockquote' }, 59 - { value: 'code', label: 'Code Block' }, 60 - { value: 'bullet-list', label: 'Bullet List' }, 61 - { value: 'ordered-list', label: 'Ordered List' } 62 - ]} 63 - bind:value={selectedType} 64 - /> 65 - <!-- <Tooltip withContext text="Bold" delayDuration={0}> 66 - {#snippet child({ props })} --> 67 - <Toggle 68 - size="sm" 69 - onclick={() => editor?.chain().focus().toggleBold().run()} 70 - bind:pressed={() => isBold, (bold) => {}} 71 - > 72 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 73 - <path 74 - fill-rule="evenodd" 75 - 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" 76 - clip-rule="evenodd" 77 - /> 78 - </svg> 79 - 80 - <span class="sr-only">Bold</span> 81 - </Toggle> 82 - <!-- {/snippet} 83 - </Tooltip> --> 84 - <Toggle 85 - size="sm" 86 - onclick={() => editor?.chain().focus().toggleItalic().run()} 87 - bind:pressed={() => isItalic, (italic) => {}} 88 - > 89 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 90 - <path 91 - fill-rule="evenodd" 92 - 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" 93 - clip-rule="evenodd" 94 - /> 95 - </svg> 96 - 97 - <span class="sr-only">Italic</span> 98 - </Toggle> 99 - 100 - <Toggle 101 - size="sm" 102 - onclick={() => editor?.chain().focus().toggleUnderline().run()} 103 - bind:pressed={() => isUnderline, (underline) => {}} 104 - > 105 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 106 - <path 107 - fill-rule="evenodd" 108 - 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" 109 - clip-rule="evenodd" 110 - /> 111 - </svg> 112 - 113 - <span class="sr-only">Underline</span> 114 - </Toggle> 115 - 116 - <Toggle 117 - size="sm" 118 - onclick={() => editor?.chain().focus().toggleStrike().run()} 119 - bind:pressed={() => isStrikethrough, (strikethrough) => {}} 120 - > 121 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 122 - <path 123 - fill-rule="evenodd" 124 - 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" 125 - clip-rule="evenodd" 126 - /> 127 - </svg> 128 - 129 - <span class="sr-only">Strikethrough</span> 130 - </Toggle> 131 - 132 - <Toggle 133 - size="sm" 134 - onclick={() => { 135 - clickedLink(); 136 - }} 137 - bind:pressed={() => isLink, (link) => {}} 138 - > 139 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 140 - <path 141 - fill-rule="evenodd" 142 - 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" 143 - clip-rule="evenodd" 144 - /> 145 - </svg> 146 - 147 - <span class="sr-only">Link</span> 148 - </Toggle> 149 - 150 - <!-- <Toggle 151 - size="sm" 152 - onclick={() => { 153 - fileInput?.click(); 154 - }} 155 - bind:pressed={() => isImage, (image) => {}} 156 - > 157 - <svg 158 - xmlns="http://www.w3.org/2000/svg" 159 - viewBox="0 0 24 24" 160 - fill="none" 161 - stroke="currentColor" 162 - stroke-width="2" 163 - stroke-linecap="round" 164 - stroke-linejoin="round" 165 - ><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /><circle cx="9" cy="9" r="2" /><path 166 - d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" 167 - /></svg 168 - > 169 - </Toggle> --> 170 - 171 - <input 172 - type="file" 173 - accept="image/*" 174 - class="hidden" 175 - onchange={handleFileProcess} 176 - tabindex="-1" 177 - bind:this={fileInput} 178 - /> 179 - </div>
-72
packages/text/src/lib/components/rich-text-editor/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>
+178 -112
packages/text/src/lib/components/rich-text-editor/code.css
··· 1 - /* Basic editor styles */ 1 + /* Structural styles for code blocks */ 2 2 .tiptap { 3 3 :first-child { 4 4 margin-top: 0; 5 5 } 6 6 7 7 pre { 8 - background: var(--color-base-200); 9 8 border-radius: 1rem; 10 - color: var(--color-base-900); 11 9 font-family: 'JetBrainsMono', monospace; 12 - border: 1px solid var(--color-base-300); 13 10 margin: 1.5rem 0; 14 11 padding: 0.75rem 1rem; 15 12 ··· 19 16 font-size: 0.8rem; 20 17 padding: 0; 21 18 } 22 - 23 - /* Code styling */ 24 - .hljs-comment, 25 - .hljs-quote { 26 - color: var(--color-base-500); 27 - } 28 - 29 - .hljs-variable, 30 - .hljs-template-variable, 31 - .hljs-attribute, 32 - .hljs-tag, 33 - .hljs-regexp, 34 - .hljs-link, 35 - .hljs-name, 36 - .hljs-selector-id, 37 - .hljs-selector-class { 38 - color: var(--color-accent-600); 39 - } 40 - 41 - .hljs-number, 42 - .hljs-meta, 43 - .hljs-built_in, 44 - .hljs-builtin-name, 45 - .hljs-literal, 46 - .hljs-type, 47 - .hljs-params { 48 - color: oklch(from var(--color-accent-600) l c calc(h + 30)); 49 - } 50 - 51 - .hljs-string, 52 - .hljs-symbol, 53 - .hljs-bullet { 54 - color: oklch(from var(--color-accent-600) l c calc(h + 60)); 55 - } 56 - 57 - .hljs-title, 58 - .hljs-section { 59 - color: oklch(from var(--color-accent-700) l c calc(h + 15)); 60 - } 61 - 62 - .hljs-keyword, 63 - .hljs-selector-tag { 64 - color: oklch(from var(--color-accent-700) l c calc(h + 45)); 65 - } 66 - 67 - .hljs-emphasis { 68 - font-style: italic; 69 - } 70 - 71 - .hljs-strong { 72 - font-weight: 700; 73 - } 74 19 } 75 20 } 76 21 77 - .dark .tiptap { 78 - pre { 79 - background: var(--color-base-900); 80 - color: var(--color-base-200); 81 - border: 1px solid var(--color-base-800); 22 + /* GitHub Light theme (default) */ 23 + .tiptap pre { 24 + background: #ffffff; 25 + color: #24292e; 26 + border: 1px solid #d0d7de; 27 + } 82 28 83 - code { 84 - background: none; 85 - color: inherit; 86 - font-size: 0.8rem; 87 - padding: 0; 88 - } 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 + } 89 38 90 - /* Code styling */ 91 - .hljs-comment, 92 - .hljs-quote { 93 - color: var(--color-base-400); 94 - } 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 + } 95 45 96 - .hljs-variable, 97 - .hljs-template-variable, 98 - .hljs-attribute, 99 - .hljs-tag, 100 - .hljs-regexp, 101 - .hljs-link, 102 - .hljs-name, 103 - .hljs-selector-id, 104 - .hljs-selector-class { 105 - color: var(--color-accent-400); 106 - } 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 + } 107 58 108 - .hljs-number, 109 - .hljs-meta, 110 - .hljs-built_in, 111 - .hljs-builtin-name, 112 - .hljs-literal, 113 - .hljs-type, 114 - .hljs-params { 115 - color: oklch(from var(--color-accent-400) l c calc(h + 30)); 116 - } 59 + .tiptap pre .hljs-meta .hljs-string, 60 + .tiptap pre .hljs-regexp, 61 + .tiptap pre .hljs-string { 62 + color: #032f62; 63 + } 117 64 118 - .hljs-string, 119 - .hljs-symbol, 120 - .hljs-bullet { 121 - color: oklch(from var(--color-accent-400) l c calc(h + 60)); 122 - } 65 + .tiptap pre .hljs-built_in, 66 + .tiptap pre .hljs-symbol { 67 + color: #e36209; 68 + } 123 69 124 - .hljs-title, 125 - .hljs-section { 126 - color: oklch(from var(--color-accent-300) l c calc(h + 15)); 127 - } 70 + .tiptap pre .hljs-code, 71 + .tiptap pre .hljs-comment, 72 + .tiptap pre .hljs-formula { 73 + color: #6a737d; 74 + } 128 75 129 - .hljs-keyword, 130 - .hljs-selector-tag { 131 - color: oklch(from var(--color-accent-300) l c calc(h + 45)); 132 - } 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 + } 133 82 134 - .hljs-emphasis { 135 - font-style: italic; 136 - } 83 + .tiptap pre .hljs-subst { 84 + color: #24292e; 85 + } 137 86 138 - .hljs-strong { 139 - font-weight: 700; 140 - } 141 - } 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; 142 208 }
+27
packages/text/src/lib/components/rich-text-editor/index.ts
··· 1 + // Main component (all-in-one convenience) 1 2 export { default as RichTextEditor } from './RichTextEditor.svelte'; 2 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 3 24 export type RichTextTypes = 4 25 | 'paragraph' 5 26 | 'heading-1' ··· 9 30 | 'code' 10 31 | 'bullet-list' 11 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';
-28
packages/text/src/lib/components/rich-text-editor copy/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 - onMount(() => { 9 - const pos = props.getPos(); 10 - if (pos == null) return; 11 - 12 - props.deleteNode(); 13 - props.editor 14 - .chain() 15 - .focus() 16 - .insertContentAt(pos, [ 17 - { 18 - type: 'image', 19 - attrs: { src: props.node.attrs.preview, alt: 'image', title: 'image' } 20 - } 21 - ]) 22 - .run(); 23 - }); 24 - </script> 25 - 26 - <NodeViewWrapper> 27 - <img src={props.node.attrs.preview} alt="Upload preview" /> 28 - </NodeViewWrapper>
-115
packages/text/src/lib/components/rich-text-editor copy/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 - });
-87
packages/text/src/lib/components/rich-text-editor copy/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 = 0 }: Props = $props(); 19 - 20 - let activeIndex = $state(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 - {#each items as item, index (item.value)} 74 - <button 75 - onclick={() => item.command({ editor, range })} 76 - class={cn( 77 - 'text-base-900 dark:text-base-200 group relative isolate flex w-full min-w-28 cursor-pointer items-center gap-3 px-3 py-2 font-medium [&_svg]:size-3.5', 78 - activeIndex === index 79 - ? 'text-accent-700 dark:text-accent-400 bg-accent-500/10' 80 - : 'hover:bg-accent-500/10' 81 - )} 82 - > 83 - <Icon name={item.value} /> 84 - {item.label} 85 - </button> 86 - {/each} 87 - </menu>
-199
packages/text/src/lib/components/rich-text-editor copy/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 - 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 - // Create a virtual reference element for floating-ui 150 - const virtualRef = { 151 - getBoundingClientRect: () => rect 152 - }; 153 - 154 - computePosition(virtualRef, floatingEl, { 155 - placement: 'bottom-start', 156 - middleware: [offset(8), flip(), shift({ padding: 8 })] 157 - }).then(({ x, y }) => { 158 - Object.assign(floatingEl.style, { 159 - left: `${x}px`, 160 - top: `${y}px` 161 - }); 162 - }); 163 - } 164 - 165 - return { 166 - onStart: (props: SuggestionProps) => { 167 - floatingEl = document.createElement('div'); 168 - floatingEl.style.position = 'absolute'; 169 - floatingEl.style.zIndex = '50'; 170 - document.body.appendChild(floatingEl); 171 - 172 - component = mount(SuggestionSelect, { 173 - target: floatingEl, 174 - props 175 - }); 176 - 177 - updatePosition(props.clientRect); 178 - }, 179 - onUpdate: (props: SuggestionProps) => { 180 - component.setItems(props.items); 181 - component.setRange(props.range); 182 - updatePosition(props.clientRect); 183 - }, 184 - onKeyDown: (props: SuggestionKeyDownProps) => { 185 - if (props.event.key === 'Escape') { 186 - floatingEl.style.display = 'none'; 187 - return true; 188 - } 189 - 190 - return component.onKeyDown(props.event); 191 - }, 192 - onExit: () => { 193 - unmount(component); 194 - floatingEl.remove(); 195 - } 196 - }; 197 - } 198 - }; 199 - }
+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';
+8
apps/docs/src/routes/(main)/components/social/atproto-handle-popup/+page.svelte
··· 1 + <script lang="ts"> 2 + import ATProtoHandlePopup from './ATProtoHandlePopup.md'; 3 + import { Prose } from '@foxui/all'; 4 + </script> 5 + 6 + <Prose> 7 + <ATProtoHandlePopup /> 8 + </Prose>
+20
apps/docs/src/routes/(main)/components/social/atproto-handle-popup/ATProtoHandlePopup.md
··· 1 + <script lang="ts"> 2 + import Example from './Example.svelte'; 3 + </script> 4 + 5 + # Atproto Handle Popup 6 + 7 + ## Example 8 + 9 + <Example /> 10 + 11 + ## Usage 12 + 13 + ```svelte 14 + <script lang="ts"> 15 + import { ATProtoHandlePopup } from '@foxui/social'; 16 + </script> 17 + 18 + <ATProtoHandlePopup 19 + onselected={(actor) => console.log('Selected actor:', actor)} /> 20 + ```
+5
apps/docs/src/routes/(main)/components/social/atproto-handle-popup/Example.svelte
··· 1 + <script lang="ts"> 2 + import { AtprotoHandlePopup, toast } from '@foxui/all'; 3 + </script> 4 + 5 + <AtprotoHandlePopup onselected={(actor) => toast.success(`Selected handle: ${actor.handle}`)} />
+13 -20
apps/docs/src/routes/(main)/components/social/atproto-login/ATProtoLogin.md
··· 10 10 11 11 ## Usage 12 12 13 - ### Client side login 13 + ### Add modal to your `+layout.svelte` 14 14 15 15 ```svelte 16 16 <script lang="ts"> 17 - import { AtprotoLogin } from '@foxui/social'; 17 + import { AtprotoLoginModal } from '@foxui/social'; 18 18 </script> 19 19 20 - <AtprotoLogin 20 + <AtprotoLoginModal 21 21 login={async (handle) => { 22 22 // do login and return true if login 23 + // is successful and should hide the modal 24 + }} 25 + signup={async () => { 26 + // do signup and return true if signup 23 27 // is successful and should hide the modal 24 28 }} 25 29 /> 26 30 ``` 27 31 28 - ### Use with forms 29 - 30 - Pass in `formAction` and `formMethod` to use the BlueskyLogin component as a form submit handler. 32 + ### Open the modal 31 33 32 34 ```svelte 33 - <ATProtoLogin formAction="/login" formMethod="post" /> 34 - ``` 35 + <script lang="ts"> 36 + import { atProtoLoginModalState, Button } from '@foxui/all'; 37 + </script> 35 38 36 - And then use like a normal form in your `+page.server.ts` or `+layout.server.ts` file: 37 - 38 - ```ts 39 - export const actions: Actions = { 40 - login: async ({ request }) => { 41 - const formData = await request.formData(); 42 - let handle = formData.get('handle') as string; 43 - 44 - // authorization logic here 45 - } 46 - }; 47 - ``` 39 + <Button onclick={() => atProtoLoginModalState.show()}>Login</Button> 40 + ```
+13 -6
apps/docs/src/routes/(main)/components/social/atproto-login/Example.svelte
··· 1 1 <script lang="ts"> 2 - import { toast } from '@foxui/all'; 3 - import { AtprotoLogin } from '@foxui/all'; 2 + import { AtprotoLoginModal, atProtoLoginModalState, Button, toast } from '@foxui/all'; 4 3 </script> 5 4 6 - <AtprotoLogin 7 - login={async (handle: string) => { 5 + 6 + 7 + <Button onclick={() => atProtoLoginModalState.show()}> 8 + Login 9 + </Button> 10 + 11 + <AtprotoLoginModal login={async (handle: string) => { 8 12 if (!handle) { 9 13 toast.error('Please enter a handle'); 10 14 return false; ··· 15 19 } 16 20 toast.success(`Login successful for ${handle}`); 17 21 return true; 18 - }} 19 - /> 22 + }} signup={async () => { 23 + toast.success('Signup successful'); 24 + atProtoLoginModalState.hide(); 25 + return true; 26 + }} />