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 +14 -7
- package/dist/base.css +46 -2
- package/dist/dom.d.ts +8 -6
- package/dist/dom.js +10 -7
- package/dist/editor.d.ts +44 -34
- package/dist/editor.js +173 -46
- package/dist/react.d.ts +5 -6
- package/dist/react.js +7 -15
- package/dist/tiptap.d.ts +10 -7
- package/dist/tiptap.js +45 -8
- package/package.json +1 -1
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 =>
|
|
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
|
|
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
|
-
`
|
|
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}
|
|
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,
|
|
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
|
|
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.
|
|
27
|
-
* the origin check and the
|
|
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
|
|
40
|
-
export declare function
|
|
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
|
|
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.
|
|
70
|
-
* the origin check and the
|
|
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
|
|
108
|
-
export function
|
|
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.
|
|
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
|
|
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
|
|
65
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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,
|
|
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
|
|
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,
|
|
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 {
|
|
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', '
|
|
161
|
-
['addRowAfter', '
|
|
162
|
-
['deleteColumn', '
|
|
163
|
-
['deleteRow', '
|
|
164
|
-
['deleteTable', '
|
|
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
|
|
173
|
-
* two titled their buttons with the internal key
|
|
174
|
-
* "codeBlock", and inline code and code block shared one icon and
|
|
175
|
-
*
|
|
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: '
|
|
181
|
-
{ key: '
|
|
182
|
-
{ key: '
|
|
183
|
-
{ key: '
|
|
184
|
-
{ key: '
|
|
185
|
-
{ key: '
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
{ key: '
|
|
190
|
-
{ key: '
|
|
191
|
-
{ key: '
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
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,
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
return (_jsx(
|
|
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
|
-
|
|
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)') :
|
|
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
|
|
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
|
-
|
|
417
|
-
|
|
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
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
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,
|
|
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
|
|
887
|
-
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
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 =>
|
|
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
|
-
|
|
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.
|
|
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",
|