quoin-editor 0.3.0 → 0.4.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/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  // src/canvas/BlockCanvas.tsx
4
- import * as React11 from "react";
4
+ import * as React15 from "react";
5
5
  import {
6
6
  DndContext,
7
7
  KeyboardSensor,
@@ -68,12 +68,13 @@ function paletteBlocks(definitions) {
68
68
  import { Code as Code2, FileText, MessageSquare, Image, Paperclip } from "lucide-react";
69
69
 
70
70
  // src/blocks/MarkdownBlockEditor.tsx
71
- import * as React5 from "react";
71
+ import * as React7 from "react";
72
72
 
73
73
  // src/upload/FilePickerButton.tsx
74
74
  import { Upload } from "lucide-react";
75
75
 
76
76
  // src/ui/button.tsx
77
+ import * as React2 from "react";
77
78
  import { Slot } from "@radix-ui/react-slot";
78
79
  import { cva } from "class-variance-authority";
79
80
 
@@ -84,6 +85,15 @@ function cn(...inputs) {
84
85
  return twMerge(clsx(inputs));
85
86
  }
86
87
 
88
+ // src/ui/focus.ts
89
+ var FOCUS_RING = [
90
+ "outline-hidden",
91
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid",
92
+ "focus-visible:outline-[var(--quoin-ring)]"
93
+ ].join(" ");
94
+ var HIGHLIGHT_BAR = "data-[highlighted]:shadow-[inset_2px_0_0_var(--quoin-foreground)]";
95
+ var SELECTED_BAR = "data-[selected=true]:shadow-[inset_2px_0_0_var(--quoin-foreground)]";
96
+
87
97
  // src/ui/touch-target.ts
88
98
  var TAP_TARGET_FLOOR = "pointer-coarse:min-h-11";
89
99
  var SQUARE_TAP_TARGET_FLOOR = `${TAP_TARGET_FLOOR} pointer-coarse:min-w-11`;
@@ -96,7 +106,10 @@ var ICON_TAP_TARGET_FLOOR = [
96
106
  // src/ui/button.tsx
97
107
  import { jsx as jsx2 } from "react/jsx-runtime";
98
108
  var buttonVariants = cva(
99
- "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,background-color,border-color,box-shadow,opacity] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shrink-0 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
109
+ [
110
+ "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,background-color,border-color,box-shadow,opacity] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shrink-0",
111
+ FOCUS_RING
112
+ ],
100
113
  {
101
114
  variants: {
102
115
  variant: {
@@ -121,17 +134,12 @@ var buttonVariants = cva(
121
134
  defaultVariants: { variant: "default", size: "default" }
122
135
  }
123
136
  );
124
- function Button({
125
- className,
126
- variant = "default",
127
- size = "default",
128
- asChild = false,
129
- ...props
130
- }) {
137
+ var Button = React2.forwardRef(function Button2({ className, variant = "default", size = "default", asChild = false, ...props }, ref) {
131
138
  const Comp = asChild ? Slot : "button";
132
139
  return /* @__PURE__ */ jsx2(
133
140
  Comp,
134
141
  {
142
+ ref,
135
143
  "data-slot": "button",
136
144
  "data-variant": variant,
137
145
  "data-size": size,
@@ -139,12 +147,12 @@ function Button({
139
147
  ...props
140
148
  }
141
149
  );
142
- }
150
+ });
143
151
 
144
152
  // src/upload/FilePickerButton.tsx
145
153
  import { jsx as jsx3, jsxs } from "react/jsx-runtime";
146
154
  function FilePickerButton({ label, accept, disabled, onPick }) {
147
- return /* @__PURE__ */ jsxs("span", { className: "relative inline-flex rounded-md has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50", children: [
155
+ return /* @__PURE__ */ jsxs("span", { className: "relative inline-flex rounded-md has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--quoin-ring)]", children: [
148
156
  /* @__PURE__ */ jsx3(Button, { asChild: true, variant: "outline", size: "sm", children: /* @__PURE__ */ jsxs("span", { "aria-hidden": "true", className: disabled ? "pointer-events-none opacity-50" : void 0, children: [
149
157
  /* @__PURE__ */ jsx3(Upload, { "aria-hidden": "true" }),
150
158
  label
@@ -235,15 +243,18 @@ function insertCommand(command, value, caret) {
235
243
  };
236
244
  }
237
245
 
246
+ // src/markdown/platform.ts
247
+ function isMac() {
248
+ if (typeof navigator === "undefined") return false;
249
+ const { userAgentData, platform } = navigator;
250
+ return /mac|iphone|ipad/i.test(userAgentData?.platform || platform || "");
251
+ }
252
+
238
253
  // src/markdown/shortcut-label.ts
239
254
  var MAC_LABEL = { mod: "\u2318", alt: "\u2325", shift: "\u21E7" };
240
255
  var OTHER_LABEL = { mod: "Ctrl", alt: "Alt", shift: "Shift" };
241
- function onMac() {
242
- if (typeof navigator === "undefined") return false;
243
- return /Mac|iPhone|iPad/.test(navigator.userAgent);
244
- }
245
256
  function formatShortcut(hint) {
246
- const mac = onMac();
257
+ const mac = isMac();
247
258
  const labels = mac ? MAC_LABEL : OTHER_LABEL;
248
259
  const parts = hint.split("+").map((part) => labels[part] ?? part.toUpperCase());
249
260
  return parts.join(mac ? "" : "+");
@@ -297,7 +308,8 @@ function DialogContent({
297
308
  DialogPrimitive.Close,
298
309
  {
299
310
  className: cn(
300
- "ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none",
311
+ "absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100",
312
+ FOCUS_RING,
301
313
  ICON_TAP_TARGET_FLOOR
302
314
  ),
303
315
  children: [
@@ -597,6 +609,7 @@ function CommandItem({
597
609
  "data-slot": "command-item",
598
610
  className: cn(
599
611
  "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
612
+ SELECTED_BAR,
600
613
  className
601
614
  ),
602
615
  ...props
@@ -713,7 +726,7 @@ function SlashPalette({
713
726
  }
714
727
 
715
728
  // src/markdown/useMarkdownKeymap.ts
716
- import * as React2 from "react";
729
+ import * as React3 from "react";
717
730
 
718
731
  // src/markdown/list-continuation.ts
719
732
  var LIST_MARKER = /^(\s*)((?:[-*+] \[[ xX]\] )|(?:[-*+] )|(?:\d+[.)] )|(?:> ))(.*)$/;
@@ -937,11 +950,12 @@ function useMarkdownKeymap({
937
950
  commit,
938
951
  onOpenPalette
939
952
  }) {
940
- const onKeyDown = React2.useCallback(
953
+ const onKeyDown = React3.useCallback(
941
954
  (event) => {
955
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
942
956
  const el = event.currentTarget;
943
957
  const { value, selectionStart, selectionEnd } = el;
944
- const mod = event.metaKey || event.ctrlKey;
958
+ const mod = isMac() ? event.metaKey : event.ctrlKey;
945
959
  const run = (edit) => {
946
960
  if (!edit) return;
947
961
  event.preventDefault();
@@ -982,7 +996,7 @@ function useMarkdownKeymap({
982
996
  },
983
997
  [commit, onOpenPalette]
984
998
  );
985
- const onPaste = React2.useCallback(
999
+ const onPaste = React3.useCallback(
986
1000
  (event) => {
987
1001
  const el = event.currentTarget;
988
1002
  const edit = linkPaste(
@@ -1001,7 +1015,7 @@ function useMarkdownKeymap({
1001
1015
  }
1002
1016
 
1003
1017
  // src/markdown/useSlashPalette.ts
1004
- import * as React3 from "react";
1018
+ import * as React4 from "react";
1005
1019
 
1006
1020
  // src/markdown/caret-coordinates.ts
1007
1021
  var COPIED = [
@@ -1052,10 +1066,10 @@ function caretRect(el) {
1052
1066
 
1053
1067
  // src/markdown/useSlashPalette.ts
1054
1068
  function useSlashPalette(ref) {
1055
- const [open, setOpen] = React3.useState(false);
1056
- const [caret, setCaret] = React3.useState(null);
1057
- const offsetRef = React3.useRef(0);
1058
- const openAtCaret = React3.useCallback(() => {
1069
+ const [open, setOpen] = React4.useState(false);
1070
+ const [caret, setCaret] = React4.useState(null);
1071
+ const offsetRef = React4.useRef(0);
1072
+ const openAtCaret = React4.useCallback(() => {
1059
1073
  const el = ref.current;
1060
1074
  if (!el) return;
1061
1075
  offsetRef.current = el.selectionStart;
@@ -1066,11 +1080,17 @@ function useSlashPalette(ref) {
1066
1080
  }
1067
1081
 
1068
1082
  // src/hooks/useAutoGrow.ts
1069
- import * as React4 from "react";
1083
+ import * as React6 from "react";
1084
+
1085
+ // src/hooks/use-isomorphic-layout-effect.ts
1086
+ import * as React5 from "react";
1087
+ var useIsomorphicLayoutEffect = typeof document === "undefined" ? React5.useEffect : React5.useLayoutEffect;
1088
+
1089
+ // src/hooks/useAutoGrow.ts
1070
1090
  function useAutoGrow(value) {
1071
- const ref = React4.useRef(null);
1072
- const lastWidth = React4.useRef(null);
1073
- const fit = React4.useCallback(() => {
1091
+ const ref = React6.useRef(null);
1092
+ const lastWidth = React6.useRef(null);
1093
+ const fit = React6.useCallback(() => {
1074
1094
  const el = ref.current;
1075
1095
  if (!el) return;
1076
1096
  const scroller = document.scrollingElement ?? document.documentElement;
@@ -1079,8 +1099,8 @@ function useAutoGrow(value) {
1079
1099
  if (el.scrollHeight > 0) el.style.height = `${el.scrollHeight}px`;
1080
1100
  if (scroller.scrollTop !== scrollTop) scroller.scrollTop = scrollTop;
1081
1101
  }, []);
1082
- React4.useLayoutEffect(fit, [fit, value]);
1083
- React4.useEffect(() => {
1102
+ useIsomorphicLayoutEffect(fit, [fit, value]);
1103
+ React6.useEffect(() => {
1084
1104
  const el = ref.current;
1085
1105
  if (!el || typeof ResizeObserver === "undefined") return;
1086
1106
  const observer = new ResizeObserver(([entry]) => {
@@ -1102,17 +1122,18 @@ var str = (v) => typeof v === "string" ? v : "";
1102
1122
  import { jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
1103
1123
  var BOX = [
1104
1124
  "-mx-2 min-h-7 w-[calc(100%+1rem)] resize-none rounded-md border-0 bg-transparent px-2 py-0",
1105
- "text-base leading-7 outline-none placeholder:text-muted-foreground",
1106
- "transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:ring-ring/50"
1125
+ "text-base leading-7 placeholder:text-muted-foreground",
1126
+ "transition-[color,box-shadow]",
1127
+ FOCUS_RING
1107
1128
  ].join(" ");
1108
1129
  function MarkdownBlockEditor({ data, onChange, onInsertBlock, onDone }) {
1109
1130
  const { onError } = useQuoin();
1110
1131
  const markdown = str(data.markdown);
1111
1132
  const ref = useAutoGrow(markdown);
1112
1133
  const palette = useSlashPalette(ref);
1113
- const [shortcutsOpen, setShortcutsOpen] = React5.useState(false);
1134
+ const [shortcutsOpen, setShortcutsOpen] = React7.useState(false);
1114
1135
  const set = (patch) => onChange({ ...data, ...patch });
1115
- const commit = React5.useCallback(
1136
+ const commit = React7.useCallback(
1116
1137
  (next) => onChange({ ...data, markdown: next }),
1117
1138
  [data, onChange]
1118
1139
  );
@@ -1178,6 +1199,7 @@ function MarkdownBlockEditor({ data, onChange, onInsertBlock, onDone }) {
1178
1199
  onBlockCommand: (type) => {
1179
1200
  palette.setOpen(false);
1180
1201
  onInsertBlock?.(type);
1202
+ onDone?.();
1181
1203
  },
1182
1204
  onShortcuts: () => {
1183
1205
  palette.setOpen(false);
@@ -1214,7 +1236,8 @@ function SelectTrigger({ className, children, ...props }) {
1214
1236
  {
1215
1237
  "data-slot": "select-trigger",
1216
1238
  className: cn(
1217
- "border-input data-[placeholder]:text-muted-foreground flex h-9 w-full cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
1239
+ "border-input data-[placeholder]:text-muted-foreground flex h-9 w-full cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
1240
+ FOCUS_RING,
1218
1241
  TAP_TARGET_FLOOR,
1219
1242
  className
1220
1243
  ),
@@ -1253,6 +1276,7 @@ function SelectItem({ className, children, ...props }) {
1253
1276
  "data-slot": "select-item",
1254
1277
  className: cn(
1255
1278
  "focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
1279
+ HIGHLIGHT_BAR,
1256
1280
  TAP_TARGET_FLOOR,
1257
1281
  className
1258
1282
  ),
@@ -1266,22 +1290,25 @@ function SelectItem({ className, children, ...props }) {
1266
1290
  }
1267
1291
 
1268
1292
  // src/ui/textarea.tsx
1293
+ import * as React8 from "react";
1269
1294
  import { jsx as jsx11 } from "react/jsx-runtime";
1270
- function Textarea({ className, ...props }) {
1295
+ var Textarea = React8.forwardRef(function Textarea2({ className, ...props }, ref) {
1271
1296
  return /* @__PURE__ */ jsx11(
1272
1297
  "textarea",
1273
1298
  {
1299
+ ref,
1274
1300
  "data-slot": "textarea",
1275
1301
  className: cn(
1276
- "border-input placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:opacity-50 md:text-sm",
1277
- "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
1278
- "aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
1302
+ "border-input placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 md:text-sm",
1303
+ FOCUS_RING,
1304
+ "focus-visible:border-ring",
1305
+ "aria-invalid:border-destructive",
1279
1306
  className
1280
1307
  ),
1281
1308
  ...props
1282
1309
  }
1283
1310
  );
1284
- }
1311
+ });
1285
1312
 
1286
1313
  // src/blocks/CalloutBlockEditor.tsx
1287
1314
  import { jsx as jsx12, jsxs as jsxs8 } from "react/jsx-runtime";
@@ -1304,24 +1331,27 @@ function CalloutBlockEditor({ data, onChange }) {
1304
1331
  }
1305
1332
 
1306
1333
  // src/ui/input.tsx
1334
+ import * as React9 from "react";
1307
1335
  import { jsx as jsx13 } from "react/jsx-runtime";
1308
- function Input({ className, type, ...props }) {
1336
+ var Input = React9.forwardRef(function Input2({ className, type, ...props }, ref) {
1309
1337
  return /* @__PURE__ */ jsx13(
1310
1338
  "input",
1311
1339
  {
1340
+ ref,
1312
1341
  type,
1313
1342
  "data-slot": "input",
1314
1343
  className: cn(
1315
- "border-input placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:opacity-50 md:text-sm",
1316
- "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
1317
- "aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
1344
+ "border-input placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:opacity-50 md:text-sm",
1345
+ FOCUS_RING,
1346
+ "focus-visible:border-ring",
1347
+ "aria-invalid:border-destructive",
1318
1348
  TAP_TARGET_FLOOR,
1319
1349
  className
1320
1350
  ),
1321
1351
  ...props
1322
1352
  }
1323
1353
  );
1324
- }
1354
+ });
1325
1355
 
1326
1356
  // src/blocks/CodeBlockEditor.tsx
1327
1357
  import { jsx as jsx14, jsxs as jsxs9 } from "react/jsx-runtime";
@@ -1356,15 +1386,15 @@ function CodeBlockEditor({ data, onChange }) {
1356
1386
  }
1357
1387
 
1358
1388
  // src/blocks/ImageBlockEditor.tsx
1359
- import { useEffect as useEffect2, useRef as useRef3 } from "react";
1389
+ import { useEffect as useEffect3, useRef as useRef3 } from "react";
1360
1390
 
1361
1391
  // src/upload/UploadField.tsx
1362
- import * as React6 from "react";
1392
+ import * as React10 from "react";
1363
1393
  import { jsx as jsx15, jsxs as jsxs10 } from "react/jsx-runtime";
1364
1394
  function UploadField({ category, label, accept, onUploaded }) {
1365
1395
  const { upload, onError } = useQuoin();
1366
- const [busy, setBusy] = React6.useState(false);
1367
- const [error, setError] = React6.useState(null);
1396
+ const [busy, setBusy] = React10.useState(false);
1397
+ const [error, setError] = React10.useState(null);
1368
1398
  async function run(file) {
1369
1399
  if (!upload) return;
1370
1400
  setBusy(true);
@@ -1377,11 +1407,12 @@ function UploadField({ category, label, accept, onUploaded }) {
1377
1407
  setBusy(false);
1378
1408
  return;
1379
1409
  }
1380
- setBusy(false);
1381
1410
  try {
1382
- onUploaded(result);
1411
+ await onUploaded(result);
1383
1412
  } catch (cause) {
1384
1413
  onError(cause instanceof Error ? cause : new Error(String(cause)));
1414
+ } finally {
1415
+ setBusy(false);
1385
1416
  }
1386
1417
  }
1387
1418
  return /* @__PURE__ */ jsxs10("div", { className: "flex flex-col items-start gap-1.5", children: [
@@ -1416,14 +1447,18 @@ import { jsx as jsx16, jsxs as jsxs11 } from "react/jsx-runtime";
1416
1447
  function ImageBlockEditor({ data, onChange }) {
1417
1448
  const { resolveAssetUrl } = useQuoin();
1418
1449
  const latest = useRef3(data);
1419
- useEffect2(() => {
1450
+ useEffect3(() => {
1420
1451
  latest.current = data;
1421
1452
  });
1422
1453
  const set = (patch) => onChange({ ...latest.current, ...patch });
1423
- const onUploaded = async (r) => {
1424
- const src = resolveAssetUrl(r.storageKey);
1454
+ const newestKey = useRef3("");
1455
+ const onUploaded = async ({ storageKey }) => {
1456
+ newestKey.current = storageKey;
1457
+ const { width: _width, height: _height, ...unsized } = latest.current;
1458
+ onChange({ ...unsized, storageKey });
1459
+ const src = resolveAssetUrl(storageKey);
1425
1460
  const size = src ? await intrinsicSize(src) : null;
1426
- set({ storageKey: r.storageKey, ...size });
1461
+ if (size && newestKey.current === storageKey) set({ storageKey, ...size });
1427
1462
  };
1428
1463
  return /* @__PURE__ */ jsxs11("div", { className: "flex flex-col gap-2", children: [
1429
1464
  /* @__PURE__ */ jsx16(UploadField, { category: "image", label: "Upload image", onUploaded }),
@@ -1451,7 +1486,7 @@ function ImageBlockEditor({ data, onChange }) {
1451
1486
  }
1452
1487
  )
1453
1488
  ] }),
1454
- str(data.storageKey) && /* @__PURE__ */ jsx16("span", { className: "text-xs text-muted-foreground", children: "Image attached \u2713" })
1489
+ str(data.storageKey) && /* @__PURE__ */ jsx16("span", { className: "text-xs text-muted-foreground", children: "Image attached" })
1455
1490
  ] });
1456
1491
  }
1457
1492
 
@@ -1478,12 +1513,12 @@ function FileBlockEditor({ data, onChange }) {
1478
1513
  // src/blocks/previews.tsx
1479
1514
  import { jsx as jsx18 } from "react/jsx-runtime";
1480
1515
  var HINT = "text-sm text-muted-foreground";
1481
- var TONE_BORDER = {
1482
- info: "border-primary",
1483
- tip: "border-accent-foreground",
1484
- warning: "border-muted-foreground",
1485
- success: "border-secondary-foreground",
1486
- danger: "border-destructive"
1516
+ var TONE = {
1517
+ info: "border-info bg-info/15",
1518
+ tip: "border-primary bg-primary/15",
1519
+ warning: "border-warning bg-warning/15",
1520
+ success: "border-success bg-success/15",
1521
+ danger: "border-destructive bg-destructive/15"
1487
1522
  };
1488
1523
  function MarkdownPreview({ data }) {
1489
1524
  const markdown = str(data.markdown);
@@ -1510,8 +1545,8 @@ function CalloutPreview({ data }) {
1510
1545
  const body = str(data.body);
1511
1546
  if (body.trim() === "") return /* @__PURE__ */ jsx18("p", { className: HINT, children: "Empty callout" });
1512
1547
  const tone = str(data.tone) || "info";
1513
- const border = TONE_BORDER[tone] ?? "border-border";
1514
- return /* @__PURE__ */ jsx18("div", { "data-tone": tone, className: `rounded-md border-l-4 ${border} bg-muted/50 p-3 text-sm`, children: body });
1548
+ const paint = TONE[tone] ?? "border-border bg-muted/50";
1549
+ return /* @__PURE__ */ jsx18("div", { "data-tone": tone, className: `rounded-md border-l-4 ${paint} p-3 text-sm`, children: body });
1515
1550
  }
1516
1551
 
1517
1552
  // src/blocks/core.ts
@@ -1559,7 +1594,7 @@ var coreBlocks = [
1559
1594
  ];
1560
1595
 
1561
1596
  // src/hooks/useBlockList.ts
1562
- import * as React7 from "react";
1597
+ import * as React11 from "react";
1563
1598
 
1564
1599
  // src/hooks/new-block-id.ts
1565
1600
  function newBlockId() {
@@ -1626,8 +1661,8 @@ function reducer(state, action) {
1626
1661
  }
1627
1662
  }
1628
1663
  function useBlockList(initial) {
1629
- const [state, dispatch] = React7.useReducer(reducer, { blocks: initial, removals: /* @__PURE__ */ new Map() });
1630
- const insertAt = React7.useCallback(
1664
+ const [state, dispatch] = React11.useReducer(reducer, { blocks: initial, removals: /* @__PURE__ */ new Map() });
1665
+ const insertAt = React11.useCallback(
1631
1666
  (index, type, data = {}) => {
1632
1667
  const id = newBlockId();
1633
1668
  dispatch({ type: "insertAt", index, block: { id, type, data } });
@@ -1635,25 +1670,25 @@ function useBlockList(initial) {
1635
1670
  },
1636
1671
  []
1637
1672
  );
1638
- const update = React7.useCallback((id, data) => {
1673
+ const update = React11.useCallback((id, data) => {
1639
1674
  dispatch({ type: "update", id, data });
1640
1675
  }, []);
1641
- const remove = React7.useCallback((id) => {
1676
+ const remove = React11.useCallback((id) => {
1642
1677
  removalSeq += 1;
1643
1678
  const token = removalSeq;
1644
1679
  dispatch({ type: "remove", id, token });
1645
1680
  return token;
1646
1681
  }, []);
1647
- const undo = React7.useCallback((token) => {
1682
+ const undo = React11.useCallback((token) => {
1648
1683
  dispatch({ type: "undo", token });
1649
1684
  }, []);
1650
- const move = React7.useCallback((index, dir) => {
1685
+ const move = React11.useCallback((index, dir) => {
1651
1686
  dispatch({ type: "move", index, dir });
1652
1687
  }, []);
1653
- const reorder = React7.useCallback((from, to) => {
1688
+ const reorder = React11.useCallback((from, to) => {
1654
1689
  dispatch({ type: "reorder", from, to });
1655
1690
  }, []);
1656
- const reset = React7.useCallback((blocks) => {
1691
+ const reset = React11.useCallback((blocks) => {
1657
1692
  dispatch({ type: "reset", blocks });
1658
1693
  }, []);
1659
1694
  return { blocks: state.blocks, insertAt, update, remove, undo, move, reorder, reset };
@@ -1735,7 +1770,8 @@ function DropdownMenuItem({
1735
1770
  "data-slot": "dropdown-menu-item",
1736
1771
  "data-inset": inset,
1737
1772
  className: cn(
1738
- "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4",
1773
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4",
1774
+ HIGHLIGHT_BAR,
1739
1775
  TAP_TARGET_FLOOR,
1740
1776
  className
1741
1777
  ),
@@ -1812,7 +1848,7 @@ function BlockActionsMenu({
1812
1848
  }
1813
1849
 
1814
1850
  // src/canvas/BlockCard.tsx
1815
- import * as React8 from "react";
1851
+ import * as React12 from "react";
1816
1852
  import { Pencil } from "lucide-react";
1817
1853
 
1818
1854
  // src/canvas/BlockEditor.tsx
@@ -1906,32 +1942,36 @@ function BlockCardImpl({
1906
1942
  const definition = blockByType(blockTypes, block.type);
1907
1943
  const Preview = definition?.preview;
1908
1944
  const editable = Boolean(definition?.editor) && !readOnly;
1909
- const [editing, setEditing] = React8.useState(editingByDefault && editable);
1910
- const editButtonRef = React8.useRef(null);
1911
- const editorContainerRef = React8.useRef(null);
1912
- const returnFocusOnClose = React8.useRef(false);
1913
- const clickedRef = React8.useRef(null);
1914
- const scrollTopRef = React8.useRef(null);
1915
- const handleInsertBlock = React8.useCallback(
1945
+ const [editing, setEditing] = React12.useState(editingByDefault && editable);
1946
+ const editButtonRef = React12.useRef(null);
1947
+ const editorContainerRef = React12.useRef(null);
1948
+ const returnFocusOnClose = React12.useRef(false);
1949
+ const clickedRef = React12.useRef(null);
1950
+ const scrollTopRef = React12.useRef(null);
1951
+ const seedRef = React12.useRef(initialSelection);
1952
+ React12.useEffect(() => {
1953
+ seedRef.current = initialSelection;
1954
+ });
1955
+ const handleInsertBlock = React12.useCallback(
1916
1956
  (type) => onInsertBlock?.(index + 1, type),
1917
1957
  [onInsertBlock, index]
1918
1958
  );
1919
- const leaveEditing = React8.useCallback(() => {
1959
+ const leaveEditing = React12.useCallback(() => {
1920
1960
  returnFocusOnClose.current = true;
1921
1961
  setEditing(false);
1922
1962
  }, []);
1923
- const settleRef = React8.useRef(null);
1963
+ const settleRef = React12.useRef(null);
1924
1964
  const clearSettle = () => {
1925
1965
  if (settleRef.current === null) return;
1926
1966
  window.clearTimeout(settleRef.current);
1927
1967
  settleRef.current = null;
1928
1968
  };
1929
- React8.useEffect(() => clearSettle, []);
1969
+ React12.useEffect(() => clearSettle, []);
1930
1970
  function stillInside(node) {
1931
1971
  if (!node) return false;
1932
1972
  return Boolean(editorContainerRef.current?.contains(node)) || Boolean(node.closest(PORTALLED_OVERLAY));
1933
1973
  }
1934
- React8.useEffect(() => {
1974
+ React12.useEffect(() => {
1935
1975
  if (!editing) return;
1936
1976
  function onPointerDown(e) {
1937
1977
  if (stillInside(e.target)) return;
@@ -1948,7 +1988,7 @@ function BlockCardImpl({
1948
1988
  setEditing(false);
1949
1989
  }, 0);
1950
1990
  }
1951
- React8.useEffect(() => {
1991
+ React12.useEffect(() => {
1952
1992
  if (!editing) return;
1953
1993
  const field = editorContainerRef.current?.querySelector(
1954
1994
  'textarea, input, [role="combobox"]'
@@ -1959,7 +1999,7 @@ function BlockCardImpl({
1959
1999
  if (clicked === null || !(field instanceof HTMLTextAreaElement)) {
1960
2000
  field.focus();
1961
2001
  if (field instanceof HTMLTextAreaElement) {
1962
- const at = initialSelection ?? { start: field.value.length, end: field.value.length };
2002
+ const at = seedRef.current ?? { start: field.value.length, end: field.value.length };
1963
2003
  field.setSelectionRange(at.start, at.end);
1964
2004
  }
1965
2005
  return;
@@ -1967,26 +2007,26 @@ function BlockCardImpl({
1967
2007
  const offset = sourceOffsetFor(field.value, clicked);
1968
2008
  field.focus({ preventScroll: true });
1969
2009
  field.setSelectionRange(offset, offset);
1970
- }, [editing, initialSelection]);
1971
- React8.useLayoutEffect(() => {
2010
+ }, [editing]);
2011
+ useIsomorphicLayoutEffect(() => {
1972
2012
  const scrollTop = scrollTopRef.current;
1973
2013
  scrollTopRef.current = null;
1974
2014
  if (!editing || scrollTop === null) return;
1975
2015
  const scroller = document.scrollingElement ?? document.documentElement;
1976
2016
  if (scroller.scrollTop !== scrollTop) scroller.scrollTop = scrollTop;
1977
2017
  }, [editing]);
1978
- React8.useEffect(() => {
2018
+ React12.useEffect(() => {
1979
2019
  if (editing || !returnFocusOnClose.current) return;
1980
2020
  returnFocusOnClose.current = false;
1981
2021
  editButtonRef.current?.focus();
1982
2022
  }, [editing]);
1983
- const reportedType = React8.useRef(null);
1984
- React8.useEffect(() => {
2023
+ const reportedType = React12.useRef(null);
2024
+ React12.useEffect(() => {
1985
2025
  if (definition || reportedType.current === block.type) return;
1986
2026
  reportedType.current = block.type;
1987
2027
  onError(new Error(`No block type is registered for "${block.type}".`));
1988
2028
  }, [definition, block.type, onError]);
1989
- React8.useEffect(() => {
2029
+ React12.useEffect(() => {
1990
2030
  if (!readOnly || !definition || definition.preview) return;
1991
2031
  devWarn(
1992
2032
  `the block type "${block.type}" has no \`preview\`, so a read-only canvas can draw only its label. Give its definition a \`preview\` to render the block itself.`
@@ -2067,20 +2107,21 @@ function BlockCardImpl({
2067
2107
  )
2068
2108
  ] });
2069
2109
  }
2070
- var BlockCard = React8.memo(BlockCardImpl);
2110
+ var BlockCard = React12.memo(BlockCardImpl);
2071
2111
 
2072
2112
  // src/canvas/BlockInserter.tsx
2073
- import * as React9 from "react";
2113
+ import * as React13 from "react";
2074
2114
  import { Plus } from "lucide-react";
2075
2115
  import { jsx as jsx23, jsxs as jsxs16 } from "react/jsx-runtime";
2076
2116
  function BlockInserter({
2077
2117
  index,
2078
2118
  onInsert,
2079
- label
2119
+ label,
2120
+ canvasRef
2080
2121
  }) {
2081
2122
  const { blockTypes } = useQuoin();
2082
2123
  const options = paletteBlocks(blockTypes);
2083
- const insertedAtRef = React9.useRef(null);
2124
+ const insertedAtRef = React13.useRef(null);
2084
2125
  function handleSelect(type) {
2085
2126
  insertedAtRef.current = index;
2086
2127
  onInsert(index, type);
@@ -2090,7 +2131,7 @@ function BlockInserter({
2090
2131
  insertedAtRef.current = null;
2091
2132
  if (insertedAt === null) return;
2092
2133
  event.preventDefault();
2093
- const card = document.querySelector(`[data-block-index="${insertedAt}"]`);
2134
+ const card = canvasRef.current?.querySelector(`[data-block-index="${insertedAt}"]`);
2094
2135
  if (!card) return;
2095
2136
  if (typeof card.scrollIntoView === "function") {
2096
2137
  card.scrollIntoView({ block: "center", behavior: "smooth" });
@@ -2126,21 +2167,22 @@ function BlockInserter({
2126
2167
  }
2127
2168
 
2128
2169
  // src/canvas/DocumentStarter.tsx
2129
- import * as React10 from "react";
2170
+ import * as React14 from "react";
2130
2171
  import { jsx as jsx24, jsxs as jsxs17 } from "react/jsx-runtime";
2131
2172
  var LINE = [
2132
- "w-full resize-none rounded-md border-0 bg-transparent px-2 py-1 outline-none",
2173
+ "w-full resize-none rounded-md border-0 bg-transparent px-2 py-1",
2133
2174
  "text-base leading-7 placeholder:text-muted-foreground",
2134
- "transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:ring-ring/50"
2175
+ "transition-[color,box-shadow]",
2176
+ FOCUS_RING
2135
2177
  ].join(" ");
2136
2178
  function DocumentStarter({
2137
2179
  autoFocus = false,
2138
2180
  onStart,
2139
2181
  onInsertBlock
2140
2182
  }) {
2141
- const ref = React10.useRef(null);
2183
+ const ref = React14.useRef(null);
2142
2184
  const palette = useSlashPalette(ref);
2143
- const [shortcutsOpen, setShortcutsOpen] = React10.useState(false);
2185
+ const [shortcutsOpen, setShortcutsOpen] = React14.useState(false);
2144
2186
  function handleChange(e) {
2145
2187
  const value = e.target.value;
2146
2188
  e.target.value = "";
@@ -2162,6 +2204,7 @@ function DocumentStarter({
2162
2204
  defaultValue: "",
2163
2205
  onChange: handleChange,
2164
2206
  onKeyDown: (e) => {
2207
+ if (e.nativeEvent.isComposing || e.keyCode === 229) return;
2165
2208
  if (e.key !== "/") return;
2166
2209
  e.preventDefault();
2167
2210
  palette.openAtCaret();
@@ -2288,14 +2331,36 @@ function SortableBlock({
2288
2331
  }
2289
2332
 
2290
2333
  // src/canvas/unacknowledged.ts
2291
- var UNACKNOWLEDGED_LIMIT = 64;
2334
+ var STRUCTURAL_ECHO_LIMIT = 64;
2335
+ function createUnacknowledged() {
2336
+ return { sequences: /* @__PURE__ */ new WeakMap(), emitted: 0, acknowledged: 0, newest: [] };
2337
+ }
2338
+ function keepNewest(outstanding) {
2339
+ const kept = Math.min(STRUCTURAL_ECHO_LIMIT, outstanding.emitted - outstanding.acknowledged);
2340
+ outstanding.newest.splice(0, outstanding.newest.length - kept);
2341
+ }
2292
2342
  function remember(outstanding, blocks) {
2293
- outstanding.add(blocks);
2294
- while (outstanding.size > UNACKNOWLEDGED_LIMIT) {
2295
- const oldest = outstanding.values().next();
2296
- if (oldest.done) return;
2297
- outstanding.delete(oldest.value);
2343
+ outstanding.emitted += 1;
2344
+ outstanding.sequences.set(blocks, outstanding.emitted);
2345
+ outstanding.newest.push(blocks);
2346
+ keepNewest(outstanding);
2347
+ }
2348
+ function copiedSequence(outstanding, blocks) {
2349
+ for (const emitted of outstanding.newest) {
2350
+ if (sameBlocks(emitted, blocks)) return outstanding.sequences.get(emitted);
2298
2351
  }
2352
+ return void 0;
2353
+ }
2354
+ function acknowledge(outstanding, blocks) {
2355
+ const echoed = outstanding.sequences.get(blocks) ?? copiedSequence(outstanding, blocks);
2356
+ if (echoed === void 0 || echoed <= outstanding.acknowledged) return false;
2357
+ outstanding.acknowledged = echoed;
2358
+ keepNewest(outstanding);
2359
+ return true;
2360
+ }
2361
+ function forgetAll(outstanding) {
2362
+ outstanding.acknowledged = outstanding.emitted;
2363
+ keepNewest(outstanding);
2299
2364
  }
2300
2365
 
2301
2366
  // src/canvas/BlockCanvas.tsx
@@ -2316,20 +2381,20 @@ function Canvas({
2316
2381
  useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
2317
2382
  useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
2318
2383
  );
2319
- const dndId = React11.useId();
2320
- const [newBlockId2, setNewBlockId] = React11.useState(null);
2321
- const [seedSelection, setSeedSelection] = React11.useState(null);
2322
- const listRef = React11.useRef(null);
2323
- const pendingDeleteIndexRef = React11.useRef(null);
2324
- const removedRef = React11.useRef(null);
2325
- const announcements = React11.useMemo(
2384
+ const dndId = React15.useId();
2385
+ const [newBlockId2, setNewBlockId] = React15.useState(null);
2386
+ const [seedSelection, setSeedSelection] = React15.useState(null);
2387
+ const listRef = React15.useRef(null);
2388
+ const pendingDeleteIndexRef = React15.useRef(null);
2389
+ const removedRef = React15.useRef(null);
2390
+ const announcements = React15.useMemo(
2326
2391
  () => buildAnnouncements(list.blocks, blockTypes),
2327
2392
  [list.blocks, blockTypes]
2328
2393
  );
2329
- const lastEmitted = React11.useRef(blocks);
2330
- const lastSupplied = React11.useRef(blocks);
2331
- const unacknowledged = React11.useRef(/* @__PURE__ */ new Set());
2332
- React11.useEffect(() => {
2394
+ const lastEmitted = React15.useRef(blocks);
2395
+ const lastSupplied = React15.useRef(blocks);
2396
+ const unacknowledged = React15.useRef(createUnacknowledged());
2397
+ React15.useEffect(() => {
2333
2398
  if (list.blocks === lastEmitted.current) return;
2334
2399
  lastEmitted.current = list.blocks;
2335
2400
  remember(unacknowledged.current, list.blocks);
@@ -2338,21 +2403,22 @@ function Canvas({
2338
2403
  removedRef.current = null;
2339
2404
  if (removed) onDelete?.(removed.block, removed.index);
2340
2405
  }, [list.blocks, onChange, onDelete]);
2341
- React11.useEffect(() => {
2406
+ React15.useEffect(() => {
2342
2407
  const previous = lastSupplied.current;
2343
2408
  lastSupplied.current = blocks;
2344
- if (unacknowledged.current.has(blocks)) {
2345
- if (blocks === lastEmitted.current) unacknowledged.current.clear();
2346
- return;
2347
- }
2409
+ if (acknowledge(unacknowledged.current, blocks)) return;
2348
2410
  if (sameBlocks(blocks, previous) || sameBlocks(blocks, list.blocks)) return;
2349
- unacknowledged.current.clear();
2411
+ forgetAll(unacknowledged.current);
2350
2412
  lastEmitted.current = blocks;
2351
2413
  reset(blocks);
2352
2414
  }, [blocks, reset]);
2353
- const insertPlain = React11.useCallback(
2415
+ React15.useEffect(() => {
2416
+ if (newBlockId2 === null) return;
2417
+ setNewBlockId(null);
2418
+ setSeedSelection(null);
2419
+ }, [newBlockId2]);
2420
+ const insertPlain = React15.useCallback(
2354
2421
  (index, type, data) => {
2355
- setSeedSelection(null);
2356
2422
  const seed = data ?? blockByType(blockTypes, type)?.initialData?.() ?? {};
2357
2423
  setNewBlockId(insertAt(index, type, seed));
2358
2424
  },
@@ -2362,10 +2428,9 @@ function Canvas({
2362
2428
  const block = list.blocks[index];
2363
2429
  pendingDeleteIndexRef.current = index;
2364
2430
  if (block) removedRef.current = { block, index };
2365
- setNewBlockId((current) => current === id2 ? null : current);
2366
2431
  remove(id2);
2367
2432
  }
2368
- React11.useEffect(() => {
2433
+ React15.useEffect(() => {
2369
2434
  const index = pendingDeleteIndexRef.current;
2370
2435
  pendingDeleteIndexRef.current = null;
2371
2436
  if (index === null || !listRef.current) return;
@@ -2391,7 +2456,7 @@ function Canvas({
2391
2456
  };
2392
2457
  if (readOnly) return /* @__PURE__ */ jsx27(ReadOnlyCanvas, { blocks: list.blocks, onUpdate: update, root: rootProps });
2393
2458
  return /* @__PURE__ */ jsxs19("div", { ...rootProps, children: [
2394
- /* @__PURE__ */ jsx27(BlockInserter, { index: 0, onInsert: insertPlain, label: "Insert a block at the start" }),
2459
+ /* @__PURE__ */ jsx27(BlockInserter, { index: 0, onInsert: insertPlain, label: "Insert a block at the start", canvasRef: listRef }),
2395
2460
  /* @__PURE__ */ jsx27(
2396
2461
  DndContext,
2397
2462
  {
@@ -2435,7 +2500,8 @@ function Canvas({
2435
2500
  {
2436
2501
  index: i + 1,
2437
2502
  onInsert: insertPlain,
2438
- label: `Insert a block after block ${i + 1}`
2503
+ label: `Insert a block after block ${i + 1}`,
2504
+ canvasRef: listRef
2439
2505
  }
2440
2506
  )
2441
2507
  ] }, b.id)) }) })
@@ -2467,7 +2533,7 @@ function BlockCanvas({
2467
2533
  id,
2468
2534
  "aria-label": ariaLabel
2469
2535
  }) {
2470
- const config = React11.useMemo(
2536
+ const config = React15.useMemo(
2471
2537
  () => ({ blockTypes, upload, resolveAssetUrl, onError }),
2472
2538
  [blockTypes, upload, resolveAssetUrl, onError]
2473
2539
  );
@@ -2487,71 +2553,134 @@ function BlockCanvas({
2487
2553
  }
2488
2554
 
2489
2555
  // src/hooks/useAutosave.ts
2490
- import * as React12 from "react";
2556
+ import * as React16 from "react";
2557
+
2558
+ // src/hooks/autosave-backoff.ts
2559
+ function clearTimer(ref) {
2560
+ if (ref.current !== null) clearTimeout(ref.current);
2561
+ ref.current = null;
2562
+ }
2563
+ var alwaysRetry = () => true;
2491
2564
  function reportedFailure(result) {
2492
2565
  return typeof result === "object" && result !== null && "ok" in result && result.ok === false;
2493
2566
  }
2567
+ async function attemptSave(run) {
2568
+ try {
2569
+ const result = await run();
2570
+ return reportedFailure(result) ? { error: result } : null;
2571
+ } catch (error) {
2572
+ return { error };
2573
+ }
2574
+ }
2575
+ function nextRetryDelay(error, retriesSoFar, { debounceMs, maxBackoffMs, maxRetries, isRetryable }) {
2576
+ if (retriesSoFar >= maxRetries || !isRetryable(error)) return null;
2577
+ return Math.min(debounceMs * 2 ** (retriesSoFar + 1), maxBackoffMs);
2578
+ }
2579
+
2580
+ // src/hooks/useAutosave.ts
2494
2581
  function useAutosave({
2495
2582
  deps,
2496
2583
  build,
2497
2584
  save,
2498
2585
  debounceMs = 2e3,
2499
- maxBackoffMs = 3e4
2586
+ maxBackoffMs = 3e4,
2587
+ maxRetries = 8,
2588
+ isRetryable = alwaysRetry,
2589
+ flushOnHide = true
2500
2590
  }) {
2501
- const [status, setStatus] = React12.useState("idle");
2502
- const dirtyRef = React12.useRef(false);
2503
- const retryRef = React12.useRef(0);
2504
- const backoffRef = React12.useRef(null);
2505
- const buildRef = React12.useRef(build);
2506
- const saveRef = React12.useRef(save);
2507
- const runRef = React12.useRef(() => {
2591
+ const [status, setStatus] = React16.useState("idle");
2592
+ const [error, setError] = React16.useState(null);
2593
+ const dirtyRef = React16.useRef(false);
2594
+ const inFlightRef = React16.useRef(false);
2595
+ const disposedRef = React16.useRef(false);
2596
+ const retriesRef = React16.useRef(0);
2597
+ const debounceRef = React16.useRef(null);
2598
+ const backoffRef = React16.useRef(null);
2599
+ const buildRef = React16.useRef(build);
2600
+ const saveRef = React16.useRef(save);
2601
+ const runRef = React16.useRef(() => {
2508
2602
  });
2509
- React12.useEffect(() => {
2603
+ React16.useEffect(() => {
2510
2604
  buildRef.current = build;
2511
2605
  saveRef.current = save;
2606
+ const policy = { debounceMs, maxBackoffMs, maxRetries, isRetryable };
2512
2607
  runRef.current = () => {
2608
+ clearTimer(debounceRef);
2609
+ clearTimer(backoffRef);
2610
+ if (inFlightRef.current) return;
2611
+ inFlightRef.current = true;
2612
+ dirtyRef.current = false;
2613
+ if (!disposedRef.current) setStatus("saving");
2513
2614
  void (async () => {
2514
- setStatus("saving");
2515
- try {
2516
- const res = await saveRef.current(buildRef.current());
2517
- if (reportedFailure(res)) throw new Error("save rejected");
2518
- retryRef.current = 0;
2519
- dirtyRef.current = false;
2520
- setStatus("saved");
2521
- } catch {
2522
- setStatus("retrying");
2523
- retryRef.current += 1;
2524
- const delay = Math.min(debounceMs * 2 ** retryRef.current, maxBackoffMs);
2525
- if (backoffRef.current) clearTimeout(backoffRef.current);
2526
- backoffRef.current = setTimeout(() => runRef.current(), delay);
2615
+ const failure = await attemptSave(() => saveRef.current(buildRef.current()));
2616
+ inFlightRef.current = false;
2617
+ if (disposedRef.current) {
2618
+ if (dirtyRef.current) runRef.current();
2619
+ return;
2620
+ }
2621
+ if (!failure) {
2622
+ retriesRef.current = 0;
2623
+ setError(null);
2624
+ if (dirtyRef.current) runRef.current();
2625
+ else setStatus("saved");
2626
+ return;
2627
+ }
2628
+ dirtyRef.current = true;
2629
+ setError(failure.error);
2630
+ const delay = nextRetryDelay(failure.error, retriesRef.current, policy);
2631
+ if (delay === null) {
2632
+ setStatus("failed");
2633
+ return;
2527
2634
  }
2635
+ retriesRef.current += 1;
2636
+ setStatus("retrying");
2637
+ backoffRef.current = setTimeout(() => runRef.current(), delay);
2528
2638
  })();
2529
2639
  };
2530
2640
  });
2531
- const seenRef = React12.useRef(null);
2532
- React12.useEffect(() => {
2641
+ const seenRef = React16.useRef(null);
2642
+ React16.useEffect(() => {
2533
2643
  const seen2 = seenRef.current;
2534
2644
  seenRef.current = deps;
2535
2645
  if (seen2 === null) return;
2536
2646
  const unchanged = seen2.length === deps.length && seen2.every((v, i) => v === deps[i]);
2537
2647
  if (!unchanged) dirtyRef.current = true;
2538
2648
  }, deps);
2539
- React12.useEffect(() => {
2649
+ React16.useEffect(() => {
2540
2650
  if (!dirtyRef.current) return;
2541
- const handle = setTimeout(() => runRef.current(), debounceMs);
2542
- return () => clearTimeout(handle);
2651
+ clearTimer(debounceRef);
2652
+ debounceRef.current = setTimeout(() => runRef.current(), debounceMs);
2653
+ return () => clearTimer(debounceRef);
2543
2654
  }, deps);
2544
- React12.useEffect(
2545
- () => () => {
2546
- if (backoffRef.current) clearTimeout(backoffRef.current);
2547
- },
2548
- []
2549
- );
2550
- const retry = React12.useCallback(() => {
2551
- if (backoffRef.current) clearTimeout(backoffRef.current);
2655
+ React16.useEffect(() => {
2656
+ disposedRef.current = false;
2657
+ return () => {
2658
+ disposedRef.current = true;
2659
+ clearTimer(debounceRef);
2660
+ clearTimer(backoffRef);
2661
+ if (dirtyRef.current) runRef.current();
2662
+ };
2663
+ }, []);
2664
+ const retry = React16.useCallback(() => {
2665
+ retriesRef.current = 0;
2552
2666
  runRef.current();
2553
2667
  }, []);
2554
- return { status, retry };
2668
+ const flush = React16.useCallback(() => {
2669
+ if (dirtyRef.current) retry();
2670
+ }, [retry]);
2671
+ React16.useEffect(() => {
2672
+ if (!flushOnHide) return;
2673
+ const onVisibility = () => {
2674
+ if (document.visibilityState === "hidden") flush();
2675
+ };
2676
+ window.addEventListener("pagehide", flush);
2677
+ document.addEventListener("visibilitychange", onVisibility);
2678
+ return () => {
2679
+ window.removeEventListener("pagehide", flush);
2680
+ document.removeEventListener("visibilitychange", onVisibility);
2681
+ };
2682
+ }, [flushOnHide, flush]);
2683
+ return { status, error, retry, flush };
2555
2684
  }
2556
2685
  export {
2557
2686
  BlockCanvas,