wiki-formant 0.79.0 → 0.81.0

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/README.md CHANGED
@@ -693,12 +693,12 @@ export const X = xAccount({ siteName: 'AcuiQ', handle: '@acu_iq', posts: "a name
693
693
  addCopyButtons(el); // every <pre> gets one, once
694
694
  sortTables(el); // every column-headed table sorts by its headers
695
695
  hydrateTweetEmbeds(el); // placeholders get a live src
696
- const off = onTweetResize(h => sizeTweetEmbeds(el, h));
696
+ const off = onTweetResize((h, source) => sizeTweetEmbed(el, h, source));
697
697
  ```
698
698
 
699
699
  `addCopyButton` was **byte-identical** in two BlockRenderers, down to the SVG path data. Its idempotence guard now lives inside the function rather than in a `pre:not(:has(…))` at the call site, where it can be — and was — retyped.
700
700
 
701
- In React, `useArticlePasses(ref, [blocks])` runs the three passes and `useTweetEmbeds(ref, [html])` the embed pair, from `wiki-formant/react`. Three renderers had the effect written out and disagreed on its dependencies: two ran once on mount, so a page swapped in without a remount kept its old tables unsortable.
701
+ In React, `useArticlePasses(ref, [blocks])` (`wiki-formant/react`) runs them all, the embed pair included. Three renderers had the effect written out and disagreed on its dependencies: two ran once on mount, so a page swapped in without a remount kept its old tables unsortable.
702
702
 
703
703
  `activateTabGroups` turns stored `[data-tabs]` markup into a working tab group. The editor persists tabs as nested divs, which is the right thing to store — it survives a markdown twin, a plain HTML render and a reader with JavaScript off, all of which show every tab in order. Making one of them pressable is a reader-side job, and it sits beside the other passes rather than inside a component.
704
704
 
@@ -706,7 +706,7 @@ In React, `useArticlePasses(ref, [blocks])` runs the three passes and `useTweetE
706
706
 
707
707
  `scrollTables(html)` is the one table pass that runs on the string, before render: it wraps each stored table in a `.table-scroll` box, and `base.css` lets that box scroll sideways. A table box ignores `overflow`, so a table with an address or a URL in a cell is as wide as that token whatever its column, and a scroll box added by an effect would arrive after the table had already been laid out too wide. Call it on the render path beside `injectHeadingIds`. A table already in a box keeps it, so it is safe on HTML that has been through it once. `articleHtml(html, { used, slug, anchor, links })` (`wiki-formant/headings`) is that chain: heading ids, the scroll boxes, and, given `links`, `normaliseLinks`.
708
708
 
709
- The embed's origin is written down once. It is both the embed host and the allow-list `onTweetResize` checks before believing a posted height, and it had been spelled out at four call sites across two repos. Any page can `postMessage`; only the embed host may size the embed.
709
+ The embed's origin is written down once. It is both the embed host and the allow-list `onTweetResize` checks before believing a posted height, and it had been spelled out at four call sites across two repos. Any page can `postMessage`; only the embed host may size the embed. Every embed on a page posts to the same listener, so `onTweetResize` hands over the window that posted, and `sizeTweetEmbed` sizes only that one's iframe: two posts on a page had both been drawn at whichever height arrived last.
710
710
 
711
711
  ## Search
712
712
 
@@ -799,7 +799,7 @@ The bytes say what the file is, never the name or type, and SVG is refused. Ever
799
799
 
800
800
  ## Editor nodes
801
801
 
802
- `wiki-formant/tiptap` carries the custom nodes both wiki editors had written twice: `Iframe`, `YouTube` (the stock extension plus the paste rule it does not ship with), `TwitterEmbed`, `createMapEmbed`, `createCodeBlock` and `createTabs`. `Iframe` and `createMapEmbed` draw a frame only for an https src on `DEFAULT_IFRAME_HOSTS` (`embedSrc`), since an editor draws stored HTML where its author is signed in, and a `javascript:` src runs there under any CSP that allows inline script.
802
+ `wiki-formant/tiptap` carries the custom nodes both wiki editors had written twice: `Iframe`, `YouTube` (the stock extension plus the paste rule it does not ship with), `TwitterEmbed`, `createMapEmbed`, `createCodeBlock` and `createTabs`. `embedNodes({ resolveMapUrl })` is the first four as one list, which an editor puts in its `nodes`: a pasted YouTube, X or map link becomes its player as it lands, and the toolbar's embed prompt has a node for every link it recognises. `resolveMapUrl` defaults to `wiki-formant/maps`', through `/api/resolve-map`. `Iframe` and `createMapEmbed` draw a frame only for an https src on `DEFAULT_IFRAME_HOSTS` (`embedSrc`), since an editor draws stored HTML where its author is signed in, and a `javascript:` src runs there under any CSP that allows inline script.
803
803
 
804
804
  ```ts
805
805
  const CodeBlock = createCodeBlock({
@@ -810,19 +810,26 @@ const CodeBlock = createCodeBlock({
810
810
 
811
811
  The language list defaults to `CODE_LANGS` and `DEFAULT_LANG` from `wiki-formant/blocks`, which two wikis had typed identically; a wiki with its own passes `langs` and `defaultLang`. The ones that take config take it because that is exactly where the two copies differed — class tokens, the language list, and the API route a shortened map URL has to be resolved through. Injecting them is what lets one wiki keep `text-jupiter` and the other `text-accent` without either forking the node, and it keeps this file from dragging an icon library in behind it.
812
812
 
813
- `createTabs` returns `TabGroup` and `TabItem` together: `tabGroup`'s content expression is `tabItem+`, so registering one without the other leaves a node type the schema cannot satisfy. A pasted short map link inserts immediately with `about:blank` and swaps its `src` when the redirect resolves — pasting must not block on a network hop, and the node has to exist for the reader to see anything happen.
813
+ Both node views draw in default classes (`code-block…`, `tabs-editor`, `tab-button`…) that `base.css` styles plainly enough for any site, the editor's rule showing the tab the writer picked included; pass `classNames` and `icons` to draw them your own way. `createTabs` returns `TabGroup` and `TabItem` together: `tabGroup`'s content expression is `tabItem+`, so registering one without the other leaves a node type the schema cannot satisfy. A pasted short map link inserts immediately with `about:blank` and swaps its `src` when the redirect resolves — pasting must not block on a network hop, and the node has to exist for the reader to see anything happen.
814
814
 
815
815
  `createHeadingIds({ slug })` decorates each heading in the editor with the id its published copy will carry, through `uniqueHeadingId` — the dedupe `injectHeadingIds` uses — so a rail listing headings mid-edit links to the anchors readers will get. `uploadImageTo('/api/upload')` is the `uploadImage` two editors had written identically.
816
816
 
817
- `toolbarActions([...])` picks the formatting commands, in order, with the labels a screen reader announces — two toolbars titled buttons with the internal key, so one said "codeBlock". `WikiToolbar` lays them out the way three wikis had each assembled them by hand: the format buttons, Remove link while the cursor is in a link, upload and embed, the insert buttons, and the table controls while the cursor is in a table. Every button is a `ToolbarButton` (named, and `aria-pressed`, since these are toggles); the link button opens the address field and reads as on while it is open. Which buttons, icons and classes are yours; a toolbar shaped differently builds from `ToolbarButton` and `runTableAction` directly.
817
+ `WikiToolbar` shows what the editor can do and nothing else: each command whose node, mark or command the editor's extensions include, so an app's buttons follow from its nodes and no app keeps a second list. They come in groups – undo and redo; bold, italic, inline code and link; the three headings; lists, quote and code block; then upload (given `onUpload`), embed (where there are embed nodes), table, tabs and divider – with Remove link after Link while the cursor is in a link, and the table controls while it is in a table. Four apps had each listed buttons by hand: one had no numbered list, one had a code-block node and no button for it, and only one had undo. Every button is a `ToolbarButton`, named and `aria-pressed`; the icons, the classes and anything after the buttons (`children`) are yours.
818
818
 
819
819
  ```tsx
820
- <WikiToolbar editor={editor} isActive={isActive} prompt={url} actions={FORMAT} trailing={INSERT} onUpload={triggerUpload} embed
820
+ <WikiToolbar editor={editor} prompt={url} onUpload={triggerUpload}
821
821
  icons={{ ...ICONS, unlink: <Unlink />, upload: <Upload />, embed: <Globe /> }}
822
822
  classNames={{ toolbar: 'toolbar', button: 'toolbar-btn', pressed: 'toolbar-btn-on', divider: 'toolbar-divider' }} />
823
823
  <UrlPromptField prompt={url} classNames={{ row: 'row', input: 'input', apply: 'btn', cancel: 'btn-quiet' }} />
824
824
  ```
825
825
 
826
+ `WikiLine` is a one-line field – a title, a heading, a caption, a button – edited in the element that shows it. Its schema is the line and the `marks` it may keep (`bold`, `italic`, `link`), so a typed "- " makes no list and a pasted heading runs into the line; its value is that HTML, or plain text with no marks. It has its own undo, and `onFocus` hands over its editor, so one `WikiToolbar` can drive whichever field was last touched. `base.css` gives it a placeholder from `data-placeholder`.
827
+
828
+ ```tsx
829
+ <WikiLine as="h1" value={title} onChange={setTitle} placeholder="The page's title" />
830
+ <WikiLine as="p" marks={['bold', 'italic', 'link']} value={note} onChange={setNote} placeholder="A note" />
831
+ ```
832
+
826
833
  `useUrlPrompt(editor, { resolveMapUrl })` is the link and embed address field under a toolbar, in place of a `window.prompt` that blocks the page and cannot show the link the cursor is in. Three editors had written it and one still used the prompt; only one copy both opened a link field on the current address and unlinked on an emptied one. The hook carries both, closes the field when the editor it serves changes (a toolbar shared between blocks would otherwise apply it to the wrong one), and leaves the markup to you.
827
834
 
828
835
  `UrlPromptField` is its markup: an address input on which Enter applies and Escape closes, and Apply and Cancel.
package/dist/base.css CHANGED
@@ -180,6 +180,41 @@ html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
180
180
  stands in for the group rather than every tab at once. */
181
181
  .tab-panel:not(.active) { display: none; }
182
182
  [data-tabs]:not([data-tabs-init]) [data-tab-item] ~ [data-tab-item] { display: none; }
183
+ .tabs-list { display: flex; flex-wrap: wrap; gap: 0.25rem; }
184
+ .tab-button { font: inherit; color: inherit; background: none; border: 0; border-bottom: 2px solid transparent; padding: 0.4em 0.8em; cursor: pointer; }
185
+ .tab-button.active { border-bottom-color: currentColor; }
186
+
187
+ /* ---- editor node views (createCodeBlock, createTabs) ---------------------------
188
+ Their default classes, drawn plainly enough to sit on any site; an app that
189
+ passes its own classes styles them itself. The tab editor shows the tab the
190
+ writer picked, which the stored-group rule above would otherwise hide. */
191
+ .code-block { position: relative; }
192
+ .code-block-control { position: absolute; top: 0.4rem; right: 0.4rem; z-index: 1; font-size: 0.8rem; }
193
+ :is(.code-block-button, .code-block-option, .tab-title, .tab-remove, .tab-add) { font: inherit; color: inherit; background: none; border: 0; }
194
+ :is(.code-block-button, .code-block-option, .tab-remove, .tab-add) { padding: 0.15em 0.5em; cursor: pointer; }
195
+ .code-block-menu { position: absolute; right: 0; display: grid; max-height: 16rem; overflow: auto; background: Canvas; color: CanvasText; border: 1px solid color-mix(in srgb, currentColor 20%, transparent); border-radius: 0.4rem; }
196
+ .code-block-option { text-align: left; }
197
+ .code-block-option.active { font-weight: 600; }
198
+ .tabs-editor { border: 1px solid color-mix(in srgb, currentColor 20%, transparent); border-radius: 0.5rem; }
199
+ .tabs-editor .tabs-list { align-items: center; padding: 0.4rem; border-bottom: 1px solid color-mix(in srgb, currentColor 20%, transparent); }
200
+ .tabs-editor .tab-button { display: inline-flex; align-items: center; padding: 0.15em 0.3em; border-bottom: 0; border-radius: 0.3rem; }
201
+ .tabs-editor .tab-button.active { background: color-mix(in srgb, currentColor 10%, transparent); }
202
+ .tab-title { width: 7em; outline: none; }
203
+ .tab-remove, .tab-add { opacity: 0.7; }
204
+ .tabs-content { padding: 1rem; }
205
+ .tabs-editor [data-tab-item] { display: none; }
206
+ [data-tabs][data-active-tab='0'] .tabs-editor [data-tab-item]:nth-of-type(1),
207
+ [data-tabs][data-active-tab='1'] .tabs-editor [data-tab-item]:nth-of-type(2),
208
+ [data-tabs][data-active-tab='2'] .tabs-editor [data-tab-item]:nth-of-type(3),
209
+ [data-tabs][data-active-tab='3'] .tabs-editor [data-tab-item]:nth-of-type(4),
210
+ [data-tabs][data-active-tab='4'] .tabs-editor [data-tab-item]:nth-of-type(5),
211
+ [data-tabs][data-active-tab='5'] .tabs-editor [data-tab-item]:nth-of-type(6),
212
+ [data-tabs][data-active-tab='6'] .tabs-editor [data-tab-item]:nth-of-type(7),
213
+ [data-tabs][data-active-tab='7'] .tabs-editor [data-tab-item]:nth-of-type(8) { display: block; }
214
+
215
+ /* ---- one-line fields (WikiLine) --------------------------------------------- */
216
+ [data-wiki-line] { outline: none; cursor: text; white-space: pre-wrap; }
217
+ [data-wiki-line]:is(:empty, :has(> .ProseMirror-trailingBreak))::before { content: attr(data-placeholder); opacity: 0.6; }
183
218
 
184
219
  /* ---- table scroll boxes (scrollTables, React tables) -----------------------
185
220
  A table wider than its column scrolls inside its own box, at every width,
@@ -432,8 +467,8 @@ pre:hover > .code-copy-btn,
432
467
  .stats-count { font-variant-numeric: tabular-nums; }
433
468
 
434
469
  /* ---- embeds ------------------------------------------------------------------
435
- The players the `wiki-formant/tiptap` nodes store, filling their column at a
436
- screen's proportions. The node writes a width and height onto the iframe,
470
+ The players the `wiki-formant/tiptap` nodes store, a video or map filling its
471
+ column at a screen's proportions. The node writes a width and height onto the iframe,
437
472
  which `aspect-ratio` with `height: auto` overrides. Spacing, corners,
438
473
  borders and the editor's selection ring are the site's. */
439
474
  :is(div[data-youtube-video], .map-embed) iframe {
@@ -442,3 +477,12 @@ pre:hover > .code-copy-btn,
442
477
  height: auto;
443
478
  aspect-ratio: 16 / 9;
444
479
  }
480
+
481
+ /* A post on X at the width X lays it out for, as tall as it says it is
482
+ (`onTweetResize`), and a little before it has said. */
483
+ .twitter-embed iframe {
484
+ display: block;
485
+ width: 550px;
486
+ max-width: 100%;
487
+ min-height: 200px;
488
+ }
package/dist/dom.d.ts CHANGED
@@ -20,13 +20,15 @@ export declare function addCopyButtons(root: ParentNode, options?: CopyButtonOpt
20
20
  /** The embed iframe's src. `dnt=true` opts the embed out of Twitter's tracking. */
21
21
  export declare const tweetEmbedSrc: (tweetId: string | number) => string;
22
22
  /**
23
- * Subscribe to the height the embed posts back once it has laid itself out.
23
+ * Subscribe to the height an embed posts back once it has laid itself out,
24
+ * with the window that posted it.
24
25
  *
25
26
  * The iframe renders at an unknown height and there is no other way to learn
26
- * it. The caller decides which iframes the height applies to; this only owns
27
- * the origin check and the message shape. Returns a disposer.
27
+ * it. Every embed on the page posts to the same listener, so the source is how
28
+ * a height finds its own iframe. This only owns the origin check and the
29
+ * message shape. Returns a disposer.
28
30
  */
29
- export declare function onTweetResize(resize: (height: number) => void): () => void;
31
+ export declare function onTweetResize(resize: (height: number, source: MessageEventSource | null) => void): () => void;
30
32
  /**
31
33
  * Point every un-hydrated `[data-twitter-embed]` placeholder under `root` at
32
34
  * the real embed URL.
@@ -36,8 +38,8 @@ export declare function onTweetResize(resize: (height: number) => void): () => v
36
38
  * of the same content does not reload every embed on the page.
37
39
  */
38
40
  export declare function hydrateTweetEmbeds(root: ParentNode): void;
39
- /** Apply a measured height to every embed iframe under `root`. */
40
- export declare function sizeTweetEmbeds(root: ParentNode, height: number): void;
41
+ /** Apply a posted height to the embed iframe under `root` that posted it, and to no other. */
42
+ export declare function sizeTweetEmbed(root: ParentNode, height: number, source: MessageEventSource | null): void;
41
43
  export interface TabGroupClassNames {
42
44
  list?: string;
43
45
  panels?: string;
package/dist/dom.js CHANGED
@@ -63,11 +63,13 @@ export const TWITTER_ORIGIN = 'https://platform.twitter.com';
63
63
  /** The embed iframe's src. `dnt=true` opts the embed out of Twitter's tracking. */
64
64
  export const tweetEmbedSrc = (tweetId) => `${TWITTER_ORIGIN}/embed/Tweet.html?id=${tweetId}&dnt=true`;
65
65
  /**
66
- * Subscribe to the height the embed posts back once it has laid itself out.
66
+ * Subscribe to the height an embed posts back once it has laid itself out,
67
+ * with the window that posted it.
67
68
  *
68
69
  * The iframe renders at an unknown height and there is no other way to learn
69
- * it. The caller decides which iframes the height applies to; this only owns
70
- * the origin check and the message shape. Returns a disposer.
70
+ * it. Every embed on the page posts to the same listener, so the source is how
71
+ * a height finds its own iframe. This only owns the origin check and the
72
+ * message shape. Returns a disposer.
71
73
  */
72
74
  export function onTweetResize(resize) {
73
75
  const handleMessage = (e) => {
@@ -78,7 +80,7 @@ export function onTweetResize(resize) {
78
80
  return;
79
81
  const height = data.params?.[0]?.height;
80
82
  if (height)
81
- resize(height);
83
+ resize(height, e.source);
82
84
  };
83
85
  window.addEventListener('message', handleMessage);
84
86
  return () => window.removeEventListener('message', handleMessage);
@@ -104,10 +106,11 @@ export function hydrateTweetEmbeds(root) {
104
106
  }
105
107
  }
106
108
  }
107
- /** Apply a measured height to every embed iframe under `root`. */
108
- export function sizeTweetEmbeds(root, height) {
109
+ /** Apply a posted height to the embed iframe under `root` that posted it, and to no other. */
110
+ export function sizeTweetEmbed(root, height, source) {
109
111
  for (const iframe of Array.from(root.querySelectorAll('[data-twitter-embed] iframe'))) {
110
- iframe.style.height = `${height}px`;
112
+ if (iframe.contentWindow === source)
113
+ iframe.style.height = `${height}px`;
111
114
  }
112
115
  }
113
116
  /**
package/dist/editor.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AnyExtension, Editor } from '@tiptap/core';
1
+ import { type AnyExtension, type Editor } from '@tiptap/core';
2
2
  import { type ChangeEvent, type KeyboardEvent, type ReactNode, type RefObject } from 'react';
3
3
  /**
4
4
  * Re-exported so the COMMAND AUGMENTATIONS reach consumers.
@@ -37,18 +37,18 @@ export declare const TABLE_ACTIONS: ReadonlyArray<[command: string, label: strin
37
37
  export type ActiveDescriptor = string | [string, Record<string, unknown>];
38
38
  /** Run one of `TABLE_ACTIONS` — the one place the command name is looked up untyped. */
39
39
  export declare function runTableAction(editor: Editor, command: string): void;
40
- export type ToolbarKey = 'bold' | 'italic' | 'code' | 'link' | 'h2' | 'h3' | 'h4' | 'bulletList' | 'orderedList' | 'blockquote' | 'codeBlock' | 'divider' | 'table' | 'tabs';
40
+ export type ToolbarKey = 'undo' | 'redo' | 'bold' | 'italic' | 'code' | 'link' | 'h2' | 'h3' | 'h4' | 'bulletList' | 'orderedList' | 'blockquote' | 'codeBlock' | 'divider' | 'table' | 'tabs';
41
41
  export interface ToolbarAction {
42
42
  key: ToolbarKey;
43
43
  /** What a screen reader announces and a tooltip shows. */
44
44
  label: string;
45
+ /** The node, mark or command the editor needs for the button to show. */
46
+ needs: string;
45
47
  active?: ActiveDescriptor;
46
48
  /** Absent on `link`, which needs a URL: call `withUrl` once you have one. */
47
49
  run?: (editor: Editor) => void;
48
50
  withUrl?: (editor: Editor, url: string) => void;
49
51
  }
50
- /** The actions a toolbar shows, in the order it shows them. */
51
- export declare function toolbarActions(keys: readonly ToolbarKey[]): ToolbarAction[];
52
52
  /**
53
53
  * A toolbar button that says what it is and whether it is on. `aria-pressed`
54
54
  * is right here, unlike on a facet link: this is a toggle.
@@ -61,50 +61,43 @@ export declare function ToolbarButton({ label, pressed, onPress, className, chil
61
61
  children: ReactNode;
62
62
  }): import("react").JSX.Element;
63
63
  export interface WikiToolbarProps {
64
- editor: Editor;
65
- /** `useWikiEditor`'s `isActive`. */
66
- isActive: (a?: ActiveDescriptor) => boolean;
64
+ /** The editor it drives; with none it shows only `children`. */
65
+ editor: Editor | null;
67
66
  /** `useUrlPrompt`'s state: the link button opens it, and shows pressed while it is open. */
68
67
  prompt: UrlPromptState;
69
- /** The formatting buttons, before Remove link, upload and embed. */
70
- actions: readonly ToolbarAction[];
71
- /** The insert buttons after them, such as table and tabs. */
72
- trailing?: readonly ToolbarAction[];
73
- /** Shows the upload button, which calls this: `useWikiEditor`'s `triggerUpload`. */
68
+ /** Shows the upload button where the editor takes images: `useWikiEditor`'s `triggerUpload`. */
74
69
  onUpload?: () => void;
75
- /** Shows the embed button, which opens the prompt on an embed address. */
76
- embed?: boolean;
77
- icons: Partial<Record<ToolbarKey, ReactNode>> & {
78
- unlink: ReactNode;
79
- upload?: ReactNode;
80
- embed?: ReactNode;
81
- };
70
+ icons: Partial<Record<ToolbarKey | 'unlink' | 'upload' | 'embed', ReactNode>>;
82
71
  classNames?: {
83
72
  toolbar?: string;
84
73
  button?: string;
85
74
  /** Added to a button that is on. */
86
75
  pressed?: string;
76
+ /** Drawn between groups, and before the table controls. */
87
77
  divider?: string;
88
78
  /** A table control, which is a word rather than an icon. */
89
79
  tableButton?: string;
90
80
  /** Added to the table controls that delete. */
91
81
  danger?: string;
92
82
  };
83
+ /** After the buttons: a hint, or controls of the app's own. */
84
+ children?: ReactNode;
93
85
  }
94
86
  /**
95
- * The formatting bar three wikis had each assembled from `toolbarActions`,
96
- * `ToolbarButton` and `TABLE_ACTIONS`, in the same order with the same rules:
97
- * a link button that needs an address and so opens the prompt, a Remove link
98
- * button while the cursor is in a link, upload and embed between the format
99
- * and insert groups, and the table controls while the cursor is in a table.
100
- * Which buttons, which icons and which classes are the caller's.
87
+ * The formatting bar. It shows what the editor can do and nothing else: every
88
+ * command whose node, mark or command the editor has (`supports`), grouped,
89
+ * then upload where it takes images and embed where it has embed nodes, then
90
+ * the table controls while the cursor is in a table. Remove link follows Link
91
+ * while the cursor is in a link. The same bar drives a whole article, a one-line
92
+ * field and an editor shared between blocks; which icons and classes are the
93
+ * caller's. Every button is a `ToolbarButton`, named and `aria-pressed`.
101
94
  */
102
- export declare function WikiToolbar({ editor, isActive, prompt, actions, trailing, onUpload, embed, icons, classNames: c }: WikiToolbarProps): import("react").JSX.Element;
95
+ export declare function WikiToolbar({ editor, prompt, onUpload, icons, classNames: c, children }: WikiToolbarProps): import("react").JSX.Element;
103
96
  export interface UrlPromptFieldProps {
104
97
  prompt: UrlPromptState;
98
+ /** The link field's placeholder, where the wiki's own paths differ from a full address. */
105
99
  placeholders?: {
106
100
  link?: string;
107
- embed?: string;
108
101
  };
109
102
  classNames?: {
110
103
  row?: string;
@@ -170,14 +163,31 @@ export interface WikiEditorState {
170
163
  handleFileChange: (e: ChangeEvent<HTMLInputElement>) => Promise<void>;
171
164
  /** Open the file picker — what an upload toolbar button calls. */
172
165
  triggerUpload: () => void;
173
- isActive: (a?: ActiveDescriptor) => boolean;
174
166
  }
175
167
  /**
176
- * The editor, its upload plumbing and the state a toolbar reads.
177
- *
178
- * The toolbar itself stays with the consumer: the two wikis differ on which
179
- * buttons exist, which icon set draws them, and whether a link is entered in a
180
- * `window.prompt` or an inline field. Those are real differences. What they had
181
- * no business differing on is everything below.
168
+ * The editor and its upload plumbing. `WikiToolbar` given the editor draws
169
+ * the buttons its extensions allow; the icons and classes are the app's.
182
170
  */
183
171
  export declare function useWikiEditor({ value, onChange, placeholder, nodes, proseClass, uploadImage, debounceMs, }: WikiEditorOptions): WikiEditorState;
172
+ /** What a one-line field may keep. With none, its value is plain text. */
173
+ export type LineMark = 'bold' | 'italic' | 'link';
174
+ export interface WikiLineProps {
175
+ as: 'h1' | 'h2' | 'h3' | 'p' | 'span' | 'div' | 'small' | 'figcaption';
176
+ value: string;
177
+ onChange: (value: string) => void;
178
+ /** Shown while it is empty, and its accessible name. */
179
+ placeholder: string;
180
+ marks?: readonly LineMark[];
181
+ className?: string;
182
+ /** The editor, as it takes focus: what a toolbar shared between fields claims. */
183
+ onFocus?: (editor: Editor) => void;
184
+ }
185
+ /**
186
+ * One line of words – a title, a heading, a caption, a button – edited in the
187
+ * element that shows it, so it reads as the page draws it. Its schema is the
188
+ * line and the `marks` it may keep, so nothing else can get in: no list from a
189
+ * typed "- ", no heading from a paste. Its value is that HTML, or plain text
190
+ * when it keeps no marks. It has its own undo, and `WikiToolbar` given its
191
+ * editor shows undo and its marks. `base.css` gives it its placeholder.
192
+ */
193
+ export declare function WikiLine({ as: Tag, value, onChange, placeholder, marks, className, onFocus }: WikiLineProps): import("react").JSX.Element;
package/dist/editor.js CHANGED
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  // editor.tsx — the wiki rich-text editor's engine, minus its chrome.
4
4
  //
5
5
  // `tiptap.tsx` holds the custom NODES both wikis needed (iframe, tweet, map,
@@ -30,7 +30,8 @@ import TiptapTableCell from '@tiptap/extension-table-cell';
30
30
  import TiptapTableHeader from '@tiptap/extension-table-header';
31
31
  import Placeholder from '@tiptap/extension-placeholder';
32
32
  import { useEditor } from '@tiptap/react';
33
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
33
+ import { Editor as TiptapEditor, Node as TiptapNode } from '@tiptap/core';
34
+ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react';
34
35
  import { toMapEmbedUrl } from './maps.js';
35
36
  import { cx } from './html.js';
36
37
  /** The view and the editor's type, so a consumer imports nothing from `@tiptap/*` itself. */
@@ -157,47 +158,57 @@ function insertEmbed(editor, url, { resolveMapUrl }) {
157
158
  }
158
159
  /** The table controls, shown only while the selection is inside a table. */
159
160
  export const TABLE_ACTIONS = [
160
- ['addColumnAfter', '+Col'],
161
- ['addRowAfter', '+Row'],
162
- ['deleteColumn', '-Col', true],
163
- ['deleteRow', '-Row', true],
164
- ['deleteTable', '-Tbl', true],
161
+ ['addColumnAfter', 'Add column'],
162
+ ['addRowAfter', 'Add row'],
163
+ ['deleteColumn', 'Remove column', true],
164
+ ['deleteRow', 'Remove row', true],
165
+ ['deleteTable', 'Remove table', true],
165
166
  ];
166
167
  /** Run one of `TABLE_ACTIONS` — the one place the command name is looked up untyped. */
167
168
  export function runTableAction(editor, command) {
168
169
  const chain = editor.chain().focus();
169
170
  chain[command]?.().run();
170
171
  }
172
+ const heading = (level, label) => ({
173
+ key: `h${level}`,
174
+ label,
175
+ needs: 'heading',
176
+ active: ['heading', { level }],
177
+ run: e => e.chain().focus().toggleHeading({ level }).run(),
178
+ });
171
179
  /**
172
- * The formatting commands, labelled. Three toolbars ran these identically and
173
- * two titled their buttons with the internal key — a screen reader said
174
- * "codeBlock", and inline code and code block shared one icon and one name.
175
- * Upload and embed stay with the caller: they are the parts that differ.
180
+ * The formatting commands, labelled, in toolbar order. Three toolbars ran these
181
+ * identically and two titled their buttons with the internal key – a screen
182
+ * reader said "codeBlock", and inline code and code block shared one icon and
183
+ * one name.
176
184
  *
177
185
  * @internal
178
186
  */
179
187
  export const TOOLBAR_ACTIONS = [
180
- { key: 'bold', label: 'Bold', active: 'bold', run: e => e.chain().focus().toggleBold().run() },
181
- { key: 'italic', label: 'Italic', active: 'italic', run: e => e.chain().focus().toggleItalic().run() },
182
- { key: 'code', label: 'Inline code', active: 'code', run: e => e.chain().focus().toggleCode().run() },
183
- { key: 'link', label: 'Link', active: 'link', withUrl: (e, href) => e.chain().focus().setLink({ href }).run() },
184
- { key: 'h2', label: 'Heading 2', active: ['heading', { level: 2 }], run: e => e.chain().focus().toggleHeading({ level: 2 }).run() },
185
- { key: 'h3', label: 'Heading 3', active: ['heading', { level: 3 }], run: e => e.chain().focus().toggleHeading({ level: 3 }).run() },
186
- { key: 'h4', label: 'Heading 4', active: ['heading', { level: 4 }], run: e => e.chain().focus().toggleHeading({ level: 4 }).run() },
187
- { key: 'bulletList', label: 'Bulleted list', active: 'bulletList', run: e => e.chain().focus().toggleBulletList().run() },
188
- { key: 'orderedList', label: 'Numbered list', active: 'orderedList', run: e => e.chain().focus().toggleOrderedList().run() },
189
- { key: 'blockquote', label: 'Quote', active: 'blockquote', run: e => e.chain().focus().toggleBlockquote().run() },
190
- { key: 'codeBlock', label: 'Code block', active: 'codeBlock', run: e => e.chain().focus().toggleCodeBlock().run() },
191
- { key: 'divider', label: 'Divider', run: e => e.chain().focus().setHorizontalRule().run() },
188
+ { key: 'undo', label: 'Undo', needs: 'undo', run: e => e.chain().focus().undo().run() },
189
+ { key: 'redo', label: 'Redo', needs: 'redo', run: e => e.chain().focus().redo().run() },
190
+ { key: 'bold', label: 'Bold', needs: 'bold', active: 'bold', run: e => e.chain().focus().toggleBold().run() },
191
+ { key: 'italic', label: 'Italic', needs: 'italic', active: 'italic', run: e => e.chain().focus().toggleItalic().run() },
192
+ { key: 'code', label: 'Inline code', needs: 'code', active: 'code', run: e => e.chain().focus().toggleCode().run() },
193
+ { key: 'link', label: 'Link', needs: 'link', active: 'link', withUrl: (e, href) => e.chain().focus().setLink({ href }).run() },
194
+ heading(2, 'Heading'),
195
+ heading(3, 'Subheading'),
196
+ heading(4, 'Small heading'),
197
+ { key: 'bulletList', label: 'Bulleted list', needs: 'bulletList', active: 'bulletList', run: e => e.chain().focus().toggleBulletList().run() },
198
+ { key: 'orderedList', label: 'Numbered list', needs: 'orderedList', active: 'orderedList', run: e => e.chain().focus().toggleOrderedList().run() },
199
+ { key: 'blockquote', label: 'Quote', needs: 'blockquote', active: 'blockquote', run: e => e.chain().focus().toggleBlockquote().run() },
200
+ { key: 'codeBlock', label: 'Code block', needs: 'codeBlock', active: 'codeBlock', run: e => e.chain().focus().toggleCodeBlock().run() },
192
201
  {
193
202
  key: 'table',
194
203
  label: 'Table',
204
+ needs: 'table',
195
205
  active: 'table',
196
206
  run: e => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
197
207
  },
198
208
  {
199
209
  key: 'tabs',
200
210
  label: 'Tabs',
211
+ needs: 'tabGroup',
201
212
  active: 'tabGroup',
202
213
  // Two, not one: a group of one is a heading with extra steps, and the node
203
214
  // refuses to delete its last tab anyway.
@@ -210,10 +221,43 @@ export const TOOLBAR_ACTIONS = [
210
221
  })
211
222
  .run(),
212
223
  },
224
+ { key: 'divider', label: 'Divider', needs: 'horizontalRule', run: e => e.chain().focus().setHorizontalRule().run() },
213
225
  ];
214
- /** The actions a toolbar shows, in the order it shows them. */
215
- export function toolbarActions(keys) {
216
- return keys.flatMap(key => TOOLBAR_ACTIONS.filter(a => a.key === key));
226
+ /** The groups the toolbar draws, a divider between each. Upload and embed lead the last. */
227
+ const GROUPS = [
228
+ ['undo', 'redo'],
229
+ ['bold', 'italic', 'code', 'link'],
230
+ ['h2', 'h3', 'h4'],
231
+ ['bulletList', 'orderedList', 'blockquote', 'codeBlock'],
232
+ ['table', 'tabs', 'divider'],
233
+ ];
234
+ /**
235
+ * Whether the editor can do what an action does: its schema has the node or
236
+ * mark, or it has the command, and a heading button's level is one the editor
237
+ * allows. What a toolbar shows follows from the extensions an editor was
238
+ * built with, so no app keeps a list of buttons beside its list of nodes.
239
+ *
240
+ * @internal
241
+ */
242
+ export function supports(editor, { key, needs }) {
243
+ const { nodes, marks } = editor.schema;
244
+ if (needs === 'heading') {
245
+ const levels = editor.extensionManager.extensions.find(e => e.name === 'heading')?.options.levels;
246
+ return !!nodes.heading && !!levels?.includes(Number(key.slice(1)));
247
+ }
248
+ return !!(nodes[needs] || marks[needs] || needs in editor.commands);
249
+ }
250
+ /** The editor redrawn on every change, for a toolbar whose editor is not the component's own. */
251
+ function useTransactions(editor) {
252
+ const [, redraw] = useReducer((n) => n + 1, 0);
253
+ useEffect(() => {
254
+ if (!editor)
255
+ return;
256
+ editor.on('transaction', redraw);
257
+ return () => {
258
+ editor.off('transaction', redraw);
259
+ };
260
+ }, [editor]);
217
261
  }
218
262
  /**
219
263
  * A toolbar button that says what it is and whether it is on. `aria-pressed`
@@ -223,19 +267,35 @@ export function ToolbarButton({ label, pressed, onPress, className, children, })
223
267
  return (_jsx("button", { type: "button", title: label, "aria-label": label, "aria-pressed": pressed, className: className, onClick: onPress, children: children }));
224
268
  }
225
269
  /**
226
- * The formatting bar three wikis had each assembled from `toolbarActions`,
227
- * `ToolbarButton` and `TABLE_ACTIONS`, in the same order with the same rules:
228
- * a link button that needs an address and so opens the prompt, a Remove link
229
- * button while the cursor is in a link, upload and embed between the format
230
- * and insert groups, and the table controls while the cursor is in a table.
231
- * Which buttons, which icons and which classes are the caller's.
270
+ * The formatting bar. It shows what the editor can do and nothing else: every
271
+ * command whose node, mark or command the editor has (`supports`), grouped,
272
+ * then upload where it takes images and embed where it has embed nodes, then
273
+ * the table controls while the cursor is in a table. Remove link follows Link
274
+ * while the cursor is in a link. The same bar drives a whole article, a one-line
275
+ * field and an editor shared between blocks; which icons and classes are the
276
+ * caller's. Every button is a `ToolbarButton`, named and `aria-pressed`.
232
277
  */
233
- export function WikiToolbar({ editor, isActive, prompt, actions, trailing = [], onUpload, embed, icons, classNames: c = {} }) {
234
- const button = ({ key, label, active, run }) => {
235
- const pressed = isActive(active) || (key === 'link' && prompt.kind === 'link');
236
- return (_jsx(ToolbarButton, { label: label, pressed: active ? pressed : undefined, onPress: () => (run ? run(editor) : prompt.toggle('link')), className: cx(c.button, pressed && c.pressed), children: icons[key] }, key));
278
+ export function WikiToolbar({ editor, prompt, onUpload, icons, classNames: c = {}, children }) {
279
+ useTransactions(editor);
280
+ if (!editor || editor.isDestroyed) {
281
+ return (_jsx("div", { className: c.toolbar, role: "toolbar", "aria-label": "Text formatting", children: children }));
282
+ }
283
+ const button = (a) => {
284
+ const pressed = (!!a.active && (typeof a.active === 'string' ? editor.isActive(a.active) : editor.isActive(...a.active))) || (a.key === 'link' && prompt.kind === 'link');
285
+ return (_jsx(ToolbarButton, { label: a.label, pressed: a.active ? pressed : undefined, onPress: () => (a.run ? a.run(editor) : prompt.toggle('link')), className: cx(c.button, pressed && c.pressed), children: icons[a.key] }, a.key));
237
286
  };
238
- return (_jsxs("div", { className: c.toolbar, role: "toolbar", "aria-label": "Text formatting", children: [actions.map(button), editor.isActive('link') && (_jsx(ToolbarButton, { label: "Remove link", onPress: () => editor.chain().focus().extendMarkRange('link').unsetLink().run(), className: c.button, children: icons.unlink })), onUpload && (_jsx(ToolbarButton, { label: "Upload image", onPress: onUpload, className: c.button, children: icons.upload })), embed && (_jsx(ToolbarButton, { label: "Embed", pressed: prompt.kind === 'embed', onPress: () => prompt.toggle('embed'), className: cx(c.button, prompt.kind === 'embed' && c.pressed), children: icons.embed })), trailing.map(button), editor.isActive('table') && (_jsxs(_Fragment, { children: [_jsx("span", { className: c.divider, "aria-hidden": true }), TABLE_ACTIONS.map(([command, label, danger]) => (_jsx("button", { type: "button", className: cx(c.tableButton, danger && c.danger), onClick: () => runTableAction(editor, command), children: label }, command)))] }))] }));
287
+ const extra = {
288
+ link: editor.isActive('link') && (_jsx(ToolbarButton, { label: "Remove link", onPress: () => editor.chain().focus().extendMarkRange('link').unsetLink().run(), className: c.button, children: icons.unlink }, "unlink")),
289
+ };
290
+ const insert = [
291
+ onUpload && editor.schema.nodes.image && (_jsx(ToolbarButton, { label: "Picture", onPress: onUpload, className: c.button, children: icons.upload }, "upload")),
292
+ (editor.schema.nodes.youtube || editor.schema.nodes.iframe) && (_jsx(ToolbarButton, { label: "Video, post or map", pressed: prompt.kind === 'embed', onPress: () => prompt.toggle('embed'), className: cx(c.button, prompt.kind === 'embed' && c.pressed), children: icons.embed }, "embed")),
293
+ ].filter(Boolean);
294
+ const groups = GROUPS.map((keys, i) => [
295
+ ...(i === GROUPS.length - 1 ? insert : []),
296
+ ...keys.flatMap(key => TOOLBAR_ACTIONS.filter(a => a.key === key && supports(editor, a)).flatMap(a => [button(a), extra[a.key]])),
297
+ ].filter(Boolean)).filter(g => g.length);
298
+ return (_jsxs("div", { className: c.toolbar, role: "toolbar", "aria-label": "Text formatting", children: [groups.map((g, i) => (_jsxs(Fragment, { children: [i > 0 && c.divider && _jsx("span", { className: c.divider, "aria-hidden": true }), g] }, i))), editor.isActive('table') && (_jsxs(_Fragment, { children: [_jsx("span", { className: c.divider, "aria-hidden": true }), TABLE_ACTIONS.map(([command, label, danger]) => (_jsx("button", { type: "button", className: cx(c.tableButton, danger && c.danger), onClick: () => runTableAction(editor, command), children: label }, command)))] })), children] }));
239
299
  }
240
300
  /**
241
301
  * The address field under a toolbar, open while `prompt.kind` is set: Enter
@@ -245,7 +305,7 @@ export function UrlPromptField({ prompt, placeholders = {}, classNames: c = {} }
245
305
  if (!prompt.kind)
246
306
  return null;
247
307
  const link = prompt.kind === 'link';
248
- return (_jsxs("div", { className: c.row, children: [_jsx("input", { autoFocus: true, type: "text", inputMode: "url", autoCapitalize: "none", className: c.input, "aria-label": link ? 'Link address' : 'Embed address', placeholder: link ? (placeholders.link ?? 'https://… (empty removes the link)') : (placeholders.embed ?? 'YouTube, X, Google or Apple Maps, or any iframe URL'), value: prompt.value, onChange: e => prompt.setValue(e.target.value), onKeyDown: prompt.onKeyDown }), _jsx("button", { type: "button", className: c.apply, onClick: prompt.apply, children: link ? 'Apply' : 'Embed' }), _jsx("button", { type: "button", className: c.cancel, onClick: prompt.close, children: "Cancel" })] }));
308
+ return (_jsxs("div", { className: c.row, children: [_jsx("input", { autoFocus: true, type: "text", inputMode: "url", autoCapitalize: "none", className: c.input, "aria-label": link ? 'Link address' : 'Embed address', placeholder: link ? (placeholders.link ?? 'https://… (empty removes the link)') : 'A YouTube, X, Google Maps or Apple Maps link', value: prompt.value, onChange: e => prompt.setValue(e.target.value), onKeyDown: prompt.onKeyDown }), _jsx("button", { type: "button", className: c.apply, onClick: prompt.apply, children: link ? 'Apply' : 'Embed' }), _jsx("button", { type: "button", className: c.cancel, onClick: prompt.close, children: "Cancel" })] }));
249
309
  }
250
310
  /**
251
311
  * What a link or embed field does with its address. A link sets or replaces
@@ -335,12 +395,8 @@ export function uploadImageTo(endpoint) {
335
395
  };
336
396
  }
337
397
  /**
338
- * The editor, its upload plumbing and the state a toolbar reads.
339
- *
340
- * The toolbar itself stays with the consumer: the two wikis differ on which
341
- * buttons exist, which icon set draws them, and whether a link is entered in a
342
- * `window.prompt` or an inline field. Those are real differences. What they had
343
- * no business differing on is everything below.
398
+ * The editor and its upload plumbing. `WikiToolbar` given the editor draws
399
+ * the buttons its extensions allow; the icons and classes are the app's.
344
400
  */
345
401
  export function useWikiEditor({ value, onChange, placeholder = '', nodes = [], proseClass, uploadImage, debounceMs = 150, }) {
346
402
  const fileInputRef = useRef(null);
@@ -413,6 +469,77 @@ export function useWikiEditor({ value, onChange, placeholder = '', nodes = [], p
413
469
  fileInputRef.current.value = '';
414
470
  }, [editor, uploadImage]);
415
471
  const triggerUpload = useCallback(() => fileInputRef.current?.click(), []);
416
- const isActive = useCallback((a) => a ? Boolean(Array.isArray(a) ? editor?.isActive(a[0], a[1]) : editor?.isActive(a)) : false, [editor]);
417
- return { editor, fileInputRef, isUploading, handleFileChange, triggerUpload, isActive };
472
+ return { editor, fileInputRef, isUploading, handleFileChange, triggerUpload };
473
+ }
474
+ // A line's whole document is the line, so Enter has nowhere to go.
475
+ const LineDoc = TiptapNode.create({
476
+ name: 'doc',
477
+ topNode: true,
478
+ content: 'text*',
479
+ addKeyboardShortcuts: () => ({ Enter: () => true, 'Shift-Enter': () => true }),
480
+ });
481
+ /** A paste as one line: its blocks and breaks run on with a space, before the schema keeps only the marks the line takes. */
482
+ const pastedLine = (html) => cleanPastedHtml(html).replace(/<\/(p|h[1-6]|li|div|blockquote|tr|td|th)>|<br\s*\/?>/gi, ' ');
483
+ /**
484
+ * One line of words – a title, a heading, a caption, a button – edited in the
485
+ * element that shows it, so it reads as the page draws it. Its schema is the
486
+ * line and the `marks` it may keep, so nothing else can get in: no list from a
487
+ * typed "- ", no heading from a paste. Its value is that HTML, or plain text
488
+ * when it keeps no marks. It has its own undo, and `WikiToolbar` given its
489
+ * editor shows undo and its marks. `base.css` gives it its placeholder.
490
+ */
491
+ export function WikiLine({ as: Tag, value, onChange, placeholder, marks = [], className, onFocus }) {
492
+ const ref = useRef(null);
493
+ const [editor, setEditor] = useState(null);
494
+ const latest = useRef({ onChange, onFocus });
495
+ useEffect(() => {
496
+ latest.current = { onChange, onFocus };
497
+ });
498
+ const key = marks.join();
499
+ const out = (e) => (key ? e.getHTML() : e.getText()).replace(/\s+/g, ' ');
500
+ const content = (v) => (key ? v : { type: 'doc', content: v ? [{ type: 'text', text: v }] : [] });
501
+ // Mounted on the element itself: the element is the editable root.
502
+ useLayoutEffect(() => {
503
+ const has = (m) => key.split(',').includes(m);
504
+ const e = new TiptapEditor({
505
+ element: { mount: ref.current },
506
+ extensions: [
507
+ LineDoc,
508
+ StarterKit.configure({
509
+ document: false,
510
+ paragraph: false,
511
+ heading: false,
512
+ blockquote: false,
513
+ bulletList: false,
514
+ orderedList: false,
515
+ listItem: false,
516
+ codeBlock: false,
517
+ code: false,
518
+ horizontalRule: false,
519
+ hardBreak: false,
520
+ strike: false,
521
+ dropcursor: false,
522
+ gapcursor: false,
523
+ ...(has('bold') ? {} : { bold: false }),
524
+ ...(has('italic') ? {} : { italic: false }),
525
+ }),
526
+ ...(has('link') ? [wikiLink] : []),
527
+ ],
528
+ content: content(value),
529
+ editorProps: { transformPastedHTML: pastedLine, transformPastedText: t => t.replace(/\s+/g, ' ') },
530
+ onUpdate: ({ editor }) => latest.current.onChange(out(editor)),
531
+ onFocus: ({ editor }) => latest.current.onFocus?.(editor),
532
+ });
533
+ setEditor(e);
534
+ return () => e.destroy();
535
+ // The value is read once here; the effect below takes later ones.
536
+ // eslint-disable-next-line react-hooks/exhaustive-deps
537
+ }, [key]);
538
+ // Take a change from outside, never one the field itself just reported.
539
+ useEffect(() => {
540
+ if (editor && !editor.isFocused && out(editor) !== value)
541
+ editor.commands.setContent(content(value));
542
+ // eslint-disable-next-line react-hooks/exhaustive-deps
543
+ }, [editor, value]);
544
+ return _jsx(Tag, { ref: ref, className: className, "data-wiki-line": "", "data-placeholder": placeholder, "aria-label": placeholder });
418
545
  }
package/dist/react.d.ts CHANGED
@@ -479,14 +479,13 @@ export declare function isRailLinkActive(activePath: string, href: string, tree?
479
479
  export type { WikiLinkComponent, WikiLinkProps } from './react-server.js';
480
480
  /**
481
481
  * The `wiki-formant/dom` passes over a rendered article: tab groups, copy
482
- * buttons, sortable tables. All three renderers ran the same three in an
483
- * effect, and they disagreed on when: two ran it once on mount, so a page
484
- * swapped in without a remount kept its old tables unsortable. Each pass is
485
- * idempotent, so running on every `deps` change is safe.
482
+ * buttons, sortable tables, and each post on X hydrated and kept at the height
483
+ * it posts. All three renderers ran the same passes in an effect, and they
484
+ * disagreed on when: two ran it once on mount, so a page swapped in without a
485
+ * remount kept its old tables unsortable. Each pass is idempotent, so running
486
+ * on every `deps` change is safe.
486
487
  */
487
488
  export declare function useArticlePasses(ref: RefObject<HTMLElement | null>, deps: DependencyList): void;
488
- /** Tweet placeholders hydrated, and their iframes kept sized to the posted height. */
489
- export declare function useTweetEmbeds(ref: RefObject<HTMLElement | null>, deps: DependencyList): void;
490
489
  /**
491
490
  * Counts the page views of the app it is mounted in: place it once, in the root
492
491
  * layout. `endpoint` is the route that hands the beacon to `collect`.
package/dist/react.js CHANGED
@@ -26,7 +26,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
26
26
  // state on <html> before first paint. The hook keeps that attribute in
27
27
  // sync afterwards, so CSS has one source of truth either side of hydration.
28
28
  import { railBreakpointMismatch, readRailFloating, resolveSidebarOpen, SIDEBAR_ATTRIBUTE, } from './sidebar.js';
29
- import { activateTabGroups, addCopyButtons, hydrateTweetEmbeds, onTweetResize, sizeTweetEmbeds, sortTables, startBeacon } from './dom.js';
29
+ import { activateTabGroups, addCopyButtons, hydrateTweetEmbeds, onTweetResize, sizeTweetEmbed, sortTables, startBeacon } from './dom.js';
30
30
  import { comboboxAria } from './combobox.js';
31
31
  import { Component, createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, } from 'react';
32
32
  const readStored = (key) => {
@@ -883,10 +883,11 @@ const decodePath = (path) => {
883
883
  // ---- rendered-article passes --------------------------------------------------
884
884
  /**
885
885
  * The `wiki-formant/dom` passes over a rendered article: tab groups, copy
886
- * buttons, sortable tables. All three renderers ran the same three in an
887
- * effect, and they disagreed on when: two ran it once on mount, so a page
888
- * swapped in without a remount kept its old tables unsortable. Each pass is
889
- * idempotent, so running on every `deps` change is safe.
886
+ * buttons, sortable tables, and each post on X hydrated and kept at the height
887
+ * it posts. All three renderers ran the same passes in an effect, and they
888
+ * disagreed on when: two ran it once on mount, so a page swapped in without a
889
+ * remount kept its old tables unsortable. Each pass is idempotent, so running
890
+ * on every `deps` change is safe.
890
891
  */
891
892
  export function useArticlePasses(ref, deps) {
892
893
  useEffect(() => {
@@ -896,17 +897,8 @@ export function useArticlePasses(ref, deps) {
896
897
  activateTabGroups(root);
897
898
  addCopyButtons(root);
898
899
  sortTables(root);
899
- // eslint-disable-next-line react-hooks/exhaustive-deps
900
- }, deps);
901
- }
902
- /** Tweet placeholders hydrated, and their iframes kept sized to the posted height. */
903
- export function useTweetEmbeds(ref, deps) {
904
- useEffect(() => {
905
- const root = ref.current;
906
- if (!root)
907
- return;
908
900
  hydrateTweetEmbeds(root);
909
- return onTweetResize(height => sizeTweetEmbeds(root, height));
901
+ return onTweetResize((height, source) => sizeTweetEmbed(root, height, source));
910
902
  // eslint-disable-next-line react-hooks/exhaustive-deps
911
903
  }, deps);
912
904
  }
package/dist/tiptap.d.ts CHANGED
@@ -29,6 +29,14 @@ export interface MapEmbedOptions {
29
29
  * node has to already exist for the reader to see anything happen.
30
30
  */
31
31
  export declare function createMapEmbed({ resolveMapUrl }: MapEmbedOptions): TiptapNode<any, any>;
32
+ /**
33
+ * Every player a pasted link becomes – a YouTube video, a post on X, a Google
34
+ * or Apple map – and any other allowed iframe, as one list. Each app had
35
+ * picked its embeds by hand, and one that left `TwitterEmbed` out kept a
36
+ * pasted post as a bare link. `resolveMapUrl` follows a shortened map link,
37
+ * through `/api/resolve-map` unless an app routes it another way.
38
+ */
39
+ export declare const embedNodes: ({ resolveMapUrl }?: Partial<MapEmbedOptions>) => TiptapNode<any, any>[];
32
40
  export interface CodeBlockClassNames {
33
41
  wrapper?: string;
34
42
  /** The control cluster, positioned over the block. */
@@ -53,7 +61,7 @@ export interface CodeBlockOptions {
53
61
  };
54
62
  }
55
63
  /** A code block whose language is pickable from a dropdown. */
56
- export declare function createCodeBlock({ langs, defaultLang, classNames, icons, }?: CodeBlockOptions): TiptapNode<import("@tiptap/extension-code-block").CodeBlockOptions, any>;
64
+ export declare function createCodeBlock({ langs, defaultLang, classNames: own, icons, }?: CodeBlockOptions): TiptapNode<import("@tiptap/extension-code-block").CodeBlockOptions, any>;
57
65
  export interface TabsClassNames {
58
66
  editor?: string;
59
67
  list?: string;
@@ -74,12 +82,7 @@ export interface TabsOptions {
74
82
  /** Names a newly added tab. Receives the 1-based position. */
75
83
  tabLabel?: (index: number) => string;
76
84
  }
77
- /**
78
- * A tab group and its items, which must be registered together — `tabGroup`'s
79
- * content expression is `tabItem+`, so registering one without the other leaves
80
- * a node type the schema cannot satisfy.
81
- */
82
- export declare function createTabs({ classNames, icons, tabLabel, }?: TabsOptions): {
85
+ export declare function createTabs({ classNames: own, icons: ownIcons, tabLabel, }?: TabsOptions): {
83
86
  TabGroup: TiptapNode<any, any>;
84
87
  TabItem: TiptapNode<any, any>;
85
88
  };
package/dist/tiptap.js CHANGED
@@ -18,7 +18,7 @@ import TiptapYoutube from '@tiptap/extension-youtube';
18
18
  import TiptapCodeBlock from '@tiptap/extension-code-block';
19
19
  import { onTweetResize, tweetEmbedSrc } from './dom.js';
20
20
  import { DEFAULT_IFRAME_HOSTS } from './headers.js';
21
- import { toMapEmbedUrl } from './maps.js';
21
+ import { resolveMapUrl as resolveMapRoute, toMapEmbedUrl } from './maps.js';
22
22
  import { useClickOutside } from './react.js';
23
23
  import { cx } from './html.js';
24
24
  import { slugifyHeading, uniqueHeadingId } from './headings.js';
@@ -78,10 +78,10 @@ export const YouTube = TiptapYoutube.extend({
78
78
  function TwitterEmbedView({ node }) {
79
79
  const containerRef = useRef(null);
80
80
  // The embed posts its measured height back; `onTweetResize` owns the origin
81
- // check.
82
- useEffect(() => onTweetResize(height => {
81
+ // check, and the source keeps one tweet's height off every other.
82
+ useEffect(() => onTweetResize((height, source) => {
83
83
  const iframe = containerRef.current?.querySelector('iframe');
84
- if (iframe)
84
+ if (iframe && iframe.contentWindow === source)
85
85
  iframe.style.height = `${height}px`;
86
86
  }), []);
87
87
  return (_jsx(NodeViewWrapper, { children: _jsx("div", { ref: containerRef, className: "twitter-embed", children: _jsx("iframe", { src: tweetEmbedSrc(node.attrs.tweetId), scrolling: "no", allowFullScreen: true }) }) }));
@@ -231,14 +231,38 @@ export function createMapEmbed({ resolveMapUrl }) {
231
231
  },
232
232
  });
233
233
  }
234
+ // ---- every embed ------------------------------------------------------------
235
+ /**
236
+ * Every player a pasted link becomes – a YouTube video, a post on X, a Google
237
+ * or Apple map – and any other allowed iframe, as one list. Each app had
238
+ * picked its embeds by hand, and one that left `TwitterEmbed` out kept a
239
+ * pasted post as a bare link. `resolveMapUrl` follows a shortened map link,
240
+ * through `/api/resolve-map` unless an app routes it another way.
241
+ */
242
+ export const embedNodes = ({ resolveMapUrl = resolveMapRoute } = {}) => [
243
+ YouTube,
244
+ Iframe,
245
+ TwitterEmbed,
246
+ createMapEmbed({ resolveMapUrl }),
247
+ ];
248
+ /** The classes `base.css` draws a code block's language picker with, each replaceable. */
249
+ const CODE_BLOCK_CLASSES = {
250
+ wrapper: 'code-block',
251
+ control: 'code-block-control',
252
+ button: 'code-block-button',
253
+ dropdown: 'code-block-menu',
254
+ option: 'code-block-option',
255
+ optionActive: 'active',
256
+ };
234
257
  /** A code block whose language is pickable from a dropdown. */
235
- export function createCodeBlock({ langs = CODE_LANGS, defaultLang = DEFAULT_LANG, classNames = {}, icons = {}, } = {}) {
258
+ export function createCodeBlock({ langs = CODE_LANGS, defaultLang = DEFAULT_LANG, classNames: own = {}, icons = {}, } = {}) {
259
+ const classNames = { ...CODE_BLOCK_CLASSES, ...own };
236
260
  function CodeBlockView({ node, updateAttributes, }) {
237
261
  const [open, setOpen] = useState(false);
238
262
  const close = useCallback(() => setOpen(false), []);
239
263
  const dropdownRef = useClickOutside(close);
240
264
  const lang = node.attrs.language || defaultLang;
241
- return (_jsxs(NodeViewWrapper, { className: classNames.wrapper, children: [_jsxs("div", { ref: dropdownRef, className: classNames.control, children: [_jsxs("button", { type: "button", onClick: () => setOpen(!open), className: classNames.button, children: [icons.lang, _jsx("span", { children: lang }), icons.chevron?.(open)] }), open && (_jsx("div", { className: classNames.dropdown, children: langs.map(l => (_jsxs("button", { type: "button", onClick: () => {
265
+ return (_jsxs(NodeViewWrapper, { className: classNames.wrapper, children: [_jsxs("div", { ref: dropdownRef, className: classNames.control, children: [_jsxs("button", { type: "button", onClick: () => setOpen(!open), className: classNames.button, "aria-label": `Language: ${lang}`, "aria-expanded": open, children: [icons.lang, _jsx("span", { children: lang }), icons.chevron?.(open)] }), open && (_jsx("div", { className: classNames.dropdown, children: langs.map(l => (_jsxs("button", { type: "button", onClick: () => {
242
266
  updateAttributes({ language: l });
243
267
  setOpen(false);
244
268
  }, className: cx(classNames.option, l === lang && classNames.optionActive), children: [l, l === lang && icons.selected] }, l))) }))] }), _jsx("pre", { children: _jsx(NodeViewContent, { as: "code", className: `language-${lang}` }) })] }));
@@ -263,7 +287,20 @@ export function createCodeBlock({ langs = CODE_LANGS, defaultLang = DEFAULT_LANG
263
287
  * content expression is `tabItem+`, so registering one without the other leaves
264
288
  * a node type the schema cannot satisfy.
265
289
  */
266
- export function createTabs({ classNames = {}, icons = {}, tabLabel = i => `Tab ${i}`, } = {}) {
290
+ /** The classes `base.css` draws the tab editor with, each replaceable. */
291
+ const TABS_CLASSES = {
292
+ editor: 'tabs-editor',
293
+ list: 'tabs-list',
294
+ tab: 'tab-button',
295
+ tabActive: 'active',
296
+ title: 'tab-title',
297
+ remove: 'tab-remove',
298
+ add: 'tab-add',
299
+ content: 'tabs-content',
300
+ };
301
+ export function createTabs({ classNames: own = {}, icons: ownIcons = {}, tabLabel = i => `Tab ${i}`, } = {}) {
302
+ const classNames = { ...TABS_CLASSES, ...own };
303
+ const icons = { add: '+', remove: '×', ...ownIcons };
267
304
  function TabGroupView({ node, getPos, editor, updateAttributes, }) {
268
305
  const activeTab = node.attrs.activeTab ?? 0;
269
306
  const tabs = node.content.content || [];
@@ -299,7 +336,7 @@ export function createTabs({ classNames = {}, icons = {}, tabLabel = i => `Tab $
299
336
  tr.setNodeMarkup(posOf(index), undefined, { title });
300
337
  editor.view.dispatch(tr);
301
338
  };
302
- return (_jsx(NodeViewWrapper, { "data-tabs": "", "data-active-tab": activeTab, children: _jsxs("div", { className: classNames.editor, children: [_jsxs("div", { className: classNames.list, children: [tabs.map((tab, i) => (_jsxs("div", { className: cx(classNames.tab, activeTab === i && classNames.tabActive), children: [_jsx("input", { type: "text", value: tab.attrs.title, onChange: e => renameTab(i, e.target.value), onClick: () => setActiveTab(i), className: classNames.title }), tabs.length > 1 && (_jsx("button", { type: "button", onClick: () => removeTab(i), className: classNames.remove, children: icons.remove }))] }, i))), _jsx("button", { type: "button", onClick: addTab, className: classNames.add, children: icons.add })] }), _jsx("div", { className: classNames.content, children: _jsx(NodeViewContent, {}) })] }) }));
339
+ return (_jsx(NodeViewWrapper, { "data-tabs": "", "data-active-tab": activeTab, children: _jsxs("div", { className: classNames.editor, children: [_jsxs("div", { className: classNames.list, children: [tabs.map((tab, i) => (_jsxs("div", { className: cx(classNames.tab, activeTab === i && classNames.tabActive), children: [_jsx("input", { type: "text", value: tab.attrs.title, onChange: e => renameTab(i, e.target.value), onClick: () => setActiveTab(i), className: classNames.title, "aria-label": "Tab name" }), tabs.length > 1 && (_jsx("button", { type: "button", onClick: () => removeTab(i), className: classNames.remove, "aria-label": "Remove this tab", title: "Remove this tab", children: icons.remove }))] }, i))), _jsx("button", { type: "button", onClick: addTab, className: classNames.add, "aria-label": "Add a tab", title: "Add a tab", children: icons.add })] }), _jsx("div", { className: classNames.content, children: _jsx(NodeViewContent, {}) })] }) }));
303
340
  }
304
341
  const TabGroup = TiptapNode.create({
305
342
  name: 'tabGroup',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.79.0",
3
+ "version": "0.81.0",
4
4
  "description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
5
5
  "type": "module",
6
6
  "license": "MIT",