@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.
- package/package.json +45 -6
- package/src/components/brand-page-header.tsx +244 -44
- package/src/components/button.tsx +3 -3
- package/src/components/cta-pill.tsx +19 -4
- package/src/components/dialog.tsx +4 -0
- package/src/components/input.tsx +13 -7
- package/src/components/rich-text-editor.tsx +9 -9
- package/src/components/select.tsx +13 -11
- package/src/components/side-panel.examples.tsx +80 -0
- package/src/components/side-panel.tsx +140 -0
- package/src/components/text.tsx +6 -4
- package/src/editor/block-editor.tsx +522 -0
- package/src/editor/index.ts +3 -0
- package/src/index.ts +1 -0
- package/src/utils/safe-link-url.ts +13 -0
- package/src/utils/uic.ts +5 -0
|
@@ -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
|
+
}
|
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
|
})
|