@weasel-js/routing 1.7.0 → 1.7.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/react.js CHANGED
@@ -1,5 +1,6 @@
1
- import { createDepRegistry, createDispatcher, scopeBindings, liveScope, isDev, reportRouteConflicts, offhandKeyFor, makeToolOffhandAction, buildToolOffhandBindings, dlog, isTool, isEditableTarget, clientToCanvasRect, openPointerSession, publishLiveDispatch, pointerGestureId, itemsFromDataTransfer, itemsFromClipboardData, ScopeNode, pushOwner, validateActionId, validateActionDefaultBinding, depNodeOf, DepNode, buildDepsFromRequires } from './chunk-Q3Y3DPKH.js';
2
- import { createContext, useState, useContext, useMemo, useRef, useEffect, useCallback, useSyncExternalStore } from 'react';
1
+ import { createDepRegistry, createDispatcher, isTool, scopeBindings, liveScope, isDev, reportRouteConflicts, offhandKeyFor, makeToolOffhandAction, buildToolOffhandBindings, dlog, isEditableTarget, clientToCanvasRect, openPointerSession, LONG_PRESS_MS, publishLiveDispatch, pointerGestureId, itemsFromDataTransfer, itemsFromClipboardData, ScopeNode, pushOwner, validateActionId, validateActionDefaultBinding, depNodeOf, DepNode, buildDepsFromRequires } from './chunk-ZGKJWZ4S.js';
2
+ import { createContext, useState, useContext, useMemo, useEffect, useRef, useCallback, useSyncExternalStore } from 'react';
3
+ import { useLatest, useStableByContent, sameList } from '@weasel-js/react';
3
4
  import { jsx, Fragment } from 'react/jsx-runtime';
4
5
  import { pastDragThreshold } from '@weasel-js/gestures';
5
6
  import { resolveCursorTier } from '@weasel-js/cursor';
@@ -109,11 +110,10 @@ function useOptionalDepRegistry() {
109
110
  }
110
111
  function useDepSource(name, source) {
111
112
  const registry = useDepRegistry();
112
- const sourceRef = useRef(source);
113
- sourceRef.current = source;
113
+ const sourceRef = useLatest(source);
114
114
  useEffect(() => {
115
115
  return registry.register(name, () => sourceRef.current());
116
- }, [name, registry]);
116
+ }, [name, registry, sourceRef]);
117
117
  }
118
118
  var DROPOVER_CLASS = "weasel-dropover";
119
119
  function stylusOf(e) {
@@ -156,23 +156,21 @@ function computeMultiTouchGeometry(positions) {
156
156
  return { centroid, spread };
157
157
  }
158
158
  function useGestureDispatcher(opts) {
159
- const { canvasRef, actions, entriesById, enabled = true, keyboard = true, affordanceAt, classifyTarget, dispatcher: dispatcherOpt, clientToWorld, requestRedraw, paintedCursor, getRuleCtx, onDoubleClick, views, channels } = opts;
159
+ const { canvasRef, actions, entriesById, enabled = true, keyboard = true, affordanceAt, classifyTarget, dispatcher: dispatcherOpt, clientToWorld, requestRedraw, paintedCursor, getRuleCtx, onDoubleClick, views, channels, longPress } = opts;
160
160
  const pointerChannel = channels?.pointer !== false;
161
161
  const wheelChannel = channels?.wheel !== false;
162
162
  const pinchChannel = channels?.pinch !== false;
163
163
  const contextMenuChannel = channels?.contextMenu !== false;
164
164
  const ingestChannel = channels?.ingest !== false;
165
- const onDoubleClickRef = useRef(onDoubleClick);
166
- onDoubleClickRef.current = onDoubleClick;
165
+ const onDoubleClickRef = useLatest(onDoubleClick);
167
166
  const activeTool = useActiveToolContext();
168
167
  const depRegistry = useDepRegistry();
169
168
  const dispatcherRef = useRef(null);
170
169
  if (!dispatcherRef.current) {
171
170
  dispatcherRef.current = dispatcherOpt ?? createDispatcher();
172
171
  }
173
- const registryRef = useRef(actions);
174
- registryRef.current = actions;
175
- const ctxRef = useRef({
172
+ const registryRef = useLatest(actions);
173
+ const ctxRef = useLatest({
176
174
  actions,
177
175
  depRegistry,
178
176
  activeToolId: activeTool.active,
@@ -180,15 +178,7 @@ function useGestureDispatcher(opts) {
180
178
  entriesById,
181
179
  isMac: IS_MAC
182
180
  });
183
- ctxRef.current = {
184
- actions,
185
- depRegistry,
186
- activeToolId: activeTool.active,
187
- hotkeyStack: activeTool.hotkeyStack,
188
- entriesById,
189
- isMac: IS_MAC
190
- };
191
- const rootTargetRef = useRef({
181
+ const rootTargetRef = useLatest({
192
182
  id: null,
193
183
  dispatcher: dispatcherRef.current,
194
184
  affordanceAt,
@@ -196,30 +186,19 @@ function useGestureDispatcher(opts) {
196
186
  clientToWorld,
197
187
  getRuleCtx
198
188
  });
199
- rootTargetRef.current = {
200
- id: null,
201
- dispatcher: dispatcherRef.current,
202
- affordanceAt,
203
- classifyTarget,
204
- clientToWorld,
205
- getRuleCtx
206
- };
207
- const viewsRef = useRef(views);
208
- viewsRef.current = views;
209
- const allDispatchers = () => [
189
+ const viewsRef = useLatest(views);
190
+ const allDispatchers = useCallback(() => [
210
191
  rootTargetRef.current.dispatcher,
211
192
  ...(viewsRef.current?.targets() ?? []).map((t) => t.dispatcher)
212
- ];
213
- const requestRedrawRef = useRef(requestRedraw);
214
- requestRedrawRef.current = requestRedraw;
215
- const paintedCursorRef = useRef(paintedCursor);
216
- paintedCursorRef.current = paintedCursor;
193
+ ], [rootTargetRef, viewsRef]);
194
+ const requestRedrawRef = useLatest(requestRedraw);
195
+ const paintedCursorRef = useLatest(paintedCursor);
196
+ const longPressRef = useLatest(longPress);
217
197
  const lastClickRef = useRef(null);
218
198
  const liveToolsRef = useRef(/* @__PURE__ */ new Set());
219
199
  const prevActiveRef = useRef(activeTool.active);
220
- const entriesByIdRef = useRef(entriesById);
221
- entriesByIdRef.current = entriesById;
222
- const fireLifecycle = (id, hook) => {
200
+ const entriesByIdRef = useLatest(entriesById);
201
+ const fireLifecycle = useCallback((id, hook) => {
223
202
  const tool = entriesByIdRef.current.get(id);
224
203
  if (!tool || !isTool(tool)) return;
225
204
  const fn = tool[hook];
@@ -229,7 +208,7 @@ function useGestureDispatcher(opts) {
229
208
  } catch (err) {
230
209
  console.error(`weasel: tool "${id}".${hook} threw`, err);
231
210
  }
232
- };
211
+ }, [entriesByIdRef]);
233
212
  useEffect(() => {
234
213
  const prev = liveToolsRef.current;
235
214
  const next = new Set(
@@ -247,7 +226,7 @@ function useGestureDispatcher(opts) {
247
226
  const live = liveToolsRef.current;
248
227
  liveToolsRef.current = /* @__PURE__ */ new Set();
249
228
  for (const id of live) fireLifecycle(id, "onDeactivate");
250
- }, []);
229
+ }, [fireLifecycle]);
251
230
  useEffect(() => {
252
231
  if (!enabled) return;
253
232
  let disposed = false;
@@ -312,22 +291,27 @@ function useGestureDispatcher(opts) {
312
291
  };
313
292
  let multiTouchStart = null;
314
293
  const TAP_THRESHOLD_PX = 8;
315
- const LONG_PRESS_MS = 500;
316
294
  const longPressTimers = /* @__PURE__ */ new Map();
295
+ const clearPending = (pointerId) => {
296
+ const store = longPressRef.current?.state;
297
+ const p = store?.get();
298
+ if (p && (pointerId === void 0 || p.pointerId === pointerId)) store.set(null);
299
+ };
317
300
  const cancelLongPress = (pointerId) => {
318
301
  const t = longPressTimers.get(pointerId);
319
302
  if (t !== void 0) {
320
303
  clearTimeout(t);
321
304
  longPressTimers.delete(pointerId);
322
305
  }
306
+ clearPending(pointerId);
323
307
  };
324
308
  const cancelAllLongPress = () => {
325
309
  for (const t of longPressTimers.values()) clearTimeout(t);
326
310
  longPressTimers.clear();
311
+ clearPending();
327
312
  };
328
- const fireLongPress = (pointerId) => {
329
- const down = held.get(pointerId)?.down;
330
- if (!down) return;
313
+ const HAPTIC_MS = 15;
314
+ const longPressEvents = (down) => {
331
315
  const shared = {
332
316
  target: down.target,
333
317
  altKey: down.altKey,
@@ -338,23 +322,26 @@ function useGestureDispatcher(opts) {
338
322
  ...down.bodyTarget !== void 0 ? { bodyTarget: down.bodyTarget } : {},
339
323
  ...down.bodyKind !== void 0 ? { bodyKind: down.bodyKind } : {}
340
324
  };
341
- const result = dispatch({
342
- kind: "longpress",
343
- x: down.worldX,
344
- y: down.worldY,
345
- clientX: down.clientX,
346
- clientY: down.clientY,
347
- ...shared
348
- });
349
- if (result === "unhandled") {
350
- dispatch({
351
- kind: "contextmenu",
352
- x: down.worldX,
353
- y: down.worldY,
354
- clientX: down.clientX,
355
- clientY: down.clientY,
356
- ...shared
357
- });
325
+ const at = { x: down.worldX, y: down.worldY, clientX: down.clientX, clientY: down.clientY };
326
+ return {
327
+ longpress: { kind: "longpress", ...at, ...shared },
328
+ contextmenu: { kind: "contextmenu", ...at, ...shared }
329
+ };
330
+ };
331
+ const longPressWouldFire = (down) => {
332
+ const evs = longPressEvents(down);
333
+ const d = dispatcherNow();
334
+ return d.resolveOnly(evs.longpress, ctxNow()) !== null || d.resolveOnly(evs.contextmenu, ctxNow()) !== null;
335
+ };
336
+ const fireLongPress = (pointerId, pointerType) => {
337
+ const down = held.get(pointerId)?.down;
338
+ clearPending(pointerId);
339
+ if (!down) return;
340
+ const evs = longPressEvents(down);
341
+ let result = dispatch(evs.longpress);
342
+ if (result === "unhandled") result = dispatch(evs.contextmenu);
343
+ if (result === "handled" && longPressRef.current?.haptics !== false && (pointerType === "touch" || pointerType === "pen") && typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
344
+ navigator.vibrate(HAPTIC_MS);
358
345
  }
359
346
  };
360
347
  const DOUBLE_CLICK_MAX_MS = 600;
@@ -567,14 +554,30 @@ function useGestureDispatcher(opts) {
567
554
  if (held.size < 2) dispatch({ ...ev, stage: "press" });
568
555
  if ((e.pointerType === "touch" || e.pointerType === "pen") && held.size === 1) {
569
556
  cancelLongPress(e.pointerId);
557
+ const duration = longPressRef.current?.duration ?? LONG_PRESS_MS;
570
558
  longPressTimers.set(
571
559
  e.pointerId,
572
560
  setTimeout(() => {
573
561
  longPressTimers.delete(e.pointerId);
574
562
  if (disposed) return;
575
- fireLongPress(e.pointerId);
576
- }, LONG_PRESS_MS)
563
+ fireLongPress(e.pointerId, e.pointerType);
564
+ }, duration)
577
565
  );
566
+ const store = longPressRef.current?.state;
567
+ const rec = held.get(e.pointerId);
568
+ if (store && rec) {
569
+ store.set({
570
+ pointerId: e.pointerId,
571
+ pointerType: e.pointerType,
572
+ client: { x: e.clientX, y: e.clientY },
573
+ local: toCanvasLocal({ x: e.clientX, y: e.clientY }),
574
+ world: { x: w.x, y: w.y },
575
+ viewId: target().id,
576
+ startedAt: performance.now(),
577
+ duration,
578
+ armed: longPressWouldFire(rec.down)
579
+ });
580
+ }
578
581
  }
579
582
  if (held.size >= 2) {
580
583
  cancelAllLongPress();
@@ -1017,7 +1020,25 @@ function useGestureDispatcher(opts) {
1017
1020
  held.clear();
1018
1021
  for (const d of allDispatchers()) d.cancelAll("cancel");
1019
1022
  };
1020
- }, [enabled, keyboard, canvasRef, pointerChannel, wheelChannel, pinchChannel, contextMenuChannel, ingestChannel]);
1023
+ }, [
1024
+ enabled,
1025
+ keyboard,
1026
+ canvasRef,
1027
+ pointerChannel,
1028
+ wheelChannel,
1029
+ pinchChannel,
1030
+ contextMenuChannel,
1031
+ ingestChannel,
1032
+ allDispatchers,
1033
+ ctxRef,
1034
+ longPressRef,
1035
+ onDoubleClickRef,
1036
+ paintedCursorRef,
1037
+ registryRef,
1038
+ requestRedrawRef,
1039
+ rootTargetRef,
1040
+ viewsRef
1041
+ ]);
1021
1042
  }
1022
1043
  var ActionsContext = createContext(null);
1023
1044
  function depsFor(a, r) {
@@ -1162,9 +1183,8 @@ function actionsNodeOf(registry) {
1162
1183
  }
1163
1184
  function ActionsProvider({ children }) {
1164
1185
  const depReg = useOptionalDepRegistry();
1165
- const depRegRef = useRef(depReg);
1166
- depRegRef.current = depReg;
1167
- const node = useMemo(() => new ActionsNode(null, true, () => depRegRef.current), []);
1186
+ const depRegRef = useLatest(depReg);
1187
+ const node = useMemo(() => new ActionsNode(null, true, () => depRegRef.current), [depRegRef]);
1168
1188
  return /* @__PURE__ */ jsx(ActionsContext.Provider, { value: node.registry, children });
1169
1189
  }
1170
1190
  var IS_DEV = (() => {
@@ -1228,10 +1248,8 @@ function Yoke({ value, children }) {
1228
1248
  }
1229
1249
  function useOngoingAction(actionId) {
1230
1250
  const reg = useActionsRegistry();
1231
- const regRef = useRef(reg);
1232
- regRef.current = reg;
1233
- const idRef = useRef(actionId);
1234
- idRef.current = actionId;
1251
+ const regRef = useLatest(reg);
1252
+ const idRef = useLatest(actionId);
1235
1253
  const ctrlRef = useRef(null);
1236
1254
  const edit = useMemo(() => {
1237
1255
  const end = (reason) => {
@@ -1255,51 +1273,43 @@ function useOngoingAction(actionId) {
1255
1273
  end("cancel");
1256
1274
  }
1257
1275
  };
1258
- }, []);
1276
+ }, [regRef, idRef]);
1259
1277
  useEffect(() => () => edit.commit(), [edit, actionId]);
1260
1278
  return edit;
1261
1279
  }
1262
1280
  function useContributions(opts) {
1263
1281
  const ctx = useActiveToolContext();
1264
1282
  const actionsRegistry = useActionsRegistry();
1265
- const hasInitializedRef = useRef(false);
1266
- const isFirstRender = !hasInitializedRef.current;
1267
- const needsFirstMountSyncRef = useRef(false);
1268
- if (isFirstRender) {
1269
- hasInitializedRef.current = true;
1270
- needsFirstMountSyncRef.current = ctx.active === null && opts.focused !== null;
1271
- }
1283
+ const firstMountRef = useRef(null);
1284
+ const isFirstRender = firstMountRef.current == null;
1285
+ if (firstMountRef.current == null) firstMountRef.current = { sync: ctx.active === null && opts.focused !== null };
1272
1286
  useEffect(() => {
1273
- if (!needsFirstMountSyncRef.current) return;
1274
- needsFirstMountSyncRef.current = false;
1287
+ const first = firstMountRef.current;
1288
+ if (!first?.sync) return;
1289
+ first.sync = false;
1275
1290
  ctx.setActive(opts.focused);
1276
1291
  }, []);
1277
1292
  const focused = isFirstRender && ctx.active === null && opts.focused !== null ? opts.focused : ctx.active;
1278
- const entriesRef = useRef(opts.entries);
1279
- entriesRef.current = opts.entries;
1280
- const focusedRef = useRef(focused);
1281
- focusedRef.current = focused;
1282
- const engagedRef = useRef(ctx.hotkeyStack);
1283
- engagedRef.current = ctx.hotkeyStack;
1293
+ const entries = useStableByContent(opts.entries, sameList);
1294
+ const engaged = ctx.hotkeyStack;
1284
1295
  const setFocused = useCallback((id) => {
1285
1296
  ctx.setActive(id);
1286
1297
  }, [ctx]);
1287
1298
  const snapshot = useCallback(() => {
1288
- const engaged = engagedRef.current;
1289
1299
  const engagedIds = new Set(engaged);
1290
1300
  const ordered = [];
1291
1301
  for (const id of engaged) {
1292
- const entry = entriesRef.current.find((e) => e.id === id);
1302
+ const entry = entries.find((e) => e.id === id);
1293
1303
  if (entry && !ordered.includes(entry)) ordered.push(entry);
1294
1304
  }
1295
- for (const entry of entriesRef.current) {
1305
+ for (const entry of entries) {
1296
1306
  if (!ordered.includes(entry)) ordered.push(entry);
1297
1307
  }
1298
1308
  return {
1299
1309
  ordered,
1300
- state: { focusedId: focusedRef.current, engagedIds }
1310
+ state: { focusedId: focused, engagedIds }
1301
1311
  };
1302
- }, []);
1312
+ }, [entries, focused, engaged]);
1303
1313
  const scopedBindings = useCallback(() => {
1304
1314
  const { ordered, state } = snapshot();
1305
1315
  return scopeBindings(ordered, state);
@@ -1320,9 +1330,9 @@ function useContributions(opts) {
1320
1330
  }
1321
1331
  return [...active, ...hotkey, ...ambient];
1322
1332
  }, [snapshot]);
1323
- useOffhandAction(opts.entries);
1333
+ useOffhandAction(entries);
1324
1334
  const lastConflictSigRef = useRef(null);
1325
- const entrySig = opts.entries.map((e) => e.id).join(",");
1335
+ const entrySig = entries.map((e) => e.id).join(",");
1326
1336
  const modesRegistry = opts.modes;
1327
1337
  useEffect(() => {
1328
1338
  if (!isDev()) return;
@@ -1331,7 +1341,7 @@ function useContributions(opts) {
1331
1341
  lastConflictSigRef.current = { sig: entrySig, modes: modesRegistry };
1332
1342
  const registry = [];
1333
1343
  const ambient = [];
1334
- for (const entry of entriesRef.current) {
1344
+ for (const entry of entries) {
1335
1345
  if (entry.eligibility?.focus) registry.push(entry);
1336
1346
  if (entry.eligibility?.always || entry.eligibility?.claimed) ambient.push(entry);
1337
1347
  }
@@ -1341,18 +1351,10 @@ function useContributions(opts) {
1341
1351
  actions: actionsRegistry?.list() ?? [],
1342
1352
  ...modesRegistry ? { modes: modesRegistry.list().map(activeModeOf) } : {}
1343
1353
  });
1344
- }, [entrySig, actionsRegistry, modesRegistry]);
1354
+ }, [entrySig, entries, actionsRegistry, modesRegistry]);
1345
1355
  return useMemo(
1346
- () => ({
1347
- get entries() {
1348
- return entriesRef.current;
1349
- },
1350
- focused,
1351
- setFocused,
1352
- scopedBindings,
1353
- overlays
1354
- }),
1355
- [focused, setFocused, scopedBindings, overlays]
1356
+ () => ({ entries, focused, setFocused, scopedBindings, overlays }),
1357
+ [entries, focused, setFocused, scopedBindings, overlays]
1356
1358
  );
1357
1359
  }
1358
1360
  function useOffhandAction(entries) {
@@ -1365,8 +1367,6 @@ function useOffhandAction(entries) {
1365
1367
  return actionsRegistry.register(makeToolOffhandAction(buildToolOffhandBindings(specs)));
1366
1368
  }, [actionsRegistry, offhandSig]);
1367
1369
  }
1368
-
1369
- // src/tools/useTools.ts
1370
1370
  function declareSlot(tool, slot) {
1371
1371
  const hotkey = tool.def?.hotkey;
1372
1372
  const eligibility = {
@@ -1378,22 +1378,16 @@ function declareSlot(tool, slot) {
1378
1378
  }
1379
1379
  var NO_AMBIENT = [];
1380
1380
  function useStableSources(registry, ambient) {
1381
- const next = { registry, ambient: ambient ?? NO_AMBIENT };
1382
- const ref = useRef(next);
1383
- const prev = ref.current;
1384
- if (prev !== next && !(sameRecord(prev.registry, next.registry) && sameList(prev.ambient, next.ambient))) {
1385
- ref.current = next;
1386
- }
1387
- return ref.current;
1381
+ return useStableByContent(
1382
+ { registry, ambient: ambient ?? NO_AMBIENT },
1383
+ (a, b) => sameRecord(a.registry, b.registry) && sameList(a.ambient, b.ambient)
1384
+ );
1388
1385
  }
1389
1386
  function sameRecord(a, b) {
1390
1387
  const ak = Object.keys(a);
1391
1388
  if (ak.length !== Object.keys(b).length) return false;
1392
1389
  return ak.every((k) => k in b && a[k] === b[k]);
1393
1390
  }
1394
- function sameList(a, b) {
1395
- return a.length === b.length && a.every((t, i) => t === b[i]);
1396
- }
1397
1391
  function sameEligibility(a, b) {
1398
1392
  if (!a) return false;
1399
1393
  const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
@@ -1427,12 +1421,9 @@ function useTools(opts) {
1427
1421
  ...opts.modes ? { modes: opts.modes } : {}
1428
1422
  });
1429
1423
  const hotkeyEngaged = ctx.hotkeyStack.at(-1) ?? null;
1430
- const slottedRef = useRef(slotted);
1431
- slottedRef.current = slotted;
1432
- const activeRef = useRef(contributions.focused);
1433
- activeRef.current = contributions.focused;
1434
- const hotkeyRef = useRef(hotkeyEngaged);
1435
- hotkeyRef.current = hotkeyEngaged;
1424
+ const slottedRef = useLatest(slotted);
1425
+ const activeRef = useLatest(contributions.focused);
1426
+ const hotkeyRef = useLatest(hotkeyEngaged);
1436
1427
  const setFocused = contributions.setFocused;
1437
1428
  const setActive = useCallback(
1438
1429
  (id) => {
@@ -1442,7 +1433,7 @@ function useTools(opts) {
1442
1433
  dlog("tools", "active:", activeRef.current, "\u2192", id);
1443
1434
  setFocused(id);
1444
1435
  },
1445
- [setFocused]
1436
+ [setFocused, slottedRef, activeRef]
1446
1437
  );
1447
1438
  const engageHotkey = useCallback(
1448
1439
  (id) => {
@@ -1452,15 +1443,15 @@ function useTools(opts) {
1452
1443
  dlog("tools", "hotkey engaged:", id);
1453
1444
  ctx.pushHotkey(id);
1454
1445
  },
1455
- [ctx]
1446
+ [ctx, slottedRef]
1456
1447
  );
1457
1448
  const disengageHotkey = useCallback(() => {
1458
1449
  if (hotkeyRef.current) dlog("tools", "hotkey disengaged:", hotkeyRef.current);
1459
1450
  ctx.popHotkey();
1460
- }, [ctx]);
1451
+ }, [ctx, hotkeyRef]);
1461
1452
  const has = useCallback(
1462
- (id) => id in slottedRef.current.registry || slottedRef.current.ambient.some((t) => t.id === id),
1463
- []
1453
+ (id) => id in slotted.registry || slotted.ambient.some((t) => t.id === id),
1454
+ [slotted]
1464
1455
  );
1465
1456
  const active = contributions.focused;
1466
1457
  const getActiveOverlays = contributions.overlays;