wiki-formant 0.80.0 → 0.82.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 +11 -4
- package/bin/check-pages.mjs +36 -1
- package/dist/base.css +35 -0
- package/dist/editor.d.ts +44 -34
- package/dist/editor.js +173 -46
- package/dist/react.d.ts +5 -6
- package/dist/react.js +5 -13
- package/dist/tiptap.d.ts +2 -7
- package/dist/tiptap.js +27 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -698,7 +698,7 @@ const off = onTweetResize((h, source) => sizeTweetEmbed(el, h, source));
|
|
|
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
|
|
|
@@ -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/bin/check-pages.mjs
CHANGED
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
// URLs come from /sitemap.xml, so a route the sitemap omits is not checked —
|
|
28
28
|
// which is the right default, since an unlisted route is one nobody asked a
|
|
29
29
|
// crawler to read.
|
|
30
|
+
//
|
|
31
|
+
// Once per site it also checks the icon. miow.me changed its icon in October
|
|
32
|
+
// 2026 and every address of it 404'd at /favicon.ico, the one place a client
|
|
33
|
+
// that never reads the page's <link rel="icon"> looks; so /favicon.ico must
|
|
34
|
+
// answer an image, and the same image the home page declares.
|
|
30
35
|
import { outlineIssues } from '../dist/headings.js';
|
|
31
36
|
import { readSitemap } from '../dist/indexnow.js';
|
|
32
37
|
import { decodeEntities } from '../dist/markdown.js';
|
|
@@ -125,6 +130,36 @@ for (const url of checked) {
|
|
|
125
130
|
if (descriptionOf(page.body) === null) faults.push({ url, fault: 'noDescription', detail: '' });
|
|
126
131
|
}
|
|
127
132
|
|
|
133
|
+
// The icon, once per site: what the home page declares (a page that declares
|
|
134
|
+
// none is shown /favicon.ico) against what /favicon.ico answers, redirects
|
|
135
|
+
// followed.
|
|
136
|
+
const iconHref = html => {
|
|
137
|
+
for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
|
|
138
|
+
if (/\brel=["'](?:shortcut )?icon["']/i.test(tag)) return decodeEntities(tag.match(/\bhref=["']([^"']+)["']/i)?.[1] ?? '');
|
|
139
|
+
}
|
|
140
|
+
return '';
|
|
141
|
+
};
|
|
142
|
+
const image = async url => {
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetch(url, { headers: { 'user-agent': UA } });
|
|
145
|
+
const type = res.headers.get('content-type') ?? '';
|
|
146
|
+
return res.ok && /^image\//i.test(type) ? Buffer.from(await res.arrayBuffer()) : `${res.status} ${type}`.trim();
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return e.message;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
{
|
|
152
|
+
const home = await get(`${site}/`).catch(() => ({ body: '' }));
|
|
153
|
+
const declared = new URL(iconHref(home.body) || '/favicon.ico', `${site}/`).href;
|
|
154
|
+
const favicon = await image(`${site}/favicon.ico`);
|
|
155
|
+
const shown = declared === `${site}/favicon.ico` ? favicon : await image(declared);
|
|
156
|
+
if (!Buffer.isBuffer(favicon)) faults.push({ url: `${site}/favicon.ico`, fault: 'noFavicon', detail: favicon });
|
|
157
|
+
if (!Buffer.isBuffer(shown)) faults.push({ url: declared, fault: 'iconDead', detail: shown });
|
|
158
|
+
else if (Buffer.isBuffer(favicon) && !favicon.equals(shown)) {
|
|
159
|
+
faults.push({ url: `${site}/favicon.ico`, fault: 'faviconStale', detail: `not the icon the home page declares, ${declared.replace(site, '')}` });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
128
163
|
// Reported once per title rather than once per page, or a shared title on forty
|
|
129
164
|
// routes drowns everything else.
|
|
130
165
|
for (const [title, sharing] of titles) {
|
|
@@ -149,7 +184,7 @@ if (JSON_OUT) {
|
|
|
149
184
|
const byFault = new Map();
|
|
150
185
|
for (const f of faults) byFault.set(f.fault, [...(byFault.get(f.fault) ?? []), f]);
|
|
151
186
|
console.log(`check-pages: ${checked.length} of ${urls.length} sitemap URLs on ${site}\n`);
|
|
152
|
-
if (!faults.length) console.log(' clean — one h1 per page, no skipped levels, every title inside the budget.');
|
|
187
|
+
if (!faults.length) console.log(' clean — one h1 per page, no skipped levels, every title inside the budget, and /favicon.ico the icon the page declares.');
|
|
153
188
|
else if (!failing.length) console.log(' no failures; everything below is advisory.\n');
|
|
154
189
|
for (const [fault, list] of [...byFault].sort((a, b) => b[1].length - a[1].length)) {
|
|
155
190
|
console.log(` ${fault} (${list.length})${ADVISORY.has(fault) ? ' — advisory, does not fail' : ''}`);
|
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,
|
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 each iframe kept at the height it posts. */
|
|
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
|
@@ -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,15 +897,6 @@ 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 each iframe kept at the height it posts. */
|
|
903
|
-
export function useTweetEmbeds(ref, deps) {
|
|
904
|
-
useEffect(() => {
|
|
905
|
-
const root = ref.current;
|
|
906
|
-
if (!root)
|
|
907
|
-
return;
|
|
908
900
|
hydrateTweetEmbeds(root);
|
|
909
901
|
return onTweetResize((height, source) => sizeTweetEmbed(root, height, source));
|
|
910
902
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
package/dist/tiptap.d.ts
CHANGED
|
@@ -61,7 +61,7 @@ export interface CodeBlockOptions {
|
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
63
|
/** A code block whose language is pickable from a dropdown. */
|
|
64
|
-
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>;
|
|
65
65
|
export interface TabsClassNames {
|
|
66
66
|
editor?: string;
|
|
67
67
|
list?: string;
|
|
@@ -82,12 +82,7 @@ export interface TabsOptions {
|
|
|
82
82
|
/** Names a newly added tab. Receives the 1-based position. */
|
|
83
83
|
tabLabel?: (index: number) => string;
|
|
84
84
|
}
|
|
85
|
-
|
|
86
|
-
* A tab group and its items, which must be registered together — `tabGroup`'s
|
|
87
|
-
* content expression is `tabItem+`, so registering one without the other leaves
|
|
88
|
-
* a node type the schema cannot satisfy.
|
|
89
|
-
*/
|
|
90
|
-
export declare function createTabs({ classNames, icons, tabLabel, }?: TabsOptions): {
|
|
85
|
+
export declare function createTabs({ classNames: own, icons: ownIcons, tabLabel, }?: TabsOptions): {
|
|
91
86
|
TabGroup: TiptapNode<any, any>;
|
|
92
87
|
TabItem: TiptapNode<any, any>;
|
|
93
88
|
};
|
package/dist/tiptap.js
CHANGED
|
@@ -245,14 +245,24 @@ export const embedNodes = ({ resolveMapUrl = resolveMapRoute } = {}) => [
|
|
|
245
245
|
TwitterEmbed,
|
|
246
246
|
createMapEmbed({ resolveMapUrl }),
|
|
247
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
|
+
};
|
|
248
257
|
/** A code block whose language is pickable from a dropdown. */
|
|
249
|
-
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 };
|
|
250
260
|
function CodeBlockView({ node, updateAttributes, }) {
|
|
251
261
|
const [open, setOpen] = useState(false);
|
|
252
262
|
const close = useCallback(() => setOpen(false), []);
|
|
253
263
|
const dropdownRef = useClickOutside(close);
|
|
254
264
|
const lang = node.attrs.language || defaultLang;
|
|
255
|
-
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: () => {
|
|
256
266
|
updateAttributes({ language: l });
|
|
257
267
|
setOpen(false);
|
|
258
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}` }) })] }));
|
|
@@ -277,7 +287,20 @@ export function createCodeBlock({ langs = CODE_LANGS, defaultLang = DEFAULT_LANG
|
|
|
277
287
|
* content expression is `tabItem+`, so registering one without the other leaves
|
|
278
288
|
* a node type the schema cannot satisfy.
|
|
279
289
|
*/
|
|
280
|
-
|
|
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 };
|
|
281
304
|
function TabGroupView({ node, getPos, editor, updateAttributes, }) {
|
|
282
305
|
const activeTab = node.attrs.activeTab ?? 0;
|
|
283
306
|
const tabs = node.content.content || [];
|
|
@@ -313,7 +336,7 @@ export function createTabs({ classNames = {}, icons = {}, tabLabel = i => `Tab $
|
|
|
313
336
|
tr.setNodeMarkup(posOf(index), undefined, { title });
|
|
314
337
|
editor.view.dispatch(tr);
|
|
315
338
|
};
|
|
316
|
-
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, {}) })] }) }));
|
|
317
340
|
}
|
|
318
341
|
const TabGroup = TiptapNode.create({
|
|
319
342
|
name: 'tabGroup',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.82.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",
|