···11+---
22+'@foxui/colors': minor
33+'@foxui/visual': minor
44+'@foxui/text': minor
55+'@foxui/time': minor
66+'@foxui/all': minor
77+'@foxui/3d': minor
88+'@foxui/social': patch
99+'@foxui/core': patch
1010+---
1111+1212+add bluesky post creator, refactor text package, small fixes
···1212export * from './link-card';
1313export * from './animated-emoji-picker';
1414export * from './atproto-handle-popup';
1515+export * from './bluesky-post-creator';
15161617export function numberToHumanReadable(number: number) {
1718 if (number < 1000) {
+15
packages/text/src/lib/components/index.ts
···11export * from './plain-text-editor/';
22export * from './rich-text-editor/';
33export * from './advanced-text-area/';
44+55+export * from './core-rich-text-editor/';
66+export * from './rich-text-bubble-menu/';
77+export * from './mention/';
88+export * from './slash-menu/';
99+export * from './image/';
1010+export * from './link/';
1111+export * from './code-block/';
1212+export * from './block-type-menu/';
1313+export * from './embed/';
1414+export * from './formatting/';
1515+export * from './hashtag-decoration/';
1616+export * from '../extensions/';
1717+1818+export * as SvelteTiptap from 'svelte-tiptap';
+11
packages/text/src/lib/extensions/index.ts
···11+export { HashtagDecoration } from '../components/hashtag-decoration';
22+33+// Re-export commonly used tiptap extensions so consumers
44+// don't need direct @tiptap/* dependencies.
55+export { default as Link } from '@tiptap/extension-link';
66+export { default as Placeholder } from '@tiptap/extension-placeholder';
77+export { default as History } from '@tiptap/extension-history';
88+export { default as Underline } from '@tiptap/extension-underline';
99+export { default as Strike } from '@tiptap/extension-strike';
1010+// Re-export core tiptap types
1111+export type { JSONContent, Content, Extensions } from '@tiptap/core';
···11+export { default as BlueskyPostCreator } from './BlueskyPostCreator.svelte';
22+export { editorJsonToBlueskyPost } from './facets';
33+export type { BlueskyPostContent, BlueskyFacet } from './facets';
···11+export { CodeBlockExtension } from './CodeBlockExtension';
22+export { default as CodeBlockView } from './CodeBlockView.svelte';
33+export type { CodeBlockExtensionOptions } from './helpers';
44+55+import './code.css';
···11+import type { Editor } from '@tiptap/core';
22+import type { Component } from 'svelte';
33+import YouTubeEmbed from './YouTubeEmbed.svelte';
44+55+declare module '@tiptap/core' {
66+ interface Storage {
77+ embedExtension: {
88+ embeds: EmbedDefinition[];
99+ openModal: (() => void) | null;
1010+ };
1111+ }
1212+}
1313+1414+/**
1515+ * Defines an embed provider. Each embed has a name, a match function
1616+ * that extracts data from a URL, and a Svelte component to render.
1717+ */
1818+export interface EmbedDefinition {
1919+ /** Unique name for this embed type (e.g. 'youtube', 'spotify'). */
2020+ name: string;
2121+ /** Return embed-specific data if the URL matches, or false/null to skip. */
2222+ match: (url: string) => Record<string, any> | false | null;
2323+ /** Svelte component to render. Receives props: url, data, selected. */
2424+ component: Component;
2525+}
2626+2727+export interface EmbedExtensionOptions {
2828+ /** Array of embed definitions to check URLs against. */
2929+ embeds: EmbedDefinition[];
3030+}
3131+3232+/** Props passed to every embed component. */
3333+export interface EmbedComponentProps {
3434+ /** The original URL. */
3535+ url: string;
3636+ /** Data returned by the embed's match function. */
3737+ data: Record<string, any>;
3838+ /** Whether the node is currently selected in the editor. */
3939+ selected: boolean;
4040+}
4141+4242+const youtubeRegex =
4343+ /(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
4444+4545+/**
4646+ * Built-in YouTube embed. Matches youtube.com/watch, youtu.be, youtube.com/embed, and youtube.com/shorts URLs.
4747+ */
4848+export const youtubeEmbed: EmbedDefinition = {
4949+ name: 'youtube',
5050+ match: (url: string) => {
5151+ const m = url.match(youtubeRegex);
5252+ if (!m) return false;
5353+ return { videoId: m[1] };
5454+ },
5555+ component: YouTubeEmbed as Component
5656+};
5757+5858+/**
5959+ * Simple URL check — just verifies the string starts with http(s)://.
6060+ */
6161+export function isUrl(text: string): boolean {
6262+ try {
6363+ const url = new URL(text);
6464+ return url.protocol === 'http:' || url.protocol === 'https:';
6565+ } catch {
6666+ return false;
6767+ }
6868+}
6969+7070+/**
7171+ * Try to insert an embed for the given URL. Returns an error string if no
7272+ * embed matches, or null on success.
7373+ */
7474+export function insertEmbed(editor: Editor, url: string): string | null {
7575+ const storage = editor.storage.embedExtension as
7676+ | { embeds: EmbedDefinition[] }
7777+ | undefined;
7878+ if (!storage) return 'Embed extension is not registered';
7979+8080+ const trimmed = url.trim();
8181+ if (!isUrl(trimmed)) return 'Please enter a valid URL';
8282+8383+ for (const embed of storage.embeds) {
8484+ const data = embed.match(trimmed);
8585+ if (data && typeof data === 'object') {
8686+ editor
8787+ .chain()
8888+ .focus()
8989+ .insertContent({
9090+ type: 'embed',
9191+ attrs: {
9292+ url: trimmed,
9393+ embedName: embed.name,
9494+ embedData: data
9595+ }
9696+ })
9797+ .run();
9898+ return null;
9999+ }
100100+ }
101101+102102+ return 'No matching embed found for this URL';
103103+}
104104+105105+/**
106106+ * Open the embed modal if an EmbedModal component is mounted.
107107+ */
108108+export function openEmbedModal(editor: Editor): void {
109109+ const storage = editor.storage.embedExtension as
110110+ | { openModal: (() => void) | null }
111111+ | undefined;
112112+ storage?.openModal?.();
113113+}
+5
packages/text/src/lib/components/embed/index.ts
···11+export { EmbedExtension } from './EmbedExtension';
22+export { default as EmbedModal } from './EmbedModal.svelte';
33+export { default as YouTubeEmbed } from './YouTubeEmbed.svelte';
44+export { youtubeEmbed, isUrl, insertEmbed, openEmbedModal } from './helpers';
55+export type { EmbedDefinition, EmbedExtensionOptions, EmbedComponentProps } from './helpers';
···11+export { default as BoldToggle } from './BoldToggle.svelte';
22+export { default as ItalicToggle } from './ItalicToggle.svelte';
33+export { default as UnderlineToggle } from './UnderlineToggle.svelte';
44+export { default as StrikethroughToggle } from './StrikethroughToggle.svelte';
···11+import type { Editor } from '@tiptap/core';
22+import type { Component } from 'svelte';
33+44+declare module '@tiptap/core' {
55+ interface Storage {
66+ imageExtension: {
77+ uploads: Map<string, UploadState>;
88+ };
99+ }
1010+}
1111+1212+/**
1313+ * Upload function signature for image uploads.
1414+ * @param file - The image file to upload
1515+ * @param onProgress - Progress callback (0..1)
1616+ * @param abortSignal - Signal to abort the upload
1717+ * @returns The final URL of the uploaded image
1818+ */
1919+export type ImageUploadFn = (
2020+ file: File,
2121+ onProgress: (progress: number) => void,
2222+ abortSignal: AbortSignal
2323+) => Promise<string>;
2424+2525+export interface ImageExtensionOptions {
2626+ upload: ImageUploadFn;
2727+ /** Accepted file types. @default 'image/*' */
2828+ accept?: string;
2929+ /** Maximum file size in bytes. 0 = unlimited. @default 0 */
3030+ maxFileSize?: number;
3131+ /** Error handler for upload failures */
3232+ onError?: (error: Error) => void;
3333+ /** HTML attributes applied to final <img> elements */
3434+ HTMLAttributes?: Record<string, string>;
3535+ /** Custom Svelte component for rendering final images. Receives NodeViewProps from tiptap. */
3636+ imageComponent?: Component;
3737+}
3838+3939+export interface UploadState {
4040+ file: File;
4141+ previewUrl: string;
4242+ abortController: AbortController;
4343+}
4444+4545+/**
4646+ * Insert a file as an uploading image into the editor.
4747+ */
4848+export function insertImage(editor: Editor, file: File): void {
4949+ const storage = editor.storage.imageExtension as
5050+ | { uploads: Map<string, UploadState> }
5151+ | undefined;
5252+ if (!storage) return;
5353+5454+ const options = editor.extensionManager.extensions.find(
5555+ (ext) => ext.name === 'imageExtension'
5656+ )?.options as ImageExtensionOptions | undefined;
5757+5858+ if (options?.accept && options.accept !== 'image/*') {
5959+ const accepted = options.accept.split(',').map((s) => s.trim());
6060+ const matches = accepted.some((pattern) => {
6161+ if (pattern.startsWith('.')) {
6262+ return file.name.toLowerCase().endsWith(pattern.toLowerCase());
6363+ }
6464+ if (pattern.endsWith('/*')) {
6565+ return file.type.startsWith(pattern.replace('/*', '/'));
6666+ }
6767+ return file.type === pattern;
6868+ });
6969+ if (!matches) {
7070+ options.onError?.(new Error(`File type "${file.type}" is not accepted`));
7171+ return;
7272+ }
7373+ }
7474+7575+ if (options?.maxFileSize && options.maxFileSize > 0 && file.size > options.maxFileSize) {
7676+ options.onError?.(
7777+ new Error(
7878+ `File size ${file.size} exceeds maximum ${options.maxFileSize} bytes`
7979+ )
8080+ );
8181+ return;
8282+ }
8383+8484+ const uploadId = crypto.randomUUID();
8585+ const previewUrl = URL.createObjectURL(file);
8686+ const abortController = new AbortController();
8787+8888+ storage.uploads.set(uploadId, { file, previewUrl, abortController });
8989+9090+ editor
9191+ .chain()
9292+ .focus()
9393+ .insertContent({
9494+ type: 'imageUpload',
9595+ attrs: { uploadId, preview: previewUrl }
9696+ })
9797+ .run();
9898+}
9999+100100+/**
101101+ * Open a native file picker and insert the selected image into the editor.
102102+ */
103103+export function pickAndInsertImage(editor: Editor): void {
104104+ const options = editor.extensionManager.extensions.find(
105105+ (ext) => ext.name === 'imageExtension'
106106+ )?.options as ImageExtensionOptions | undefined;
107107+108108+ const input = document.createElement('input');
109109+ input.type = 'file';
110110+ input.accept = options?.accept ?? 'image/*';
111111+112112+ input.addEventListener('change', () => {
113113+ const file = input.files?.[0];
114114+ if (file) {
115115+ insertImage(editor, file);
116116+ }
117117+ });
118118+119119+ input.click();
120120+}
+4
packages/text/src/lib/components/image/index.ts
···11+export { ImageExtension } from './ImageExtension';
22+export { default as EditableImage } from './EditableImage.svelte';
33+export { insertImage, pickAndInsertImage } from './helpers';
44+export type { ImageUploadFn, ImageExtensionOptions } from './helpers';
···11+import type { Editor } from '@tiptap/core';
22+33+export interface LinkAddedEvent {
44+ /** The link URL. */
55+ href: string;
66+ /** The link text. */
77+ text: string;
88+ /** The editor instance. */
99+ editor: Editor;
1010+}
1111+1212+export interface LinkExtensionOptions {
1313+ /** Open links on click. @default 'whenNotEditable' */
1414+ openOnClick?: boolean | 'whenNotEditable';
1515+ /** Automatically detect and linkify URLs as you type. @default true */
1616+ autolink?: boolean;
1717+ /** Default protocol for URLs without one. @default 'https' */
1818+ defaultProtocol?: string;
1919+ /** Enable markdown link syntax [text](url). @default true */
2020+ markdown?: boolean;
2121+ /** HTML attributes applied to link elements */
2222+ HTMLAttributes?: Record<string, string>;
2323+ /** Called whenever a link is added to the editor (via typing, pasting, autolink, or UI). */
2424+ onlinkadded?: (event: LinkAddedEvent) => void;
2525+}
2626+2727+/**
2828+ * Set a link on the current selection.
2929+ */
3030+export function setLink(editor: Editor, url: string): void {
3131+ if (!url) {
3232+ editor.chain().focus().unsetLink().run();
3333+ return;
3434+ }
3535+ editor.chain().focus().setLink({ href: url }).run();
3636+}
3737+3838+/**
3939+ * Remove the link from the current selection.
4040+ */
4141+export function removeLink(editor: Editor): void {
4242+ editor.chain().focus().unsetLink().run();
4343+}
+4
packages/text/src/lib/components/link/index.ts
···11+export { LinkExtension } from './LinkExtension';
22+export { default as LinkPopover } from './LinkPopover.svelte';
33+export { setLink, removeLink } from './helpers';
44+export type { LinkExtensionOptions, LinkAddedEvent } from './helpers';
···11+export { MentionNode } from './MentionNode';
22+export type { MentionNodeOptions } from './MentionNode';
33+export { default as MentionMenu } from './MentionMenu.svelte';
44+export type { MentionItem } from './MentionMenu.svelte';
···11+// from https://github.com/Doist/typist/blob/main/src/extensions/rich-text/rich-text-link.ts
22+import { InputRule, markInputRule, markPasteRule, PasteRule } from '@tiptap/core';
33+import { Link } from '@tiptap/extension-link';
44+55+import type { LinkOptions } from '@tiptap/extension-link';
66+77+/**
88+ * The input regex for Markdown links with title support, and multiple quotation marks (required
99+ * in case the `Typography` extension is being included).
1010+ */
1111+const inputRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)$/i;
1212+1313+/**
1414+ * The paste regex for Markdown links with title support, and multiple quotation marks (required
1515+ * in case the `Typography` extension is being included).
1616+ */
1717+const pasteRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)/gi;
1818+1919+/**
2020+ * Input rule built specifically for the `Link` extension, which ignores the auto-linked URL in
2121+ * parentheses (e.g., `(https://doist.dev)`).
2222+ *
2323+ * @see https://github.com/ueberdosis/tiptap/discussions/1865
2424+ */
2525+function linkInputRule(config: Parameters<typeof markInputRule>[0]) {
2626+ const defaultMarkInputRule = markInputRule(config);
2727+2828+ return new InputRule({
2929+ find: config.find,
3030+ handler(props) {
3131+ const { tr } = props.state;
3232+3333+ defaultMarkInputRule.handler(props);
3434+ tr.setMeta('preventAutolink', true);
3535+ }
3636+ });
3737+}
3838+3939+/**
4040+ * Paste rule built specifically for the `Link` extension, which ignores the auto-linked URL in
4141+ * parentheses (e.g., `(https://doist.dev)`). This extension was inspired from the multiple
4242+ * implementations found in a Tiptap discussion at GitHub.
4343+ *
4444+ * @see https://github.com/ueberdosis/tiptap/discussions/1865
4545+ */
4646+function linkPasteRule(config: Parameters<typeof markPasteRule>[0]) {
4747+ const defaultMarkPasteRule = markPasteRule(config);
4848+4949+ return new PasteRule({
5050+ find: config.find,
5151+ handler(props) {
5252+ const { tr } = props.state;
5353+5454+ defaultMarkPasteRule.handler(props);
5555+ tr.setMeta('preventAutolink', true);
5656+ }
5757+ });
5858+}
5959+6060+/**
6161+ * The options available to customize the `RichTextLink` extension.
6262+ */
6363+type RichTextLinkOptions = LinkOptions;
6464+6565+/**
6666+ * Custom extension that extends the built-in `Link` extension to add additional input/paste rules
6767+ * for converting the Markdown link syntax (i.e. `[Doist](https://doist.com)`) into links, and also
6868+ * adds support for the `title` attribute.
6969+ */
7070+const RichTextLink = Link.extend<RichTextLinkOptions>({
7171+ inclusive: false,
7272+ addOptions() {
7373+ return {
7474+ ...this.parent?.(),
7575+ openOnClick: 'whenNotEditable'
7676+ };
7777+ },
7878+ addAttributes() {
7979+ return {
8080+ ...this.parent?.(),
8181+ title: {
8282+ default: null
8383+ }
8484+ };
8585+ },
8686+ addInputRules() {
8787+ return [
8888+ linkInputRule({
8989+ find: inputRegex,
9090+ type: this.type,
9191+9292+ // We need to use `pop()` to remove the last capture groups from the match to
9393+ // satisfy Tiptap's `markPasteRule` expectation of having the content as the last
9494+ // capture group in the match (this makes the attribute order important)
9595+ getAttributes(match) {
9696+ return {
9797+ title: match.pop()?.trim(),
9898+ href: match.pop()?.trim()
9999+ };
100100+ }
101101+ })
102102+ ];
103103+ },
104104+ addPasteRules() {
105105+ return [
106106+ linkPasteRule({
107107+ find: pasteRegex,
108108+ type: this.type,
109109+110110+ // We need to use `pop()` to remove the last capture groups from the match to
111111+ // satisfy Tiptap's `markInputRule` expectation of having the content as the last
112112+ // capture group in the match (this makes the attribute order important)
113113+ getAttributes(match) {
114114+ return {
115115+ title: match.pop()?.trim(),
116116+ href: match.pop()?.trim()
117117+ };
118118+ }
119119+ })
120120+ ];
121121+ }
122122+});
123123+124124+export { RichTextLink };
125125+126126+export type { RichTextLinkOptions };
···11-// from https://github.com/Doist/typist/blob/main/src/extensions/rich-text/rich-text-link.ts
22-import { InputRule, markInputRule, markPasteRule, PasteRule } from '@tiptap/core';
33-import { Link } from '@tiptap/extension-link';
44-55-import type { LinkOptions } from '@tiptap/extension-link';
66-77-/**
88- * The input regex for Markdown links with title support, and multiple quotation marks (required
99- * in case the `Typography` extension is being included).
1010- */
1111-const inputRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)$/i;
1212-1313-/**
1414- * The paste regex for Markdown links with title support, and multiple quotation marks (required
1515- * in case the `Typography` extension is being included).
1616- */
1717-const pasteRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)/gi;
1818-1919-/**
2020- * Input rule built specifically for the `Link` extension, which ignores the auto-linked URL in
2121- * parentheses (e.g., `(https://doist.dev)`).
2222- *
2323- * @see https://github.com/ueberdosis/tiptap/discussions/1865
2424- */
2525-function linkInputRule(config: Parameters<typeof markInputRule>[0]) {
2626- const defaultMarkInputRule = markInputRule(config);
2727-2828- return new InputRule({
2929- find: config.find,
3030- handler(props) {
3131- const { tr } = props.state;
3232-3333- defaultMarkInputRule.handler(props);
3434- tr.setMeta('preventAutolink', true);
3535- }
3636- });
3737-}
3838-3939-/**
4040- * Paste rule built specifically for the `Link` extension, which ignores the auto-linked URL in
4141- * parentheses (e.g., `(https://doist.dev)`). This extension was inspired from the multiple
4242- * implementations found in a Tiptap discussion at GitHub.
4343- *
4444- * @see https://github.com/ueberdosis/tiptap/discussions/1865
4545- */
4646-function linkPasteRule(config: Parameters<typeof markPasteRule>[0]) {
4747- const defaultMarkPasteRule = markPasteRule(config);
4848-4949- return new PasteRule({
5050- find: config.find,
5151- handler(props) {
5252- const { tr } = props.state;
5353-5454- defaultMarkPasteRule.handler(props);
5555- tr.setMeta('preventAutolink', true);
5656- }
5757- });
5858-}
5959-6060-/**
6161- * The options available to customize the `RichTextLink` extension.
6262- */
6363-type RichTextLinkOptions = LinkOptions;
6464-6565-/**
6666- * Custom extension that extends the built-in `Link` extension to add additional input/paste rules
6767- * for converting the Markdown link syntax (i.e. `[Doist](https://doist.com)`) into links, and also
6868- * adds support for the `title` attribute.
6969- */
7070-const RichTextLink = Link.extend<RichTextLinkOptions>({
7171- inclusive: false,
7272- addOptions() {
7373- return {
7474- ...this.parent?.(),
7575- openOnClick: 'whenNotEditable'
7676- };
7777- },
7878- addAttributes() {
7979- return {
8080- ...this.parent?.(),
8181- title: {
8282- default: null
8383- }
8484- };
8585- },
8686- addInputRules() {
8787- return [
8888- linkInputRule({
8989- find: inputRegex,
9090- type: this.type,
9191-9292- // We need to use `pop()` to remove the last capture groups from the match to
9393- // satisfy Tiptap's `markPasteRule` expectation of having the content as the last
9494- // capture group in the match (this makes the attribute order important)
9595- getAttributes(match) {
9696- return {
9797- title: match.pop()?.trim(),
9898- href: match.pop()?.trim()
9999- };
100100- }
101101- })
102102- ];
103103- },
104104- addPasteRules() {
105105- return [
106106- linkPasteRule({
107107- find: pasteRegex,
108108- type: this.type,
109109-110110- // We need to use `pop()` to remove the last capture groups from the match to
111111- // satisfy Tiptap's `markInputRule` expectation of having the content as the last
112112- // capture group in the match (this makes the attribute order important)
113113- getAttributes(match) {
114114- return {
115115- title: match.pop()?.trim(),
116116- href: match.pop()?.trim()
117117- };
118118- }
119119- })
120120- ];
121121- }
122122-});
123123-124124-export { RichTextLink };
125125-126126-export type { RichTextLinkOptions };
···11+export { default as SlashMenu } from './SlashMenu.svelte';
22+export type { SlashMenuItem } from './SlashMenu.svelte';
33+export { getSlashMenuItems, getBlockTypeItems, getImageItem } from './items';
···11+## Usage
22+33+```svelte
44+<script lang="ts">
55+ import { BlueskyPostCreator, type BlueskyPostContent } from '@foxui/social';
66+77+ let content: BlueskyPostContent = $state({ text: '', facets: [] });
88+</script>
99+1010+<BlueskyPostCreator
1111+ bind:content
1212+ placeholder="What's on your mind?"
1313+ maxLength={300}
1414+/>
1515+```
1616+1717+## Features
1818+1919+- **@mentions** - Type `@` followed by at least 2 characters to search for Bluesky handles. Select from the popup to insert a mention with the user's DID.
2020+- **Links** - URLs are automatically detected and linked.
2121+- **#hashtags** - Type `#tag` and hashtags are automatically detected in the output facets.
2222+- **Character count** - Shows remaining characters with color indicators (amber < 20, red when over limit).
2323+2424+## Output
2525+2626+The `content` bindable returns a `BlueskyPostContent` object:
2727+2828+```typescript
2929+{
3030+ text: string; // Plain text of the post
3131+ facets: BlueskyFacet[]; // Byte-indexed facets for mentions, links, and tags
3232+}
3333+```
3434+3535+Each facet has the Bluesky AT Protocol format with `index.byteStart`, `index.byteEnd`, and `features` array containing the facet type (`#mention`, `#link`, or `#tag`).
···11+import Docs from './Documentation.md';
22+import Example from './Example.svelte';
33+import Card from './Card.svelte';
44+import api from './api';
55+66+export default {
77+ slug: 'bluesky-post-creator',
88+ title: 'Bluesky Post Creator',
99+ docs: Docs,
1010+ example: Example,
1111+ card: Card,
1212+ api
1313+};
···11+## Usage
22+33+`BlockTypeMenu` renders a row of block type buttons that reflect the current selection. Click a button to toggle between paragraph, headings, lists, and more.
44+55+```svelte
66+<script lang="ts">
77+ import { CoreRichTextEditor, BlockTypeMenu, type SvelteTiptap } from '@foxui/text';
88+ import type { Readable } from 'svelte/store';
99+1010+ let editor = $state<Readable<SvelteTiptap.Editor>>();
1111+</script>
1212+1313+<CoreRichTextEditor bind:editor />
1414+{#if $editor}
1515+ <BlockTypeMenu {editor} />
1616+{/if}
1717+```
1818+1919+Default items include Paragraph, Heading 1–3, Blockquote, Code Block, Bullet List, and Ordered List. Only items with registered extensions are shown.
2020+2121+## Custom items
2222+2323+Pass `items` to customize the available block types.
2424+2525+```svelte
2626+<script lang="ts">
2727+ import { BlockTypeMenu, type BlockTypeItem } from '@foxui/text';
2828+2929+ const items: BlockTypeItem[] = [
3030+ {
3131+ id: 'paragraph',
3232+ label: 'P',
3333+ isActive: (e) => e.isActive('paragraph'),
3434+ command: (e) => e.chain().focus().setParagraph().run()
3535+ },
3636+ {
3737+ id: 'heading-1',
3838+ label: 'H1',
3939+ isActive: (e) => e.isActive('heading', { level: 1 }),
4040+ command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run()
4141+ }
4242+ ];
4343+</script>
4444+4545+<BlockTypeMenu {editor} {items} />
4646+```
4747+4848+## How it works
4949+5050+`BlockTypeMenu` listens to editor transactions and tracks which items are active. The active item is highlighted with an accent color. Paragraph is only highlighted when it's the only active item — so it doesn't show as active alongside a heading or list.
···11+import Docs from './Documentation.md';
22+import Example from './Example.svelte';
33+import Card from './Card.svelte';
44+import api from './api';
55+66+export default {
77+ slug: 'block-type-menu',
88+ title: 'Block Type Menu',
99+ docs: Docs,
1010+ example: Example,
1111+ card: Card,
1212+ api
1313+};
···11+## Usage
22+33+`FormattingBubbleMenu` provides a ready-to-use formatting toolbar that appears when text is selected. It includes bold, italic, underline, strikethrough, and link controls.
44+55+```svelte
66+<script lang="ts">
77+ import { CoreRichTextEditor, FormattingBubbleMenu, type SvelteTiptap } from '@foxui/text';
88+ import type { Readable } from 'svelte/store';
99+1010+ let editor = $state<Readable<SvelteTiptap.Editor>>();
1111+</script>
1212+1313+<CoreRichTextEditor bind:editor>
1414+ <FormattingBubbleMenu {editor} />
1515+</CoreRichTextEditor>
1616+```
1717+1818+## Adding extra controls
1919+2020+Use the `children` snippet to add custom controls after the default formatting toggles.
2121+2222+```svelte
2323+<FormattingBubbleMenu {editor}>
2424+ <MyCustomButton {editor} />
2525+</FormattingBubbleMenu>
2626+```
2727+2828+## Custom bubble menu
2929+3030+Use `BubbleMenu` directly for full control over the menu contents. It provides the styled container and positioning — you supply the controls.
3131+3232+```svelte
3333+<script lang="ts">
3434+ import { CoreRichTextEditor, BubbleMenu, BoldToggle, ItalicToggle, type SvelteTiptap } from '@foxui/text';
3535+ import type { Readable } from 'svelte/store';
3636+3737+ let editor = $state<Readable<SvelteTiptap.Editor>>();
3838+</script>
3939+4040+<CoreRichTextEditor bind:editor>
4141+ <BubbleMenu {editor}>
4242+ <BoldToggle {editor} />
4343+ <ItalicToggle {editor} />
4444+ </BubbleMenu>
4545+</CoreRichTextEditor>
4646+```
4747+4848+## Controlling visibility
4949+5050+Pass `shouldShow` to control when the menu appears. By default, `FormattingBubbleMenu` hides when there's no selection or when inside a code block.
5151+5252+```svelte
5353+<BubbleMenu
5454+ {editor}
5555+ shouldShow={({ editor, from, to }) => from !== to && editor.isActive('paragraph')}
5656+>
5757+ <!-- controls -->
5858+</BubbleMenu>
5959+```
6060+6161+## How it works
6262+6363+Both components wrap svelte-tiptap's `BubbleMenu` with a styled container that supports dark mode. `FormattingBubbleMenu` automatically detects available extensions — for example, `LinkPopover` only appears if the link extension is registered.
···11+## Usage
22+33+`CoreRichTextEditor` includes `CodeBlockExtension` by default — code blocks work out of the box with syntax highlighting and a copy button.
44+55+```svelte
66+<script lang="ts">
77+ import { CoreRichTextEditor } from '@foxui/text';
88+</script>
99+1010+<CoreRichTextEditor placeholder="Type ``` to create a code block..." />
1111+```
1212+1313+## Copy button
1414+1515+`CodeBlockView` includes a copy-to-clipboard button that appears on hover. It shows a checkmark for 2 seconds after copying.
1616+1717+## Custom code block component
1818+1919+Pass `codeBlockComponent` to replace the default rendering with your own Svelte component. Your component receives tiptap's `NodeViewProps`.
2020+2121+```svelte
2222+<CoreRichTextEditor
2323+ extraExtensions={[
2424+ CodeBlockExtension.configure({
2525+ lowlight: createLowlight(all),
2626+ codeBlockComponent: MyCustomCodeBlock
2727+ })
2828+ ]}
2929+/>
3030+```
···11+## Usage
22+33+Add `EmbedExtension` via `extraExtensions` with an array of embed definitions. URLs pasted or typed on their own line will automatically convert to rich embeds.
44+55+```svelte
66+<script lang="ts">
77+ import { CoreRichTextEditor, EmbedExtension, youtubeEmbed } from '@foxui/text';
88+</script>
99+1010+<CoreRichTextEditor
1111+ extraExtensions={[
1212+ EmbedExtension.configure({
1313+ embeds: [youtubeEmbed]
1414+ })
1515+ ]}
1616+/>
1717+```
1818+1919+That's it — paste a YouTube URL and it becomes an embedded player.
2020+2121+## How detection works
2222+2323+1. **Paste** — When a URL is pasted as plain text, it's checked against all embed matchers. If one matches, an embed node is inserted immediately.
2424+2. **Typed URLs** — When the user types a URL and moves to the next line (e.g. presses Enter), the link-only paragraph is detected and converted.
2525+2626+## Built-in YouTube embed
2727+2828+The `youtubeEmbed` definition matches common YouTube URL formats:
2929+3030+- `https://www.youtube.com/watch?v=VIDEO_ID`
3131+- `https://youtu.be/VIDEO_ID`
3232+- `https://www.youtube.com/embed/VIDEO_ID`
3333+- `https://www.youtube.com/shorts/VIDEO_ID`
3434+3535+## Embed modal
3636+3737+Add `EmbedModal` as a child of the editor to let users manually insert embeds via a URL input dialog. It validates the URL against all registered embeds and shows an error if none match.
3838+3939+```svelte
4040+<script lang="ts">
4141+ import { CoreRichTextEditor, EmbedExtension, EmbedModal, youtubeEmbed, openEmbedModal } from '@foxui/text';
4242+</script>
4343+4444+<CoreRichTextEditor bind:editor extraExtensions={[EmbedExtension.configure({ embeds: [youtubeEmbed] })]}>
4545+ {#if $editor}
4646+ <EmbedModal editor={$editor} />
4747+ {/if}
4848+</CoreRichTextEditor>
4949+5050+<!-- Trigger from a button -->
5151+<button onclick={() => openEmbedModal($editor)}>Add Embed</button>
5252+```
5353+5454+The modal is also available from the slash menu — type `/embed` to open it.
5555+5656+You can also control the modal directly with a bindable `open` prop:
5757+5858+```svelte
5959+<EmbedModal editor={$editor} bind:open={embedModalOpen} />
6060+<button onclick={() => embedModalOpen = true}>Add Embed</button>
6161+```
6262+6363+## Programmatic insertion
6464+6565+Use `insertEmbed` to insert an embed from a URL without the modal. Returns `null` on success or an error string.
6666+6767+```typescript
6868+import { insertEmbed } from '@foxui/text';
6969+7070+const error = insertEmbed($editor, 'https://www.youtube.com/watch?v=dQw4w9WgXcQ');
7171+if (error) console.error(error);
7272+```
7373+7474+## Custom embeds
7575+7676+Create your own embed by defining a `name`, a `match` function, and a Svelte `component`:
7777+7878+```typescript
7979+import type { EmbedDefinition } from '@foxui/text';
8080+import SpotifyPlayer from './SpotifyPlayer.svelte';
8181+8282+const spotifyEmbed: EmbedDefinition = {
8383+ name: 'spotify',
8484+ match: (url) => {
8585+ const m = url.match(/open\.spotify\.com\/track\/(\w+)/);
8686+ return m ? { trackId: m[1] } : false;
8787+ },
8888+ component: SpotifyPlayer
8989+};
9090+```
9191+9292+Your component receives three props:
9393+9494+```svelte
9595+<!-- SpotifyPlayer.svelte -->
9696+<script lang="ts">
9797+ let { url, data, selected }: { url: string; data: Record<string, any>; selected: boolean } =
9898+ $props();
9999+</script>
100100+101101+<iframe
102102+ src="https://open.spotify.com/embed/track/{data.trackId}"
103103+ class="h-20 w-full rounded-lg {selected ? 'ring-2 ring-accent-500' : ''}"
104104+ allow="encrypted-media"
105105+></iframe>
106106+```
107107+108108+Then add it alongside the built-in embeds:
109109+110110+```svelte
111111+<CoreRichTextEditor
112112+ extraExtensions={[
113113+ EmbedExtension.configure({
114114+ embeds: [youtubeEmbed, spotifyEmbed]
115115+ })
116116+ ]}
117117+/>
118118+```
119119+120120+## Multiple embed providers
121121+122122+Pass as many embed definitions as you like. The first match wins — order matters.
123123+124124+```typescript
125125+EmbedExtension.configure({
126126+ embeds: [youtubeEmbed, spotifyEmbed, vimeoEmbed, twitterEmbed]
127127+})
128128+```
···11+## Usage
22+33+Individual formatting toggle buttons for bold, italic, underline, and strikethrough. Drop them into any toolbar or bubble menu.
44+55+```svelte
66+<script lang="ts">
77+ import { CoreRichTextEditor, BoldToggle, ItalicToggle, UnderlineToggle, StrikethroughToggle, type SvelteTiptap } from '@foxui/text';
88+ import type { Readable } from 'svelte/store';
99+1010+ let editor = $state<Readable<SvelteTiptap.Editor>>();
1111+</script>
1212+1313+<CoreRichTextEditor bind:editor />
1414+{#if $editor}
1515+ <div class="flex gap-1">
1616+ <BoldToggle {editor} />
1717+ <ItalicToggle {editor} />
1818+ <UnderlineToggle {editor} />
1919+ <StrikethroughToggle {editor} />
2020+ </div>
2121+{/if}
2222+```
2323+2424+## Reading state
2525+2626+Each toggle exposes a bindable state prop (`isBold`, `isItalic`, `isUnderline`, `isStrikethrough`) that reactively tracks whether the mark is active at the current selection.
2727+2828+```svelte
2929+<script lang="ts">
3030+ let isBold = $state(false);
3131+</script>
3232+3333+<BoldToggle {editor} bind:isBold />
3434+<p>Bold is {isBold ? 'on' : 'off'}</p>
3535+```
3636+3737+## In a bubble menu
3838+3939+These toggles are what `FormattingBubbleMenu` uses internally. You can also use them inside a custom `BubbleMenu`.
4040+4141+```svelte
4242+<BubbleMenu {editor}>
4343+ <BoldToggle {editor} />
4444+ <ItalicToggle {editor} />
4545+</BubbleMenu>
4646+```
4747+4848+## How it works
4949+5050+Each toggle uses `@foxui/core`'s `Toggle` component and listens to editor transactions to stay in sync. Clicking a toggle calls the corresponding tiptap chain command (e.g. `toggleBold()`).
···11+import Docs from './Documentation.md';
22+import Example from './Example.svelte';
33+import Card from './Card.svelte';
44+import api from './api';
55+66+export default {
77+ slug: 'formatting-toggles',
88+ title: 'Formatting Toggles',
99+ docs: Docs,
1010+ example: Example,
1111+ card: Card,
1212+ api
1313+};
···11+## Usage
22+33+`HashtagDecoration` highlights `#hashtags` in the editor with a CSS class. Add it via `extraExtensions` and style the `.hashtag` class.
44+55+```svelte
66+<script lang="ts">
77+ import { CoreRichTextEditor, HashtagDecoration } from '@foxui/text';
88+</script>
99+1010+<CoreRichTextEditor
1111+ extraExtensions={[HashtagDecoration]}
1212+ placeholder="Try typing #hello..."
1313+/>
1414+1515+<style>
1616+ :global(.hashtag) {
1717+ color: var(--color-accent-600);
1818+ font-weight: 500;
1919+ }
2020+</style>
2121+```
2222+2323+## How it works
2424+2525+`HashtagDecoration` is a tiptap Extension that creates ProseMirror decorations for hashtag patterns. It scans text nodes for `#word` patterns and applies `class="hashtag"` as an inline decoration.
2626+2727+The regex supports:
2828+- Standard ASCII letters and digits: `#hello`, `#tag123`
2929+- International characters: `#café`, `#über`, `#naïve`
3030+3131+Hashtags must start with a letter after the `#` — `#123` won't match.
3232+3333+## Styling
3434+3535+The extension only adds the `hashtag` CSS class — you control the visual style. Some examples:
3636+3737+```css
3838+/* Accent color */
3939+:global(.hashtag) {
4040+ color: var(--color-accent-600);
4141+}
4242+4343+/* Blue with background */
4444+:global(.hashtag) {
4545+ color: #2563eb;
4646+ background: #2563eb15;
4747+ border-radius: 4px;
4848+ padding: 0 2px;
4949+}
5050+```
···11+import type { APISchema } from '$lib/types/schema';
22+33+export default [
44+ {
55+ title: 'HashtagDecoration',
66+ description:
77+ 'A tiptap Extension that decorates #hashtags with a CSS class. Add via extraExtensions and style the .hashtag class.',
88+ props: {}
99+ }
1010+] satisfies APISchema[];
···11+import Docs from './Documentation.md';
22+import Example from './Example.svelte';
33+import Card from './Card.svelte';
44+import api from './api';
55+66+export default {
77+ slug: 'hashtag-decoration',
88+ title: 'Hashtag Decoration',
99+ docs: Docs,
1010+ example: Example,
1111+ card: Card,
1212+ api
1313+};
···11+## Usage
22+33+Add `ImageExtension` via `extraExtensions` with an `upload` function. Images can then be pasted, dropped, or inserted programmatically.
44+55+```svelte
66+<script lang="ts">
77+ import { CoreRichTextEditor, ImageExtension, type SvelteTiptap } from '@foxui/text';
88+ import type { Readable } from 'svelte/store';
99+1010+ let editor = $state<Readable<SvelteTiptap.Editor>>();
1111+1212+ async function upload(file: File, onProgress: (p: number) => void, signal: AbortSignal) {
1313+ const res = await fetch('/api/upload', { method: 'POST', body: file, signal });
1414+ return (await res.json()).url;
1515+ }
1616+</script>
1717+1818+<CoreRichTextEditor
1919+ bind:editor
2020+ extraExtensions={[ImageExtension.configure({ upload })]}
2121+/>
2222+```
2323+2424+That's it — paste or drag-drop an image and it will upload automatically.
2525+2626+## Programmatic insertion
2727+2828+Use `pickAndInsertImage` to open a file picker, or `insertImage` to insert a file directly. Great for toolbar buttons or slash menu items.
2929+3030+```svelte
3131+<script lang="ts">
3232+ import { pickAndInsertImage, insertImage } from '@foxui/text';
3333+</script>
3434+3535+<!-- File picker button -->
3636+<button onclick={() => pickAndInsertImage($editor)}>Add Image</button>
3737+3838+<!-- Insert a file you already have -->
3939+<button onclick={() => insertImage($editor, myFile)}>Insert</button>
4040+```
4141+4242+## Slash menu integration
4343+4444+Add an image item to `SlashMenu` for a `/image` command.
4545+4646+```svelte
4747+<SlashMenu
4848+ editor={$editor}
4949+ items={[
5050+ {
5151+ id: 'image',
5252+ label: 'Image',
5353+ command: ({ editor, range }) => {
5454+ editor.chain().focus().deleteRange(range).run();
5555+ pickAndInsertImage(editor);
5656+ }
5757+ }
5858+ ]}
5959+/>
6060+```
6161+6262+## Editable alt text
6363+6464+Use the built-in `EditableImage` component to let users edit alt text. It shows a pencil button on hover that opens a modal with a textarea.
6565+6666+```svelte
6767+<script lang="ts">
6868+ import { ImageExtension, EditableImage } from '@foxui/text';
6969+</script>
7070+7171+<CoreRichTextEditor
7272+ extraExtensions={[
7373+ ImageExtension.configure({
7474+ upload: myUpload,
7575+ imageComponent: EditableImage
7676+ })
7777+ ]}
7878+/>
7979+```
8080+8181+## Custom image component
8282+8383+Pass `imageComponent` to replace the default `<img>` rendering with your own Svelte component. Your component receives tiptap's `NodeViewProps` — use `props.node.attrs.src` for the image URL and `props.updateAttributes()` to update node data.
8484+8585+```svelte
8686+<!-- CustomImage.svelte -->
8787+<script lang="ts">
8888+ import type { NodeViewProps } from '@tiptap/core';
8989+ import { NodeViewWrapper } from 'svelte-tiptap';
9090+9191+ let props: NodeViewProps = $props();
9292+</script>
9393+9494+<NodeViewWrapper>
9595+ <figure class="my-4">
9696+ <img
9797+ src={props.node.attrs.src}
9898+ alt={props.node.attrs.alt}
9999+ class="rounded-lg shadow-md"
100100+ />
101101+ {#if props.node.attrs.alt}
102102+ <figcaption class="mt-1 text-sm text-gray-500">{props.node.attrs.alt}</figcaption>
103103+ {/if}
104104+ </figure>
105105+</NodeViewWrapper>
106106+```
107107+108108+```svelte
109109+<script lang="ts">
110110+ import { ImageExtension } from '@foxui/text';
111111+ import CustomImage from './CustomImage.svelte';
112112+</script>
113113+114114+<CoreRichTextEditor
115115+ extraExtensions={[
116116+ ImageExtension.configure({
117117+ upload: myUpload,
118118+ imageComponent: CustomImage
119119+ })
120120+ ]}
121121+/>
122122+```
123123+124124+## How it works
125125+126126+`ImageExtension` is a single tiptap Extension that bundles everything:
127127+128128+- **`@tiptap/extension-image`** for rendering final `<img>` elements
129129+- **Upload placeholder node** with a Svelte NodeView showing a preview and progress bar
130130+- **ProseMirror plugins** for `handlePaste` and `handleDrop` — no external DOM handlers needed
131131+- **Extension storage** with a runtime `Map` so `File` objects reach the upload function (node attributes stay serializable)
132132+133133+When an image is inserted, a blob preview is shown immediately. The `upload` function runs in the background. Once it resolves, the placeholder is replaced with a final image using the **returned URL**.
134134+135135+## Works with any editor
136136+137137+`ImageExtension` works with `CoreRichTextEditor`, `RichTextEditor`, or any tiptap editor that supports `extraExtensions`.
+79
apps/docs/src/lib/docs/text/image/Example.svelte
···11+<script lang="ts">
22+ import {
33+ CoreRichTextEditor,
44+ ImageExtension,
55+ EditableImage,
66+ pickAndInsertImage,
77+ Box,
88+ Button,
99+ type SvelteTiptap
1010+ } from '@foxui/all';
1111+ import type { Readable } from 'svelte/store';
1212+1313+ let editor = $state<Readable<SvelteTiptap.Editor>>();
1414+ let errorMessage = $state('');
1515+1616+ async function fakeUpload(
1717+ file: File,
1818+ onProgress: (p: number) => void,
1919+ signal: AbortSignal
2020+ ): Promise<string> {
2121+ // Read the actual file as a data URL so the same image is shown after "upload"
2222+ const dataUrl = await new Promise<string>((resolve, reject) => {
2323+ const reader = new FileReader();
2424+ reader.onload = () => resolve(reader.result as string);
2525+ reader.onerror = () => reject(new Error('Failed to read file'));
2626+ reader.readAsDataURL(file);
2727+ });
2828+2929+ // Simulate upload progress
3030+ return new Promise((resolve, reject) => {
3131+ let progress = 0;
3232+ const interval = setInterval(() => {
3333+ progress += 0.1;
3434+ onProgress(Math.min(progress, 1));
3535+ if (progress >= 1) {
3636+ clearInterval(interval);
3737+ resolve(dataUrl);
3838+ }
3939+ }, 200);
4040+ signal.addEventListener('abort', () => {
4141+ clearInterval(interval);
4242+ reject(new Error('Upload cancelled'));
4343+ });
4444+ });
4545+ }
4646+</script>
4747+4848+<Box class="not-prose">
4949+ <CoreRichTextEditor
5050+ bind:editor
5151+ placeholder="Paste or drop an image..."
5252+ extraExtensions={[
5353+ ImageExtension.configure({
5454+ upload: fakeUpload,
5555+ imageComponent: EditableImage,
5656+ onError: (err) => {
5757+ errorMessage = err.message;
5858+ }
5959+ })
6060+ ]}
6161+ class="min-h-24"
6262+ />
6363+6464+ <div class="mt-2 flex items-center gap-2">
6565+ <Button
6666+ onclick={() => {
6767+ if ($editor) {
6868+ pickAndInsertImage($editor);
6969+ }
7070+ }}
7171+ >
7272+ Add Image
7373+ </Button>
7474+7575+ {#if errorMessage}
7676+ <span class="text-sm text-red-500">{errorMessage}</span>
7777+ {/if}
7878+ </div>
7979+</Box>
+105
apps/docs/src/lib/docs/text/image/api.ts
···11+import type { APISchema } from '$lib/types/schema';
22+33+export default [
44+ {
55+ title: 'ImageExtension',
66+ description:
77+ 'A tiptap Extension that bundles image display, upload placeholders, and paste/drop handling. Add via extraExtensions.',
88+ props: {
99+ upload: {
1010+ type: {
1111+ type: 'function',
1212+ definition: '(file: File, onProgress: (p: number) => void, signal: AbortSignal) => Promise<string>'
1313+ },
1414+ description: 'Upload function. Receives the file, a progress callback (0–1), and an AbortSignal. Must return the final image URL.',
1515+ required: true
1616+ },
1717+ accept: {
1818+ type: 'string',
1919+ description: 'Accepted file types for the file picker and validation.',
2020+ default: "'image/*'"
2121+ },
2222+ maxFileSize: {
2323+ type: 'number',
2424+ description: 'Maximum file size in bytes. 0 means unlimited.',
2525+ default: '0'
2626+ },
2727+ onError: {
2828+ type: {
2929+ type: 'function',
3030+ definition: '(error: Error) => void'
3131+ },
3232+ description: 'Called when an upload fails or a file is rejected.'
3333+ },
3434+ HTMLAttributes: {
3535+ type: { type: 'object', definition: 'Record<string, string>' },
3636+ description: 'HTML attributes applied to final <img> elements.',
3737+ default: '{}'
3838+ },
3939+ imageComponent: {
4040+ type: { type: 'object', definition: 'Component' },
4141+ description:
4242+ 'Custom Svelte component for rendering final images. Receives NodeViewProps from tiptap (node.attrs.src, node.attrs.alt, etc.).'
4343+ }
4444+ }
4545+ },
4646+ {
4747+ title: 'EditableImage',
4848+ description:
4949+ 'A pre-built image component with alt text editing. Shows a pencil button on hover that opens a modal with a textarea. Use as imageComponent option.',
5050+ props: {}
5151+ },
5252+ {
5353+ title: 'pickAndInsertImage',
5454+ description:
5555+ 'Helper function that opens a native file picker and inserts the selected image into the editor.',
5656+ props: {
5757+ editor: {
5858+ type: { type: 'object', definition: 'Editor' },
5959+ description: 'The tiptap editor instance.',
6060+ required: true
6161+ }
6262+ }
6363+ },
6464+ {
6565+ title: 'insertImage',
6666+ description:
6767+ 'Helper function that inserts a file directly as an uploading image into the editor.',
6868+ props: {
6969+ editor: {
7070+ type: { type: 'object', definition: 'Editor' },
7171+ description: 'The tiptap editor instance.',
7272+ required: true
7373+ },
7474+ file: {
7575+ type: { type: 'object', definition: 'File' },
7676+ description: 'The image file to upload.',
7777+ required: true
7878+ }
7979+ }
8080+ },
8181+ {
8282+ title: 'ImageUploadFn',
8383+ description: 'Type signature for the upload function.',
8484+ props: {
8585+ file: {
8686+ type: { type: 'object', definition: 'File' },
8787+ description: 'The image file to upload.',
8888+ required: true
8989+ },
9090+ onProgress: {
9191+ type: {
9292+ type: 'function',
9393+ definition: '(progress: number) => void'
9494+ },
9595+ description: 'Progress callback. Values range from 0 to 1.',
9696+ required: true
9797+ },
9898+ abortSignal: {
9999+ type: { type: 'object', definition: 'AbortSignal' },
100100+ description: 'Signal to abort the upload when the placeholder is deleted.',
101101+ required: true
102102+ }
103103+ }
104104+ }
105105+] satisfies APISchema[];
+14
apps/docs/src/lib/docs/text/image/index.ts
···11+import Docs from './Documentation.md';
22+import Example from './Example.svelte';
33+import Card from './Card.svelte';
44+import api from './api';
55+66+export default {
77+ slug: 'image',
88+ title: 'Image Extension',
99+ docs: Docs,
1010+ example: Example,
1111+ card: Card,
1212+ api,
1313+ sources: [{ href: 'https://tiptap.dev/docs/editor/extensions/nodes/image', label: 'TipTap' }]
1414+};
···11+## Usage
22+33+`CoreRichTextEditor` includes `LinkExtension` by default. Pasted and typed URLs are automatically converted to links, and Markdown syntax `[text](url)` is supported out of the box.
44+55+```svelte
66+<CoreRichTextEditor />
77+```
88+99+`FormattingBubbleMenu` automatically includes a `LinkPopover` when the link extension is detected — no extra setup needed.
1010+1111+```svelte
1212+<CoreRichTextEditor bind:editor>
1313+ <FormattingBubbleMenu {editor} />
1414+</CoreRichTextEditor>
1515+```
1616+1717+## Standalone link popover
1818+1919+`LinkPopover` is a toggle button with a popover for editing URLs. It follows the same pattern as `BoldToggle` / `ItalicToggle` — drop it into any toolbar.
2020+2121+```svelte
2222+<LinkPopover {editor} />
2323+```
2424+2525+## Reacting to new links
2626+2727+Use the `onlinkadded` callback to run logic whenever a link is added — whether by typing, pasting, autolink, or the link popover.
2828+2929+Via `CoreRichTextEditor` (uses default extensions):
3030+3131+```svelte
3232+<CoreRichTextEditor
3333+ onlinkadded={({ href, text }) => {
3434+ console.log(`Link added: ${text} → ${href}`);
3535+ }}
3636+/>
3737+```
3838+3939+Or configure it directly on the extension:
4040+4141+```svelte
4242+<CoreRichTextEditor
4343+ extensions={[
4444+ StarterKit.configure({ link: false }),
4545+ LinkExtension.configure({
4646+ onlinkadded: ({ href, text, editor }) => {
4747+ // fetch preview, validate, transform, etc.
4848+ }
4949+ })
5050+ ]}
5151+/>
5252+```
5353+5454+## Programmatic usage
5555+5656+```ts
5757+import { setLink, removeLink } from '@foxui/text';
5858+5959+// Set a link on the current selection
6060+setLink(editor, 'https://example.com');
6161+6262+// Remove link from the current selection
6363+removeLink(editor);
6464+```
6565+6666+## How it works
6767+6868+`LinkExtension` wraps `@tiptap/extension-link` with:
6969+7070+- **Autolink** — URLs typed or pasted are automatically detected and linked
7171+- **Markdown syntax** — `[text](url "title")` input and paste rules, with `title` attribute support
7272+- **Configurable** — toggle autolink, markdown, open-on-click behavior, default protocol
7373+7474+`LinkPopover` tracks the current selection via editor transactions. When the selection is inside a link, the toggle is pressed and clicking it opens the popover pre-filled with the current URL.
+14
apps/docs/src/lib/docs/text/link/Example.svelte
···11+<script lang="ts">
22+ import { CoreRichTextEditor, FormattingBubbleMenu, Box, type SvelteTiptap } from '@foxui/all';
33+ import type { Readable } from 'svelte/store';
44+55+ let editor = $state<Readable<SvelteTiptap.Editor>>();
66+</script>
77+88+<Box class="not-prose">
99+ <CoreRichTextEditor bind:editor class="min-h-24">
1010+ {#if $editor}
1111+ <FormattingBubbleMenu {editor} />
1212+ {/if}
1313+ </CoreRichTextEditor>
1414+</Box>
+84
apps/docs/src/lib/docs/text/link/api.ts
···11+import type { APISchema } from '$lib/types/schema';
22+33+export default [
44+ {
55+ title: 'LinkExtension',
66+ description:
77+ 'A tiptap Extension that bundles link detection, autolink, and optional Markdown syntax. Add via extraExtensions.',
88+ props: {
99+ openOnClick: {
1010+ type: "boolean | 'whenNotEditable'",
1111+ description: 'Whether clicking a link opens it.',
1212+ default: "'whenNotEditable'"
1313+ },
1414+ autolink: {
1515+ type: 'boolean',
1616+ description: 'Automatically detect and linkify URLs as you type.',
1717+ default: 'true'
1818+ },
1919+ defaultProtocol: {
2020+ type: 'string',
2121+ description: 'Protocol added to URLs without one.',
2222+ default: "'https'"
2323+ },
2424+ markdown: {
2525+ type: 'boolean',
2626+ description: 'Enable Markdown link syntax [text](url "title").',
2727+ default: 'true'
2828+ },
2929+ HTMLAttributes: {
3030+ type: { type: 'object', definition: 'Record<string, string>' },
3131+ description: 'HTML attributes applied to link elements.',
3232+ default: '{}'
3333+ },
3434+ onlinkadded: {
3535+ type: { type: 'function', definition: '(event: LinkAddedEvent) => void' },
3636+ description:
3737+ 'Called whenever a link is added to the editor (via typing, pasting, autolink, or UI). Receives { href, text, editor }.'
3838+ }
3939+ }
4040+ },
4141+ {
4242+ title: 'LinkPopover',
4343+ description:
4444+ 'A toggle button with a popover for setting, editing, or removing links on the current selection. Works standalone, in a bubble menu, or in any toolbar.',
4545+ props: {
4646+ editor: {
4747+ type: { type: 'object', definition: 'Readable<Editor>' },
4848+ description: 'The editor store.',
4949+ required: true
5050+ },
5151+ class: {
5252+ type: 'string',
5353+ description: 'CSS classes for the popover content.'
5454+ }
5555+ }
5656+ },
5757+ {
5858+ title: 'setLink',
5959+ description: 'Helper function to set a link on the current selection. Unsets the link if URL is empty.',
6060+ props: {
6161+ editor: {
6262+ type: { type: 'object', definition: 'Editor' },
6363+ description: 'The tiptap editor instance.',
6464+ required: true
6565+ },
6666+ url: {
6767+ type: 'string',
6868+ description: 'The URL to set.',
6969+ required: true
7070+ }
7171+ }
7272+ },
7373+ {
7474+ title: 'removeLink',
7575+ description: 'Helper function to remove the link from the current selection.',
7676+ props: {
7777+ editor: {
7878+ type: { type: 'object', definition: 'Editor' },
7979+ description: 'The tiptap editor instance.',
8080+ required: true
8181+ }
8282+ }
8383+ }
8484+] satisfies APISchema[];
+14
apps/docs/src/lib/docs/text/link/index.ts
···11+import Docs from './Documentation.md';
22+import Example from './Example.svelte';
33+import Card from './Card.svelte';
44+import api from './api';
55+66+export default {
77+ slug: 'link',
88+ title: 'Link Extension',
99+ docs: Docs,
1010+ example: Example,
1111+ card: Card,
1212+ api,
1313+ sources: [{ href: 'https://tiptap.dev/docs/editor/extensions/marks/link', label: 'TipTap' }]
1414+};
···11+## Usage
22+33+Add `MentionNode` via `extraExtensions`, then render `MentionMenu` as a child — similar to how `BubbleMenu` and `FloatingMenu` work in svelte-tiptap.
44+55+```svelte
66+<script lang="ts">
77+ import {
88+ CoreRichTextEditor,
99+ MentionNode,
1010+ MentionMenu,
1111+ type MentionItem
1212+ type SvelteTiptap
1313+ } from '@foxui/text';
1414+ import type { Readable } from 'svelte/store';
1515+1616+ let editor = $state<Readable<SvelteTiptap.Editor>>();
1717+1818+ async function searchUsers(query: string): Promise<MentionItem[]> {
1919+ const res = await fetch(`/api/users?q=${query}`);
2020+ const users = await res.json();
2121+ return users.map((u) => ({
2222+ id: u.id,
2323+ label: u.name,
2424+ avatar: u.avatarUrl
2525+ }));
2626+ }
2727+</script>
2828+2929+<CoreRichTextEditor
3030+ bind:editor
3131+ extraExtensions={[MentionNode]}
3232+>
3333+ {#if $editor}
3434+ <MentionMenu editor={$editor} items={searchUsers}>
3535+ {#snippet item({ item: mentionItem, isActive, select })}
3636+ <button onclick={select}>
3737+ {mentionItem.label}
3838+ </button>
3939+ {/snippet}
4040+ </MentionMenu>
4141+ {/if}
4242+</CoreRichTextEditor>
4343+```
4444+4545+## Custom rendering
4646+4747+The `item` snippet receives `{ item, isActive, select }`. Use this to fully customize how each suggestion looks — add avatars, secondary text, keyboard highlight styling, etc.
4848+4949+If no `item` snippet is provided, a minimal default rendering is used.
5050+5151+## How it works
5252+5353+`MentionNode` is a tiptap Node extension that defines an inline, non-editable mention node with `id`, `label`, and optional `data` attributes.
5454+5555+`MentionMenu` follows the same pattern as svelte-tiptap's `BubbleMenu` — it takes an `editor` prop and registers a suggestion plugin on mount. The popup is positioned using `@floating-ui/dom` and supports keyboard navigation (Arrow keys + Enter + Escape).
5656+5757+## Works with any editor
5858+5959+Both `CoreRichTextEditor` and `RichTextEditor` support `extraExtensions` to add extensions without replacing defaults. You can also use `MentionNode` + `MentionMenu` with any tiptap editor setup.
···11+import type { APISchema } from '$lib/types/schema';
22+33+export default [
44+ {
55+ title: 'MentionMenu',
66+ description:
77+ 'A suggestion popup component for @mentions. Registers a tiptap suggestion plugin on mount, like BubbleMenu/FloatingMenu.',
88+ props: {
99+ editor: {
1010+ type: { type: 'object', definition: 'Editor' },
1111+ description: 'The tiptap editor instance.',
1212+ required: true
1313+ },
1414+ items: {
1515+ type: {
1616+ type: 'function',
1717+ definition: '(query: string) => MentionItem[] | Promise<MentionItem[]>'
1818+ },
1919+ description: 'Function that returns mention suggestions for a search query.',
2020+ required: true
2121+ },
2222+ char: {
2323+ type: 'string',
2424+ description: 'Trigger character for the mention popup.',
2525+ default: "'@'"
2626+ },
2727+ minQueryLength: {
2828+ type: 'number',
2929+ description: 'Minimum characters after trigger before searching.',
3030+ default: '2'
3131+ },
3232+ pluginKey: {
3333+ type: 'string',
3434+ description: 'Unique key for the suggestion plugin.',
3535+ default: "'mentionMenu'"
3636+ },
3737+ item: {
3838+ type: {
3939+ type: 'function',
4040+ definition:
4141+ 'Snippet<[{ item: MentionItem; isActive: boolean; select: () => void }]>'
4242+ },
4343+ description: 'Snippet for custom rendering of each suggestion item.'
4444+ },
4545+ class: {
4646+ type: 'string',
4747+ description: 'CSS classes for the popup container.'
4848+ }
4949+ }
5050+ },
5151+ {
5252+ title: 'MentionNode',
5353+ description:
5454+ 'A tiptap Node extension for inline mention nodes. Add to editor extensions to enable mentions.',
5555+ props: {
5656+ HTMLAttributes: {
5757+ type: { type: 'object', definition: 'Record<string, unknown>' },
5858+ description: 'HTML attributes applied to mention nodes.',
5959+ default: '{}'
6060+ },
6161+ renderLabel: {
6262+ type: {
6363+ type: 'function',
6464+ definition: '({ node }) => string'
6565+ },
6666+ description: 'Custom function to render the mention label text.',
6767+ default: "'({ node }) => `@${node.attrs.label}`'"
6868+ }
6969+ }
7070+ },
7171+ {
7272+ title: 'MentionItem',
7373+ description: 'The data shape for mention suggestions.',
7474+ props: {
7575+ id: {
7676+ type: 'string',
7777+ description: 'Unique identifier for the mention (e.g., user ID, DID).',
7878+ required: true
7979+ },
8080+ label: {
8181+ type: 'string',
8282+ description: 'Display label for the mention (e.g., username, handle).',
8383+ required: true
8484+ },
8585+ avatar: {
8686+ type: 'string',
8787+ description: 'Optional avatar URL.'
8888+ },
8989+ data: {
9090+ type: { type: 'object', definition: 'Record<string, unknown>' },
9191+ description: 'Optional extra data stored on the mention node.'
9292+ }
9393+ }
9494+ }
9595+] satisfies APISchema[];
+13
apps/docs/src/lib/docs/text/mention/index.ts
···11+import Docs from './Documentation.md';
22+import Example from './Example.svelte';
33+import Card from './Card.svelte';
44+import api from './api';
55+66+export default {
77+ slug: 'mention',
88+ title: 'Mention Menu',
99+ docs: Docs,
1010+ example: Example,
1111+ card: Card,
1212+ api
1313+};
···77 let content = $state('');
88</script>
991010-<RichTextEditor bind:content={content} placeholder="Write something..." />
1010+<RichTextEditor bind:content placeholder="Write something..." />
1111+```
1212+1313+This gives you a fully featured editor with a formatting bubble menu and `/` slash commands out of the box.
1414+1515+## Image uploads
1616+1717+Pass an upload function to `image` to enable image paste, drop, and the `/image` slash command. Images use `EditableImage` (with alt text editing) by default.
1818+1919+```svelte
2020+<script lang="ts">
2121+ import { RichTextEditor } from '@foxui/text';
2222+2323+ async function upload(file: File, onProgress: (p: number) => void, signal: AbortSignal) {
2424+ const res = await fetch('/api/upload', { method: 'POST', body: file, signal });
2525+ return (await res.json()).url;
2626+ }
2727+</script>
2828+2929+<RichTextEditor image={upload} />
3030+```
3131+3232+## Embeds
3333+3434+Pass `embeds={true}` for built-in YouTube embeds, or pass an array of custom `EmbedDefinition`s. An embed modal is automatically included, and `/embed` appears in the slash menu.
3535+3636+```svelte
3737+<!-- YouTube embeds -->
3838+<RichTextEditor embeds={true} />
3939+4040+<!-- Custom embed providers -->
4141+<RichTextEditor embeds={[youtubeEmbed, spotifyEmbed]} />
4242+```
4343+4444+## Disabling features
4545+4646+Turn off individual features with boolean props.
4747+4848+```svelte
4949+<!-- No bubble menu -->
5050+<RichTextEditor bubbleMenu={false} />
5151+5252+<!-- No slash menu -->
5353+<RichTextEditor slashMenu={false} />
5454+```
5555+5656+## All CoreRichTextEditor props
5757+5858+`RichTextEditor` passes all props through to `CoreRichTextEditor`, so you can use `extraExtensions`, `markdown`, `onlinkadded`, etc.
5959+6060+```svelte
6161+<RichTextEditor
6262+ image={upload}
6363+ embeds={true}
6464+ markdown
6565+ placeholder="Write Markdown..."
6666+/>
1167```
···11+<script lang="ts">
22+ import { CoreRichTextEditor, SlashMenu, Box, type SvelteTiptap } from '@foxui/all';
33+ import type { Readable } from 'svelte/store';
44+55+ let editor = $state<Readable<SvelteTiptap.Editor>>();
66+</script>
77+88+<Box>
99+ <CoreRichTextEditor bind:editor placeholder="Type / to open the command menu..." class="min-h-24">
1010+ <SlashMenu editor={$editor} />
1111+ </CoreRichTextEditor>
1212+</Box>
+103
apps/docs/src/lib/docs/text/slash-menu/api.ts
···11+import type { APISchema } from '$lib/types/schema';
22+33+export default [
44+ {
55+ title: 'SlashMenu',
66+ description:
77+ 'A "/" command menu for the editor. Type "/" to open, then search and select block types or actions. Registers as a ProseMirror plugin.',
88+ props: {
99+ editor: {
1010+ type: { type: 'object', definition: 'Editor' },
1111+ description: 'The tiptap editor instance.',
1212+ required: true
1313+ },
1414+ items: {
1515+ type: {
1616+ type: 'union',
1717+ definition:
1818+ 'SlashMenuItem[] | ((query: string) => SlashMenuItem[] | Promise<SlashMenuItem[]>)'
1919+ },
2020+ description:
2121+ 'Menu items as a static array or async function. Defaults to all available block types.'
2222+ },
2323+ char: {
2424+ type: 'string',
2525+ description: 'Trigger character for the menu.',
2626+ default: "'/'"
2727+ },
2828+ pluginKey: {
2929+ type: 'string',
3030+ description: 'Unique key for the suggestion plugin.',
3131+ default: "'slashMenu'"
3232+ },
3333+ item: {
3434+ type: {
3535+ type: 'function',
3636+ definition:
3737+ 'Snippet<[{ item: SlashMenuItem; isActive: boolean; select: () => void }]>'
3838+ },
3939+ description: 'Snippet for custom rendering of each menu item.'
4040+ },
4141+ class: {
4242+ type: 'string',
4343+ description: 'CSS classes for the popup container.'
4444+ }
4545+ }
4646+ },
4747+ {
4848+ title: 'SlashMenuItem',
4949+ description: 'The data shape for slash menu items.',
5050+ props: {
5151+ id: {
5252+ type: 'string',
5353+ description: 'Unique identifier for the item.',
5454+ required: true
5555+ },
5656+ label: {
5757+ type: 'string',
5858+ description: 'Display label.',
5959+ required: true
6060+ },
6161+ description: {
6262+ type: 'string',
6363+ description: 'Optional description shown below the label.'
6464+ },
6565+ icon: {
6666+ type: 'string',
6767+ description: 'Optional icon identifier.'
6868+ },
6969+ command: {
7070+ type: {
7171+ type: 'function',
7272+ definition: '(props: { editor: Editor; range: Range }) => void'
7373+ },
7474+ description: 'Function called when the item is selected.',
7575+ required: true
7676+ }
7777+ }
7878+ },
7979+ {
8080+ title: 'getSlashMenuItems',
8181+ description:
8282+ 'Returns default slash menu items filtered to only those with registered extensions. Includes block types, image, and embed.',
8383+ props: {
8484+ editor: {
8585+ type: { type: 'object', definition: 'Editor' },
8686+ description: 'The tiptap editor instance.',
8787+ required: true
8888+ }
8989+ }
9090+ },
9191+ {
9292+ title: 'getBlockTypeItems',
9393+ description:
9494+ 'Returns block type items only (no image/embed). Useful for block type selectors.',
9595+ props: {
9696+ editor: {
9797+ type: { type: 'object', definition: 'Editor' },
9898+ description: 'The tiptap editor instance.',
9999+ required: true
100100+ }
101101+ }
102102+ }
103103+] satisfies APISchema[];
+13
apps/docs/src/lib/docs/text/slash-menu/index.ts
···11+import Docs from './Documentation.md';
22+import Example from './Example.svelte';
33+import Card from './Card.svelte';
44+import api from './api';
55+66+export default {
77+ slug: 'slash-menu',
88+ title: 'Slash Menu',
99+ docs: Docs,
1010+ example: Example,
1111+ card: Card,
1212+ api
1313+};