wiki-formant 0.43.0 → 0.44.1
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 +10 -0
- package/dist/editor.d.ts +38 -1
- package/dist/editor.js +78 -3
- package/dist/react.d.ts +15 -0
- package/dist/react.js +40 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -312,6 +312,8 @@ const { ref, onScroll, at, step, go } = useSnapRow();
|
|
|
312
312
|
|
|
313
313
|
`useClickOutside` is fifteen lines and was in all three. Only one had the `offsetParent` check, and without it a container hidden at the current breakpoint still answers outside-clicks — so on a phone, a tap anywhere dismisses the popover the reader is looking at, because the hidden desktop copy got there first.
|
|
314
314
|
|
|
315
|
+
`useUnsavedChanges(dirty)` asks before an edit with unsaved changes is left: the browser's own prompt on a reload or a closed tab, and the `confirmLeave()` it returns for a Back link or Cancel, which a client-side navigation never sends through `beforeunload`. If a save leaves by a full page load, pass false once it has landed, or the save's own exit asks. `useSaveShortcut(save)` binds ⌘S and Ctrl+S, reading `save` when the key is pressed.
|
|
316
|
+
|
|
315
317
|
### The listbox contract
|
|
316
318
|
|
|
317
319
|
`useTypeahead` shared the state machine across five surfaces and left the ARIA
|
|
@@ -625,6 +627,14 @@ The ones that take config take it because that is exactly where the two copies d
|
|
|
625
627
|
|
|
626
628
|
`TOOLBAR_ACTIONS` are the formatting commands with the labels a screen reader announces — two toolbars titled buttons with the internal key, so one said "codeBlock". Pick yours in order with `toolbarActions([...])`, render each through `ToolbarButton` (named, and `aria-pressed`, since these are toggles), and run a table command with `runTableAction`. Upload and embed stay with the caller; they are the parts that differ.
|
|
627
629
|
|
|
630
|
+
`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. `applyUrlPrompt` is what it runs, exported for a toolbar that keeps its own state.
|
|
631
|
+
|
|
632
|
+
```tsx
|
|
633
|
+
const url = useUrlPrompt(editor, { resolveMapUrl });
|
|
634
|
+
<ToolbarButton label="Link" pressed={url.kind === 'link'} onPress={() => url.toggle('link')}>…</ToolbarButton>
|
|
635
|
+
{url.kind && <input autoFocus value={url.value} onChange={e => url.setValue(e.target.value)} onKeyDown={url.onKeyDown} />}
|
|
636
|
+
```
|
|
637
|
+
|
|
628
638
|
A link is stored as its `href` and the `link` class, nothing else (`wikiLink`). 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.
|
|
629
639
|
|
|
630
640
|
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.
|
package/dist/editor.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AnyExtension, Editor } from '@tiptap/core';
|
|
2
|
-
import { type ChangeEvent, type ReactNode, type RefObject } from 'react';
|
|
2
|
+
import { type ChangeEvent, type KeyboardEvent, type ReactNode, type RefObject } from 'react';
|
|
3
3
|
/**
|
|
4
4
|
* Re-exported so the COMMAND AUGMENTATIONS reach consumers.
|
|
5
5
|
*
|
|
@@ -91,6 +91,43 @@ export declare function ToolbarButton({ label, pressed, onPress, className, chil
|
|
|
91
91
|
className?: string;
|
|
92
92
|
children: ReactNode;
|
|
93
93
|
}): import("react").JSX.Element;
|
|
94
|
+
export type UrlPromptKind = 'link' | 'embed';
|
|
95
|
+
/**
|
|
96
|
+
* What a link or embed field does with its address. A link sets or replaces
|
|
97
|
+
* the link the cursor is in, across the whole link rather than the collapsed
|
|
98
|
+
* selection, and an emptied address takes it off while keeping the text. An
|
|
99
|
+
* embed with no address does nothing.
|
|
100
|
+
*/
|
|
101
|
+
export declare function applyUrlPrompt(editor: Editor, kind: UrlPromptKind, url: string, { resolveMapUrl }: {
|
|
102
|
+
resolveMapUrl: (url: string) => Promise<string | null>;
|
|
103
|
+
}): void;
|
|
104
|
+
export interface UrlPromptState {
|
|
105
|
+
/** The open field, or null. */
|
|
106
|
+
kind: UrlPromptKind | null;
|
|
107
|
+
value: string;
|
|
108
|
+
setValue: (value: string) => void;
|
|
109
|
+
/** Open the field, or close it when it is already open on this kind. */
|
|
110
|
+
toggle: (kind: UrlPromptKind) => void;
|
|
111
|
+
close: () => void;
|
|
112
|
+
/** `applyUrlPrompt` with the field's address, then close. */
|
|
113
|
+
apply: () => void;
|
|
114
|
+
/** Enter applies and Escape closes: the field's `onKeyDown`. */
|
|
115
|
+
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The state of a link or embed address field under a toolbar, in place of a
|
|
119
|
+
* `window.prompt`, which blocks the page and cannot show the link the cursor is
|
|
120
|
+
* already in. Three editors had written this, and only one had both of what it
|
|
121
|
+
* needs: a link field that opens on the current address, and an emptied one
|
|
122
|
+
* that unlinks. The field closes when the editor it serves changes, since a
|
|
123
|
+
* toolbar shared between blocks would otherwise apply it to the wrong one.
|
|
124
|
+
*
|
|
125
|
+
* The markup stays with the caller: an input bound to `value`, `setValue` and
|
|
126
|
+
* `onKeyDown`, and buttons calling `apply` and `close`.
|
|
127
|
+
*/
|
|
128
|
+
export declare function useUrlPrompt(editor: Editor | null, options: {
|
|
129
|
+
resolveMapUrl: (url: string) => Promise<string | null>;
|
|
130
|
+
}): UrlPromptState;
|
|
94
131
|
/**
|
|
95
132
|
* An `uploadImage` that POSTs the file as `file` form data and reads `{ url }`
|
|
96
133
|
* back. Two editors carried it character for character, `alert` included.
|
package/dist/editor.js
CHANGED
|
@@ -15,6 +15,9 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
15
15
|
// 2. Blur flushes the pending debounce. The change is debounced 150ms, and
|
|
16
16
|
// clicking Save blurs the editor before the click lands, so without the
|
|
17
17
|
// flush the final keystroke of an edit that ends in a click is dropped.
|
|
18
|
+
// It flushes only an edit still pending: Tiptap rewrites stored HTML into
|
|
19
|
+
// its own form, so reporting every blur told a page that had only been
|
|
20
|
+
// clicked through it had unsaved changes.
|
|
18
21
|
//
|
|
19
22
|
// Every @tiptap package here is an OPTIONAL PEER. A consumer that only wants
|
|
20
23
|
// the taxonomy or the MCP transport installs none of them.
|
|
@@ -212,6 +215,67 @@ export function toolbarActions(keys) {
|
|
|
212
215
|
export function ToolbarButton({ label, pressed, onPress, className, children, }) {
|
|
213
216
|
return (_jsx("button", { type: "button", title: label, "aria-label": label, "aria-pressed": pressed, className: className, onClick: onPress, children: children }));
|
|
214
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* What a link or embed field does with its address. A link sets or replaces
|
|
220
|
+
* the link the cursor is in, across the whole link rather than the collapsed
|
|
221
|
+
* selection, and an emptied address takes it off while keeping the text. An
|
|
222
|
+
* embed with no address does nothing.
|
|
223
|
+
*/
|
|
224
|
+
export function applyUrlPrompt(editor, kind, url, { resolveMapUrl }) {
|
|
225
|
+
const href = url.trim();
|
|
226
|
+
if (kind === 'embed') {
|
|
227
|
+
if (href)
|
|
228
|
+
insertEmbed(editor, href, { resolveMapUrl });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const chain = editor.chain().focus().extendMarkRange('link');
|
|
232
|
+
(href ? chain.setLink({ href }) : chain.unsetLink()).run();
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The state of a link or embed address field under a toolbar, in place of a
|
|
236
|
+
* `window.prompt`, which blocks the page and cannot show the link the cursor is
|
|
237
|
+
* already in. Three editors had written this, and only one had both of what it
|
|
238
|
+
* needs: a link field that opens on the current address, and an emptied one
|
|
239
|
+
* that unlinks. The field closes when the editor it serves changes, since a
|
|
240
|
+
* toolbar shared between blocks would otherwise apply it to the wrong one.
|
|
241
|
+
*
|
|
242
|
+
* The markup stays with the caller: an input bound to `value`, `setValue` and
|
|
243
|
+
* `onKeyDown`, and buttons calling `apply` and `close`.
|
|
244
|
+
*/
|
|
245
|
+
export function useUrlPrompt(editor, options) {
|
|
246
|
+
const [state, setState] = useState(null);
|
|
247
|
+
const latest = useRef({ editor, state, options });
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
latest.current = { editor, state, options };
|
|
250
|
+
});
|
|
251
|
+
useEffect(() => setState(null), [editor]);
|
|
252
|
+
const toggle = useCallback((kind) => {
|
|
253
|
+
const { editor, state } = latest.current;
|
|
254
|
+
if (state?.kind === kind)
|
|
255
|
+
return setState(null);
|
|
256
|
+
const href = kind === 'link' ? editor?.getAttributes('link').href : undefined;
|
|
257
|
+
setState({ kind, value: href ?? '' });
|
|
258
|
+
}, []);
|
|
259
|
+
const close = useCallback(() => setState(null), []);
|
|
260
|
+
const setValue = useCallback((value) => setState(s => (s ? { ...s, value } : s)), []);
|
|
261
|
+
const apply = useCallback(() => {
|
|
262
|
+
const { editor, state, options } = latest.current;
|
|
263
|
+
if (editor && state)
|
|
264
|
+
applyUrlPrompt(editor, state.kind, state.value, options);
|
|
265
|
+
setState(null);
|
|
266
|
+
}, []);
|
|
267
|
+
const onKeyDown = useCallback((e) => {
|
|
268
|
+
if (e.key === 'Enter') {
|
|
269
|
+
e.preventDefault();
|
|
270
|
+
apply();
|
|
271
|
+
}
|
|
272
|
+
else if (e.key === 'Escape') {
|
|
273
|
+
e.preventDefault();
|
|
274
|
+
close();
|
|
275
|
+
}
|
|
276
|
+
}, [apply, close]);
|
|
277
|
+
return { kind: state?.kind ?? null, value: state?.value ?? '', setValue, toggle, close, apply, onKeyDown };
|
|
278
|
+
}
|
|
215
279
|
// ---- the editor -------------------------------------------------------------
|
|
216
280
|
/**
|
|
217
281
|
* An `uploadImage` that POSTs the file as `file` form data and reads `{ url }`
|
|
@@ -250,6 +314,9 @@ export function useWikiEditor({ value, onChange, placeholder = '', nodes = [], p
|
|
|
250
314
|
const initialValueRef = useRef(value);
|
|
251
315
|
const onChangeRef = useRef(onChange);
|
|
252
316
|
const debounceRef = useRef(undefined);
|
|
317
|
+
// An edit made and not yet reported. `onUpdate` fires for edits only, never
|
|
318
|
+
// for the `setContent` that loads the stored value.
|
|
319
|
+
const pendingRef = useRef(false);
|
|
253
320
|
// Written AFTER render, not during: a ref mutated mid-render can tear under
|
|
254
321
|
// concurrent rendering. Only read from events and timeouts, which run later.
|
|
255
322
|
useEffect(() => {
|
|
@@ -267,12 +334,20 @@ export function useWikiEditor({ value, onChange, placeholder = '', nodes = [], p
|
|
|
267
334
|
},
|
|
268
335
|
onUpdate: ({ editor }) => {
|
|
269
336
|
clearTimeout(debounceRef.current);
|
|
270
|
-
|
|
337
|
+
pendingRef.current = true;
|
|
338
|
+
debounceRef.current = setTimeout(() => {
|
|
339
|
+
pendingRef.current = false;
|
|
340
|
+
onChangeRef.current(editor.getHTML());
|
|
341
|
+
}, debounceMs);
|
|
271
342
|
},
|
|
272
|
-
// Flush
|
|
273
|
-
//
|
|
343
|
+
// Flush a pending edit on blur, so a quick Save — which blurs the editor
|
|
344
|
+
// before the click fires — never drops the final keystroke. A blur with no
|
|
345
|
+
// edit reports nothing.
|
|
274
346
|
onBlur: ({ editor }) => {
|
|
275
347
|
clearTimeout(debounceRef.current);
|
|
348
|
+
if (!pendingRef.current)
|
|
349
|
+
return;
|
|
350
|
+
pendingRef.current = false;
|
|
276
351
|
onChangeRef.current(editor.getHTML());
|
|
277
352
|
},
|
|
278
353
|
immediatelyRender: false,
|
package/dist/react.d.ts
CHANGED
|
@@ -315,6 +315,21 @@ export interface BlockActionsProps {
|
|
|
315
315
|
* for a bar without it.
|
|
316
316
|
*/
|
|
317
317
|
export declare function BlockActions({ index, total, ops, icons, className, buttonClassName, blockLabel }: BlockActionsProps): import("react").JSX.Element;
|
|
318
|
+
/**
|
|
319
|
+
* Asks before an edit with unsaved changes is left. While `dirty`, a reload or
|
|
320
|
+
* a closed tab gets the browser's own prompt; an in-app exit (a Back link,
|
|
321
|
+
* Cancel) never passes through `beforeunload`, so it asks `confirmLeave()`,
|
|
322
|
+
* the function this returns, and goes only on a true.
|
|
323
|
+
*
|
|
324
|
+
* Pass false once a save has landed if the page then leaves by a full load
|
|
325
|
+
* (`location.href = …`), or the save's own exit asks the question.
|
|
326
|
+
*/
|
|
327
|
+
export declare function useUnsavedChanges(dirty: boolean, message?: string): () => boolean;
|
|
328
|
+
/**
|
|
329
|
+
* ⌘S, or Ctrl+S, runs `save` in place of the browser saving the page as a
|
|
330
|
+
* file. `save` is read when the key is pressed, so it may close over state.
|
|
331
|
+
*/
|
|
332
|
+
export declare function useSaveShortcut(save: () => void): void;
|
|
318
333
|
/** Resolves to an error message to show, or null on success. */
|
|
319
334
|
export type RestoreRevision<Id> = (id: Id) => Promise<string | null>;
|
|
320
335
|
/**
|
package/dist/react.js
CHANGED
|
@@ -615,6 +615,46 @@ export function BlockActions({ index, total, ops, icons, className, buttonClassN
|
|
|
615
615
|
}, children: icon }));
|
|
616
616
|
return (_jsxs("span", { className: className, children: [button(name('Move', ' up'), icons.up, () => ops.move(index, index - 1), index === 0), button(name('Move', ' down'), icons.down, () => ops.move(index, index + 1), index === total - 1), icons.duplicate !== undefined && button(name('Duplicate'), icons.duplicate, () => ops.duplicate(index)), button(name('Delete'), icons.remove, () => ops.remove(index))] }));
|
|
617
617
|
}
|
|
618
|
+
// ---- leaving an edit ---------------------------------------------------------
|
|
619
|
+
/**
|
|
620
|
+
* Asks before an edit with unsaved changes is left. While `dirty`, a reload or
|
|
621
|
+
* a closed tab gets the browser's own prompt; an in-app exit (a Back link,
|
|
622
|
+
* Cancel) never passes through `beforeunload`, so it asks `confirmLeave()`,
|
|
623
|
+
* the function this returns, and goes only on a true.
|
|
624
|
+
*
|
|
625
|
+
* Pass false once a save has landed if the page then leaves by a full load
|
|
626
|
+
* (`location.href = …`), or the save's own exit asks the question.
|
|
627
|
+
*/
|
|
628
|
+
export function useUnsavedChanges(dirty, message = 'Leave without saving your changes?') {
|
|
629
|
+
useEffect(() => {
|
|
630
|
+
if (!dirty)
|
|
631
|
+
return;
|
|
632
|
+
const warn = (e) => e.preventDefault();
|
|
633
|
+
window.addEventListener('beforeunload', warn);
|
|
634
|
+
return () => window.removeEventListener('beforeunload', warn);
|
|
635
|
+
}, [dirty]);
|
|
636
|
+
return useCallback(() => !dirty || window.confirm(message), [dirty, message]);
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* ⌘S, or Ctrl+S, runs `save` in place of the browser saving the page as a
|
|
640
|
+
* file. `save` is read when the key is pressed, so it may close over state.
|
|
641
|
+
*/
|
|
642
|
+
export function useSaveShortcut(save) {
|
|
643
|
+
const latest = useRef(save);
|
|
644
|
+
useEffect(() => {
|
|
645
|
+
latest.current = save;
|
|
646
|
+
});
|
|
647
|
+
useEffect(() => {
|
|
648
|
+
const onKey = (e) => {
|
|
649
|
+
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === 's') {
|
|
650
|
+
e.preventDefault();
|
|
651
|
+
latest.current();
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
window.addEventListener('keydown', onKey);
|
|
655
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
656
|
+
}, []);
|
|
657
|
+
}
|
|
618
658
|
/**
|
|
619
659
|
* POST `{ revisionId }` to `endpoint`, the transport two of the wikis use; the
|
|
620
660
|
* third passes its server action instead.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.1",
|
|
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",
|