@podoba/react 0.0.30 → 0.0.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,522 @@
1
+ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { EditorContent, useEditor, useEditorState, type Editor } from '@tiptap/react'
4
+ import { BubbleMenu } from '@tiptap/react/menus'
5
+ import type { EditorState } from '@tiptap/pm/state'
6
+ import StarterKit from '@tiptap/starter-kit'
7
+ import Placeholder from '@tiptap/extension-placeholder'
8
+ import Highlight from '@tiptap/extension-highlight'
9
+ import TaskList from '@tiptap/extension-task-list'
10
+ import TaskItem from '@tiptap/extension-task-item'
11
+ import { clsx } from 'clsx'
12
+ import { SAFE_LINK_HINT, safeLinkUrl } from '../utils/safe-link-url'
13
+
14
+ /**
15
+ * BlockEditor — a Notion-style block/rich-text editor for podoba, on Tiptap 3
16
+ * (ProseMirror). Opt-in subpath (`@podoba/react/editor`) so the base library stays
17
+ * free of the ProseMirror weight — importing this is what pulls Tiptap into a bundle.
18
+ * The Tiptap packages are OPTIONAL PEERS: install them alongside @podoba/react only
19
+ * if you import this subpath.
20
+ *
21
+ * Serialises to an **HTML string** (controlled `value` / `onChange(html)`), so it is a
22
+ * drop-in upgrade for any `set:html` / `dangerouslySetInnerHTML` renderer with no value
23
+ * migration. SECURITY: like any editor, it can emit arbitrary HTML — sanitise on the
24
+ * SERVER on write; the `value`/`onChange` contract here is presentation only.
25
+ *
26
+ * CONTROLLED-VALUE CONTRACT: echo `onChange`'s HTML back as `value` VERBATIM. The
27
+ * editor only re-seeds its document when `value` differs from what it last emitted, so
28
+ * a parent that normalises/sanitises before echoing would re-seed on every keystroke
29
+ * and send the caret back to the start. Sanitise on write to your store, not in render.
30
+ *
31
+ * Notion features: `/` slash menu (self-contained — insert paragraph/heading/list/
32
+ * to-do/quote/code/divider), an inline bubble toolbar (bold/italic/strike/highlight/
33
+ * code/link), and StarterKit's markdown input rules. Styling rides `@tailwindcss/
34
+ * typography` (`prose`), which @podoba/tailwind already registers, plus design tokens
35
+ * so it flips under `[data-theme="dark"]`.
36
+ *
37
+ * Prefer this over {@link ../components/rich-text-editor RichTextEditor} for
38
+ * document-shaped content; the dependency-free contentEditable one stays the right
39
+ * pick for a short caption/bio field where ProseMirror is not worth installing.
40
+ */
41
+ export type BlockEditorProps = {
42
+ /** Controlled HTML value. Must be echoed back verbatim — see the contract above. */
43
+ value: string
44
+ /** Called with the editor's HTML on every change. */
45
+ onChange: (html: string) => void
46
+ /** Empty-document placeholder (also the `/`-hint). */
47
+ placeholder?: string
48
+ /** Optional visible label rendered above the editor; names the editable region. */
49
+ label?: ReactNode
50
+ /** Set false for a read-only render. */
51
+ editable?: boolean
52
+ /** Min height of the editable surface (default 200px). */
53
+ minHeight?: number | string
54
+ /** Class on the outer container. */
55
+ className?: string
56
+ /** Accessible name when there is no visible `label`. */
57
+ 'aria-label'?: string
58
+ }
59
+
60
+ type SlashCommand = {
61
+ title: string
62
+ hint: string
63
+ keywords: string[]
64
+ run: (editor: Editor) => void
65
+ }
66
+
67
+ // The `/` block palette. Kept generic (no domain blocks) — a CMS/app composes richer
68
+ // block types around this at the document level; this is the text-block vocabulary.
69
+ const SLASH_COMMANDS: readonly SlashCommand[] = [
70
+ { title: 'Text', hint: 'Plain paragraph', keywords: ['text', 'paragraph', 'body', 'p'], run: (e) => e.chain().focus().setParagraph().run() },
71
+ { title: 'Heading 1', hint: 'Big section heading', keywords: ['h1', 'heading', 'title'], run: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() },
72
+ { title: 'Heading 2', hint: 'Medium heading', keywords: ['h2', 'subheading'], run: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() },
73
+ { title: 'Heading 3', hint: 'Small heading', keywords: ['h3'], run: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() },
74
+ { title: 'Bulleted list', hint: 'Unordered list', keywords: ['bullet', 'unordered', 'ul', 'list'], run: (e) => e.chain().focus().toggleBulletList().run() },
75
+ { title: 'Numbered list', hint: 'Ordered list', keywords: ['numbered', 'ordered', 'ol', 'list'], run: (e) => e.chain().focus().toggleOrderedList().run() },
76
+ { title: 'To-do list', hint: 'Checklist', keywords: ['todo', 'task', 'checkbox', 'check'], run: (e) => e.chain().focus().toggleTaskList().run() },
77
+ { title: 'Quote', hint: 'Block quote', keywords: ['quote', 'blockquote', 'citation'], run: (e) => e.chain().focus().toggleBlockquote().run() },
78
+ { title: 'Code', hint: 'Code block', keywords: ['code', 'snippet', 'pre'], run: (e) => e.chain().focus().toggleCodeBlock().run() },
79
+ { title: 'Divider', hint: 'Horizontal rule', keywords: ['divider', 'rule', 'hr', 'separator'], run: (e) => e.chain().focus().setHorizontalRule().run() },
80
+ ]
81
+
82
+ /** Open palette: the doc position of the trigger `/`, the query typed after it, and
83
+ * the highlighted row. Screen position is derived from `from` at paint time, never
84
+ * stored — so the menu can be re-placed on scroll/resize without stale coordinates. */
85
+ type SlashState = { from: number; query: string; index: number }
86
+
87
+ /** Filter the palette by title or keyword. Exported for tests. */
88
+ export function filterCommands(query: string): SlashCommand[] {
89
+ const q = query.trim().toLowerCase()
90
+ if (!q) return [...SLASH_COMMANDS]
91
+ return SLASH_COMMANDS.filter((c) => c.title.toLowerCase().includes(q) || c.keywords.some((k) => k.includes(q)))
92
+ }
93
+
94
+ /** A `/` only opens the palette at the start of a block or after whitespace — never
95
+ * mid-word, so `and/or`, `https://…` and `/api/v2` stay plain text. Code blocks are
96
+ * excluded outright: a `/` there is always literal. Exported for tests. */
97
+ export function canOpenSlash(state: EditorState, from: number): boolean {
98
+ if (from < 0 || from > state.doc.content.size) return false
99
+ const $from = state.doc.resolve(from)
100
+ if ($from.parent.type.spec.code) return false
101
+ if ($from.parentOffset === 0) return true
102
+ // textBetween returns '' for a non-text node (inline image, mention) — treat that
103
+ // as a boundary too; only a real word character blocks the trigger.
104
+ return /^\s*$/.test($from.parent.textBetween($from.parentOffset - 1, $from.parentOffset))
105
+ }
106
+
107
+ const btn =
108
+ 'inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-small text-fg-muted transition-colors hover:bg-surface-muted hover:text-fg data-[active=true]:bg-surface-muted data-[active=true]:text-fg'
109
+
110
+ /** Marks the bubble toolbar toggles. `active` keys read off the useEditorState
111
+ * snapshot below — Tiptap 3 does NOT re-render on transactions, so a plain
112
+ * `editor.isActive()` read during render would go stale on selection-only changes. */
113
+ const MARK_TOOLS = [
114
+ { key: 'bold', title: 'Bold', label: <b>B</b>, run: (e: Editor) => e.chain().focus().toggleBold().run() },
115
+ { key: 'italic', title: 'Italic', label: <i>i</i>, run: (e: Editor) => e.chain().focus().toggleItalic().run() },
116
+ { key: 'strike', title: 'Strikethrough', label: <s>S</s>, run: (e: Editor) => e.chain().focus().toggleStrike().run() },
117
+ { key: 'highlight', title: 'Highlight', label: 'H', run: (e: Editor) => e.chain().focus().toggleHighlight().run() },
118
+ { key: 'code', title: 'Inline code', label: '</>', run: (e: Editor) => e.chain().focus().toggleCode().run() },
119
+ ] as const
120
+
121
+ const LinkIcon = () => (
122
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
123
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
124
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
125
+ </svg>
126
+ )
127
+
128
+ export function BlockEditor({ value, onChange, placeholder = "Write, or press '/' for blocks…", label, editable = true, minHeight = 200, className, ...aria }: BlockEditorProps) {
129
+ const onChangeRef = useRef(onChange)
130
+ onChangeRef.current = onChange
131
+ const lastEmitted = useRef<string>(value)
132
+ // The Placeholder extension is configured once at editor creation, so read the
133
+ // current prop through a ref — that keeps `placeholder` live without recreating
134
+ // the whole editor on every change.
135
+ const placeholderRef = useRef(placeholder)
136
+ placeholderRef.current = placeholder
137
+
138
+ // Slash state lives in a REF as well as in React state: ProseMirror handlers run
139
+ // synchronously inside the key event, long before React re-renders, so reading a
140
+ // render-assigned ref would lag a full render (two fast `/`s would double-open).
141
+ // `setSlash` writes both, so handlers always see what the last one wrote.
142
+ const slashRef = useRef<SlashState | null>(null)
143
+ const [slash, setSlashState] = useState<SlashState | null>(null)
144
+ const setSlash = useCallback((next: SlashState | null) => {
145
+ slashRef.current = next
146
+ setSlashState(next)
147
+ }, [])
148
+
149
+ const [linkOpen, setLinkOpen] = useState(false)
150
+ const linkOpenRef = useRef(false)
151
+ linkOpenRef.current = linkOpen
152
+ const [linkDraft, setLinkDraft] = useState('')
153
+ const [linkError, setLinkError] = useState<string | null>(null)
154
+ const linkInputRef = useRef<HTMLInputElement>(null)
155
+
156
+ const menuRef = useRef<HTMLDivElement>(null)
157
+ const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null)
158
+ // The palette portals to <body>, so mount first — a portal has no server render.
159
+ const [mounted, setMounted] = useState(false)
160
+ useEffect(() => setMounted(true), [])
161
+
162
+ const reactId = useId()
163
+ const labelId = `${reactId}-label`
164
+ const listboxId = `${reactId}-slash`
165
+ const optionId = (i: number) => `${listboxId}-option-${i}`
166
+
167
+ // `runSlash` closes over `editor`, which does not exist yet — hand the handlers a
168
+ // ref instead of relying on Tiptap re-applying options after every render.
169
+ const runSlashRef = useRef<(cmd: SlashCommand) => void>(() => {})
170
+
171
+ const ariaLabel = aria['aria-label']
172
+ const filtered = useMemo(() => filterCommands(slash?.query ?? ''), [slash?.query])
173
+ const activeOption = slash ? Math.min(slash.index, Math.max(filtered.length - 1, 0)) : 0
174
+ const paletteOpen = slash !== null && filtered.length > 0
175
+
176
+ const editor = useEditor({
177
+ editable,
178
+ // Explicit: Tiptap otherwise renders immediately and warns on every SSR pass.
179
+ // A published library cannot assume a client-only host.
180
+ immediatelyRender: false,
181
+ extensions: [
182
+ StarterKit.configure({ heading: { levels: [1, 2, 3] }, link: { openOnClick: false, autolink: true } }),
183
+ Highlight,
184
+ TaskList,
185
+ TaskItem.configure({ nested: true }),
186
+ Placeholder.configure({ placeholder: () => placeholderRef.current }),
187
+ ],
188
+ content: value || '',
189
+ editorProps: {
190
+ attributes: {
191
+ class: clsx(
192
+ 'prose prose-sm max-w-none px-4 py-3 text-fg outline-none',
193
+ 'prose-headings:text-fg prose-p:text-fg prose-strong:text-fg prose-a:text-accent-strong',
194
+ 'prose-code:text-fg prose-blockquote:text-fg-muted prose-li:text-fg',
195
+ ),
196
+ style: `min-height:${typeof minHeight === 'number' ? `${minHeight}px` : minHeight}`,
197
+ // The contenteditable IS the textbox — naming the EditorContent wrapper
198
+ // instead would leave it nameless to assistive tech.
199
+ role: 'textbox',
200
+ 'aria-multiline': 'true',
201
+ ...(label ? { 'aria-labelledby': labelId } : {}),
202
+ ...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
203
+ 'aria-expanded': paletteOpen ? 'true' : 'false',
204
+ ...(paletteOpen ? { 'aria-controls': listboxId, 'aria-activedescendant': optionId(activeOption) } : {}),
205
+ },
206
+ // The `/` trigger fires on the actual character insert, so it covers every
207
+ // input method (IME, synthetic input, paste of a single char) with one path.
208
+ handleTextInput(view, from, _to, text) {
209
+ if (text !== '/' || slashRef.current) return false
210
+ if (!canOpenSlash(view.state, from)) return false
211
+ setSlash({ from, query: '', index: 0 })
212
+ return false // let the `/` type; onUpdate tracks it as the query prefix
213
+ },
214
+ handleKeyDown(_view, event) {
215
+ const s = slashRef.current
216
+ if (!s) return false
217
+ if (event.key === 'Escape') {
218
+ setSlash(null)
219
+ return true
220
+ }
221
+ const list = filterCommands(s.query)
222
+ // Nothing to pick — close and let the key through. Swallowing Enter and
223
+ // the arrows here is what used to strand the caret after `/api/v2`.
224
+ if (list.length === 0) {
225
+ setSlash(null)
226
+ return false
227
+ }
228
+ if (event.key === 'ArrowDown') {
229
+ setSlash({ ...s, index: (s.index + 1) % list.length })
230
+ return true
231
+ }
232
+ if (event.key === 'ArrowUp') {
233
+ setSlash({ ...s, index: (s.index - 1 + list.length) % list.length })
234
+ return true
235
+ }
236
+ if (event.key === 'Enter' || event.key === 'Tab') {
237
+ runSlashRef.current(list[Math.min(s.index, list.length - 1)])
238
+ return true
239
+ }
240
+ return false
241
+ },
242
+ },
243
+ onUpdate({ editor }) {
244
+ const html = editor.getHTML()
245
+ lastEmitted.current = html
246
+ onChangeRef.current(html)
247
+ // Track the `/query` the caret is typing after an open palette.
248
+ const s = slashRef.current
249
+ if (!s) return
250
+ const to = editor.state.selection.from
251
+ if (to < s.from) return setSlash(null)
252
+ const text = editor.state.doc.textBetween(s.from, to, '\n', '\n')
253
+ if (!text.startsWith('/')) return setSlash(null)
254
+ const query = text.slice(1)
255
+ // A block query is one word. Whitespace, or a query that matches nothing,
256
+ // means the user is writing prose — close rather than linger invisibly.
257
+ if (/\s/.test(query) || filterCommands(query).length === 0) return setSlash(null)
258
+ setSlash({ ...s, query, index: 0 })
259
+ },
260
+ // A pure caret move (click, arrow, select-all) fires no update — close the
261
+ // palette when the caret leaves the `/query` it belongs to.
262
+ onSelectionUpdate({ editor }) {
263
+ const s = slashRef.current
264
+ if (!s) return
265
+ const to = editor.state.selection.from
266
+ if (to < s.from || to > s.from + s.query.length + 1) setSlash(null)
267
+ },
268
+ onBlur() {
269
+ setSlash(null)
270
+ },
271
+ })
272
+
273
+ // Push EXTERNAL value changes into the editor (switching records, resets) without
274
+ // clobbering the caret on our own keystrokes.
275
+ useEffect(() => {
276
+ if (!editor) return
277
+ if (value !== lastEmitted.current && value !== editor.getHTML()) {
278
+ editor.commands.setContent(value || '', { emitUpdate: false })
279
+ lastEmitted.current = value
280
+ }
281
+ }, [value, editor])
282
+
283
+ // Tiptap's own re-render pass re-applies options with `editable` pinned to the
284
+ // live instance value, so the prop has to be pushed through this side channel.
285
+ useEffect(() => {
286
+ if (editor) editor.setEditable(editable)
287
+ }, [editable, editor])
288
+
289
+ const runSlash = useCallback(
290
+ (cmd: SlashCommand | undefined) => {
291
+ const s = slashRef.current
292
+ if (!editor || !s || !cmd) return
293
+ // Clamp: the doc can have shrunk under an open palette (an external
294
+ // setContent), which would make deleteRange throw on a stale position.
295
+ const size = editor.state.doc.content.size
296
+ const from = Math.min(Math.max(s.from, 0), size)
297
+ const to = Math.min(Math.max(editor.state.selection.from, from), size)
298
+ editor.chain().focus().deleteRange({ from, to }).run()
299
+ cmd.run(editor)
300
+ setSlash(null)
301
+ },
302
+ [editor, setSlash],
303
+ )
304
+ runSlashRef.current = runSlash
305
+
306
+ // Place the palette from the caret's CURRENT viewport coords, clamped into the
307
+ // viewport and flipped above the caret when it would overflow the bottom.
308
+ const placeMenu = useCallback(() => {
309
+ const s = slashRef.current
310
+ const el = menuRef.current
311
+ if (!editor || !s || !el) return
312
+ const size = editor.state.doc.content.size
313
+ if (s.from > size) return setSlash(null)
314
+ const caret = editor.view.coordsAtPos(s.from)
315
+ const rect = el.getBoundingClientRect()
316
+ const gap = 6
317
+ const edge = 8
318
+ let top = caret.bottom + gap
319
+ if (top + rect.height > window.innerHeight - edge) top = Math.max(edge, caret.top - gap - rect.height)
320
+ const left = Math.max(edge, Math.min(caret.left, window.innerWidth - rect.width - edge))
321
+ setMenuPos((prev) => (prev && prev.left === left && prev.top === top ? prev : { left, top }))
322
+ }, [editor, setSlash])
323
+
324
+ useLayoutEffect(() => {
325
+ if (slash) placeMenu()
326
+ else setMenuPos(null)
327
+ }, [slash, placeMenu])
328
+
329
+ // Fixed coordinates go stale the moment anything scrolls — including a scroll
330
+ // inside the editor itself, hence the capture-phase listener.
331
+ useEffect(() => {
332
+ if (!slash) return
333
+ const replace = () => placeMenu()
334
+ window.addEventListener('scroll', replace, true)
335
+ window.addEventListener('resize', replace)
336
+ return () => {
337
+ window.removeEventListener('scroll', replace, true)
338
+ window.removeEventListener('resize', replace)
339
+ }
340
+ }, [slash, placeMenu])
341
+
342
+ // Selection-driven mark states. Tiptap 3's useEditor deliberately does not
343
+ // re-render on transactions; this subscription is what keeps the toolbar honest.
344
+ const active = useEditorState({
345
+ editor,
346
+ selector: ({ editor }) =>
347
+ editor
348
+ ? {
349
+ bold: editor.isActive('bold'),
350
+ italic: editor.isActive('italic'),
351
+ strike: editor.isActive('strike'),
352
+ highlight: editor.isActive('highlight'),
353
+ code: editor.isActive('code'),
354
+ link: editor.isActive('link'),
355
+ hasSelection: !editor.state.selection.empty,
356
+ }
357
+ : null,
358
+ })
359
+
360
+ const closeLink = useCallback(() => {
361
+ setLinkOpen(false)
362
+ setLinkError(null)
363
+ linkOpenRef.current = false
364
+ }, [])
365
+
366
+ // The link panel replaces the toolbar in place; a collapsed selection means the
367
+ // bubble menu is on its way out, so it must not strand an open panel.
368
+ useEffect(() => {
369
+ if (linkOpen && active && !active.hasSelection && !active.link) closeLink()
370
+ }, [linkOpen, active, closeLink])
371
+
372
+ useEffect(() => {
373
+ if (linkOpen) linkInputRef.current?.focus()
374
+ }, [linkOpen])
375
+
376
+ const openLink = () => {
377
+ if (!editor) return
378
+ setLinkDraft((editor.getAttributes('link').href as string | undefined) ?? '')
379
+ setLinkError(null)
380
+ linkOpenRef.current = true
381
+ setLinkOpen(true)
382
+ }
383
+
384
+ const applyLink = () => {
385
+ if (!editor) return
386
+ if (linkDraft.trim() === '') {
387
+ editor.chain().focus().extendMarkRange('link').unsetLink().run()
388
+ closeLink()
389
+ return
390
+ }
391
+ const url = safeLinkUrl(linkDraft)
392
+ if (!url) return setLinkError(SAFE_LINK_HINT)
393
+ editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
394
+ closeLink()
395
+ }
396
+
397
+ return (
398
+ <div className={clsx('flex w-full flex-col gap-2', className)}>
399
+ {label ? (
400
+ <span id={labelId} className="text-small font-medium text-fg">
401
+ {label}
402
+ </span>
403
+ ) : null}
404
+ <div className="relative rounded-lg border border-border bg-surface focus-within:border-brand-green">
405
+ {editor ? (
406
+ <BubbleMenu
407
+ editor={editor}
408
+ // Mirrors Tiptap's default (focus + non-empty selection), plus: stay up
409
+ // while the link panel owns focus, or typing a URL would dismiss itself.
410
+ shouldShow={({ editor, view, state, element }) => {
411
+ const inMenu = element.contains(document.activeElement)
412
+ if (!editor.isEditable || !(view.hasFocus() || inMenu)) return false
413
+ return linkOpenRef.current || !state.selection.empty
414
+ }}
415
+ className="flex flex-col gap-1 rounded-lg border border-border bg-surface-card p-1 shadow-md"
416
+ >
417
+ {linkOpen ? (
418
+ <>
419
+ <div className="flex items-center gap-1">
420
+ <input
421
+ ref={linkInputRef}
422
+ type="text"
423
+ value={linkDraft}
424
+ aria-label="Link URL"
425
+ aria-invalid={linkError ? true : undefined}
426
+ placeholder="https://, mailto:, /path"
427
+ className="h-8 w-56 rounded-md border border-border bg-surface px-2 text-small text-fg outline-none placeholder:text-fg-subtle focus:border-brand-green"
428
+ onChange={(e) => {
429
+ setLinkDraft(e.target.value)
430
+ setLinkError(null)
431
+ }}
432
+ onKeyDown={(e) => {
433
+ if (e.key === 'Enter') {
434
+ e.preventDefault()
435
+ applyLink()
436
+ }
437
+ if (e.key === 'Escape') {
438
+ e.preventDefault()
439
+ closeLink()
440
+ editor.chain().focus().run()
441
+ }
442
+ }}
443
+ />
444
+ <button type="button" className={btn} onMouseDown={(e) => e.preventDefault()} onClick={applyLink} title="Apply link">
445
+ Apply
446
+ </button>
447
+ <button
448
+ type="button"
449
+ className={btn}
450
+ onMouseDown={(e) => e.preventDefault()}
451
+ onClick={() => {
452
+ closeLink()
453
+ editor.chain().focus().run()
454
+ }}
455
+ title="Cancel"
456
+ >
457
+ Cancel
458
+ </button>
459
+ </div>
460
+ {linkError ? <span className="px-1 pb-0.5 text-caption text-danger">{linkError}</span> : null}
461
+ </>
462
+ ) : (
463
+ <div className="flex items-center gap-0.5">
464
+ {MARK_TOOLS.map((tool) => (
465
+ // preventDefault on mousedown keeps the editor selection while the button is clicked.
466
+ <button
467
+ key={tool.key}
468
+ type="button"
469
+ className={btn}
470
+ data-active={active?.[tool.key] ?? false}
471
+ aria-pressed={active?.[tool.key] ?? false}
472
+ onMouseDown={(e) => e.preventDefault()}
473
+ onClick={() => tool.run(editor)}
474
+ title={tool.title}
475
+ >
476
+ {tool.label}
477
+ </button>
478
+ ))}
479
+ <button type="button" className={btn} data-active={active?.link ?? false} aria-pressed={active?.link ?? false} onMouseDown={(e) => e.preventDefault()} onClick={openLink} title="Link">
480
+ <LinkIcon />
481
+ </button>
482
+ </div>
483
+ )}
484
+ </BubbleMenu>
485
+ ) : null}
486
+
487
+ <EditorContent editor={editor} />
488
+
489
+ {mounted && paletteOpen && slash
490
+ ? createPortal(
491
+ <div
492
+ ref={menuRef}
493
+ id={listboxId}
494
+ role="listbox"
495
+ aria-label="Insert block"
496
+ className="fixed z-50 max-h-72 w-64 overflow-auto rounded-lg border border-border bg-surface-card p-1 shadow-md"
497
+ style={{ left: menuPos?.left ?? 0, top: menuPos?.top ?? 0, visibility: menuPos ? 'visible' : 'hidden' }}
498
+ >
499
+ {filtered.map((cmd, i) => (
500
+ // role=option must sit on a plain element — a <button> would override it.
501
+ // Focus stays in the editor; aria-activedescendant on the textbox drives AT.
502
+ <div
503
+ key={cmd.title}
504
+ id={optionId(i)}
505
+ role="option"
506
+ aria-selected={i === activeOption}
507
+ className={clsx('flex w-full cursor-pointer flex-col items-start rounded-md px-3 py-1.5 text-left', i === activeOption ? 'bg-surface-muted' : 'hover:bg-surface-muted')}
508
+ onMouseDown={(e) => e.preventDefault()}
509
+ onClick={() => runSlash(cmd)}
510
+ >
511
+ <span className="text-small text-fg">{cmd.title}</span>
512
+ <span className="text-caption text-fg-subtle">{cmd.hint}</span>
513
+ </div>
514
+ ))}
515
+ </div>,
516
+ document.body,
517
+ )
518
+ : null}
519
+ </div>
520
+ </div>
521
+ )
522
+ }
@@ -0,0 +1,3 @@
1
+ // @podoba/react/editor — the opt-in Tiptap block editor. Kept behind a subpath so the
2
+ // base @podoba/react bundle never pulls ProseMirror; importing here is the opt-in.
3
+ export { BlockEditor, type BlockEditorProps } from './block-editor'
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ export * from "./components/file-upload";
30
30
  export * from "./components/date-field";
31
31
  export * from "./components/date-picker";
32
32
  export * from "./components/dialog";
33
+ export * from "./components/side-panel";
33
34
  export * from "./components/dropdown-menu";
34
35
  export * from "./components/context-menu";
35
36
  export * from "./components/tooltip";
@@ -0,0 +1,13 @@
1
+ /** Allow-list for a link href: http(s), mailto, tel, or a relative/anchor path.
2
+ * The prefix allow-list inherently rejects `javascript:`/`data:`/`vbscript:`.
3
+ *
4
+ * Shared by every editor surface that takes a user-typed URL (RichTextEditor's
5
+ * toolbar, BlockEditor's link panel) so the allow-list can never drift between them.
6
+ * This is a UI guard, NOT the security boundary — sanitise HTML on the SERVER on write. */
7
+ export function safeLinkUrl(raw: string): string | null {
8
+ const url = raw.trim()
9
+ return /^(https?:\/\/|mailto:|tel:|\/|#)/i.test(url) ? url : null
10
+ }
11
+
12
+ /** The message shown when {@link safeLinkUrl} rejects an input. */
13
+ export const SAFE_LINK_HINT = 'Only http(s), mailto, tel, or relative (/, #) links are allowed.'
package/src/utils/uic.ts CHANGED
@@ -82,6 +82,11 @@ const twMerge = extendTailwindMerge({
82
82
  // Custom card/panel radius key (rounded-panel) → dedupes against other
83
83
  // rounded-* utilities. xl/2xl are stock keys tailwind-merge already knows.
84
84
  radius: ['panel'],
85
+ // Custom blur/shadow keys, so `backdrop-blur-modal-backdrop` dedupes against
86
+ // a caller's `backdrop-blur-lg` (Dialog size="full" does exactly this) and
87
+ // `shadow-modal-surface` isn't misread as a shadow COLOR.
88
+ blur: ['modal-backdrop'],
89
+ shadow: ['modal-surface'],
85
90
  },
86
91
  },
87
92
  })