@podoba/react 0.0.30 → 0.0.32

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podoba/react",
3
- "version": "0.0.30",
3
+ "version": "0.0.32",
4
4
  "type": "module",
5
5
  "description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
6
6
  "repository": {
@@ -13,7 +13,8 @@
13
13
  "main": "./src/index.ts",
14
14
  "types": "./src/index.ts",
15
15
  "exports": {
16
- ".": "./src/index.ts"
16
+ ".": "./src/index.ts",
17
+ "./editor": "./src/editor/index.ts"
17
18
  },
18
19
  "files": [
19
20
  "src",
@@ -27,17 +28,48 @@
27
28
  "typecheck": "tsc --build"
28
29
  },
29
30
  "dependencies": {
30
- "@podoba/tokens": "^0.0.30",
31
- "@podoba/tailwind": "^0.0.30",
31
+ "@podoba/tokens": "^0.0.32",
32
+ "@podoba/tailwind": "^0.0.32",
32
33
  "react-aria-components": "1.18.0",
33
34
  "class-variance-authority": "0.7.1",
34
35
  "clsx": "2.1.1",
35
36
  "tailwind-merge": "3.6.0",
36
37
  "@radix-ui/react-slot": "1.2.4"
37
38
  },
39
+ "//tiptap": "Only `@podoba/react/editor` (BlockEditor) needs Tiptap. Optional peers, not dependencies: the base package must not make every consumer install ProseMirror, and a peer keeps ONE @tiptap/pm in the tree — two copies of prosemirror-model break instanceof across the editor.",
38
40
  "peerDependencies": {
39
41
  "react": ">=19",
40
- "react-dom": ">=19"
42
+ "react-dom": ">=19",
43
+ "@tiptap/react": "^3.27.4",
44
+ "@tiptap/pm": "^3.27.4",
45
+ "@tiptap/starter-kit": "^3.27.4",
46
+ "@tiptap/extension-placeholder": "^3.27.4",
47
+ "@tiptap/extension-highlight": "^3.27.4",
48
+ "@tiptap/extension-task-list": "^3.27.4",
49
+ "@tiptap/extension-task-item": "^3.27.4"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "@tiptap/react": {
53
+ "optional": true
54
+ },
55
+ "@tiptap/pm": {
56
+ "optional": true
57
+ },
58
+ "@tiptap/starter-kit": {
59
+ "optional": true
60
+ },
61
+ "@tiptap/extension-placeholder": {
62
+ "optional": true
63
+ },
64
+ "@tiptap/extension-highlight": {
65
+ "optional": true
66
+ },
67
+ "@tiptap/extension-task-list": {
68
+ "optional": true
69
+ },
70
+ "@tiptap/extension-task-item": {
71
+ "optional": true
72
+ }
41
73
  },
42
74
  "devDependencies": {
43
75
  "react": "^19.2.1",
@@ -45,6 +77,13 @@
45
77
  "@types/react": "^19.2.7",
46
78
  "@types/react-dom": "^19.2.3",
47
79
  "typescript": "^5.9.3",
48
- "@types/bun": "^1.3.3"
80
+ "@types/bun": "^1.3.3",
81
+ "@tiptap/react": "^3.27.4",
82
+ "@tiptap/pm": "^3.27.4",
83
+ "@tiptap/starter-kit": "^3.27.4",
84
+ "@tiptap/extension-placeholder": "^3.27.4",
85
+ "@tiptap/extension-highlight": "^3.27.4",
86
+ "@tiptap/extension-task-list": "^3.27.4",
87
+ "@tiptap/extension-task-item": "^3.27.4"
49
88
  }
50
89
  }
@@ -37,7 +37,8 @@ export type BrandPageHeaderProps = {
37
37
  * gs-style "title to go back": a muted, clickable PARENT link rendered as the
38
38
  * line ABOVE the title (so the title reads "Parent ⏎ Current"). Pass a real
39
39
  * router `<Link>` so it's an anchor (cmd/middle-click, deep-link-safe), exactly
40
- * like gs's `parentLink`. The header styles it muted with a hover→fg affordance.
40
+ * like gs's `parentLink`. The header uses the source's decorative `fg-subtle`
41
+ * (#b3b3b3) with a hover→fg affordance.
41
42
  */
42
43
  parentLink?: ReactNode
43
44
  /** Optional breadcrumb trail rendered above the greeting. */
@@ -128,7 +129,7 @@ export function BrandPageHeader({
128
129
  // `[&_a]` styles the nested router <Link> (anchor) without @app/ui
129
130
  // importing the router.
130
131
  <>
131
- <span className="text-fg-muted transition-colors [&_a:hover]:text-fg [&_a]:text-fg-muted [&_a]:no-underline [&_a]:outline-none [&_a:focus-visible]:underline">
132
+ <span className="text-fg-subtle transition-colors [&_a:hover]:text-fg [&_a]:text-fg-subtle [&_a]:no-underline [&_a]:outline-none [&_a:focus-visible]:underline">
132
133
  {parentLink}
133
134
  </span>
134
135
  <br />
@@ -19,9 +19,9 @@ export const Button = uic(RACButton, {
19
19
  'data-[pending]:opacity-70 data-[pending]:cursor-progress',
20
20
  variants: {
21
21
  variant: {
22
- // primary (gs `_primary`): base neutral-400 (#242423 = surface-inverted)
23
- // hover neutral-600 (#333333) + `0 0 5px rgba(0,0,0,.5)` shadow → active back
24
- // to neutral-400 (surface-inverted, no shadow).
22
+ // primary (gs `_primary`): base brand-primary (#0d0d0d light / brand-green on
23
+ // dark) → hover neutral-600 (#333333) + `0 0 5px rgba(0,0,0,.5)` shadow →
24
+ // active back to brand-primary (no shadow).
25
25
  primary:
26
26
  'bg-brand-primary text-fg-inverted hover:bg-neutral-600 hover:shadow-[0_0_5px_0_rgba(0,0,0,0.5)] data-[pressed]:bg-brand-primary data-[pressed]:shadow-none',
27
27
  // secondary: #f7f6f2 (surface-card) + 1px #eceae1 (border); hover bg #eceae1
@@ -133,6 +133,10 @@ export const Dialog = ({
133
133
  // Either way the RACDialog render-prop `close` resolves against the active
134
134
  // overlay state, so `close()` works in both modes.
135
135
  <ModalOverlay
136
+ // Full-screen is an immersive takeover: swap the light scrim for a heavier
137
+ // backdrop blur so the page behind reads as clearly blurred around the
138
+ // near-fullscreen canvas. (tailwind-merge dedupes the base blur/scrim.)
139
+ className={isFlex ? 'bg-black/25 backdrop-blur-lg' : undefined}
136
140
  isOpen={isOpen}
137
141
  defaultOpen={defaultOpen}
138
142
  onOpenChange={onOpenChange}
@@ -20,15 +20,21 @@ import { uic } from '../utils/uic'
20
20
  */
21
21
  const StyledInput = uic(RACInput, {
22
22
  displayName: 'InputControl',
23
- // gs source: borderless cream fill, 8px radius, 14/18 text and 16px inline
24
- // padding. Focus keeps an explicit ring for keyboard accessibility.
23
+ // White fill + border so the field reads as editable. The gs original used a
24
+ // borderless cream (#f7f6f2 surface-card) fill, but our Card surface is ALSO
25
+ // surface-card — and in dark theme surface-card and surface are the SAME colour
26
+ // (#242423) — so a borderless cream field vanishes on both. Inverted: active =
27
+ // surface, disabled = the muted cream. border #eceae1 → border · hover #aba89c →
28
+ // fg-subtle · focus #75e7b8 → brand-green · error → danger. This is the shared
29
+ // filled-field skin (textarea / combobox / date-field / number-field /
30
+ // search-field); `fieldSize` adds the single-line height on top.
25
31
  baseClass:
26
- 'w-full rounded-lg border-0 bg-surface-card px-4 text-small text-fg ' +
32
+ 'w-full rounded-lg border border-border bg-surface px-4 text-small text-fg ' +
27
33
  'outline-none transition-colors duration-200 placeholder:text-fg-muted ' +
28
- 'data-[hovered]:bg-surface-muted ' +
29
- 'data-[focused]:ring-2 data-[focused]:ring-ring ' +
30
- 'data-[invalid]:ring-2 data-[invalid]:ring-danger ' +
31
- 'data-[disabled]:bg-surface-muted data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
34
+ 'data-[hovered]:border-fg-subtle ' +
35
+ 'data-[focused]:border-brand-green data-[focused]:ring-2 data-[focused]:ring-ring ' +
36
+ 'data-[invalid]:border-danger data-[invalid]:ring-2 data-[invalid]:ring-danger ' +
37
+ 'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
32
38
  variants: {
33
39
  // `fieldSize` (not `size`) to avoid colliding with the native <input size>
34
40
  // attribute, which RAC's Input inherits (a numeric prop).
@@ -1,12 +1,19 @@
1
1
  import { type ClipboardEvent as ReactClipboardEvent, type ReactNode, useEffect, useRef } from 'react'
2
2
  import { clsx } from 'clsx'
3
+ import { SAFE_LINK_HINT, safeLinkUrl } from '../utils/safe-link-url'
3
4
  import { useInFocusOverlay } from './focus-context'
4
5
 
5
6
  /**
6
7
  * RichTextEditor — a dependency-free contentEditable WYSIWYG that emits an HTML
7
8
  * string (round-trips with any `set:html` / `dangerouslySetInnerHTML` renderer,
8
9
  * so no value migration and no bundled ProseMirror). Ported from pramen's
9
- * cms-editor `RichText`; TipTap is the upgrade path if tables/embeds are needed.
10
+ * cms-editor `RichText`.
11
+ *
12
+ * Still the right pick for a short caption/bio field. For document-shaped content
13
+ * — blocks, a `/` palette, tables/embeds — reach for `BlockEditor` from
14
+ * `@podoba/react/editor` instead; it is the Tiptap-backed upgrade this component's
15
+ * `execCommand` core cannot grow into. Both emit the same HTML-string value, so
16
+ * swapping one for the other needs no data migration.
10
17
  *
11
18
  * The `prose prose-sm` body relies on the @tailwindcss/typography plugin, which
12
19
  * @podoba/tailwind's preset already registers.
@@ -32,7 +39,7 @@ const RT_TOOLS: Array<{ label: string; title: string; run: (exec: (c: string, a?
32
39
  if (!raw) return
33
40
  const url = safeLinkUrl(raw)
34
41
  if (!url) {
35
- window.alert('Only http(s), mailto, tel, or relative (/, #) links are allowed.')
42
+ window.alert(SAFE_LINK_HINT)
36
43
  return
37
44
  }
38
45
  x('createLink', url)
@@ -42,13 +49,6 @@ const RT_TOOLS: Array<{ label: string; title: string; run: (exec: (c: string, a?
42
49
  { label: 'Clear', title: 'Clear formatting', run: (x) => x('removeFormat') },
43
50
  ]
44
51
 
45
- /** Allow-list for a link href: http(s), mailto, tel, or a relative/anchor path.
46
- * The prefix allow-list inherently rejects `javascript:`/`data:`/`vbscript:`. */
47
- function safeLinkUrl(raw: string): string | null {
48
- const url = raw.trim()
49
- return /^(https?:\/\/|mailto:|tel:|\/|#)/i.test(url) ? url : null
50
- }
51
-
52
52
  /** Cosmetic scrub (NOT a security boundary — sanitise on the server). */
53
53
  function scrubHtml(html: string): string {
54
54
  return html
@@ -21,24 +21,26 @@ import { useInFocusOverlay } from './focus-context'
21
21
  * RAC handles the listbox ARIA pattern, keyboard navigation, typeahead and
22
22
  * focus management. Styling via `uic`. Pass options as `SelectItem` children.
23
23
  *
24
- * Re-skinned 1:1 to gs-platform's designer spec (Figma GraphicStandard 1.5,
25
- * node 2115-4271 — `Select.module.scss`): a tall (min-h 58px) borderless filled
26
- * trigger on the cream `surface-card`, matching our `Input` / `Textarea` fill so
27
- * the form controls stay consistent. gs token map: bg #f7f6f2surface-card ·
28
- * hover #eceae1 → surface-muted · text #0d0d0d → fg · placeholder → fg-muted ·
24
+ * Re-skinned to gs-platform's designer spec (Figma GraphicStandard 1.5, node
25
+ * 2115-4271 — `Select.module.scss`): a tall (58px) filled trigger matching our
26
+ * `Input` / `Textarea` so the form controls stay consistent. gs token map:
27
+ * hover border #aba89c fg-subtle · text #0d0d0d fg · placeholder fg-muted ·
29
28
  * 8px radius → rounded-lg · 6px item radius → rounded-md · error → danger.
30
29
  */
31
30
  const SelectTrigger = uic(RACButton, {
32
31
  displayName: 'SelectTrigger',
33
- // Exact gs source control: 58px tall, 20px inline padding, borderless cream
34
- // fill and 8px radius.
32
+ // gs source control geometry (58px tall, 20px inline padding, 8px radius) with
33
+ // the shared bordered fill: gs draws it borderless on cream, but our Card is
34
+ // also surface-card and dark theme collapses surface-card onto surface, so a
35
+ // borderless cream trigger disappears. White fill + border keeps it visible and
36
+ // consistent with the other form controls; hover darkens the border.
35
37
  baseClass:
36
- 'flex h-control-tall w-full items-center justify-between gap-2.5 rounded-lg border-0 bg-surface-card px-5 ' +
38
+ 'flex h-control-tall w-full items-center justify-between gap-2.5 rounded-lg border border-border bg-surface px-5 ' +
37
39
  'text-small text-fg outline-none transition-colors ' +
38
- 'data-[hovered]:bg-surface-muted ' +
40
+ 'data-[hovered]:border-fg-subtle ' +
39
41
  'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
40
- 'group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger ' +
41
- 'data-[disabled]:bg-surface-muted data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
42
+ 'group-data-[invalid]:border-danger group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger ' +
43
+ 'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
42
44
  })
43
45
 
44
46
  export const SelectItem = uic(ListBoxItem, {
@@ -6,10 +6,12 @@ import { uic } from '../utils/uic'
6
6
  * They express the `@app/tokens` type ramp as variant props so screens stop
7
7
  * hand-writing `text-[NNpx]` literals (the scale, not arbitrary pixels). Two ramps:
8
8
  *
9
- * • <Text> — the SIZE-ONLY UI ramp (micro · caption · label · compact · callout ·
10
- * body · subtitle · title · headline · display). Like the tokens, these set
11
- * font-size only; pair with `leading-*` via `className` where a fixed leading
12
- * matters. `weight` + `tone` cover the common cases.
9
+ * • <Text> — the UI ramp (micro · caption · label · compact · small · callout ·
10
+ * body · subtitle · title · headline · display). Four of these carry the gs
11
+ * source leading with them (compact 13/16, small 14/18, body 16/22,
12
+ * display 28/30); the rest are size-only pair those with `leading-*` via
13
+ * `className` where a fixed leading matters. `weight` + `tone` cover the
14
+ * common cases.
13
15
  * • <Heading> — the semantic heading ramp (`level` 1–5 → text-heading1…5), which
14
16
  * DO carry a matching line-height. Renders an <h2> by default; pass `asChild`
15
17
  * to project the styles onto the correct heading element for the document
@@ -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'
@@ -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
  })