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 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
- debounceRef.current = setTimeout(() => onChangeRef.current(editor.getHTML()), debounceMs);
337
+ pendingRef.current = true;
338
+ debounceRef.current = setTimeout(() => {
339
+ pendingRef.current = false;
340
+ onChangeRef.current(editor.getHTML());
341
+ }, debounceMs);
271
342
  },
272
- // Flush the pending debounce on blur, so a quick Save — which blurs the
273
- // editor before the click fires — never drops the final keystroke.
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.43.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",