feat(rich-text): @bytelyst/rich-text@0.1.0 on Tiptap v3

RichTextEditor (toolbar + slash menu + async mentions, SSR-safe via
immediatelyRender:false) + RichTextViewer (generateHTML, server-renderable) +
standalone Toolbar. Pure filterSlashItems/filterUsers helpers. 12/12 vitest
(incl. live editor mount + bold toggle in happy-dom); tsc clean; published to
Gitea.
This commit is contained in:
saravanakumardb1 2026-05-28 18:20:34 -07:00
parent 3224199894
commit fc8502ac0c
14 changed files with 1586 additions and 0 deletions

View File

@ -0,0 +1,48 @@
{
"name": "@bytelyst/rich-text",
"version": "0.1.0",
"type": "module",
"description": "Tiptap v3 rich-text editor + viewer with a token-themed toolbar, slash-menu and async mention extensions. SSR-safe for Next.",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run --pool forks",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"dependencies": {
"@tiptap/core": "^3.23.6",
"@tiptap/extension-link": "^3.23.6",
"@tiptap/extension-mention": "^3.23.6",
"@tiptap/extension-placeholder": "^3.23.6",
"@tiptap/html": "^3.23.6",
"@tiptap/pm": "^3.23.6",
"@tiptap/react": "^3.23.6",
"@tiptap/starter-kit": "^3.23.6",
"@tiptap/suggestion": "^3.23.6",
"clsx": "^2.1.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"happy-dom": "^18.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"typescript": "^5.7.3",
"vitest": "^4.0.18"
}
}

View File

@ -0,0 +1,74 @@
import * as React from 'react';
import type { SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion';
import type { MentionUser } from './mention.js';
export interface MentionListHandle {
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
}
/** Keyboard-navigable people picker shown while typing `@`. */
export const MentionList = React.forwardRef<MentionListHandle, SuggestionProps<MentionUser>>(
function MentionList(props, ref) {
const { items, command } = props;
const [selected, setSelected] = React.useState(0);
React.useEffect(() => setSelected(0), [items]);
const select = React.useCallback(
(index: number) => {
const item = items[index];
if (item) command({ id: item.id, label: item.label });
},
[items, command]
);
React.useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
setSelected(s => (s + items.length - 1) % items.length);
return true;
}
if (event.key === 'ArrowDown') {
setSelected(s => (s + 1) % items.length);
return true;
}
if (event.key === 'Enter') {
select(selected);
return true;
}
return false;
},
}));
return (
<div
role="listbox"
aria-label="Mention people"
className="min-w-48 overflow-hidden rounded-lg border border-[var(--bl-border,rgba(0,0,0,0.12))] bg-[var(--bl-surface-card,#fff)] p-1 shadow-lg"
>
{items.length === 0 ? (
<div className="px-2.5 py-1.5 text-sm text-[var(--bl-text-tertiary,#999)]">No people</div>
) : (
items.map((item, index) => (
<button
key={item.id}
type="button"
role="option"
aria-selected={index === selected}
onMouseEnter={() => setSelected(index)}
onClick={() => select(index)}
className={
'flex w-full items-center rounded-md px-2.5 py-1.5 text-left text-sm ' +
(index === selected
? 'bg-[var(--bl-accent-muted,rgba(99,102,241,0.12))]'
: 'bg-transparent')
}
>
{item.label}
</button>
))
)}
</div>
);
}
);

View File

@ -0,0 +1,84 @@
'use client';
import * as React from 'react';
import { clsx } from 'clsx';
import { EditorContent, useEditor } from '@tiptap/react';
import type { AnyExtension, JSONContent } from '@tiptap/core';
import { buildExtensions } from './extensions.js';
import { SlashCommands } from './slashMenu.js';
import { createMention, type UserSearchFn } from './mention.js';
import { Toolbar } from './Toolbar.js';
export interface RichTextEditorProps {
/** Initial document — Tiptap JSON or an HTML string. */
content?: JSONContent | string;
/** Called with the latest JSON document on every change. */
onChange?: (doc: JSONContent) => void;
/** Placeholder shown when empty. */
placeholder?: string;
/** Enable the `/` slash command menu (default true). */
enableSlashMenu?: boolean;
/** Provide to enable `@`-mentions backed by this people search. */
mentionSearch?: UserSearchFn;
/** Read-only mode. */
editable?: boolean;
className?: string;
ariaLabel?: string;
}
/**
* Full editor surface: token-themed toolbar + Tiptap editing area, with an
* optional slash menu and async mentions. SSR-safe (`immediatelyRender:
* false`) so it hydrates cleanly under Next.
*/
export function RichTextEditor({
content,
onChange,
placeholder,
enableSlashMenu = true,
mentionSearch,
editable = true,
className,
ariaLabel = 'Rich text editor',
}: RichTextEditorProps) {
const extra = React.useMemo<AnyExtension[]>(() => {
const list: AnyExtension[] = [];
if (enableSlashMenu) list.push(SlashCommands);
if (mentionSearch) list.push(createMention(mentionSearch));
// Extensions are fixed for a mount; deliberately not reactive.
return list;
}, []);
const editor = useEditor({
immediatelyRender: false,
editable,
extensions: buildExtensions({ placeholder, extra }),
content,
editorProps: {
attributes: {
role: 'textbox',
'aria-multiline': 'true',
'aria-label': ariaLabel,
class: 'bl-rich-text-content min-h-40 p-3 outline-none',
},
},
onUpdate: ({ editor: e }) => onChange?.(e.getJSON()),
});
React.useEffect(() => {
editor?.setEditable(editable);
}, [editor, editable]);
return (
<div
className={clsx(
'overflow-hidden rounded-xl border border-[var(--bl-border,rgba(0,0,0,0.12))] bg-[var(--bl-surface-card,#fff)]',
className
)}
>
{editable && <Toolbar editor={editor} />}
<EditorContent editor={editor} />
</div>
);
}

View File

@ -0,0 +1,29 @@
import * as React from 'react';
import { clsx } from 'clsx';
import { generateHTML } from '@tiptap/html';
import Mention from '@tiptap/extension-mention';
import type { JSONContent } from '@tiptap/core';
import { buildExtensions } from './extensions.js';
export interface RichTextViewerProps {
/** Tiptap JSON document to render read-only. */
doc: JSONContent;
className?: string;
}
/**
* Read-only renderer. Serialises a Tiptap JSON doc to HTML using the same
* extension schema as the editor (so output matches), with no editing surface
* and no client runtime safe to render on the server.
*/
export function RichTextViewer({ doc, className }: RichTextViewerProps) {
const html = React.useMemo(() => generateHTML(doc, buildExtensions({ extra: [Mention] })), [doc]);
return (
<div
className={clsx('bl-rich-text-content', className)}
// Content is produced by Tiptap's own serializer from a trusted JSON doc.
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}

View File

@ -0,0 +1,86 @@
import * as React from 'react';
import type { SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion';
import type { SlashItem } from './slashMenu.js';
export interface SlashMenuListHandle {
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
}
/**
* Keyboard-navigable list rendered inside the slash-menu popup. Exposes an
* imperative `onKeyDown` so the Suggestion plugin can route //Enter here.
*/
export const SlashMenuList = React.forwardRef<SlashMenuListHandle, SuggestionProps<SlashItem>>(
function SlashMenuList(props, ref) {
const { items, command } = props;
const [selected, setSelected] = React.useState(0);
React.useEffect(() => setSelected(0), [items]);
const select = React.useCallback(
(index: number) => {
const item = items[index];
if (item) command(item);
},
[items, command]
);
React.useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
setSelected(s => (s + items.length - 1) % items.length);
return true;
}
if (event.key === 'ArrowDown') {
setSelected(s => (s + 1) % items.length);
return true;
}
if (event.key === 'Enter') {
select(selected);
return true;
}
return false;
},
}));
if (items.length === 0) {
return (
<div
role="listbox"
aria-label="Slash commands"
className="min-w-56 rounded-lg border border-[var(--bl-border,rgba(0,0,0,0.12))] bg-[var(--bl-surface-card,#fff)] p-2 text-sm text-[var(--bl-text-tertiary,#999)] shadow-lg"
>
No matches
</div>
);
}
return (
<div
role="listbox"
aria-label="Slash commands"
className="min-w-56 overflow-hidden rounded-lg border border-[var(--bl-border,rgba(0,0,0,0.12))] bg-[var(--bl-surface-card,#fff)] p-1 shadow-lg"
>
{items.map((item, index) => (
<button
key={item.title}
type="button"
role="option"
aria-selected={index === selected}
onMouseEnter={() => setSelected(index)}
onClick={() => select(index)}
className={
'flex w-full flex-col items-start rounded-md px-2.5 py-1.5 text-left text-sm ' +
(index === selected
? 'bg-[var(--bl-accent-muted,rgba(99,102,241,0.12))]'
: 'bg-transparent')
}
>
<span className="font-medium text-[var(--bl-text-primary,#111)]">{item.title}</span>
<span className="text-xs text-[var(--bl-text-tertiary,#999)]">{item.description}</span>
</button>
))}
</div>
);
}
);

View File

@ -0,0 +1,125 @@
import * as React from 'react';
import { clsx } from 'clsx';
import type { Editor } from '@tiptap/core';
export interface ToolbarProps {
editor: Editor | null;
}
interface ToolButton {
label: string;
/** aria-label / accessible name. */
name: string;
isActive?: (e: Editor) => boolean;
run: (e: Editor) => void;
}
const BUTTONS: ToolButton[] = [
{
label: 'B',
name: 'Bold',
isActive: e => e.isActive('bold'),
run: e => e.chain().focus().toggleBold().run(),
},
{
label: 'I',
name: 'Italic',
isActive: e => e.isActive('italic'),
run: e => e.chain().focus().toggleItalic().run(),
},
{
label: 'H1',
name: 'Heading 1',
isActive: e => e.isActive('heading', { level: 1 }),
run: e => e.chain().focus().toggleHeading({ level: 1 }).run(),
},
{
label: 'H2',
name: 'Heading 2',
isActive: e => e.isActive('heading', { level: 2 }),
run: e => e.chain().focus().toggleHeading({ level: 2 }).run(),
},
{
label: '• List',
name: 'Bullet list',
isActive: e => e.isActive('bulletList'),
run: e => e.chain().focus().toggleBulletList().run(),
},
{
label: '1. List',
name: 'Numbered list',
isActive: e => e.isActive('orderedList'),
run: e => e.chain().focus().toggleOrderedList().run(),
},
{
label: '❝',
name: 'Quote',
isActive: e => e.isActive('blockquote'),
run: e => e.chain().focus().toggleBlockquote().run(),
},
{
label: '</>',
name: 'Code',
isActive: e => e.isActive('code'),
run: e => e.chain().focus().toggleCode().run(),
},
{
label: 'Link',
name: 'Link',
isActive: e => e.isActive('link'),
run: e => {
const prev = (e.getAttributes('link').href as string) ?? '';
const url = typeof window !== 'undefined' ? window.prompt('Link URL', prev) : prev;
if (url === null) return;
if (url === '') {
e.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
e.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
},
},
];
/** Token-themed formatting toolbar bound to a Tiptap editor instance. */
export function Toolbar({ editor }: ToolbarProps) {
// Re-render on selection/content changes so active states stay correct.
const [, force] = React.useReducer((x: number) => x + 1, 0);
React.useEffect(() => {
if (!editor) return;
editor.on('transaction', force);
return () => {
editor.off('transaction', force);
};
}, [editor]);
if (!editor) return null;
return (
<div
role="toolbar"
aria-label="Formatting"
className="flex flex-wrap items-center gap-1 border-b border-[var(--bl-border,rgba(0,0,0,0.12))] p-1.5"
>
{BUTTONS.map(b => {
const active = b.isActive?.(editor) ?? false;
return (
<button
key={b.name}
type="button"
aria-label={b.name}
aria-pressed={active}
onClick={() => b.run(editor)}
className={clsx(
'min-w-8 rounded-md px-2 py-1 text-sm font-medium',
active
? 'bg-[var(--bl-accent,#6366f1)] text-white'
: 'text-[var(--bl-text-secondary,#444)] hover:bg-[var(--bl-surface-hover,rgba(0,0,0,0.05))]'
)}
>
{b.label}
</button>
);
})}
</div>
);
}

View File

@ -0,0 +1,143 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import * as React from 'react';
import { Editor } from '@tiptap/core';
import type { JSONContent } from '@tiptap/core';
import type { SuggestionProps } from '@tiptap/suggestion';
import { RichTextViewer } from '../RichTextViewer.js';
import { RichTextEditor } from '../RichTextEditor.js';
import { Toolbar } from '../Toolbar.js';
import { buildExtensions } from '../extensions.js';
import { defaultSlashItems, filterSlashItems, type SlashItem } from '../slashMenu.js';
import { filterUsers, type MentionUser } from '../mention.js';
import { SlashMenuList } from '../SlashMenuList.js';
import { MentionList } from '../MentionList.js';
beforeEach(() => cleanup());
/* ── 9.B.4 — slash filter (pure) ─────────────────────────────────────── */
describe('filterSlashItems', () => {
it('returns all items for an empty query', () => {
expect(filterSlashItems('')).toHaveLength(defaultSlashItems.length);
});
it('matches by title (case-insensitive)', () => {
const r = filterSlashItems('HEAD');
expect(r.length).toBeGreaterThan(0);
expect(r.every(i => /head/i.test(i.title))).toBe(true);
});
it('matches by alias', () => {
const r = filterSlashItems('ul');
expect(r.some(i => i.title === 'Bullet list')).toBe(true);
});
});
/* ── 9.B.5 — mention people search (pure) ────────────────────────────── */
describe('filterUsers', () => {
const people: MentionUser[] = [
{ id: '1', label: 'Ada Lovelace' },
{ id: '2', label: 'Alan Turing' },
{ id: '3', label: 'Grace Hopper' },
];
it('filters case-insensitively by label', () => {
expect(filterUsers('ada', people).map(u => u.id)).toEqual(['1']);
});
it('returns all (capped) for an empty query', () => {
expect(filterUsers('', people, 2)).toHaveLength(2);
});
});
/* ── 9.B.2 — viewer renders HTML from JSON ───────────────────────────── */
describe('RichTextViewer', () => {
const doc: JSONContent = {
type: 'doc',
content: [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Title' }] },
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'bold' }],
},
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] }],
},
],
},
],
};
it('serialises headings, marks and lists', () => {
const { container } = render(<RichTextViewer doc={doc} />);
expect(container.querySelector('h1')?.textContent).toBe('Title');
expect(container.querySelector('strong')?.textContent).toBe('bold');
expect(container.querySelector('ul li')?.textContent).toBe('one');
});
});
/* ── 9.B.3 — toolbar commands ────────────────────────────────────────── */
describe('Toolbar', () => {
it('toggles bold on the bound editor', () => {
const editor = new Editor({
element: document.createElement('div'),
extensions: buildExtensions(),
content: '<p>hello</p>',
});
editor.commands.selectAll();
render(<Toolbar editor={editor} />);
fireEvent.click(screen.getByLabelText('Bold'));
expect(editor.getHTML()).toContain('<strong>');
editor.destroy();
});
it('renders nothing without an editor', () => {
const { container } = render(<Toolbar editor={null} />);
expect(container.firstChild).toBeNull();
});
});
/* ── 9.B.2 — editor mounts (SSR-safe) ────────────────────────────────── */
describe('RichTextEditor', () => {
it('mounts a toolbar + editable textbox', async () => {
render(<RichTextEditor content="<p>hi</p>" />);
expect(await screen.findByRole('textbox')).toBeTruthy();
expect(screen.getByRole('toolbar', { name: 'Formatting' })).toBeTruthy();
});
});
/* ── 9.B.4 / 9.B.5 — suggestion list components ──────────────────────── */
function slashProps(
items: SlashItem[],
command: (i: SlashItem) => void
): SuggestionProps<SlashItem> {
return { items, command } as unknown as SuggestionProps<SlashItem>;
}
describe('SlashMenuList', () => {
it('renders an option per item and invokes command on click', () => {
const command = vi.fn();
render(<SlashMenuList {...slashProps(defaultSlashItems, command)} />);
const options = screen.getAllByRole('option');
expect(options).toHaveLength(defaultSlashItems.length);
fireEvent.click(screen.getByText('Heading 1'));
expect(command).toHaveBeenCalledWith(defaultSlashItems[0]);
});
it('shows an empty state when there are no items', () => {
render(<SlashMenuList {...slashProps([], vi.fn())} />);
expect(screen.getByText('No matches')).toBeTruthy();
});
});
describe('MentionList', () => {
it('renders people and emits {id,label} on click', () => {
const command = vi.fn();
const people: MentionUser[] = [{ id: '7', label: 'Ada' }];
render(
<MentionList {...({ items: people, command } as unknown as SuggestionProps<MentionUser>)} />
);
fireEvent.click(screen.getByText('Ada'));
expect(command).toHaveBeenCalledWith({ id: '7', label: 'Ada' });
});
});

View File

@ -0,0 +1,26 @@
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import type { AnyExtension } from '@tiptap/core';
export interface BuildExtensionsOptions {
/** Placeholder text shown when the doc is empty. */
placeholder?: string;
/** Extra extensions to append (e.g. a configured Mention or SlashCommands). */
extra?: AnyExtension[];
}
/**
* The canonical extension set shared by `<RichTextEditor>` and
* `<RichTextViewer>` keeping them in sync guarantees that serialised HTML
* matches what the editor produced.
*/
export function buildExtensions(options: BuildExtensionsOptions = {}): AnyExtension[] {
const { placeholder = 'Write something…', extra = [] } = options;
return [
StarterKit.configure({ link: false }),
Link.configure({ openOnClick: false, autolink: true }),
Placeholder.configure({ placeholder }),
...extra,
];
}

View File

@ -0,0 +1,33 @@
/**
* @bytelyst/rich-text Tiptap v3 editor + viewer for the ByteLyst ecosystem.
*
* Exports (0.1.0 Wave 9.B):
* <RichTextEditor> toolbar + editing surface, slash menu, async mentions
* <RichTextViewer> SSR-safe read-only renderer (generateHTML)
* <Toolbar> standalone formatting toolbar bound to an Editor
* buildExtensions shared StarterKit + Link + Placeholder schema
* SlashCommands `/` command extension + defaultSlashItems / filterSlashItems
* createMention `@`-mention extension factory + filterUsers helper
*
* Built on Tiptap v3.23.x (current stable). SSR-safe for Next via
* `immediatelyRender: false`.
*/
export { RichTextEditor } from './RichTextEditor.js';
export type { RichTextEditorProps } from './RichTextEditor.js';
export { RichTextViewer } from './RichTextViewer.js';
export type { RichTextViewerProps } from './RichTextViewer.js';
export { Toolbar } from './Toolbar.js';
export type { ToolbarProps } from './Toolbar.js';
export { buildExtensions } from './extensions.js';
export type { BuildExtensionsOptions } from './extensions.js';
export { SlashCommands, defaultSlashItems, filterSlashItems } from './slashMenu.js';
export type { SlashItem } from './slashMenu.js';
export { createMention, filterUsers } from './mention.js';
export type { MentionUser, UserSearchFn } from './mention.js';
export type { JSONContent } from '@tiptap/core';

View File

@ -0,0 +1,81 @@
import Mention from '@tiptap/extension-mention';
import type { AnyExtension } from '@tiptap/core';
import type { SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion';
import { ReactRenderer } from '@tiptap/react';
import { MentionList } from './MentionList.js';
export interface MentionUser {
id: string;
label: string;
}
/** Resolver that returns matching people for a query (sync or async). */
export type UserSearchFn = (query: string) => MentionUser[] | Promise<MentionUser[]>;
/**
* Pure, case-insensitive in-memory matcher handy as a default `UserSearchFn`
* and directly unit-testable. Caps results at `limit`.
*/
export function filterUsers(query: string, users: MentionUser[], limit = 5): MentionUser[] {
const q = query.trim().toLowerCase();
const matches = q ? users.filter(u => u.label.toLowerCase().includes(q)) : users;
return matches.slice(0, limit);
}
/**
* Build a configured `@`-mention extension backed by an async people search.
* Renders a keyboard-navigable popup at the caret.
*/
export function createMention(search: UserSearchFn): AnyExtension {
return Mention.configure({
HTMLAttributes: { class: 'bl-mention' },
suggestion: {
char: '@',
items: async ({ query }) => await search(query),
render: () => {
let component: ReactRenderer<{ onKeyDown: (p: SuggestionKeyDownProps) => boolean }> | null =
null;
let popup: HTMLDivElement | null = null;
const position = (props: SuggestionProps<MentionUser>) => {
if (!popup) return;
const rect = props.clientRect?.();
if (!rect) return;
popup.style.left = `${rect.left}px`;
popup.style.top = `${rect.bottom + 4}px`;
};
return {
onStart: (props: SuggestionProps<MentionUser>) => {
component = new ReactRenderer(MentionList, { props, editor: props.editor });
popup = document.createElement('div');
popup.setAttribute('data-mention-menu', 'true');
popup.style.position = 'absolute';
popup.style.zIndex = '50';
popup.appendChild(component.element);
document.body.appendChild(popup);
position(props);
},
onUpdate: (props: SuggestionProps<MentionUser>) => {
component?.updateProps(props);
position(props);
},
onKeyDown: (props: SuggestionKeyDownProps) => {
if (props.event.key === 'Escape') {
popup?.remove();
return true;
}
return component?.ref?.onKeyDown(props) ?? false;
},
onExit: () => {
popup?.remove();
popup = null;
component?.destroy();
component = null;
},
};
},
},
});
}

View File

@ -0,0 +1,138 @@
import { Extension } from '@tiptap/core';
import type { Editor, Range } from '@tiptap/core';
import Suggestion, { type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion';
import { ReactRenderer } from '@tiptap/react';
import { SlashMenuList } from './SlashMenuList.js';
export interface SlashItem {
title: string;
description: string;
aliases?: string[];
command: (props: { editor: Editor; range: Range }) => void;
}
/** The built-in block actions offered by the slash menu. */
export const defaultSlashItems: SlashItem[] = [
{
title: 'Heading 1',
description: 'Big section heading',
aliases: ['h1', 'title'],
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run(),
},
{
title: 'Heading 2',
description: 'Medium section heading',
aliases: ['h2', 'subtitle'],
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run(),
},
{
title: 'Bullet list',
description: 'Unordered list',
aliases: ['ul', 'unordered'],
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).toggleBulletList().run(),
},
{
title: 'Numbered list',
description: 'Ordered list',
aliases: ['ol', 'ordered'],
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).toggleOrderedList().run(),
},
{
title: 'Quote',
description: 'Blockquote',
aliases: ['blockquote'],
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).toggleBlockquote().run(),
},
{
title: 'Code block',
description: 'Monospaced code',
aliases: ['code', 'pre'],
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
];
/** Pure, case-insensitive filter over title + aliases — unit-testable. */
export function filterSlashItems(
query: string,
items: SlashItem[] = defaultSlashItems
): SlashItem[] {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(
item =>
item.title.toLowerCase().includes(q) ||
(item.aliases ?? []).some(a => a.toLowerCase().includes(q))
);
}
/**
* `/`-triggered command menu. Built on `@tiptap/suggestion`; the popup is a
* React component positioned at the caret. Only mounts while the user is
* typing a slash command, so it never appears on first render.
*/
export const SlashCommands = Extension.create({
name: 'slashCommands',
addProseMirrorPlugins() {
return [
Suggestion<SlashItem>({
editor: this.editor,
char: '/',
startOfLine: false,
items: ({ query }) => filterSlashItems(query),
command: ({ editor, range, props }) => props.command({ editor, range }),
render: () => {
let component: ReactRenderer<{
onKeyDown: (p: SuggestionKeyDownProps) => boolean;
}> | null = null;
let popup: HTMLDivElement | null = null;
const position = (props: SuggestionProps<SlashItem>) => {
if (!popup) return;
const rect = props.clientRect?.();
if (!rect) return;
popup.style.left = `${rect.left}px`;
popup.style.top = `${rect.bottom + 4}px`;
};
return {
onStart: (props: SuggestionProps<SlashItem>) => {
component = new ReactRenderer(SlashMenuList, { props, editor: props.editor });
popup = document.createElement('div');
popup.setAttribute('data-slash-menu', 'true');
popup.style.position = 'absolute';
popup.style.zIndex = '50';
popup.appendChild(component.element);
document.body.appendChild(popup);
position(props);
},
onUpdate: (props: SuggestionProps<SlashItem>) => {
component?.updateProps(props);
position(props);
},
onKeyDown: (props: SuggestionKeyDownProps) => {
if (props.event.key === 'Escape') {
popup?.remove();
return true;
}
return component?.ref?.onKeyDown(props) ?? false;
},
onExit: () => {
popup?.remove();
popup = null;
component?.destroy();
component = null;
},
};
},
}),
];
},
});

View File

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}

View File

@ -0,0 +1,2 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });

706
pnpm-lock.yaml generated
View File

@ -1106,6 +1106,64 @@ importers:
specifier: ^5.7.3 specifier: ^5.7.3
version: 5.9.3 version: 5.9.3
packages/rich-text:
dependencies:
'@tiptap/core':
specifier: ^3.23.6
version: 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/extension-link':
specifier: ^3.23.6
version: 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)
'@tiptap/extension-mention':
specifier: ^3.23.6
version: 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)(@tiptap/suggestion@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))
'@tiptap/extension-placeholder':
specifier: ^3.23.6
version: 3.23.6(@tiptap/extensions@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))
'@tiptap/html':
specifier: ^3.23.6
version: 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)(happy-dom@18.0.1)
'@tiptap/pm':
specifier: ^3.23.6
version: 3.23.6
'@tiptap/react':
specifier: ^3.23.6
version: 3.23.6(@floating-ui/dom@1.7.5)(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tiptap/starter-kit':
specifier: ^3.23.6
version: 3.23.6
'@tiptap/suggestion':
specifier: ^3.23.6
version: 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)
clsx:
specifier: ^2.1.0
version: 2.1.1
devDependencies:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/react':
specifier: ^19.2.14
version: 19.2.14
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
happy-dom:
specifier: ^18.0.1
version: 18.0.1
react:
specifier: ^19.2.4
version: 19.2.4
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/secure-storage-web: packages/secure-storage-web:
devDependencies: devDependencies:
fake-indexeddb: fake-indexeddb:
@ -6609,6 +6667,276 @@ packages:
peerDependencies: peerDependencies:
'@testing-library/dom': '>=7.21.4' '@testing-library/dom': '>=7.21.4'
'@tiptap/core@3.23.6':
resolution:
{
integrity: sha512-MRB3pHz4Oxqmcawh0cQ5iOGdY5xtNYp/1CoK7hdTLzw5K0C6/gTC2VvanB1R4INaB6EpBkxG/GiWkVirDRnuXw==,
}
peerDependencies:
'@tiptap/pm': 3.23.6
'@tiptap/extension-blockquote@3.23.6':
resolution:
{
integrity: sha512-2RmnqNqTltZ2k1F7IfjoDNs935Uq4rRDR7d98mqkg3OlDktcQIyBpv0t9dTay6H5bkQeZUuS8ogK2S1E8Edjug==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/extension-bold@3.23.6':
resolution:
{
integrity: sha512-1LMhjnytdbbhWHSoOwnLxZAOQZWPkKyXVCNmaIk0Mhi4tLPUXptG4qKS5sVYTCveE5H6IBPFrbgBFi5dMI6krA==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/extension-bubble-menu@3.23.6':
resolution:
{
integrity: sha512-Mwkyp9LkDHFbqmWRIkp63FinRxFu3ajC4qSb9t4mnHsb4kAdbNLLsGtbFg+le0SWk4CxGwAOwM7SzeJ+6UGqCA==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/pm': 3.23.6
'@tiptap/extension-bullet-list@3.23.6':
resolution:
{
integrity: sha512-RMRgfXZykr/13X8UBOwvpgysVOo9KchwqMoEbvqQSj4YFfU56iIn59C8sbxiQ1sKfeltUf0wH4fPc0I4iwKqAA==,
}
peerDependencies:
'@tiptap/extension-list': 3.23.6
'@tiptap/extension-code-block@3.23.6':
resolution:
{
integrity: sha512-4kccgcn5yHThxrzsIhJny3EwfEZYIk+BjUCL4uIuzOyWvExtGhZ6JMHVCZeMhI8D1/bX1LNkkAKN5DXPzH4lXQ==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/pm': 3.23.6
'@tiptap/extension-code@3.23.6':
resolution:
{
integrity: sha512-KG8KXFYyLrtYvT7AZ1WGV61ofx8pDe5g9pH658MERxqQGii+Pyfc6xkz04l7XeBts/7+571UQp/0O7i/z560TA==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/extension-document@3.23.6':
resolution:
{
integrity: sha512-XDAIgG9KcKumFM9KJWUEUhXPbFIhhl47bfy5GknareWTRKke85rcoj/oxKKO9ihLZr8JfpbXjqnS4SCm5yhYPw==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/extension-dropcursor@3.23.6':
resolution:
{
integrity: sha512-+XWEoRKf3lXxi7Le1aOM2xU1XHwxICGpXjT3m4QaYqUgIpsq8gQEuso6kVg8DnTD7biKQs6+oIQ0o2b/gTW9WA==,
}
peerDependencies:
'@tiptap/extensions': 3.23.6
'@tiptap/extension-floating-menu@3.23.6':
resolution:
{
integrity: sha512-2kjuDcEq69lEcECl75xqY5MyzUSh2zcC5aLrpwP1WwhJz5bxsIFHiaps5AP6h9R4A+ZBj5b2haay2Y1wDUU3VA==,
}
peerDependencies:
'@floating-ui/dom': ^1.0.0
'@tiptap/core': 3.23.6
'@tiptap/pm': 3.23.6
'@tiptap/extension-gapcursor@3.23.6':
resolution:
{
integrity: sha512-wbKmxXsszxWacEkrHucRpSQbiKjz4fmOebD6OVyL9AcrmlbxNk8vcM3iyh/8cVeRy09XY+morM165t/u7/z4IQ==,
}
peerDependencies:
'@tiptap/extensions': 3.23.6
'@tiptap/extension-hard-break@3.23.6':
resolution:
{
integrity: sha512-KeUm+tkUfIVSX9QM9XOIhaay0Fn36sLKUo5NVYjN3uJaxFvaZXZmTlxdO85OTdgF2P5sqh9LomrIgliaFRGk4w==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/extension-heading@3.23.6':
resolution:
{
integrity: sha512-A/0jPhxnUh9THSZymlu0OGPZe1wdFdwHAXnRCmqvYUCwJjrG7LCC/ahzmcj1tcNzI9hgHyuYPSfev8RXYrNu/w==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/extension-horizontal-rule@3.23.6':
resolution:
{
integrity: sha512-hEUlz4H+I64r+TH6LCuNCRgO7JTHncXGmx9+WbU69EOfY8O0ZurcgeJc8HeiAKL+r9YuC1e5YHfFxgCaaC0jlg==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/pm': 3.23.6
'@tiptap/extension-italic@3.23.6':
resolution:
{
integrity: sha512-wol5KdwCPAvpiYhH9PLlvO8ZnJHwZtIboVevrfOGgBcKlXRA3dedR4OAMXHnUtkkzu9KtliLg1+TYzEx4JZG9Q==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/extension-link@3.23.6':
resolution:
{
integrity: sha512-KNZz7z7P2/qbQsx5bPAbSPjrKDg1VHsedGlLHJCr8U2VRD5VgmDLkMpkouP1CsDg15qgyUKv/nDib5KgPpLNWA==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/pm': 3.23.6
'@tiptap/extension-list-item@3.23.6':
resolution:
{
integrity: sha512-3zzyhdkUWcHVpXuvy6KiIwjh29rbH6gEDEqPQqHLrl1XGnO9pnShC7pSHctlCDjmcx3O4n9cd4QMtVBlUerbiA==,
}
peerDependencies:
'@tiptap/extension-list': 3.23.6
'@tiptap/extension-list-keymap@3.23.6':
resolution:
{
integrity: sha512-x8bPcLViGzg/RAmQM/XtmfqIwQ/Pv9Q8mkd+OgfUiTqjeJqKwVQmiqbLFNa7zw81+H61M+HDU+qGAaQ3vRIMjw==,
}
peerDependencies:
'@tiptap/extension-list': 3.23.6
'@tiptap/extension-list@3.23.6':
resolution:
{
integrity: sha512-z6vj9+Qht2sjdQkyyHcUpsC/yCIZqTrQiyHDhs/HGKrfvoANyAZGpqdNeKf1wSyjIso+27tQuIH5NDfk8ygyNw==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/pm': 3.23.6
'@tiptap/extension-mention@3.23.6':
resolution:
{
integrity: sha512-rSjeAAtuMwMA1lj4nbxz3rbmM06yPFUc8TFzhrEpmA4/l5XNWOk/PQef6uiGN+Isv2Z2PrIhr8XrR7Me8OSCiA==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/pm': 3.23.6
'@tiptap/suggestion': 3.23.6
'@tiptap/extension-ordered-list@3.23.6':
resolution:
{
integrity: sha512-1m/wWB/ZtXcmG2vNdiUkCqsOgqv5vBjCv/mVaHhF9OvV+zQS8YDjoWE7zEuT/GgELdT77Xq8lHrn4nCDudB3/A==,
}
peerDependencies:
'@tiptap/extension-list': 3.23.6
'@tiptap/extension-paragraph@3.23.6':
resolution:
{
integrity: sha512-+7m58LUSncodjrIyXks4RZ3tLNYrvgT77wRR4l3HnM5OABY3GDsDTqi7c1t1yI29NVOSk/DUacqy6UwYAj1DGg==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/extension-placeholder@3.23.6':
resolution:
{
integrity: sha512-8I6b2aevF74aLgymKMxbDxSLxWA2y+2dh0zZDeI8sRZ2m6WHHes+Kyuuwkq1HIPcR+ZLpbec74cmf6lcL/yvqQ==,
}
peerDependencies:
'@tiptap/extensions': 3.23.6
'@tiptap/extension-strike@3.23.6':
resolution:
{
integrity: sha512-oF7FEZ37f15aCe5kPgzGDYf/m+hr7VdQ/Ko/Hds/UM9pX7AG1fdtmRrl6wqkRqDM/incZaC/AQR2/Dpo2VCNGQ==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/extension-text@3.23.6':
resolution:
{
integrity: sha512-ipoC2TkIAIOTiF5ByiGgvQB1DqDyfP90wrUB3mohBcgvp7lQnwHszCDGv8dNnmcUek8uXV/uoLu2VXeVQlxjPA==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/extension-underline@3.23.6':
resolution:
{
integrity: sha512-P55wGIZGYTVH92Fq0cgI4/O9AhLCaJC3hhxg15RSERP5/YegM9eJHDK/GQ1EE/DvYA+xpYGOV6agKwAUqfA/Iw==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/extensions@3.23.6':
resolution:
{
integrity: sha512-X09/Db1teB+ifXzDGVVFmOeQRx7wTAayE9/280spxpsHkHZvJ5bHRvWIzUzviMIjbBz+NPDIKYPK7gMfh9iaig==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/pm': 3.23.6
'@tiptap/html@3.23.6':
resolution:
{
integrity: sha512-cfQ8ijvkhkbt02x0tjtcahubWCqxkO7IJow0j2MgS6FHdXKv2QUrIvcpAqIqdv0lA4ozWmdmUpLFv+lA95kcPg==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/pm': 3.23.6
happy-dom: ^20.8.9
'@tiptap/pm@3.23.6':
resolution:
{
integrity: sha512-in5CaMaWlJcH2A1q6GJKFtrodE8WLS3M9tIi/f89jPmIVHJShpodC0KZDNyJkrVBQomYk0DEh86Utm6ASXzQww==,
}
'@tiptap/react@3.23.6':
resolution:
{
integrity: sha512-Tw9KZkYqFMk3vaJAEQKqEYIO/iq3cSJe7OUEGBul4k4GaMQeLItLf5EYhUd0GIPXci1WVVPNntKJsHfX25M37w==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/pm': 3.23.6
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
'@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
'@tiptap/starter-kit@3.23.6':
resolution:
{
integrity: sha512-gykwtGWrnWCmtql1hid3opac/KV8zQvOAnu3bTqIqcHrn1FusbUwKmNzavSbfGvcktHM3hFjb35W48JyVLyu/A==,
}
'@tiptap/suggestion@3.23.6':
resolution:
{
integrity: sha512-YAoI2jctPClcyUhIcpxb1QlrUFG2a1Xsv1gS4tIfgh5KoOuEfGfCoeCq89TKgz/rHeP+ktRhzg1E2E4EY68HEA==,
}
peerDependencies:
'@tiptap/core': 3.23.6
'@tiptap/pm': 3.23.6
'@ts-morph/common@0.27.0': '@ts-morph/common@0.27.0':
resolution: resolution:
{ {
@ -9739,6 +10067,13 @@ packages:
integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==,
} }
fast-equals@5.4.0:
resolution:
{
integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==,
}
engines: { node: '>=6.0.0' }
fast-glob@3.3.1: fast-glob@3.3.1:
resolution: resolution:
{ {
@ -11624,6 +11959,12 @@ packages:
integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==, integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==,
} }
linkifyjs@4.3.3:
resolution:
{
integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==,
}
lint-staged@15.5.2: lint-staged@15.5.2:
resolution: resolution:
{ {
@ -12991,6 +13332,12 @@ packages:
} }
engines: { node: '>=18' } engines: { node: '>=18' }
orderedmap@2.1.1:
resolution:
{
integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==,
}
outdent@0.5.0: outdent@0.5.0:
resolution: resolution:
{ {
@ -13533,6 +13880,78 @@ packages:
integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==, integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==,
} }
prosemirror-changeset@2.4.1:
resolution:
{
integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==,
}
prosemirror-commands@1.7.1:
resolution:
{
integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==,
}
prosemirror-dropcursor@1.8.2:
resolution:
{
integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==,
}
prosemirror-gapcursor@1.4.1:
resolution:
{
integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==,
}
prosemirror-history@1.5.0:
resolution:
{
integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==,
}
prosemirror-keymap@1.2.3:
resolution:
{
integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==,
}
prosemirror-model@1.25.7:
resolution:
{
integrity: sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==,
}
prosemirror-schema-list@1.5.1:
resolution:
{
integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==,
}
prosemirror-state@1.4.4:
resolution:
{
integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==,
}
prosemirror-tables@1.8.5:
resolution:
{
integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==,
}
prosemirror-transform@1.12.0:
resolution:
{
integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==,
}
prosemirror-view@1.41.8:
resolution:
{
integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==,
}
protobufjs@7.5.4: protobufjs@7.5.4:
resolution: resolution:
{ {
@ -14067,6 +14486,12 @@ packages:
engines: { node: '>=18.0.0', npm: '>=8.0.0' } engines: { node: '>=18.0.0', npm: '>=8.0.0' }
hasBin: true hasBin: true
rope-sequence@1.3.4:
resolution:
{
integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==,
}
router@2.2.0: router@2.2.0:
resolution: resolution:
{ {
@ -15676,6 +16101,12 @@ packages:
integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==, integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==,
} }
w3c-keyname@2.2.8:
resolution:
{
integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==,
}
w3c-xmlserializer@5.0.0: w3c-xmlserializer@5.0.0:
resolution: resolution:
{ {
@ -20185,6 +20616,198 @@ snapshots:
dependencies: dependencies:
'@testing-library/dom': 10.4.0 '@testing-library/dom': 10.4.0
'@tiptap/core@3.23.6(@tiptap/pm@3.23.6)':
dependencies:
'@tiptap/pm': 3.23.6
'@tiptap/extension-blockquote@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/extension-bold@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/extension-bubble-menu@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)':
dependencies:
'@floating-ui/dom': 1.7.5
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/pm': 3.23.6
optional: true
'@tiptap/extension-bullet-list@3.23.6(@tiptap/extension-list@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/extension-list': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)
'@tiptap/extension-code-block@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/pm': 3.23.6
'@tiptap/extension-code@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/extension-document@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/extension-dropcursor@3.23.6(@tiptap/extensions@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/extensions': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)
'@tiptap/extension-floating-menu@3.23.6(@floating-ui/dom@1.7.5)(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)':
dependencies:
'@floating-ui/dom': 1.7.5
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/pm': 3.23.6
optional: true
'@tiptap/extension-gapcursor@3.23.6(@tiptap/extensions@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/extensions': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)
'@tiptap/extension-hard-break@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/extension-heading@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/extension-horizontal-rule@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/pm': 3.23.6
'@tiptap/extension-italic@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/extension-link@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/pm': 3.23.6
linkifyjs: 4.3.3
'@tiptap/extension-list-item@3.23.6(@tiptap/extension-list@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/extension-list': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)
'@tiptap/extension-list-keymap@3.23.6(@tiptap/extension-list@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/extension-list': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)
'@tiptap/extension-list@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/pm': 3.23.6
'@tiptap/extension-mention@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)(@tiptap/suggestion@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/pm': 3.23.6
'@tiptap/suggestion': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)
'@tiptap/extension-ordered-list@3.23.6(@tiptap/extension-list@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/extension-list': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)
'@tiptap/extension-paragraph@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/extension-placeholder@3.23.6(@tiptap/extensions@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/extensions': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)
'@tiptap/extension-strike@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/extension-text@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/extension-underline@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/extensions@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/pm': 3.23.6
'@tiptap/html@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)(happy-dom@18.0.1)':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/pm': 3.23.6
happy-dom: 18.0.1
'@tiptap/pm@3.23.6':
dependencies:
prosemirror-changeset: 2.4.1
prosemirror-commands: 1.7.1
prosemirror-dropcursor: 1.8.2
prosemirror-gapcursor: 1.4.1
prosemirror-history: 1.5.0
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.7
prosemirror-schema-list: 1.5.1
prosemirror-state: 1.4.4
prosemirror-tables: 1.8.5
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
'@tiptap/react@3.23.6(@floating-ui/dom@1.7.5)(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/pm': 3.23.6
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@types/use-sync-external-store': 0.0.6
fast-equals: 5.4.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@tiptap/extension-bubble-menu': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)
'@tiptap/extension-floating-menu': 3.23.6(@floating-ui/dom@1.7.5)(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)
transitivePeerDependencies:
- '@floating-ui/dom'
'@tiptap/starter-kit@3.23.6':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/extension-blockquote': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))
'@tiptap/extension-bold': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))
'@tiptap/extension-bullet-list': 3.23.6(@tiptap/extension-list@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))
'@tiptap/extension-code': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))
'@tiptap/extension-code-block': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)
'@tiptap/extension-document': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))
'@tiptap/extension-dropcursor': 3.23.6(@tiptap/extensions@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))
'@tiptap/extension-gapcursor': 3.23.6(@tiptap/extensions@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))
'@tiptap/extension-hard-break': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))
'@tiptap/extension-heading': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))
'@tiptap/extension-horizontal-rule': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)
'@tiptap/extension-italic': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))
'@tiptap/extension-link': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)
'@tiptap/extension-list': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)
'@tiptap/extension-list-item': 3.23.6(@tiptap/extension-list@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))
'@tiptap/extension-list-keymap': 3.23.6(@tiptap/extension-list@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))
'@tiptap/extension-ordered-list': 3.23.6(@tiptap/extension-list@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6))
'@tiptap/extension-paragraph': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))
'@tiptap/extension-strike': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))
'@tiptap/extension-text': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))
'@tiptap/extension-underline': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))
'@tiptap/extensions': 3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)
'@tiptap/pm': 3.23.6
'@tiptap/suggestion@3.23.6(@tiptap/core@3.23.6(@tiptap/pm@3.23.6))(@tiptap/pm@3.23.6)':
dependencies:
'@tiptap/core': 3.23.6(@tiptap/pm@3.23.6)
'@tiptap/pm': 3.23.6
'@ts-morph/common@0.27.0': '@ts-morph/common@0.27.0':
dependencies: dependencies:
fast-glob: 3.3.3 fast-glob: 3.3.3
@ -22458,6 +23081,8 @@ snapshots:
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-equals@5.4.0: {}
fast-glob@3.3.1: fast-glob@3.3.1:
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@ -23576,6 +24201,8 @@ snapshots:
lines-and-columns@1.2.4: {} lines-and-columns@1.2.4: {}
linkifyjs@4.3.3: {}
lint-staged@15.5.2: lint-staged@15.5.2:
dependencies: dependencies:
chalk: 5.6.2 chalk: 5.6.2
@ -24801,6 +25428,8 @@ snapshots:
string-width: 7.2.0 string-width: 7.2.0
strip-ansi: 7.1.2 strip-ansi: 7.1.2
orderedmap@2.1.1: {}
outdent@0.5.0: {} outdent@0.5.0: {}
outvariant@1.4.3: {} outvariant@1.4.3: {}
@ -25101,6 +25730,75 @@ snapshots:
property-information@7.1.0: {} property-information@7.1.0: {}
prosemirror-changeset@2.4.1:
dependencies:
prosemirror-transform: 1.12.0
prosemirror-commands@1.7.1:
dependencies:
prosemirror-model: 1.25.7
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-dropcursor@1.8.2:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
prosemirror-gapcursor@1.4.1:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.7
prosemirror-state: 1.4.4
prosemirror-view: 1.41.8
prosemirror-history@1.5.0:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
rope-sequence: 1.3.4
prosemirror-keymap@1.2.3:
dependencies:
prosemirror-state: 1.4.4
w3c-keyname: 2.2.8
prosemirror-model@1.25.7:
dependencies:
orderedmap: 2.1.1
prosemirror-schema-list@1.5.1:
dependencies:
prosemirror-model: 1.25.7
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-state@1.4.4:
dependencies:
prosemirror-model: 1.25.7
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
prosemirror-tables@1.8.5:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.7
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
prosemirror-transform@1.12.0:
dependencies:
prosemirror-model: 1.25.7
prosemirror-view@1.41.8:
dependencies:
prosemirror-model: 1.25.7
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
protobufjs@7.5.4: protobufjs@7.5.4:
dependencies: dependencies:
'@protobufjs/aspromise': 1.1.2 '@protobufjs/aspromise': 1.1.2
@ -25637,6 +26335,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.57.1 '@rollup/rollup-win32-x64-msvc': 4.57.1
fsevents: 2.3.3 fsevents: 2.3.3
rope-sequence@1.3.4: {}
router@2.2.0: router@2.2.0:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
@ -26557,6 +27257,10 @@ snapshots:
dependencies: dependencies:
react: 19.2.3 react: 19.2.3
use-sync-external-store@1.6.0(react@19.2.4):
dependencies:
react: 19.2.4
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
util@0.12.5: util@0.12.5:
@ -26888,6 +27592,8 @@ snapshots:
vlq@1.0.1: {} vlq@1.0.1: {}
w3c-keyname@2.2.8: {}
w3c-xmlserializer@5.0.0: w3c-xmlserializer@5.0.0:
dependencies: dependencies:
xml-name-validator: 5.0.0 xml-name-validator: 5.0.0