wiki-formant 0.50.0 → 0.51.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 +39 -6
- package/dist/block-editor.d.ts +203 -0
- package/dist/block-editor.js +218 -0
- package/dist/blocks.d.ts +67 -0
- package/dist/editor.d.ts +58 -0
- package/dist/editor.js +27 -1
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -668,16 +668,19 @@ The language list defaults to `CODE_LANGS` and `DEFAULT_LANG` from `wiki-formant
|
|
|
668
668
|
|
|
669
669
|
`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.
|
|
670
670
|
|
|
671
|
-
`toolbarActions([...])` picks the formatting commands, in order, with the labels a screen reader announces — two toolbars titled buttons with the internal key, so one said "codeBlock".
|
|
672
|
-
|
|
673
|
-
`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.
|
|
671
|
+
`toolbarActions([...])` picks the formatting commands, in order, with the labels a screen reader announces — two toolbars titled buttons with the internal key, so one said "codeBlock". `WikiToolbar` lays them out the way three wikis had each assembled them by hand: the format buttons, Remove link while the cursor is in a link, upload and embed, the insert buttons, and the table controls while the cursor is in a table. Every button is a `ToolbarButton` (named, and `aria-pressed`, since these are toggles); the link button opens the address field and reads as on while it is open. Which buttons, icons and classes are yours; a toolbar shaped differently builds from `ToolbarButton` and `runTableAction` directly.
|
|
674
672
|
|
|
675
673
|
```tsx
|
|
676
|
-
|
|
677
|
-
<
|
|
678
|
-
{
|
|
674
|
+
<WikiToolbar editor={editor} isActive={isActive} prompt={url} actions={FORMAT} trailing={INSERT} onUpload={triggerUpload} embed
|
|
675
|
+
icons={{ ...ICONS, unlink: <Unlink />, upload: <Upload />, embed: <Globe /> }}
|
|
676
|
+
classNames={{ toolbar: 'toolbar', button: 'toolbar-btn', pressed: 'toolbar-btn-on', divider: 'toolbar-divider' }} />
|
|
677
|
+
<UrlPromptField prompt={url} classNames={{ row: 'row', input: 'input', apply: 'btn', cancel: 'btn-quiet' }} />
|
|
679
678
|
```
|
|
680
679
|
|
|
680
|
+
`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.
|
|
681
|
+
|
|
682
|
+
`UrlPromptField` is its markup: an address input on which Enter applies and Escape closes, and Apply and Cancel.
|
|
683
|
+
|
|
681
684
|
A link is stored as its `href` and the `link` class, nothing else. Stock Tiptap writes `target="_blank" rel="noopener noreferrer nofollow"` onto every link, internal ones included, which on a site throws away its own link equity and opens in-site navigation in new tabs. Whether a link is external depends on the host serving the page, so `normaliseLinks` decides it at render time. A legacy link loses both attributes the next time its page is saved.
|
|
682
685
|
|
|
683
686
|
The redirect resolves through the wiki's own route, because the editor cannot read it cross-origin. `resolveMapUrl` is the client half and `resolveMapHandler` the whole route: exact shortener hosts, one hop, an allowlisted landing host, a timeout, and your sign-in check as `authorize`. One of the two copies it replaced matched `goo.gl` as a substring and followed every redirect for anyone who asked.
|
|
@@ -686,6 +689,36 @@ The redirect resolves through the wiki's own route, because the editor cannot re
|
|
|
686
689
|
export const GET = resolveMapHandler({ authorize: async () => !!(await currentUser()) });
|
|
687
690
|
```
|
|
688
691
|
|
|
692
|
+
## Block editor
|
|
693
|
+
|
|
694
|
+
`wiki-formant/block-editor` is the block list an author edits and the fields of the core types. Three wikis had written it, 500 to 700 lines each, and had drifted: one dropped a stored code language its list did not name, one let a typed count run past the row cap, one named every remove button "Remove", one never marked the block being edited. The behaviour is here once; the look arrives as a kit.
|
|
695
|
+
|
|
696
|
+
```tsx
|
|
697
|
+
const KIT: BlockEditorKit = {
|
|
698
|
+
classNames: { list: 'stack', card: 'block-card', action: 'icon-btn', input: 'input', row: 'row', … },
|
|
699
|
+
icons: { add: <Plus />, up: <ChevronUp />, down: <ChevronDown />, duplicate: <Copy />, remove: <Trash2 /> },
|
|
700
|
+
Choice: ToggleGroup, // optional: a <select> otherwise
|
|
701
|
+
Menu: PopoverMenu, // optional: an inline panel otherwise
|
|
702
|
+
};
|
|
703
|
+
const SCHEMA: BlockSchema<Block> = {
|
|
704
|
+
meta: type => BLOCK_META[type], create: createBlock, duplicate: duplicateBlock,
|
|
705
|
+
types: PAGE_TYPES, nestedTypes: ATOMIC_TYPES, single: ['infobox'],
|
|
706
|
+
renderBody: (block, onUpdate) => {
|
|
707
|
+
switch (block.type) {
|
|
708
|
+
case 'codeTabs': return <CodeTabsFields block={block} onUpdate={onUpdate} />;
|
|
709
|
+
case 'columns': return <ColumnsFields block={block} onUpdate={onUpdate} />;
|
|
710
|
+
case 'daoTimeline': return <DaoTimelineEdit block={block} onUpdate={onUpdate} />;
|
|
711
|
+
…
|
|
712
|
+
}
|
|
713
|
+
},
|
|
714
|
+
};
|
|
715
|
+
<BlockEditor blocks={blocks} onChange={setBlocks} schema={SCHEMA} kit={KIT} />
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
The dispatch stays a `switch` over the wiki's own union, so a new type is a compile error there. `CodeTabsFields`, `StatsFields`, `ReferencesFields`, `LinkGridFields`, `BannerFields`, `PageListFields`, `ColumnsFields` and `InfoboxFields` take the core blocks `wiki-formant/blocks` declares; the two containers hold the same list, offering `nestedTypes`. `Field`, `Choice` and `LimitField` are exported for a wiki's own editors, so its native types wear the same kit. A card is marked `data-selected` while it is being edited and `data-nested` inside a container; a type the build does not know is shown by its raw type and carried through untouched. Keep the kit and the schema stable — a module constant or a `useMemo` — since both reach every card through context, and a fresh one re-renders every card on every keystroke.
|
|
719
|
+
|
|
720
|
+
The core blocks are `ContentBlock`, `CodeTabsBlock`, `StatsBlock`, `LinkGridBlock`, `ReferencesBlock`, `BannerBlock`, `ColumnsBlock<A>` and `InfoboxBlock<A>`, `A` being the wiki's atomic union. A wiki builds its union from them beside its own `recentPages` and `pageList`, which hold its own page rows or a section, and its native types.
|
|
721
|
+
|
|
689
722
|
## Analytics
|
|
690
723
|
|
|
691
724
|
Page views and events, counted in the site's own Postgres instead of a hosted
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { type ComponentType, type ReactElement, type ReactNode } from 'react';
|
|
2
|
+
import { type BannerBlock, type CodeTabsBlock, type ColumnsBlock, type InfoboxBlock, type LinkGridBlock, type ReferencesBlock, type StatsBlock } from './blocks.js';
|
|
3
|
+
export interface BlockEditorClassNames {
|
|
4
|
+
/** A page's block list. */
|
|
5
|
+
list?: string;
|
|
6
|
+
/** A column's or an infobox's. */
|
|
7
|
+
nestedList?: string;
|
|
8
|
+
/** The line an empty list shows. */
|
|
9
|
+
empty?: string;
|
|
10
|
+
/** A block. It carries `data-selected` while it is the one being edited, and `data-nested` inside a container. */
|
|
11
|
+
card?: string;
|
|
12
|
+
cardHead?: string;
|
|
13
|
+
cardLabel?: string;
|
|
14
|
+
cardActions?: string;
|
|
15
|
+
/** Every icon button: a card's actions, add-below, remove. */
|
|
16
|
+
action?: string;
|
|
17
|
+
/** The Add block button at the foot of a list. */
|
|
18
|
+
addBlock?: string;
|
|
19
|
+
/** The default `Add` button: Add tab, Add link. */
|
|
20
|
+
add?: string;
|
|
21
|
+
/** The default `Menu`'s wrapper and panel. */
|
|
22
|
+
menuAnchor?: string;
|
|
23
|
+
menu?: string;
|
|
24
|
+
menuItem?: string;
|
|
25
|
+
menuText?: string;
|
|
26
|
+
menuHint?: string;
|
|
27
|
+
/** A vertical group of fields. */
|
|
28
|
+
stack?: string;
|
|
29
|
+
/** A row of inputs ending in a remove button. */
|
|
30
|
+
row?: string;
|
|
31
|
+
/** A group inside a block: a link group, a column. */
|
|
32
|
+
nest?: string;
|
|
33
|
+
/** What holds the columns of a columns block. */
|
|
34
|
+
columns?: string;
|
|
35
|
+
/** The default `Field`'s label. */
|
|
36
|
+
field?: string;
|
|
37
|
+
input?: string;
|
|
38
|
+
/** Added to a code tab's language select. */
|
|
39
|
+
lang?: string;
|
|
40
|
+
/** Added to a code tab's textarea. */
|
|
41
|
+
code?: string;
|
|
42
|
+
/** Added to a stat's figure input. */
|
|
43
|
+
figure?: string;
|
|
44
|
+
/** Added to a count input. */
|
|
45
|
+
number?: string;
|
|
46
|
+
/** Row numbers, hints and notes. */
|
|
47
|
+
muted?: string;
|
|
48
|
+
}
|
|
49
|
+
export interface BlockEditorIcons {
|
|
50
|
+
add: ReactNode;
|
|
51
|
+
up: ReactNode;
|
|
52
|
+
down: ReactNode;
|
|
53
|
+
duplicate: ReactNode;
|
|
54
|
+
remove: ReactNode;
|
|
55
|
+
}
|
|
56
|
+
export interface FieldProps {
|
|
57
|
+
label: string;
|
|
58
|
+
hint?: string;
|
|
59
|
+
children: ReactNode;
|
|
60
|
+
}
|
|
61
|
+
/** One of a few values: a toggle group, a chip row or a select. */
|
|
62
|
+
export interface ChoiceProps {
|
|
63
|
+
label: string;
|
|
64
|
+
value: string;
|
|
65
|
+
options: ReadonlyArray<{
|
|
66
|
+
value: string;
|
|
67
|
+
label: string;
|
|
68
|
+
}>;
|
|
69
|
+
onChange: (value: string) => void;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The add-block menu around its trigger. `trigger` is the button the editor
|
|
73
|
+
* drew; a popover takes it as its anchor, an inline panel renders it first.
|
|
74
|
+
*/
|
|
75
|
+
export interface MenuProps {
|
|
76
|
+
open: boolean;
|
|
77
|
+
onOpenChange: (open: boolean) => void;
|
|
78
|
+
trigger: ReactElement;
|
|
79
|
+
/** The icon in a block's head that adds under it, rather than the list's foot button. */
|
|
80
|
+
below: boolean;
|
|
81
|
+
children: ReactNode;
|
|
82
|
+
}
|
|
83
|
+
export interface AddProps {
|
|
84
|
+
onPress: () => void;
|
|
85
|
+
children: ReactNode;
|
|
86
|
+
}
|
|
87
|
+
export interface BlockEditorKit {
|
|
88
|
+
classNames: BlockEditorClassNames;
|
|
89
|
+
icons: BlockEditorIcons;
|
|
90
|
+
Field?: ComponentType<FieldProps>;
|
|
91
|
+
Choice?: ComponentType<ChoiceProps>;
|
|
92
|
+
Menu?: ComponentType<MenuProps>;
|
|
93
|
+
Add?: ComponentType<AddProps>;
|
|
94
|
+
}
|
|
95
|
+
export type EditableBlock = {
|
|
96
|
+
id: string;
|
|
97
|
+
type: string;
|
|
98
|
+
};
|
|
99
|
+
export interface BlockMeta {
|
|
100
|
+
label: string;
|
|
101
|
+
icon?: ReactNode;
|
|
102
|
+
/** The line under the label in the add menu. */
|
|
103
|
+
hint?: string;
|
|
104
|
+
}
|
|
105
|
+
export interface BlockSchema<B extends EditableBlock> {
|
|
106
|
+
/** Undefined for a type this build does not know: it is shown by its raw type and kept as it is. */
|
|
107
|
+
meta: (type: B['type']) => BlockMeta | undefined;
|
|
108
|
+
create: (type: B['type']) => B;
|
|
109
|
+
/** A copy with fresh ids at every level. */
|
|
110
|
+
duplicate: (block: B) => B;
|
|
111
|
+
/**
|
|
112
|
+
* A block's fields. The wiki's own `switch`, so it stays exhaustive over its
|
|
113
|
+
* union. Null for a type with no fields here: the card says it is kept as is.
|
|
114
|
+
*/
|
|
115
|
+
renderBody: (block: B, onUpdate: (next: B) => void) => ReactNode;
|
|
116
|
+
/** What a page's add menus offer, in their order. */
|
|
117
|
+
types: readonly B['type'][];
|
|
118
|
+
/** What a column's and an infobox's offer: never a container. */
|
|
119
|
+
nestedTypes: readonly B['type'][];
|
|
120
|
+
/** Types a list holds at most one of, such as an infobox an article lifts into its rail. */
|
|
121
|
+
single?: readonly B['type'][];
|
|
122
|
+
}
|
|
123
|
+
export interface BlockEditorProps<B extends EditableBlock> {
|
|
124
|
+
blocks: B[];
|
|
125
|
+
onChange: (blocks: B[]) => void;
|
|
126
|
+
schema: BlockSchema<B>;
|
|
127
|
+
kit: BlockEditorKit;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* A page's blocks, edited: each in a card with its add-below, move, duplicate
|
|
131
|
+
* and delete controls, and an add menu at the foot. Columns and infoboxes hold
|
|
132
|
+
* the same list, offering `nestedTypes`.
|
|
133
|
+
*/
|
|
134
|
+
export declare function BlockEditor<B extends EditableBlock>({ blocks, onChange, schema, kit }: BlockEditorProps<B>): import("react").JSX.Element;
|
|
135
|
+
/** A labelled field in the kit's style, for a wiki's own block editors as well as these. */
|
|
136
|
+
export declare function Field(props: FieldProps): import("react").JSX.Element;
|
|
137
|
+
/** One of a few values in the kit's style; a `<select>` when the kit has none. */
|
|
138
|
+
export declare function Choice(props: ChoiceProps): import("react").JSX.Element;
|
|
139
|
+
/** A count clamped to 1…`MAX_BLOCK_ROWS`, the cap the write path enforces. */
|
|
140
|
+
export declare function LimitField({ label, value, onChange }: {
|
|
141
|
+
label?: string;
|
|
142
|
+
value: number | undefined;
|
|
143
|
+
onChange: (n: number) => void;
|
|
144
|
+
}): import("react").JSX.Element;
|
|
145
|
+
/**
|
|
146
|
+
* `code` is source text, never HTML: every view escapes it. A stored language
|
|
147
|
+
* outside `langs` stays selectable rather than reading as the first option and
|
|
148
|
+
* being overwritten on the next save.
|
|
149
|
+
*/
|
|
150
|
+
export declare function CodeTabsFields({ block, onUpdate, langs, defaultLang, }: {
|
|
151
|
+
block: CodeTabsBlock;
|
|
152
|
+
onUpdate: (block: CodeTabsBlock) => void;
|
|
153
|
+
langs?: readonly string[];
|
|
154
|
+
defaultLang?: string;
|
|
155
|
+
}): import("react").JSX.Element;
|
|
156
|
+
export declare function StatsFields({ block, onUpdate, hint }: {
|
|
157
|
+
block: StatsBlock;
|
|
158
|
+
onUpdate: (block: StatsBlock) => void;
|
|
159
|
+
hint?: string;
|
|
160
|
+
}): import("react").JSX.Element;
|
|
161
|
+
/** `title` is the heading a references block shows when it stores none, and `noun` what one row is called. */
|
|
162
|
+
export declare function ReferencesFields({ block, onUpdate, title, noun, hint, }: {
|
|
163
|
+
block: ReferencesBlock;
|
|
164
|
+
onUpdate: (block: ReferencesBlock) => void;
|
|
165
|
+
title?: string;
|
|
166
|
+
noun?: string;
|
|
167
|
+
hint?: string;
|
|
168
|
+
}): import("react").JSX.Element;
|
|
169
|
+
export declare function LinkGridFields({ block, onUpdate }: {
|
|
170
|
+
block: LinkGridBlock;
|
|
171
|
+
onUpdate: (block: LinkGridBlock) => void;
|
|
172
|
+
}): import("react").JSX.Element;
|
|
173
|
+
export declare function BannerFields({ block, onUpdate }: {
|
|
174
|
+
block: BannerBlock;
|
|
175
|
+
onUpdate: (block: BannerBlock) => void;
|
|
176
|
+
}): import("react").JSX.Element;
|
|
177
|
+
/** A page a page list can pick. `detail` is shown after the title, such as its section. */
|
|
178
|
+
export interface PageOption {
|
|
179
|
+
id: string;
|
|
180
|
+
title: string;
|
|
181
|
+
detail?: string;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The picked pages in their order, each movable and removable. With `pages`
|
|
185
|
+
* the rest are offered in a picker; without, an id is typed in.
|
|
186
|
+
*/
|
|
187
|
+
export declare function PageListFields<B extends {
|
|
188
|
+
pageIds: string[];
|
|
189
|
+
}>({ block, onUpdate, pages }: {
|
|
190
|
+
block: B;
|
|
191
|
+
onUpdate: (block: B) => void;
|
|
192
|
+
pages?: readonly PageOption[];
|
|
193
|
+
}): import("react").JSX.Element;
|
|
194
|
+
/** Two to four columns, each a block list offering the schema's `nestedTypes`. */
|
|
195
|
+
export declare function ColumnsFields<A extends EditableBlock>({ block, onUpdate }: {
|
|
196
|
+
block: ColumnsBlock<A>;
|
|
197
|
+
onUpdate: (block: ColumnsBlock<A>) => void;
|
|
198
|
+
}): import("react").JSX.Element;
|
|
199
|
+
/** An infobox's blocks, offering the schema's `nestedTypes`. */
|
|
200
|
+
export declare function InfoboxFields<A extends EditableBlock>({ block, onUpdate }: {
|
|
201
|
+
block: InfoboxBlock<A>;
|
|
202
|
+
onUpdate: (block: InfoboxBlock<A>) => void;
|
|
203
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// block-editor.tsx — the block list an author edits, and the fields of the core
|
|
4
|
+
// block types.
|
|
5
|
+
//
|
|
6
|
+
// Three wikis had written this, 500 to 700 lines each, and the copies had
|
|
7
|
+
// drifted the way copies do: one dropped a stored code language that was not
|
|
8
|
+
// in its list, one let a typed count run past the row cap, one named every
|
|
9
|
+
// remove button "Remove", one never highlighted the block being edited. The
|
|
10
|
+
// behaviour lives here once. The look does not: each wiki draws its inputs,
|
|
11
|
+
// toggles and menus with its own design system, so those arrive as a KIT —
|
|
12
|
+
// class names, icons, and four optional components — and the markup here is
|
|
13
|
+
// bare semantic HTML wearing whatever the kit says.
|
|
14
|
+
//
|
|
15
|
+
// The dispatch stays with the wiki. `renderBody` is a `switch` over its own
|
|
16
|
+
// closed union, as S4 of the workspace's STRUCTURE.md requires, so a new block
|
|
17
|
+
// type is a compile error there; its cases hand the core types to the field
|
|
18
|
+
// editors below and draw its native types themselves.
|
|
19
|
+
//
|
|
20
|
+
// The kit and the schema reach every nested list through context, which is
|
|
21
|
+
// what lets a column or an infobox hold a list without being handed the whole
|
|
22
|
+
// configuration. Keep both stable — a module constant or a `useMemo` — or every
|
|
23
|
+
// card re-renders on every keystroke.
|
|
24
|
+
import { createContext, memo, useCallback, useContext, useMemo, useState, } from 'react';
|
|
25
|
+
import { BlockActions, useBlockOperations } from './react.js';
|
|
26
|
+
import { CODE_LANGS, DEFAULT_LANG, } from './blocks.js';
|
|
27
|
+
import { BANNER_VARIANTS } from './text.js';
|
|
28
|
+
import { MAX_BLOCK_ROWS } from './validation.js';
|
|
29
|
+
import { cx } from './html.js';
|
|
30
|
+
const Context = createContext(null);
|
|
31
|
+
function useEditorContext() {
|
|
32
|
+
const ctx = useContext(Context);
|
|
33
|
+
if (!ctx)
|
|
34
|
+
throw new Error('A block field editor must render inside <BlockEditor>.');
|
|
35
|
+
return ctx;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A page's blocks, edited: each in a card with its add-below, move, duplicate
|
|
39
|
+
* and delete controls, and an add menu at the foot. Columns and infoboxes hold
|
|
40
|
+
* the same list, offering `nestedTypes`.
|
|
41
|
+
*/
|
|
42
|
+
export function BlockEditor({ blocks, onChange, schema, kit }) {
|
|
43
|
+
const value = useMemo(() => ({ kit, schema: schema }), [kit, schema]);
|
|
44
|
+
return (_jsx(Context.Provider, { value: value, children: _jsx(BlockList, { blocks: blocks, onChange: onChange }) }));
|
|
45
|
+
}
|
|
46
|
+
function BlockList({ blocks, onChange, nested = false }) {
|
|
47
|
+
const { kit, schema } = useEditorContext();
|
|
48
|
+
const c = kit.classNames;
|
|
49
|
+
const ops = useBlockOperations(blocks, onChange, schema);
|
|
50
|
+
const base = nested ? schema.nestedTypes : schema.types;
|
|
51
|
+
// A string, so the offer is recomputed — and every card re-rendered — only
|
|
52
|
+
// when a single-use type comes or goes, not on every keystroke.
|
|
53
|
+
const held = (schema.single ?? []).filter(t => blocks.some(b => b.type === t)).join(' ');
|
|
54
|
+
const offered = useMemo(() => base.filter(t => !held.split(' ').includes(t)), [base, held]);
|
|
55
|
+
const insertNew = useCallback((type) => ops.insert(schema.create(type)), [ops.insert, schema]);
|
|
56
|
+
return (_jsxs("div", { className: nested ? c.nestedList : c.list, children: [blocks.length === 0 && _jsx("p", { className: c.empty, children: nested ? 'Empty.' : 'Nothing here yet. Add the first block.' }), blocks.map((block, i) => (_jsx(BlockCard, { block: block, index: i, total: blocks.length, selected: ops.selectedIndex === i, nested: nested, offered: offered, update: ops.update, move: ops.move, duplicate: ops.duplicate, remove: ops.remove, insert: ops.insert, select: ops.setSelectedIndex }, block.id))), _jsx(InsertMenu, { types: offered, onPick: insertNew })] }));
|
|
57
|
+
}
|
|
58
|
+
// Memoised on the list's ref-stable callbacks, so a keystroke in one block does
|
|
59
|
+
// not re-render every other block's editor.
|
|
60
|
+
const BlockCard = memo(function BlockCard({ block, index, total, selected, nested, offered, update, move, duplicate, remove, insert, select }) {
|
|
61
|
+
const { kit, schema } = useEditorContext();
|
|
62
|
+
const c = kit.classNames;
|
|
63
|
+
const onUpdate = useCallback((next) => update(index, next), [update, index]);
|
|
64
|
+
const onSelect = useCallback(() => select(index), [select, index]);
|
|
65
|
+
const meta = schema.meta(block.type);
|
|
66
|
+
const label = meta?.label ?? block.type;
|
|
67
|
+
const body = meta ? schema.renderBody(block, onUpdate) : null;
|
|
68
|
+
return (_jsxs("section", { className: c.card, "data-selected": selected || undefined, "data-nested": nested || undefined, onFocusCapture: onSelect, onClick: onSelect, children: [_jsxs("div", { className: c.cardHead, children: [_jsxs("span", { className: c.cardLabel, children: [meta?.icon, label] }), _jsxs("span", { className: c.cardActions, children: [_jsx(InsertMenu, { below: true, types: offered, onPick: type => insert(schema.create(type), index + 1) }), _jsx(BlockActions, { index: index, total: total, ops: { move, duplicate, remove }, icons: kit.icons, className: c.cardActions, buttonClassName: c.action, blockLabel: label })] })] }), body ?? _jsx("p", { className: c.muted, children: "Kept as it is \u2013 this editor has no fields for it." })] }));
|
|
69
|
+
});
|
|
70
|
+
function InsertMenu({ types, onPick, below = false }) {
|
|
71
|
+
const { kit, schema } = useEditorContext();
|
|
72
|
+
const c = kit.classNames;
|
|
73
|
+
const [open, setOpen] = useState(false);
|
|
74
|
+
const Menu = kit.Menu ?? InlineMenu;
|
|
75
|
+
const toggle = () => setOpen(o => !o);
|
|
76
|
+
const trigger = below ? (_jsx("button", { type: "button", className: c.action, title: "Add a block below", "aria-label": "Add a block below", "aria-expanded": open, onClick: toggle, children: kit.icons.add })) : (_jsxs("button", { type: "button", className: c.addBlock, "aria-expanded": open, onClick: toggle, children: [kit.icons.add, "Add block"] }));
|
|
77
|
+
return (_jsx(Menu, { open: open, onOpenChange: setOpen, trigger: trigger, below: below, children: types.map(type => {
|
|
78
|
+
const meta = schema.meta(type);
|
|
79
|
+
return (_jsxs("button", { type: "button", className: c.menuItem, onClick: e => {
|
|
80
|
+
// Inside a card, a pick is not also a click on the card.
|
|
81
|
+
e.stopPropagation();
|
|
82
|
+
onPick(type);
|
|
83
|
+
setOpen(false);
|
|
84
|
+
}, children: [meta?.icon, _jsxs("span", { className: c.menuText, children: [_jsx("span", { children: meta?.label ?? type }), meta?.hint && _jsx("span", { className: c.menuHint, children: meta.hint })] })] }, type));
|
|
85
|
+
}) }));
|
|
86
|
+
}
|
|
87
|
+
function InlineMenu({ open, trigger, children }) {
|
|
88
|
+
const c = useEditorContext().kit.classNames;
|
|
89
|
+
return (_jsxs("span", { className: c.menuAnchor, children: [trigger, open && _jsx("span", { className: c.menu, children: children })] }));
|
|
90
|
+
}
|
|
91
|
+
// ---- the kit's parts, resolved ----------------------------------------------
|
|
92
|
+
/** A labelled field in the kit's style, for a wiki's own block editors as well as these. */
|
|
93
|
+
export function Field(props) {
|
|
94
|
+
const { Field: Custom, classNames: c } = useEditorContext().kit;
|
|
95
|
+
if (Custom)
|
|
96
|
+
return _jsx(Custom, { ...props });
|
|
97
|
+
return (_jsxs("label", { className: c.field, children: [_jsx("span", { children: props.label }), props.children, props.hint && _jsx("small", { className: c.muted, children: props.hint })] }));
|
|
98
|
+
}
|
|
99
|
+
/** One of a few values in the kit's style; a `<select>` when the kit has none. */
|
|
100
|
+
export function Choice(props) {
|
|
101
|
+
const { Choice: Custom, classNames: c } = useEditorContext().kit;
|
|
102
|
+
if (Custom)
|
|
103
|
+
return _jsx(Custom, { ...props });
|
|
104
|
+
return (_jsx(Field, { label: props.label, children: _jsx("select", { className: c.input, value: props.value, onChange: e => props.onChange(e.target.value), children: props.options.map(o => (_jsx("option", { value: o.value, children: o.label }, o.value))) }) }));
|
|
105
|
+
}
|
|
106
|
+
function Add({ onPress, children }) {
|
|
107
|
+
const { Add: Custom, classNames: c, icons } = useEditorContext().kit;
|
|
108
|
+
if (Custom)
|
|
109
|
+
return _jsx(Custom, { onPress: onPress, children: children });
|
|
110
|
+
return (_jsxs("button", { type: "button", className: c.add, onClick: onPress, children: [icons.add, children] }));
|
|
111
|
+
}
|
|
112
|
+
function Remove({ label, onPress }) {
|
|
113
|
+
const { classNames: c, icons } = useEditorContext().kit;
|
|
114
|
+
return (_jsx("button", { type: "button", className: c.action, title: label, "aria-label": label, onClick: onPress, children: icons.remove }));
|
|
115
|
+
}
|
|
116
|
+
/** A text input. `optional` stores an emptied field as absent rather than `''`. */
|
|
117
|
+
function TextInput({ label, value, onChange, placeholder = label, optional = false, type = 'text', className, }) {
|
|
118
|
+
const c = useEditorContext().kit.classNames;
|
|
119
|
+
return (_jsx("input", { type: type, className: cx(c.input, className), "aria-label": label, placeholder: placeholder, value: value ?? '', onChange: e => onChange(optional ? e.target.value.trim() ? e.target.value : undefined : e.target.value) }));
|
|
120
|
+
}
|
|
121
|
+
const replaceAt = (items, i, item) => items.map((x, j) => (j === i ? item : x));
|
|
122
|
+
const removeAt = (items, i) => items.filter((_, j) => j !== i);
|
|
123
|
+
const newId = () => crypto.randomUUID();
|
|
124
|
+
// ---- the core blocks' fields -------------------------------------------------
|
|
125
|
+
/** A count clamped to 1…`MAX_BLOCK_ROWS`, the cap the write path enforces. */
|
|
126
|
+
export function LimitField({ label = 'How many', value, onChange }) {
|
|
127
|
+
const c = useEditorContext().kit.classNames;
|
|
128
|
+
return (_jsx(Field, { label: label, children: _jsx("input", { type: "number", min: 1, max: MAX_BLOCK_ROWS, className: cx(c.input, c.number), value: value ?? '', onChange: e => onChange(Math.min(MAX_BLOCK_ROWS, Math.max(1, Math.floor(Number(e.target.value)) || 1))) }) }));
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* `code` is source text, never HTML: every view escapes it. A stored language
|
|
132
|
+
* outside `langs` stays selectable rather than reading as the first option and
|
|
133
|
+
* being overwritten on the next save.
|
|
134
|
+
*/
|
|
135
|
+
export function CodeTabsFields({ block, onUpdate, langs = CODE_LANGS, defaultLang = DEFAULT_LANG, }) {
|
|
136
|
+
const c = useEditorContext().kit.classNames;
|
|
137
|
+
const tabs = block.tabs;
|
|
138
|
+
const set = (next) => onUpdate({ ...block, tabs: next });
|
|
139
|
+
return (_jsxs("div", { className: c.stack, children: [tabs.map((tab, i) => (_jsxs("div", { className: c.stack, children: [_jsxs("div", { className: c.row, children: [_jsx(TextInput, { label: "Tab label", value: tab.label, onChange: label => set(replaceAt(tabs, i, { ...tab, label: label ?? '' })) }), _jsx("select", { "aria-label": "Language", className: cx(c.input, c.lang), value: tab.language, onChange: e => set(replaceAt(tabs, i, { ...tab, language: e.target.value })), children: [...new Set([tab.language, ...langs])].map(l => (_jsx("option", { value: l, children: l }, l))) }), _jsx(Remove, { label: `Remove tab ${tab.label || i + 1}`, onPress: () => set(removeAt(tabs, i)) })] }), _jsx("textarea", { "aria-label": "Code", placeholder: "Code", rows: 6, spellCheck: false, className: cx(c.input, c.code), value: tab.code, onChange: e => set(replaceAt(tabs, i, { ...tab, code: e.target.value })) })] }, i))), _jsx(Add, { onPress: () => set([...tabs, { label: '', language: defaultLang, code: '' }]), children: "Add tab" })] }));
|
|
140
|
+
}
|
|
141
|
+
export function StatsFields({ block, onUpdate, hint }) {
|
|
142
|
+
const c = useEditorContext().kit.classNames;
|
|
143
|
+
const items = block.items;
|
|
144
|
+
const set = (next) => onUpdate({ ...block, items: next });
|
|
145
|
+
return (_jsxs("div", { className: c.stack, children: [_jsx(Choice, { label: "Per row", value: String(block.columns), options: ['2', '3', '4'].map(n => ({ value: n, label: n })), onChange: n => onUpdate({ ...block, columns: Number(n) }) }), items.map((item, i) => (_jsxs("div", { className: c.row, children: [_jsx(TextInput, { label: "Figure", className: c.figure, value: item.value, onChange: value => set(replaceAt(items, i, { ...item, value: value ?? '' })) }), _jsx(TextInput, { label: "What it counts", value: item.label, onChange: label => set(replaceAt(items, i, { ...item, label: label ?? '' })) }), _jsx(Remove, { label: `Remove figure ${i + 1}`, onPress: () => set(removeAt(items, i)) })] }, item.id))), _jsx(Add, { onPress: () => set([...items, { id: newId(), value: '', label: '' }]), children: "Add figure" }), hint && _jsx("small", { className: c.muted, children: hint })] }));
|
|
146
|
+
}
|
|
147
|
+
/** `title` is the heading a references block shows when it stores none, and `noun` what one row is called. */
|
|
148
|
+
export function ReferencesFields({ block, onUpdate, title = 'References', noun = 'reference', hint, }) {
|
|
149
|
+
const c = useEditorContext().kit.classNames;
|
|
150
|
+
const items = block.items;
|
|
151
|
+
const set = (next) => onUpdate({ ...block, items: next });
|
|
152
|
+
return (_jsxs("div", { className: c.stack, children: [_jsx(Field, { label: "Heading", children: _jsx(TextInput, { label: "Heading", placeholder: title, optional: true, value: block.title, onChange: t => onUpdate({ ...block, title: t }) }) }), items.map((item, i) => (_jsxs("div", { className: c.row, children: [_jsxs("span", { className: c.muted, children: [i + 1, "."] }), _jsx(TextInput, { label: "Title, author, publisher", value: item.text, onChange: text => set(replaceAt(items, i, { ...item, text: text ?? '' })) }), _jsx(TextInput, { label: "Address", type: "url", placeholder: "https://", optional: true, value: item.url, onChange: url => set(replaceAt(items, i, { ...item, url })) }), _jsx(Remove, { label: `Remove ${noun} ${i + 1}`, onPress: () => set(removeAt(items, i)) })] }, item.id))), _jsxs(Add, { onPress: () => set([...items, { id: newId(), text: '' }]), children: ["Add ", noun] }), hint && _jsx("small", { className: c.muted, children: hint })] }));
|
|
153
|
+
}
|
|
154
|
+
export function LinkGridFields({ block, onUpdate }) {
|
|
155
|
+
const c = useEditorContext().kit.classNames;
|
|
156
|
+
const groups = block.groups;
|
|
157
|
+
return (_jsxs("div", { className: c.stack, children: [_jsx(Field, { label: "Intro (optional)", children: _jsx(TextInput, { label: "Intro", placeholder: "A paragraph before the groups", optional: true, value: block.intro, onChange: intro => onUpdate({ ...block, intro }) }) }), groups.map((group, gi) => {
|
|
158
|
+
const setGroup = (next) => onUpdate({ ...block, groups: replaceAt(groups, gi, next) });
|
|
159
|
+
return (_jsxs("div", { className: cx(c.nest, c.stack), children: [_jsxs("div", { className: c.row, children: [_jsx(TextInput, { label: "Heading", value: group.heading, onChange: heading => setGroup({ ...group, heading: heading ?? '' }) }), _jsx(Remove, { label: `Remove group ${group.heading || gi + 1}`, onPress: () => onUpdate({ ...block, groups: removeAt(groups, gi) }) })] }), _jsx(TextInput, { label: "Description", placeholder: "Description (optional)", optional: true, value: group.description ?? undefined, onChange: description => setGroup({ ...group, description }) }), group.links.map((link, li) => (_jsxs("div", { className: c.row, children: [_jsx(TextInput, { label: "Label", value: link.label, onChange: label => setGroup({ ...group, links: replaceAt(group.links, li, { ...link, label: label ?? '' }) }) }), _jsx(TextInput, { label: "Address", placeholder: "/path or https://", value: link.href, onChange: href => setGroup({ ...group, links: replaceAt(group.links, li, { ...link, href: href ?? '' }) }) }), _jsx(Remove, { label: `Remove link ${link.label || li + 1}`, onPress: () => setGroup({ ...group, links: removeAt(group.links, li) }) })] }, li))), _jsx(Add, { onPress: () => setGroup({ ...group, links: [...group.links, { label: '', href: '' }] }), children: "Add link" })] }, group.id));
|
|
160
|
+
}), _jsx(Add, { onPress: () => onUpdate({ ...block, groups: [...groups, { id: newId(), heading: '', links: [] }] }), children: "Add group" })] }));
|
|
161
|
+
}
|
|
162
|
+
export function BannerFields({ block, onUpdate }) {
|
|
163
|
+
const c = useEditorContext().kit.classNames;
|
|
164
|
+
return (_jsxs("div", { className: c.stack, children: [_jsx(Choice, { label: "Notice", value: block.variant, options: BANNER_VARIANTS, onChange: v => onUpdate({ ...block, variant: v }) }), _jsx(Field, { label: "Custom message (optional)", children: _jsx(TextInput, { label: "Custom message", placeholder: "Empty for the standard notice", optional: true, value: block.text, onChange: text => onUpdate({ ...block, text }) }) })] }));
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The picked pages in their order, each movable and removable. With `pages`
|
|
168
|
+
* the rest are offered in a picker; without, an id is typed in.
|
|
169
|
+
*/
|
|
170
|
+
export function PageListFields({ block, onUpdate, pages }) {
|
|
171
|
+
const { kit } = useEditorContext();
|
|
172
|
+
const c = kit.classNames;
|
|
173
|
+
const [typed, setTyped] = useState('');
|
|
174
|
+
const ids = block.pageIds;
|
|
175
|
+
const set = (pageIds) => onUpdate({ ...block, pageIds });
|
|
176
|
+
const byId = new Map(pages?.map(p => [p.id, p]));
|
|
177
|
+
const full = ids.length >= MAX_BLOCK_ROWS;
|
|
178
|
+
const add = (id) => {
|
|
179
|
+
if (id && !ids.includes(id) && !full)
|
|
180
|
+
set([...ids, id]);
|
|
181
|
+
};
|
|
182
|
+
const unpicked = pages?.filter(p => !ids.includes(p.id)) ?? [];
|
|
183
|
+
const ops = {
|
|
184
|
+
move: (from, to) => {
|
|
185
|
+
const next = removeAt(ids, from);
|
|
186
|
+
next.splice(to, 0, ids[from]);
|
|
187
|
+
set(next);
|
|
188
|
+
},
|
|
189
|
+
duplicate: () => { },
|
|
190
|
+
remove: (i) => set(removeAt(ids, i)),
|
|
191
|
+
};
|
|
192
|
+
return (_jsxs("div", { className: c.stack, children: [ids.map((id, i) => {
|
|
193
|
+
const page = byId.get(id);
|
|
194
|
+
return (_jsxs("div", { className: c.row, children: [_jsxs("span", { className: c.muted, children: [i + 1, "."] }), _jsxs("span", { children: [pages ? (page?.title ?? `No such page: ${id}`) : id, page?.detail && _jsxs("span", { className: c.muted, children: [" \u00B7 ", page.detail] })] }), _jsx(BlockActions, { index: i, total: ids.length, ops: ops, icons: { up: kit.icons.up, down: kit.icons.down, remove: kit.icons.remove }, className: c.cardActions, buttonClassName: c.action, blockLabel: "page" })] }, i));
|
|
195
|
+
}), pages && pages.length === 0 && _jsx("p", { className: c.muted, children: "This wiki has no pages to pick yet." }), !full && pages && unpicked.length > 0 && (_jsx(Field, { label: "Add a page", children: _jsxs("select", { className: c.input, value: "", onChange: e => add(e.target.value), children: [_jsx("option", { value: "", children: "Choose\u2026" }), unpicked.map(p => (_jsx("option", { value: p.id, children: p.detail ? `${p.title} · ${p.detail}` : p.title }, p.id)))] }) })), !full && !pages && (_jsxs("div", { className: c.row, children: [_jsx("input", { type: "text", className: c.input, "aria-label": "Page id", placeholder: "Page id", value: typed, onChange: e => setTyped(e.target.value), onKeyDown: e => {
|
|
196
|
+
if (e.key !== 'Enter')
|
|
197
|
+
return;
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
add(typed.trim());
|
|
200
|
+
setTyped('');
|
|
201
|
+
} }), _jsx(Add, { onPress: () => {
|
|
202
|
+
add(typed.trim());
|
|
203
|
+
setTyped('');
|
|
204
|
+
}, children: "Add page" })] }))] }));
|
|
205
|
+
}
|
|
206
|
+
const GAPS = ['sm', 'md', 'lg'].map(v => ({ value: v, label: v }));
|
|
207
|
+
const ALIGNS = ['start', 'center', 'end', 'stretch'].map(v => ({ value: v, label: v }));
|
|
208
|
+
const MAX_COLUMNS = 4;
|
|
209
|
+
/** Two to four columns, each a block list offering the schema's `nestedTypes`. */
|
|
210
|
+
export function ColumnsFields({ block, onUpdate }) {
|
|
211
|
+
const c = useEditorContext().kit.classNames;
|
|
212
|
+
const columns = block.columns;
|
|
213
|
+
return (_jsxs("div", { className: c.stack, children: [_jsxs("div", { className: c.row, children: [_jsx(Choice, { label: "Gap", value: block.gap ?? 'md', options: GAPS, onChange: gap => onUpdate({ ...block, gap: gap }) }), _jsx(Choice, { label: "Align", value: block.align ?? 'start', options: ALIGNS, onChange: align => onUpdate({ ...block, align: align }) })] }), _jsx("div", { className: c.columns, children: columns.map((column, ci) => (_jsxs("div", { className: cx(c.nest, c.stack), children: [_jsxs("div", { className: c.cardHead, children: [_jsxs("span", { className: c.cardLabel, children: ["Column ", ci + 1] }), columns.length > 1 && _jsx(Remove, { label: `Remove column ${ci + 1}`, onPress: () => onUpdate({ ...block, columns: removeAt(columns, ci) }) })] }), _jsx(BlockList, { nested: true, blocks: column.blocks, onChange: blocks => onUpdate({ ...block, columns: replaceAt(columns, ci, { ...column, blocks: blocks }) }) })] }, column.id))) }), columns.length < MAX_COLUMNS && _jsx(Add, { onPress: () => onUpdate({ ...block, columns: [...columns, { id: newId(), blocks: [] }] }), children: "Add column" })] }));
|
|
214
|
+
}
|
|
215
|
+
/** An infobox's blocks, offering the schema's `nestedTypes`. */
|
|
216
|
+
export function InfoboxFields({ block, onUpdate }) {
|
|
217
|
+
return _jsx(BlockList, { nested: true, blocks: block.blocks, onChange: blocks => onUpdate({ ...block, blocks: blocks }) });
|
|
218
|
+
}
|
package/dist/blocks.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type RelativeTimeStyle } from './freshness.js';
|
|
2
2
|
import type { BlockGroup } from './revisions.js';
|
|
3
|
+
import { type BannerVariant } from './text.js';
|
|
3
4
|
/**
|
|
4
5
|
* The languages a code block or code tab offers, and the one a new one opens
|
|
5
6
|
* on. Two wikis carried this list character for character; a wiki with its own
|
|
@@ -50,6 +51,72 @@ export interface LinkGridGroup {
|
|
|
50
51
|
description?: string | null;
|
|
51
52
|
links: readonly LinkGridLink[];
|
|
52
53
|
}
|
|
54
|
+
export type EditableCodeTab = CodeTab & {
|
|
55
|
+
language: string;
|
|
56
|
+
};
|
|
57
|
+
export type EditableStatItem = StatItem & {
|
|
58
|
+
id: string;
|
|
59
|
+
value: string;
|
|
60
|
+
};
|
|
61
|
+
export type EditableReferenceItem = ReferenceItem & {
|
|
62
|
+
id: string;
|
|
63
|
+
url?: string;
|
|
64
|
+
};
|
|
65
|
+
export type EditableLinkGridGroup = LinkGridGroup & {
|
|
66
|
+
id: string;
|
|
67
|
+
links: LinkGridLink[];
|
|
68
|
+
};
|
|
69
|
+
export interface ContentBlock {
|
|
70
|
+
id: string;
|
|
71
|
+
type: 'content';
|
|
72
|
+
text: string;
|
|
73
|
+
}
|
|
74
|
+
export interface CodeTabsBlock {
|
|
75
|
+
id: string;
|
|
76
|
+
type: 'codeTabs';
|
|
77
|
+
tabs: EditableCodeTab[];
|
|
78
|
+
}
|
|
79
|
+
export interface StatsBlock {
|
|
80
|
+
id: string;
|
|
81
|
+
type: 'stats';
|
|
82
|
+
items: EditableStatItem[];
|
|
83
|
+
columns: 2 | 3 | 4;
|
|
84
|
+
}
|
|
85
|
+
export interface LinkGridBlock {
|
|
86
|
+
id: string;
|
|
87
|
+
type: 'linkGrid';
|
|
88
|
+
intro?: string;
|
|
89
|
+
groups: EditableLinkGridGroup[];
|
|
90
|
+
}
|
|
91
|
+
export interface ReferencesBlock {
|
|
92
|
+
id: string;
|
|
93
|
+
type: 'references';
|
|
94
|
+
title?: string;
|
|
95
|
+
items: EditableReferenceItem[];
|
|
96
|
+
}
|
|
97
|
+
/** A Wikipedia-style notice. `text` replaces the variant's standard message. */
|
|
98
|
+
export interface BannerBlock {
|
|
99
|
+
id: string;
|
|
100
|
+
type: 'banner';
|
|
101
|
+
variant: BannerVariant;
|
|
102
|
+
text?: string;
|
|
103
|
+
}
|
|
104
|
+
/** `A` is the wiki's atomic union: a container never holds another container. */
|
|
105
|
+
export interface ColumnsBlock<A> {
|
|
106
|
+
id: string;
|
|
107
|
+
type: 'columns';
|
|
108
|
+
columns: {
|
|
109
|
+
id: string;
|
|
110
|
+
blocks: A[];
|
|
111
|
+
}[];
|
|
112
|
+
gap?: 'sm' | 'md' | 'lg';
|
|
113
|
+
align?: 'start' | 'center' | 'end' | 'stretch';
|
|
114
|
+
}
|
|
115
|
+
export interface InfoboxBlock<A> {
|
|
116
|
+
id: string;
|
|
117
|
+
type: 'infobox';
|
|
118
|
+
blocks: A[];
|
|
119
|
+
}
|
|
53
120
|
export interface BlockTreeOptions<B> {
|
|
54
121
|
/** Render one leaf block. Return `''` to drop it from the document. */
|
|
55
122
|
atomic: (block: B) => string;
|
package/dist/editor.d.ts
CHANGED
|
@@ -57,6 +57,64 @@ export declare function ToolbarButton({ label, pressed, onPress, className, chil
|
|
|
57
57
|
className?: string;
|
|
58
58
|
children: ReactNode;
|
|
59
59
|
}): import("react").JSX.Element;
|
|
60
|
+
export interface WikiToolbarProps {
|
|
61
|
+
editor: Editor;
|
|
62
|
+
/** `useWikiEditor`'s `isActive`. */
|
|
63
|
+
isActive: (a?: ActiveDescriptor) => boolean;
|
|
64
|
+
/** `useUrlPrompt`'s state: the link button opens it, and shows pressed while it is open. */
|
|
65
|
+
prompt: UrlPromptState;
|
|
66
|
+
/** The formatting buttons, before Remove link, upload and embed. */
|
|
67
|
+
actions: readonly ToolbarAction[];
|
|
68
|
+
/** The insert buttons after them, such as table and tabs. */
|
|
69
|
+
trailing?: readonly ToolbarAction[];
|
|
70
|
+
/** Shows the upload button, which calls this: `useWikiEditor`'s `triggerUpload`. */
|
|
71
|
+
onUpload?: () => void;
|
|
72
|
+
/** Shows the embed button, which opens the prompt on an embed address. */
|
|
73
|
+
embed?: boolean;
|
|
74
|
+
icons: Partial<Record<ToolbarKey, ReactNode>> & {
|
|
75
|
+
unlink: ReactNode;
|
|
76
|
+
upload?: ReactNode;
|
|
77
|
+
embed?: ReactNode;
|
|
78
|
+
};
|
|
79
|
+
classNames?: {
|
|
80
|
+
toolbar?: string;
|
|
81
|
+
button?: string;
|
|
82
|
+
/** Added to a button that is on. */
|
|
83
|
+
pressed?: string;
|
|
84
|
+
divider?: string;
|
|
85
|
+
/** A table control, which is a word rather than an icon. */
|
|
86
|
+
tableButton?: string;
|
|
87
|
+
/** Added to the table controls that delete. */
|
|
88
|
+
danger?: string;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The formatting bar three wikis had each assembled from `toolbarActions`,
|
|
93
|
+
* `ToolbarButton` and `TABLE_ACTIONS`, in the same order with the same rules:
|
|
94
|
+
* a link button that needs an address and so opens the prompt, a Remove link
|
|
95
|
+
* button while the cursor is in a link, upload and embed between the format
|
|
96
|
+
* and insert groups, and the table controls while the cursor is in a table.
|
|
97
|
+
* Which buttons, which icons and which classes are the caller's.
|
|
98
|
+
*/
|
|
99
|
+
export declare function WikiToolbar({ editor, isActive, prompt, actions, trailing, onUpload, embed, icons, classNames: c }: WikiToolbarProps): import("react").JSX.Element;
|
|
100
|
+
export interface UrlPromptFieldProps {
|
|
101
|
+
prompt: UrlPromptState;
|
|
102
|
+
placeholders?: {
|
|
103
|
+
link?: string;
|
|
104
|
+
embed?: string;
|
|
105
|
+
};
|
|
106
|
+
classNames?: {
|
|
107
|
+
row?: string;
|
|
108
|
+
input?: string;
|
|
109
|
+
apply?: string;
|
|
110
|
+
cancel?: string;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The address field under a toolbar, open while `prompt.kind` is set: Enter
|
|
115
|
+
* applies, Escape closes, and an emptied link address unlinks.
|
|
116
|
+
*/
|
|
117
|
+
export declare function UrlPromptField({ prompt, placeholders, classNames: c }: UrlPromptFieldProps): import("react").JSX.Element | null;
|
|
60
118
|
export type UrlPromptKind = 'link' | 'embed';
|
|
61
119
|
export interface UrlPromptState {
|
|
62
120
|
/** The open field, or null. */
|
package/dist/editor.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } 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,
|
|
@@ -32,6 +32,7 @@ import Placeholder from '@tiptap/extension-placeholder';
|
|
|
32
32
|
import { useEditor } from '@tiptap/react';
|
|
33
33
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
34
34
|
import { toMapEmbedUrl } from './maps.js';
|
|
35
|
+
import { cx } from './html.js';
|
|
35
36
|
// ---- paste scrubbing --------------------------------------------------------
|
|
36
37
|
/**
|
|
37
38
|
* Reduce pasted HTML to structure plus the three attributes that carry meaning.
|
|
@@ -219,6 +220,31 @@ export function toolbarActions(keys) {
|
|
|
219
220
|
export function ToolbarButton({ label, pressed, onPress, className, children, }) {
|
|
220
221
|
return (_jsx("button", { type: "button", title: label, "aria-label": label, "aria-pressed": pressed, className: className, onClick: onPress, children: children }));
|
|
221
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* The formatting bar three wikis had each assembled from `toolbarActions`,
|
|
225
|
+
* `ToolbarButton` and `TABLE_ACTIONS`, in the same order with the same rules:
|
|
226
|
+
* a link button that needs an address and so opens the prompt, a Remove link
|
|
227
|
+
* button while the cursor is in a link, upload and embed between the format
|
|
228
|
+
* and insert groups, and the table controls while the cursor is in a table.
|
|
229
|
+
* Which buttons, which icons and which classes are the caller's.
|
|
230
|
+
*/
|
|
231
|
+
export function WikiToolbar({ editor, isActive, prompt, actions, trailing = [], onUpload, embed, icons, classNames: c = {} }) {
|
|
232
|
+
const button = ({ key, label, active, run }) => {
|
|
233
|
+
const pressed = isActive(active) || (key === 'link' && prompt.kind === 'link');
|
|
234
|
+
return (_jsx(ToolbarButton, { label: label, pressed: active ? pressed : undefined, onPress: () => (run ? run(editor) : prompt.toggle('link')), className: cx(c.button, pressed && c.pressed), children: icons[key] }, key));
|
|
235
|
+
};
|
|
236
|
+
return (_jsxs("div", { className: c.toolbar, role: "toolbar", "aria-label": "Text formatting", children: [actions.map(button), editor.isActive('link') && (_jsx(ToolbarButton, { label: "Remove link", onPress: () => editor.chain().focus().extendMarkRange('link').unsetLink().run(), className: c.button, children: icons.unlink })), onUpload && (_jsx(ToolbarButton, { label: "Upload image", onPress: onUpload, className: c.button, children: icons.upload })), embed && (_jsx(ToolbarButton, { label: "Embed", pressed: prompt.kind === 'embed', onPress: () => prompt.toggle('embed'), className: cx(c.button, prompt.kind === 'embed' && c.pressed), children: icons.embed })), trailing.map(button), editor.isActive('table') && (_jsxs(_Fragment, { children: [_jsx("span", { className: c.divider, "aria-hidden": true }), TABLE_ACTIONS.map(([command, label, danger]) => (_jsx("button", { type: "button", className: cx(c.tableButton, danger && c.danger), onClick: () => runTableAction(editor, command), children: label }, command)))] }))] }));
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The address field under a toolbar, open while `prompt.kind` is set: Enter
|
|
240
|
+
* applies, Escape closes, and an emptied link address unlinks.
|
|
241
|
+
*/
|
|
242
|
+
export function UrlPromptField({ prompt, placeholders = {}, classNames: c = {} }) {
|
|
243
|
+
if (!prompt.kind)
|
|
244
|
+
return null;
|
|
245
|
+
const link = prompt.kind === 'link';
|
|
246
|
+
return (_jsxs("div", { className: c.row, children: [_jsx("input", { autoFocus: true, type: "text", inputMode: "url", autoCapitalize: "none", className: c.input, "aria-label": link ? 'Link address' : 'Embed address', placeholder: link ? (placeholders.link ?? 'https://… (empty removes the link)') : (placeholders.embed ?? 'YouTube, X, Google or Apple Maps, or any iframe URL'), value: prompt.value, onChange: e => prompt.setValue(e.target.value), onKeyDown: prompt.onKeyDown }), _jsx("button", { type: "button", className: c.apply, onClick: prompt.apply, children: link ? 'Apply' : 'Embed' }), _jsx("button", { type: "button", className: c.cancel, onClick: prompt.close, children: "Cancel" })] }));
|
|
247
|
+
}
|
|
222
248
|
/**
|
|
223
249
|
* What a link or embed field does with its address. A link sets or replaces
|
|
224
250
|
* the link the cursor is in, across the whole link rather than the collapsed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.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",
|
|
@@ -208,6 +208,11 @@
|
|
|
208
208
|
"import": "./dist/editor.js",
|
|
209
209
|
"default": "./dist/editor.js"
|
|
210
210
|
},
|
|
211
|
+
"./block-editor": {
|
|
212
|
+
"types": "./dist/block-editor.d.ts",
|
|
213
|
+
"import": "./dist/block-editor.js",
|
|
214
|
+
"default": "./dist/block-editor.js"
|
|
215
|
+
},
|
|
211
216
|
"./link-check": {
|
|
212
217
|
"types": "./dist/link-check.d.ts",
|
|
213
218
|
"import": "./dist/link-check.js",
|