wiki-formant 0.42.0 → 0.44.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 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.
@@ -775,8 +785,8 @@ monthly.
775
785
  ```
776
786
 
777
787
  A price point may carry `open`, `high` and `low` (its `value` is the close)
778
- and `volume`. Points that carry the candle get a Line/Candles toggle, opening on
779
- candles with `candles`; points that carry volume get a volume pane under the
788
+ and `volume`. Points that carry the candle get a candles switch, a candlestick icon
789
+ pressed while they show, opening on candles with `candles`; points that carry volume get a volume pane under the
780
790
  price, a fifth of the height. Candles take `--chart-up` and `--chart-down` from
781
791
  the canvas box, else its `color`. A week of candles opens on its first, closes
782
792
  on its last, spans the highest high and lowest low, and sums its volume. A point
package/dist/base.css CHANGED
@@ -191,8 +191,10 @@ pre:hover > .code-copy-btn,
191
191
  }
192
192
  .chart-controls > div { display: flex; gap: 2px; }
193
193
  .chart-controls :is(button, a) { font: inherit; color: inherit; text-decoration: none; background: none; cursor: pointer; opacity: 0.6; }
194
+ /* An icon sits on the text's line, so an icon button is as tall as a labelled one. */
195
+ .chart-icon { display: inline-block; width: 1em; height: 1em; vertical-align: -0.125em; }
194
196
  /* A button is pressed; a link (a stats page's window) is the page shown. */
195
- .chart-controls :is([aria-pressed='true'], [aria-current]) { opacity: 1; }
197
+ .chart-controls :is(button, a):is([aria-pressed='true'], [aria-current]) { opacity: 1; }
196
198
 
197
199
  /* ---- Stats ----------------------------------------------------------------
198
200
  `--share` is a row's value over the largest in its list, 0 to 1. Bars take
package/dist/chart.d.ts CHANGED
@@ -49,7 +49,7 @@ export interface TimeChartProps {
49
49
  fromZero?: boolean;
50
50
  /** The value axis and crosshair label. Read on every draw, so it need not be stable. */
51
51
  format?: (n: number) => string;
52
- /** Open on candles, where the points carry them. A Line/Candles toggle shows either way. */
52
+ /** Open on candles, where the points carry them. A candles toggle shows either way. */
53
53
  candles?: boolean;
54
54
  /** In pixels. Otherwise the stylesheet's `--chart-height`, else 15rem. */
55
55
  height?: number;
package/dist/chart.js CHANGED
@@ -261,5 +261,5 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
261
261
  c.api.timeScale().fitContent();
262
262
  }, [ready, source, view, candles]);
263
263
  const status = source && 'error' in source ? source.error : points && !view?.shown.length ? 'No data in this range' : null;
264
- return (_jsxs("figure", { className: className ? `chart ${className}` : 'chart', children: [_jsx("div", { ref: box, className: "chart-canvas", role: "img", "aria-label": label, style: height ? { height } : undefined }), (!ready || (!points && !status)) && _jsx("div", { className: "chart-status skeleton", "aria-hidden": true }), status && _jsx("p", { className: "chart-status", children: status }), (controls || ranges.length > 1 || (view && (view.steps.length > 1 || view.ohlc))) && (_jsxs("div", { className: "chart-controls", children: [controls, ranges.length > 1 && (_jsx("div", { role: "group", "aria-label": "Range", children: ranges.map(r => (_jsx("button", { type: "button", "aria-pressed": r === range, onClick: () => { setRange(r); setStep(null); }, children: RANGE[r].label }, r))) })), view && view.steps.length > 1 && (_jsx("div", { role: "group", "aria-label": "Step", children: view.steps.map(s => (_jsx("button", { type: "button", "aria-pressed": s === view.step, onClick: () => setStep(s), title: `One point per ${s}`, children: STEP[s].label }, s))) })), view?.ohlc && (_jsxs("div", { role: "group", "aria-label": "Style", children: [_jsx("button", { type: "button", "aria-pressed": !candles, onClick: () => setCandles(false), children: "Line" }), _jsx("button", { type: "button", "aria-pressed": candles, onClick: () => setCandles(true), children: "Candles" })] }))] }))] }));
264
+ return (_jsxs("figure", { className: className ? `chart ${className}` : 'chart', children: [_jsx("div", { ref: box, className: "chart-canvas", role: "img", "aria-label": label, style: height ? { height } : undefined }), (!ready || (!points && !status)) && _jsx("div", { className: "chart-status skeleton", "aria-hidden": true }), status && _jsx("p", { className: "chart-status", children: status }), (controls || ranges.length > 1 || (view && (view.steps.length > 1 || view.ohlc))) && (_jsxs("div", { className: "chart-controls", children: [controls, ranges.length > 1 && (_jsx("div", { role: "group", "aria-label": "Range", children: ranges.map(r => (_jsx("button", { type: "button", "aria-pressed": r === range, onClick: () => { setRange(r); setStep(null); }, children: RANGE[r].label }, r))) })), view && view.steps.length > 1 && (_jsx("div", { role: "group", "aria-label": "Step", children: view.steps.map(s => (_jsx("button", { type: "button", "aria-pressed": s === view.step, onClick: () => setStep(s), title: `One point per ${s}`, children: STEP[s].label }, s))) })), view?.ohlc && (_jsx("div", { children: _jsx("button", { type: "button", "aria-pressed": candles, "aria-label": "Candles", title: "Candles", onClick: () => setCandles(c => !c), children: _jsxs("svg", { className: "chart-icon", viewBox: "0 0 12 12", "aria-hidden": true, children: [_jsx("path", { d: "M3.5 1v2M3.5 9v2M8.5 2v3M8.5 9v1", stroke: "currentColor" }), _jsx("rect", { x: "2", y: "3", width: "3", height: "6", rx: "0.5", fill: "currentColor" }), _jsx("rect", { x: "7", y: "5", width: "3", height: "4", rx: "0.5", fill: "currentColor" })] }) }) }))] }))] }));
265
265
  }
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
@@ -212,6 +212,67 @@ export function toolbarActions(keys) {
212
212
  export function ToolbarButton({ label, pressed, onPress, className, children, }) {
213
213
  return (_jsx("button", { type: "button", title: label, "aria-label": label, "aria-pressed": pressed, className: className, onClick: onPress, children: children }));
214
214
  }
215
+ /**
216
+ * What a link or embed field does with its address. A link sets or replaces
217
+ * the link the cursor is in, across the whole link rather than the collapsed
218
+ * selection, and an emptied address takes it off while keeping the text. An
219
+ * embed with no address does nothing.
220
+ */
221
+ export function applyUrlPrompt(editor, kind, url, { resolveMapUrl }) {
222
+ const href = url.trim();
223
+ if (kind === 'embed') {
224
+ if (href)
225
+ insertEmbed(editor, href, { resolveMapUrl });
226
+ return;
227
+ }
228
+ const chain = editor.chain().focus().extendMarkRange('link');
229
+ (href ? chain.setLink({ href }) : chain.unsetLink()).run();
230
+ }
231
+ /**
232
+ * The state of a link or embed address field under a toolbar, in place of a
233
+ * `window.prompt`, which blocks the page and cannot show the link the cursor is
234
+ * already in. Three editors had written this, and only one had both of what it
235
+ * needs: a link field that opens on the current address, and an emptied one
236
+ * that unlinks. The field closes when the editor it serves changes, since a
237
+ * toolbar shared between blocks would otherwise apply it to the wrong one.
238
+ *
239
+ * The markup stays with the caller: an input bound to `value`, `setValue` and
240
+ * `onKeyDown`, and buttons calling `apply` and `close`.
241
+ */
242
+ export function useUrlPrompt(editor, options) {
243
+ const [state, setState] = useState(null);
244
+ const latest = useRef({ editor, state, options });
245
+ useEffect(() => {
246
+ latest.current = { editor, state, options };
247
+ });
248
+ useEffect(() => setState(null), [editor]);
249
+ const toggle = useCallback((kind) => {
250
+ const { editor, state } = latest.current;
251
+ if (state?.kind === kind)
252
+ return setState(null);
253
+ const href = kind === 'link' ? editor?.getAttributes('link').href : undefined;
254
+ setState({ kind, value: href ?? '' });
255
+ }, []);
256
+ const close = useCallback(() => setState(null), []);
257
+ const setValue = useCallback((value) => setState(s => (s ? { ...s, value } : s)), []);
258
+ const apply = useCallback(() => {
259
+ const { editor, state, options } = latest.current;
260
+ if (editor && state)
261
+ applyUrlPrompt(editor, state.kind, state.value, options);
262
+ setState(null);
263
+ }, []);
264
+ const onKeyDown = useCallback((e) => {
265
+ if (e.key === 'Enter') {
266
+ e.preventDefault();
267
+ apply();
268
+ }
269
+ else if (e.key === 'Escape') {
270
+ e.preventDefault();
271
+ close();
272
+ }
273
+ }, [apply, close]);
274
+ return { kind: state?.kind ?? null, value: state?.value ?? '', setValue, toggle, close, apply, onKeyDown };
275
+ }
215
276
  // ---- the editor -------------------------------------------------------------
216
277
  /**
217
278
  * An `uploadImage` that POSTs the file as `file` form data and reads `{ url }`
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.42.0",
3
+ "version": "0.44.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",