@livetemplate/client 0.11.5 → 0.11.7

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.
@@ -14,6 +14,11 @@ exports.handleAnimateDirectives = handleAnimateDirectives;
14
14
  exports.handleToastDirectives = handleToastDirectives;
15
15
  exports.setupToastClickOutside = setupToastClickOutside;
16
16
  exports.handleShadowRootHydration = handleShadowRootHydration;
17
+ exports.handleAreaSelectDirectives = handleAreaSelectDirectives;
18
+ exports.teardownAreaSelectForRoot = teardownAreaSelectForRoot;
19
+ exports.handleURLHashDirective = handleURLHashDirective;
20
+ exports.teardownURLHashForRoot = teardownURLHashForRoot;
21
+ exports.__resetURLHashUnencodedWarnedForTesting = __resetURLHashUnencodedWarnedForTesting;
17
22
  const reactive_attributes_1 = require("./reactive-attributes");
18
23
  // ─── Trigger parsing for lvt-fx: attributes ─────────────────────────────────
19
24
  const FX_LIFECYCLE_SET = new Set(["pending", "success", "error", "done"]);
@@ -858,4 +863,850 @@ function handleShadowRootHydration(rootElement) {
858
863
  tpl.remove();
859
864
  }
860
865
  }
866
+ // areaSelectArmed tracks the cleanup callback for every element that
867
+ // currently has a `lvt-fx:area-select` handler attached. Map (not
868
+ // WeakMap) because the sweep needs to iterate to detect elements whose
869
+ // attribute was removed by a server diff — without iteration those
870
+ // elements would keep their listeners and silently dispatch the old
871
+ // action on subsequent drags. Detached elements are cleaned up via
872
+ // the same sweep (isConnected check).
873
+ const areaSelectArmed = new Map();
874
+ // areaSelectWarnedParents dedupes the "parent not positioned" dev-warn
875
+ // so a user who repeatedly drags on a mis-configured element gets a
876
+ // single console message instead of one per pointerdown. WeakSet so
877
+ // detached parents don't leak.
878
+ //
879
+ // Known limitation: once a parent is in the set, the warn never fires
880
+ // again on that DOM node — even if the developer subsequently adds
881
+ // `position: relative` to fix the issue. The WeakSet is per-object
882
+ // (different DOM node = different entry), so re-mounting the parent
883
+ // resets the dedupe; in-place CSS fixes do not. Fine in practice
884
+ // (the user already saw the warn once, on the broken render).
885
+ const areaSelectWarnedParents = new WeakSet();
886
+ // MIN_AREA_FRACTION filters accidental click-style gestures where the
887
+ // user meant to click, not drag. 2% of the element's rendered size is
888
+ // big enough to be intentional on touch + mouse but small enough that
889
+ // anyone seriously trying to annotate a tiny region can still do it.
890
+ const MIN_AREA_FRACTION = 0.02;
891
+ /**
892
+ * Apply area-select directives. `lvt-fx:area-select="<actionName>"` on
893
+ * an element (typically an `<img>` inside a positioned parent) lets
894
+ * the user drag a rectangle locally — a `<div>` overlay tracks the
895
+ * gesture in real time without a server round-trip — and on
896
+ * `pointerup` dispatches a single livetemplate action with the final
897
+ * `{x, y, w, h}` as 0..1 fractions of the element's rendered bounding
898
+ * rect. The image's intrinsic dimensions don't matter for the
899
+ * fractions: any uniform scale (zoom, responsive layout) preserves
900
+ * the fraction. The consumer scales to pixels using the natural size
901
+ * if it needs them.
902
+ *
903
+ * Contract:
904
+ * - Host's `parentElement` must establish a positioning context
905
+ * (`position: relative` / `absolute` / `fixed`). The overlay is
906
+ * `position: absolute` inside that parent so it follows the host
907
+ * on scroll / reflow.
908
+ * - Consumers usually pair this with `touch-action: none` on the
909
+ * host so iOS Safari doesn't interpret the drag as a pinch/scroll.
910
+ * - `<img>` and other natively-draggable hosts work automatically:
911
+ * the directive calls `preventDefault()` on `dragstart` so the
912
+ * browser's native drag (which would otherwise steal the gesture)
913
+ * is suppressed.
914
+ * - On pointer-cancel (e.g. system gesture, app switch), the overlay
915
+ * is removed and no action is dispatched — same effect as cancelling
916
+ * a click on `mouseleave`.
917
+ * - Drags smaller than `MIN_AREA_FRACTION` in BOTH dimensions are
918
+ * dropped — a click on the host still fires normal `click`
919
+ * handlers via the compatibility mouse events.
920
+ * - For text-bearing hosts, set `user-select: none` (the directive
921
+ * deliberately does NOT call `preventDefault()` on `pointerdown`
922
+ * so click handlers still receive the gesture; that means the
923
+ * browser's default text-selection-on-drag behaviour also fires
924
+ * unless the host opts out via CSS).
925
+ * - The overlay uses `z-index: var(--lvt-area-select-z-index, 9999)`.
926
+ * 9999 is high enough for most use cases but can collide with
927
+ * portals / modals / drawers that also sit at a high z-index.
928
+ * Set `--lvt-area-select-z-index` on the host (or any ancestor)
929
+ * to override. Color + fill follow the same pattern via
930
+ * `--lvt-area-select-color` and `--lvt-area-select-fill`.
931
+ * - **No keyboard equivalent.** Pointer-only by design (a keyboard-
932
+ * selected rectangle requires a different UX — focus + arrow keys
933
+ * to position + arrow keys to size). Consumers needing a11y for
934
+ * area selection should provide a parallel form-based affordance.
935
+ *
936
+ * Idempotent across renders: an element re-armed with the same action
937
+ * keeps its existing listeners. A different action causes a tear-down
938
+ * and re-arm. Disconnected elements (and elements whose attribute was
939
+ * cleared by a server diff) get their listeners cleaned up by the
940
+ * sweep at the top of every call — we use a regular Map (not WeakMap)
941
+ * specifically so the sweep can iterate.
942
+ *
943
+ * Module-level singleton: `areaSelectArmed` is shared across all
944
+ * LiveTemplateClient instances in the same window. If two clients
945
+ * ever arm the same element with different actions, the second wins
946
+ * and the first client's send() is orphaned. Single-client use is
947
+ * unaffected.
948
+ */
949
+ function handleAreaSelectDirectives(rootElement, send) {
950
+ // Sweep stale entries before processing the current match set:
951
+ // disconnected elements AND elements where the attribute was
952
+ // removed by a server diff. Without this, a previously-armed
953
+ // element whose lvt-fx:area-select was cleared would keep its
954
+ // listeners and silently dispatch the old action on subsequent
955
+ // drags. Iterate via Array.from so cleanup()'s delete() doesn't
956
+ // disturb the iterator.
957
+ for (const [element, entry] of Array.from(areaSelectArmed)) {
958
+ if (!element.isConnected || !element.hasAttribute("lvt-fx:area-select")) {
959
+ entry.cleanup();
960
+ }
961
+ }
962
+ const matches = rootElement.querySelectorAll("[lvt-fx\\:area-select]");
963
+ if (matches.length === 0)
964
+ return;
965
+ for (const el of matches) {
966
+ const action = el.getAttribute("lvt-fx:area-select");
967
+ // Empty attribute → consumer almost certainly typoed; warn and
968
+ // skip rather than dispatching to a blank action name.
969
+ if (!action) {
970
+ console.warn(`lvt-fx:area-select requires an action name, got: ${JSON.stringify(action)}`);
971
+ continue;
972
+ }
973
+ const existing = areaSelectArmed.get(el);
974
+ if (existing && existing.action === action) {
975
+ // Idempotent re-arm: keep the listeners + WeakMap entry, but
976
+ // update the captured send so a subsequent drag dispatches
977
+ // through the latest callback (e.g. after a WebSocket
978
+ // reconnect rebuilt the transport).
979
+ existing.updateSend(send);
980
+ continue;
981
+ }
982
+ if (existing)
983
+ existing.cleanup();
984
+ areaSelectArmed.set(el, attachAreaSelect(el, action, send));
985
+ }
986
+ }
987
+ /**
988
+ * Cancel area-select listeners for every armed element under root.
989
+ * Mirrors teardownAutoClickTimers: meant for the client's disconnect /
990
+ * destroy lifecycle so the module-level singleton doesn't outlive a
991
+ * client that was torn down without a subsequent
992
+ * handleAreaSelectDirectives call (e.g. network error closed the
993
+ * socket while an element was armed). Without this, a SPA that mounts
994
+ * + tears down livetemplate trees would leak listeners across mounts.
995
+ */
996
+ function teardownAreaSelectForRoot(rootElement) {
997
+ // `contains` returns true for the node itself, so this also handles
998
+ // the (today-impossible) case of rootElement being armed directly.
999
+ for (const [element, entry] of Array.from(areaSelectArmed)) {
1000
+ if (rootElement.contains(element)) {
1001
+ entry.cleanup();
1002
+ }
1003
+ }
1004
+ }
1005
+ // urlHashArmed tracks `lvt-fx:url-hash` listeners and their last-
1006
+ // mirrored hash. Same Map-not-WeakMap reasoning as area-select: the
1007
+ // sweep iterates to detect elements whose attribute was removed by a
1008
+ // server diff, and detached elements are cleaned up via the same
1009
+ // sweep (isConnected check).
1010
+ //
1011
+ // Module-level singleton: shared across all LiveTemplateClient
1012
+ // instances in the window. Two clients arming DIFFERENT elements
1013
+ // each get their own entry; the shared window hashchange listener
1014
+ // iterates the map and dispatches through every armed entry's own
1015
+ // send, so a multi-client page sees each client receive its hash
1016
+ // event. Teardown is scoped per root via teardownURLHashForRoot, so
1017
+ // clients don't tear down each other's listeners.
1018
+ //
1019
+ // Same-element multi-arm is "last writer wins": the Map key is the
1020
+ // element, so a second client arming the same element runs the
1021
+ // existing entry's cleanup() and replaces it. The first client's
1022
+ // send is orphaned. This matches area-select's behavior and is fine
1023
+ // for the documented single-arm-per-element contract.
1024
+ const urlHashArmed = new Map();
1025
+ // urlHashWindowListener is the single window-level `hashchange`
1026
+ // listener shared across all armed elements. Registered on first arm,
1027
+ // removed when the armed map becomes empty. Per-element listeners
1028
+ // would multi-fire for the (rare) case of multiple armed elements;
1029
+ // one shared listener iterating the armed map keeps the dispatch
1030
+ // count deterministic.
1031
+ let urlHashWindowListener = null;
1032
+ /**
1033
+ * Apply url-hash directives. `lvt-fx:url-hash="<actionName>"` plus a
1034
+ * `data-lvt-url-hash="<hash>"` attribute on an element (typically the
1035
+ * `<body>`) wires a two-way bridge between server state and
1036
+ * `location.hash`:
1037
+ *
1038
+ * - **State → URL** (every render): if `data-lvt-url-hash` differs
1039
+ * from `location.hash`, mirror the data-attr into the URL via
1040
+ * `history.pushState` when the path component changed (everything
1041
+ * before the first `:`) or `history.replaceState` when only the
1042
+ * target (line range / anchor) changed. Replace is the right
1043
+ * default for line scrolls so the back-button cycles between files,
1044
+ * not between every clicked line.
1045
+ * - **URL → State** (on `hashchange` AND initial arm): dispatch
1046
+ * `{action: <actionName>, data: {hash: <hash>}}` so the server can
1047
+ * parse the hash and update its state (which then renders back as
1048
+ * a matching data-attr — closing the loop).
1049
+ *
1050
+ * The directive uses `history.pushState`/`replaceState` (not
1051
+ * `location.hash = ...`) for the state→URL direction precisely so
1052
+ * those writes do NOT fire `hashchange` — only true user-initiated
1053
+ * navigation (anchor click, address-bar edit, back-button) reaches
1054
+ * the URL→state listener. This avoids the obvious infinite loop.
1055
+ *
1056
+ * Idempotent across renders: same action → keep listener + update
1057
+ * send. Different action → cleanup + re-arm. Detached / attribute-
1058
+ * removed elements are swept on every call (same pattern as
1059
+ * area-select). The window listener is registered on first arm and
1060
+ * removed when the armed map becomes empty.
1061
+ *
1062
+ * Coexistence with `setupHashLink`: prereview-style hashes
1063
+ * (`README.md:L4`, `foo/bar.html:h-anchor`) never match a
1064
+ * `document.getElementById(...)`, so the existing dialog/popover/
1065
+ * details hash machinery silently no-ops. If a deep-link hash
1066
+ * happens to collide with an element id, both handlers will fire —
1067
+ * the server is expected to no-op on hashes that don't resolve to a
1068
+ * known file.
1069
+ *
1070
+ * **Pre-encoding contract**: `data-lvt-url-hash` must hold the hash
1071
+ * value already in URL-encoded form. The directive writes the
1072
+ * attribute verbatim into `history.pushState`/`replaceState`, so a
1073
+ * value containing spaces, `[`, `]`, `%`, or other reserved
1074
+ * characters needs to be percent-encoded by the server. The hash
1075
+ * sent to the action on `hashchange` is also passed through unmodified
1076
+ * (no decoding) — both directions are byte-exact mirrors of what's
1077
+ * in `location.hash`.
1078
+ *
1079
+ * **URL/state divergence after a non-deep-link initial load**: if
1080
+ * the user lands with a native-anchor hash (`#hero`) AND the server
1081
+ * has a selected file, the directive leaves the URL on `#hero` (case
1082
+ * b) — URL and server state diverge until the user navigates. This
1083
+ * is intentional: popovers/anchors aren't ours to overwrite. The
1084
+ * next user action that triggers a server render will re-sync only
1085
+ * once URL and state share a deep-link hash.
1086
+ *
1087
+ * **Path-only deep links require an extension**: the
1088
+ * `looksLikeDeepLinkHash` heuristic dispatches only hashes
1089
+ * containing `:`, `/`, or `.`. Extension-less root files
1090
+ * (`#Makefile`, `#Dockerfile`, `#LICENSE`) won't dispatch as
1091
+ * path-only deep links — use the line form (`#Makefile:L1`)
1092
+ * instead. The trade-off favours not clobbering native-anchor
1093
+ * machinery for single-token hashes.
1094
+ */
1095
+ function handleURLHashDirective(rootElement, send) {
1096
+ // Sweep stale entries first — disconnected hosts AND hosts whose
1097
+ // attribute was removed by a server diff. Iterate via Array.from so
1098
+ // cleanup()'s delete() doesn't disturb the iterator.
1099
+ for (const [element, entry] of Array.from(urlHashArmed)) {
1100
+ if (!element.isConnected ||
1101
+ !element.hasAttribute("lvt-fx:url-hash")) {
1102
+ entry.cleanup();
1103
+ }
1104
+ }
1105
+ // Match the root itself, descendants, AND the document body. The
1106
+ // url-hash directive is typically placed on `<body>`, but livetemplate
1107
+ // auto-injects its `<div data-lvt-id>` INSIDE body, so the rootElement
1108
+ // passed by the client is the wrapper div — a strict descendant of
1109
+ // body. Without the body check, a directive on `<body>` would never
1110
+ // arm. We accept body placement because URL hash is page-global
1111
+ // anyway; the directive's lifecycle is still tied to the wrapper via
1112
+ // teardownURLHashForRoot (called on disconnect of the wrapper).
1113
+ const matches = [];
1114
+ if (rootElement instanceof HTMLElement &&
1115
+ rootElement.hasAttribute("lvt-fx:url-hash")) {
1116
+ matches.push(rootElement);
1117
+ }
1118
+ const body = rootElement.ownerDocument?.body;
1119
+ if (body &&
1120
+ body !== rootElement &&
1121
+ body.hasAttribute("lvt-fx:url-hash") &&
1122
+ !matches.includes(body)) {
1123
+ matches.push(body);
1124
+ }
1125
+ rootElement
1126
+ .querySelectorAll("[lvt-fx\\:url-hash]")
1127
+ .forEach((el) => {
1128
+ if (!matches.includes(el))
1129
+ matches.push(el);
1130
+ });
1131
+ if (matches.length === 0)
1132
+ return;
1133
+ for (const el of matches) {
1134
+ const action = el.getAttribute("lvt-fx:url-hash");
1135
+ if (!action) {
1136
+ console.warn(`lvt-fx:url-hash requires an action name, got: ${JSON.stringify(action)}`);
1137
+ continue;
1138
+ }
1139
+ const dataHash = el.getAttribute("data-lvt-url-hash") || "";
1140
+ const existing = urlHashArmed.get(el);
1141
+ if (existing && existing.action === action) {
1142
+ existing.updateSend(send);
1143
+ mirrorDataAttrToLocation(existing, dataHash);
1144
+ continue;
1145
+ }
1146
+ if (existing)
1147
+ existing.cleanup();
1148
+ const entry = attachURLHash(el, action, send);
1149
+ urlHashArmed.set(el, entry);
1150
+ // First-arm sync: three cases, in priority order.
1151
+ const initialLocation = window.location.hash.replace(/^#/, "");
1152
+ if (initialLocation &&
1153
+ initialLocation !== dataHash &&
1154
+ looksLikeDeepLinkHash(initialLocation)) {
1155
+ // (a) URL has a deep-link hash that differs from server state.
1156
+ // URL "wins" on initial load — dispatch so the server can
1157
+ // reconcile, and seed currentDataHash so the converging render
1158
+ // doesn't try to mirror over the user's URL.
1159
+ entry.currentDataHash = initialLocation;
1160
+ send({ action, data: { hash: initialLocation } });
1161
+ }
1162
+ else if (initialLocation && !looksLikeDeepLinkHash(initialLocation)) {
1163
+ // (b) URL has a non-deep-link hash (e.g. `#hero` opening a
1164
+ // popover, or a native heading anchor). Leave it alone — it
1165
+ // belongs to other machinery (setupHashLink, native scroll).
1166
+ // Seed currentDataHash so a later mirror sees the data-attr
1167
+ // as the baseline to compare against, and only writes when
1168
+ // the user navigates away from the popover/anchor.
1169
+ entry.currentDataHash = dataHash;
1170
+ }
1171
+ else {
1172
+ // (c) URL is empty (or already matches the server). Mirror the
1173
+ // server's hash into the URL if any.
1174
+ mirrorDataAttrToLocation(entry, dataHash);
1175
+ }
1176
+ }
1177
+ }
1178
+ /**
1179
+ * Cancel url-hash listeners for every armed element under root. Same
1180
+ * lifecycle role as teardownAreaSelectForRoot.
1181
+ */
1182
+ function teardownURLHashForRoot(rootElement) {
1183
+ // Includes body when body is an ancestor of rootElement and body is
1184
+ // armed — the directive accepts body placement (see the matcher in
1185
+ // handleURLHashDirective), so teardown must symmetrically clean up
1186
+ // both directions.
1187
+ //
1188
+ // Multi-client caveat: a body-armed entry is shared across all
1189
+ // LiveTemplateClient instances (Map key is the element, so only one
1190
+ // entry per body). Tearing down client A's root will therefore also
1191
+ // tear down a body listener that client B armed last — there's no
1192
+ // "owner" tracked. Acceptable for the single-client case (the
1193
+ // common deployment) and matches the same-element-multi-arm
1194
+ // last-writer-wins behavior in attachURLHash. A "fix" that
1195
+ // restricted the body-cleanup branch to client A would leak
1196
+ // client A's own body listener — don't do that without also
1197
+ // tracking entry ownership.
1198
+ const body = rootElement.ownerDocument?.body;
1199
+ for (const [element, entry] of Array.from(urlHashArmed)) {
1200
+ if (rootElement.contains(element)) {
1201
+ entry.cleanup();
1202
+ continue;
1203
+ }
1204
+ if (body && element === body && body.contains(rootElement)) {
1205
+ entry.cleanup();
1206
+ }
1207
+ }
1208
+ }
1209
+ // mirrorDataAttrToLocation pushes `dataHash` into `location.hash` if
1210
+ // it differs from what's already in the URL. Chooses push vs replace
1211
+ // by comparing the path component (everything before the first `:`)
1212
+ // against the current location.hash's path: a path change is a "file
1213
+ // switch" (user-meaningful back-button entry) and gets pushState;
1214
+ // any other change is a target-only update (line scroll / anchor
1215
+ // scroll) and gets replaceState. Updates entry.currentDataHash so a
1216
+ // subsequent render with the same data-attr no-ops.
1217
+ //
1218
+ // Initial-mirror special case: if the URL was empty when we're
1219
+ // mirroring (no prior hash to compare against), use replaceState even
1220
+ // though the path-component comparison would say "changed". An empty
1221
+ // URL → first server hash isn't a "navigation" — we're establishing
1222
+ // the initial state. Using pushState here would let Back land the
1223
+ // user on `url-without-hash`, which re-triggers the same arm and
1224
+ // pushes the same hash again. Loop.
1225
+ //
1226
+ // Empty-dataHash special case: if the server transitions FROM a
1227
+ // selected file TO no-selection (state.URLHash() returns ""), we
1228
+ // would otherwise wipe location.hash entirely — including hashes the
1229
+ // directive doesn't own (a popover #hero the user opened during the
1230
+ // session). To stay safe, only clear when the URL currently holds a
1231
+ // deep-link-shaped hash; non-deep-link hashes are left alone.
1232
+ function mirrorDataAttrToLocation(entry, dataHash) {
1233
+ if (entry.currentDataHash === dataHash)
1234
+ return;
1235
+ const currentLocation = window.location.hash.replace(/^#/, "");
1236
+ if (currentLocation === dataHash) {
1237
+ entry.currentDataHash = dataHash;
1238
+ return;
1239
+ }
1240
+ if (currentLocation !== "" && !looksLikeDeepLinkHash(currentLocation)) {
1241
+ // URL is on something not ours (popover id, native anchor) —
1242
+ // don't clobber it, regardless of what the server's data-attr
1243
+ // says. This covers BOTH the server-clears case (dataHash="")
1244
+ // and the rarer server-changes-selection-while-popover-open
1245
+ // case (dataHash transitions from one file to another while
1246
+ // the URL is parked on a non-deep-link hash).
1247
+ entry.currentDataHash = dataHash;
1248
+ return;
1249
+ }
1250
+ warnIfUnencodedHash(dataHash);
1251
+ const targetURL = dataHash ? `#${dataHash}` : window.location.pathname + window.location.search;
1252
+ const oldPath = currentLocation.split(":")[0];
1253
+ const newPath = dataHash.split(":")[0];
1254
+ // Preserve existing history.state — passing `null` would clobber
1255
+ // anything other SPA-like code on the page stores there (scroll
1256
+ // position, modal flag, etc.). The state object is independent of
1257
+ // the URL we're rewriting, so carrying it forward is the right
1258
+ // default.
1259
+ const currentState = window.history.state;
1260
+ // Empty currentLocation means we're establishing the URL from a
1261
+ // blank slate (initial render with no prior URL hash) — that's NOT
1262
+ // a back-button-meaningful navigation, so always replaceState.
1263
+ // Otherwise: a path change is a file switch (push), a target-only
1264
+ // change is a line/anchor scroll (replace).
1265
+ if (currentLocation !== "" && oldPath !== newPath) {
1266
+ window.history.pushState(currentState, "", targetURL);
1267
+ }
1268
+ else {
1269
+ window.history.replaceState(currentState, "", targetURL);
1270
+ }
1271
+ entry.currentDataHash = dataHash;
1272
+ }
1273
+ // warnIfUnencodedHash flags `data-lvt-url-hash` values containing
1274
+ // characters that should be percent-encoded (raw space, `<`, `>`,
1275
+ // `"`, ``` ` ```, `#`, `[`, `]`, `%`). The directive writes the
1276
+ // hash verbatim into `pushState`/`replaceState`, so an unencoded
1277
+ // value will silently produce a malformed URL — `location.hash`
1278
+ // reads back differently from what was set. Cheap dev-time guard
1279
+ // against a server-side contract slip; dedupes by value to avoid
1280
+ // log spam.
1281
+ //
1282
+ // `%` is included because a raw `%` not followed by two hex digits
1283
+ // is itself a percent-encoding error. The check is a heuristic
1284
+ // (won't catch every malformed escape), but covers the common
1285
+ // "forgot to encode" cases.
1286
+ const urlHashUnencodedWarned = new Set();
1287
+ /**
1288
+ * Test-only: reset the per-page dedupe Set that suppresses repeated
1289
+ * `warnIfUnencodedHash` calls for the same hash value. Production
1290
+ * code shouldn't need this — the Set is bounded by the number of
1291
+ * unique malformed hashes — but tests that re-use the same hash
1292
+ * across cases need to clear it or the second test won't see the
1293
+ * warning. Mirrors `__resetAnimatedElementsForTesting`.
1294
+ */
1295
+ function __resetURLHashUnencodedWarnedForTesting() {
1296
+ urlHashUnencodedWarned.clear();
1297
+ }
1298
+ function warnIfUnencodedHash(hash) {
1299
+ if (!hash || urlHashUnencodedWarned.has(hash))
1300
+ return;
1301
+ if (/[ <>"`#\[\]]/.test(hash) || /%(?![0-9A-Fa-f]{2})/.test(hash)) {
1302
+ urlHashUnencodedWarned.add(hash);
1303
+ console.warn(`lvt-fx:url-hash: data-lvt-url-hash="${hash}" contains characters that should be percent-encoded. The directive writes it verbatim into history.pushState/replaceState; malformed URLs result. Server-side FormatHash (or equivalent) should percent-escape path segments and target ids before serialization.`);
1304
+ }
1305
+ }
1306
+ // looksLikeDeepLinkHash discriminates URL hashes the prereview deep-
1307
+ // link grammar can produce (file path with optional :L<n> or :h-id)
1308
+ // from hashes that belong to other native machinery (HTML element
1309
+ // anchors, dialog/popover/details ids, etc.). Deep-link hashes always
1310
+ // contain at least one of: `:` (target separator), `/` (nested path),
1311
+ // or `.` (file extension). Empty → false.
1312
+ //
1313
+ // False positives are possible but cheap. A heading id like
1314
+ // `#v1.0.0`, `#menu/item`, or `#key:value` matches this heuristic
1315
+ // and will dispatch the action — but the consuming server is
1316
+ // expected to no-op on hashes whose path doesn't resolve to a known
1317
+ // file (prereview's SetURLHash does, via the loadDiffCached failure
1318
+ // path). The cost is one wasted roundtrip per false positive, which
1319
+ // is acceptable for the alternative of missing real deep links.
1320
+ //
1321
+ // False negatives: extension-less filenames at the repo root —
1322
+ // `#Makefile`, `#Dockerfile`, `#LICENSE` — don't match this
1323
+ // heuristic and won't be dispatched as path-only deep links. The
1324
+ // workaround is the line-form (`#Makefile:L1`), which always
1325
+ // dispatches. This trade-off is deliberate: a heuristic that
1326
+ // matched single-token hashes would also clobber every native
1327
+ // anchor / popover id, which is a much worse default. Consumers
1328
+ // that need extension-less file deep links can build a richer
1329
+ // directive on top.
1330
+ function looksLikeDeepLinkHash(hash) {
1331
+ if (!hash)
1332
+ return false;
1333
+ return hash.includes(":") || hash.includes("/") || hash.includes(".");
1334
+ }
1335
+ function attachURLHash(el, action, initialSend) {
1336
+ const entry = {
1337
+ action,
1338
+ send: initialSend,
1339
+ cleanup: () => {
1340
+ urlHashArmed.delete(el);
1341
+ if (urlHashArmed.size === 0 && urlHashWindowListener) {
1342
+ window.removeEventListener("hashchange", urlHashWindowListener);
1343
+ urlHashWindowListener = null;
1344
+ }
1345
+ },
1346
+ updateSend: (s) => {
1347
+ entry.send = s;
1348
+ },
1349
+ currentDataHash: "",
1350
+ };
1351
+ if (!urlHashWindowListener) {
1352
+ urlHashWindowListener = () => {
1353
+ const hash = window.location.hash.replace(/^#/, "");
1354
+ // Only dispatch hashes that look like deep-link targets — they
1355
+ // contain `:` (target separator), `/` (nested path), or `.`
1356
+ // (file extension). Plain element-id hashes like `#hero` or
1357
+ // `#confirm-delete-xyz` belong to the native anchor / dialog /
1358
+ // popover / details machinery (setupHashLink handles those) and
1359
+ // would otherwise be dispatched here, prompt a server no-op,
1360
+ // then get clobbered by the mirror step when the server's
1361
+ // data-attr (unchanged) doesn't match.
1362
+ //
1363
+ // Empty hash (user cleared the URL bar) is also intentionally
1364
+ // ignored. The directive treats the server as the source of
1365
+ // truth for "what's selected"; an empty URL is "user navigated
1366
+ // away from a hash" but not "deselect everything". If the user
1367
+ // wants to deselect, they use the in-app affordance
1368
+ // (clearSelection / Escape) which makes the server emit an
1369
+ // empty data-attr — at which point the mirror step propagates
1370
+ // the empty hash back to the URL.
1371
+ if (!looksLikeDeepLinkHash(hash))
1372
+ return;
1373
+ // Iterate via Array.from in case a dispatched action triggers a
1374
+ // render that mutates the armed map (e.g. tears down this
1375
+ // element). Each armed entry dispatches through its OWN send +
1376
+ // action so multi-arm is deterministic — typically the body is
1377
+ // the only armed element so this is one iteration.
1378
+ for (const e of Array.from(urlHashArmed.values())) {
1379
+ // Record the user-driven hash as the new baseline so the
1380
+ // next render's mirror step doesn't immediately revert it.
1381
+ e.currentDataHash = hash;
1382
+ e.send({ action: e.action, data: { hash } });
1383
+ }
1384
+ };
1385
+ window.addEventListener("hashchange", urlHashWindowListener);
1386
+ }
1387
+ return entry;
1388
+ }
1389
+ // attachAreaSelect captures `send` in a mutable local so the
1390
+ // idempotent re-arm path (same element, same action) can swap it via
1391
+ // the returned `updateSend` callback without tearing down + rebuilding
1392
+ // listeners. Listeners reference the closure-captured `send` variable
1393
+ // directly, so reassigning it propagates instantly. This guards
1394
+ // against the stale-closure trap a caller would hit if their `send`
1395
+ // reference changed across renders — e.g. a reconnect rebuilt the
1396
+ // transport.
1397
+ function attachAreaSelect(el, action, initialSend) {
1398
+ let send = initialSend;
1399
+ let overlay = null;
1400
+ let startClientX = 0;
1401
+ let startClientY = 0;
1402
+ let pointerId = -1;
1403
+ // Capture the parent at pointerdown time so a server diff that moves
1404
+ // the host to a NEW parent mid-drag doesn't split the drag across
1405
+ // two positioning contexts. updateOverlay positions against this
1406
+ // cached parent for the lifetime of the gesture; the overlay itself
1407
+ // stays a child of the parent we appended it to (overlay removal
1408
+ // uses overlay.parentElement, which is independent).
1409
+ let dragParent = null;
1410
+ // Cache the host's rect at pointerdown — startClientX/Y are captured
1411
+ // in the SAME frame, so the start corner is meaningful only against
1412
+ // the rect that existed then. If a server diff repositions the host
1413
+ // mid-drag, finalize would otherwise clamp the (old-coord-system)
1414
+ // startClientX against the new rect and silently produce wrong
1415
+ // fractions. Anchoring to the start-rect keeps the dispatched
1416
+ // rectangle pinned to the visual region the user actually dragged.
1417
+ let startRect = null;
1418
+ const removeOverlay = () => {
1419
+ if (overlay) {
1420
+ // Element.remove() is a no-op if the node isn't in the DOM,
1421
+ // so we don't need the parent-null guard the older two-step
1422
+ // pattern needed.
1423
+ overlay.remove();
1424
+ }
1425
+ overlay = null;
1426
+ };
1427
+ const finalize = (e, dispatch) => {
1428
+ if (pointerId === -1)
1429
+ return;
1430
+ // CRITICAL ORDER: reset pointerId + dragParent + startRect BEFORE
1431
+ // calling releasePointerCapture. Chromium fires lostpointercapture
1432
+ // SYNCHRONOUSLY during releasePointerCapture, which lands in
1433
+ // onLostCapture → finalize(null, false). Without the early reset,
1434
+ // the nested finalize sees pointerId still matching and runs to
1435
+ // completion (clearing startRect), then the outer finalize
1436
+ // resumes with startRect == null and silently drops the
1437
+ // dispatched action. Resetting first makes the nested call
1438
+ // return at the `pointerId === -1` guard, leaving outer state
1439
+ // intact.
1440
+ const capturedPointerId = pointerId;
1441
+ const rect = startRect;
1442
+ pointerId = -1;
1443
+ dragParent = null;
1444
+ startRect = null;
1445
+ try {
1446
+ el.releasePointerCapture(capturedPointerId);
1447
+ }
1448
+ catch {
1449
+ // Capture may already be gone (e.g. pointercancel) — ignore.
1450
+ }
1451
+ // Remove the per-gesture pointerleave fallback so a NEXT drag
1452
+ // doesn't inherit a stale listener from this one. {once: true}
1453
+ // only auto-removes if it fires; a stuck drag never fired it.
1454
+ el.removeEventListener("pointerleave", onPointerLeaveCancel);
1455
+ if (!dispatch || !e || !rect) {
1456
+ removeOverlay();
1457
+ return;
1458
+ }
1459
+ if (rect.width <= 0 || rect.height <= 0) {
1460
+ removeOverlay();
1461
+ return;
1462
+ }
1463
+ // Clamp the two corners to the rect BEFORE computing fractions so
1464
+ // a drag that escapes the element still yields a rectangle inside
1465
+ // it (x ∈ [0,1], w ∈ [0,1-x]). Otherwise a far-off-rect endpoint
1466
+ // would push w past 1 even with x already > 0.
1467
+ const rectRight = rect.left + rect.width;
1468
+ const rectBottom = rect.top + rect.height;
1469
+ const x0 = clampRange(Math.min(startClientX, e.clientX), rect.left, rectRight);
1470
+ const y0 = clampRange(Math.min(startClientY, e.clientY), rect.top, rectBottom);
1471
+ const x1 = clampRange(Math.max(startClientX, e.clientX), rect.left, rectRight);
1472
+ const y1 = clampRange(Math.max(startClientY, e.clientY), rect.top, rectBottom);
1473
+ const x = (x0 - rect.left) / rect.width;
1474
+ const y = (y0 - rect.top) / rect.height;
1475
+ const w = (x1 - x0) / rect.width;
1476
+ const h = (y1 - y0) / rect.height;
1477
+ removeOverlay();
1478
+ // Reject zero-area rectangles outright. The MIN_AREA_FRACTION
1479
+ // check below uses `&&` (drop only when BOTH dims are small) so
1480
+ // a wide-but-thin selection is preserved — but a literal
1481
+ // 60%×0 (or 0×60%) collapses to no region, can't be rendered
1482
+ // sensibly, and would divide by zero in any pixel-space
1483
+ // conversion downstream. Drop independently of the threshold.
1484
+ if (w <= 0 || h <= 0)
1485
+ return;
1486
+ // Drop when BOTH dimensions are below the threshold (intentional
1487
+ // `&&` — NOT `||`). A wide-but-thin drag (e.g. an underline across
1488
+ // an annotated row) or a tall-but-thin drag (e.g. a vertical
1489
+ // highlight) is a real selection in this directive's contract,
1490
+ // not an accidental click. `||` would drop those legitimate
1491
+ // gestures. The click-vs-drag boundary lives in "the rect has
1492
+ // basically no area" — that's both dims below the threshold.
1493
+ if (w < MIN_AREA_FRACTION && h < MIN_AREA_FRACTION) {
1494
+ // Treat as a click, not a drag. Don't dispatch; let normal click
1495
+ // handlers (if any) run via the platform.
1496
+ return;
1497
+ }
1498
+ send({ action, data: { x, y, w, h } });
1499
+ };
1500
+ const onPointerLeaveCancel = (e) => {
1501
+ // Fallback for the rare case where setPointerCapture failed: without
1502
+ // capture, pointermove + pointerup stop arriving once the pointer
1503
+ // leaves the host, freezing the overlay. Treating pointerleave as
1504
+ // a cancel keeps the overlay from getting stuck on screen.
1505
+ // Guard on pointerId — in multi-touch, a SECONDARY pointer's
1506
+ // leave shouldn't cancel the primary drag.
1507
+ if (e.pointerId !== pointerId)
1508
+ return;
1509
+ finalize(null, false);
1510
+ };
1511
+ // Chromium fires `dragstart` on an <img> after the first mousemove
1512
+ // following mousedown, yanking the gesture away from pointer events
1513
+ // before pointerup arrives — the overlay flashes and capture is
1514
+ // lost. preventDefault on dragstart suppresses the native image
1515
+ // drag without breaking pointer events. Cheap to attach on every
1516
+ // element type (non-img hosts simply never fire dragstart).
1517
+ const onDragStart = (e) => e.preventDefault();
1518
+ const onPointerDown = (e) => {
1519
+ // Only primary button (left mouse / single touch / pen tip). Modifier
1520
+ // keys passed through so the server-side handler can decide what to
1521
+ // do with them via subsequent renders.
1522
+ if (!e.isPrimary || e.button !== 0)
1523
+ return;
1524
+ // Re-entrancy guard: if a prior drag never finished (e.g. capture
1525
+ // failed silently, then pointer left the element with no pointerup
1526
+ // ever delivered), the closed-over pointerId variable would still
1527
+ // hold the stale id. Cancel the prior drag — removing its overlay
1528
+ // and listeners — before starting a fresh one.
1529
+ if (pointerId !== -1)
1530
+ finalize(null, false);
1531
+ const parent = el.parentElement;
1532
+ if (!parent)
1533
+ return; // overlay needs a positioned container
1534
+ // Dev-time check: if the parent doesn't establish a positioning
1535
+ // context, the overlay's `position: absolute` will resolve against
1536
+ // the nearest positioned ANCESTOR — a distant element with no
1537
+ // visible relationship to the host. Result: overlay paints in
1538
+ // the wrong place with no error, just a confusing visual.
1539
+ // Check against the positive list of positioned values; the
1540
+ // default "static" and an unset/empty value both fail it (jsdom
1541
+ // returns "" for unset position). Dedupe via WeakSet so a user
1542
+ // dragging repeatedly on the same mis-configured parent gets ONE
1543
+ // console message, not one per pointerdown.
1544
+ if (!areaSelectWarnedParents.has(parent)) {
1545
+ const parentPos = window.getComputedStyle(parent).position;
1546
+ if (parentPos !== "relative" &&
1547
+ parentPos !== "absolute" &&
1548
+ parentPos !== "fixed" &&
1549
+ parentPos !== "sticky") {
1550
+ console.warn("lvt-fx:area-select: parentElement has no positioning context; the drag overlay will be mis-positioned. " +
1551
+ "Add position:relative (or absolute/fixed/sticky) to the parent.", parent);
1552
+ areaSelectWarnedParents.add(parent);
1553
+ }
1554
+ }
1555
+ startClientX = e.clientX;
1556
+ startClientY = e.clientY;
1557
+ pointerId = e.pointerId;
1558
+ dragParent = parent;
1559
+ startRect = el.getBoundingClientRect();
1560
+ let captureOk = false;
1561
+ try {
1562
+ el.setPointerCapture(pointerId);
1563
+ captureOk = true;
1564
+ }
1565
+ catch {
1566
+ // Capture failure is non-fatal — without it, leaving the element
1567
+ // mid-drag will lose pointermove. Fall back to pointerleave as
1568
+ // the cancel signal so the overlay can't get stuck.
1569
+ }
1570
+ if (!captureOk) {
1571
+ el.addEventListener("pointerleave", onPointerLeaveCancel, { once: true });
1572
+ }
1573
+ overlay = document.createElement("div");
1574
+ overlay.className = "lvt-area-select-overlay";
1575
+ overlay.setAttribute("aria-hidden", "true");
1576
+ // Inline styles so the directive doesn't depend on a CSS class
1577
+ // shipped by the consumer. Consumers can override via the class
1578
+ // selector if they want a different look.
1579
+ overlay.style.cssText =
1580
+ "position:absolute;pointer-events:none;border:2px solid var(--lvt-area-select-color,#4cc2ff);" +
1581
+ "background:var(--lvt-area-select-fill,rgba(76,194,255,0.18));box-sizing:border-box;" +
1582
+ "z-index:var(--lvt-area-select-z-index,9999);";
1583
+ parent.appendChild(overlay);
1584
+ updateOverlay(e);
1585
+ // NOT calling e.preventDefault() here: doing so on pointerdown
1586
+ // suppresses the compatibility mouse events (mousedown → mouseup
1587
+ // → click), so a small-rect drag (which finalize() treats as a
1588
+ // click) would never reach the host's click handlers. The
1589
+ // directive's contract promises clicks still bubble. Text-
1590
+ // selection during drag is the consumer's responsibility — set
1591
+ // `user-select: none` on the host (the contract docs this).
1592
+ };
1593
+ const updateOverlay = (e) => {
1594
+ if (!overlay)
1595
+ return;
1596
+ // Use the parent captured at pointerdown — if a server diff
1597
+ // moved `el` to a new parent mid-drag, re-fetching el.parentElement
1598
+ // here would compute against the new container while the overlay
1599
+ // lives in the old, paint at the wrong place for the rest of the
1600
+ // gesture.
1601
+ const parent = dragParent;
1602
+ if (!parent)
1603
+ return;
1604
+ const elRect = el.getBoundingClientRect();
1605
+ const parentRect = parent.getBoundingClientRect();
1606
+ // Convert viewport coords (clientX/Y) to position:absolute CSS
1607
+ // offsets inside the parent. Three corrections, all subtracted
1608
+ // from / added to the same way for every value we compute:
1609
+ //
1610
+ // 1. parentRect.left/top — getBoundingClientRect is in viewport
1611
+ // coords; CSS offsets are relative to the parent's box.
1612
+ // 2. parent.clientLeft/Top — position:absolute is measured from
1613
+ // the padding box; getBoundingClientRect returns the border
1614
+ // box. A parent with a CSS border would otherwise shift the
1615
+ // overlay by the border width.
1616
+ // 3. parent.scrollLeft/Top — when the parent is scrolled, an
1617
+ // element at viewport_x = parentRect.left has CSS_left =
1618
+ // parent.scrollLeft (not 0). Without adding scroll back in,
1619
+ // the overlay paints offset by the scroll amount.
1620
+ const borderL = parent.clientLeft;
1621
+ const borderT = parent.clientTop;
1622
+ const scrollL = parent.scrollLeft;
1623
+ const scrollT = parent.scrollTop;
1624
+ const toCSSLeft = (vx) => vx - parentRect.left - borderL + scrollL;
1625
+ const toCSSTop = (vy) => vy - parentRect.top - borderT + scrollT;
1626
+ const left = toCSSLeft(Math.min(startClientX, e.clientX));
1627
+ const top = toCSSTop(Math.min(startClientY, e.clientY));
1628
+ const width = Math.abs(e.clientX - startClientX);
1629
+ const height = Math.abs(e.clientY - startClientY);
1630
+ // Clamp to the host's rendered rect (in the same CSS coord space)
1631
+ // so a drag that runs off the edge doesn't paint outside the host.
1632
+ const minLeft = toCSSLeft(elRect.left);
1633
+ const minTop = toCSSTop(elRect.top);
1634
+ const maxRight = minLeft + elRect.width;
1635
+ const maxBottom = minTop + elRect.height;
1636
+ const clampedLeft = Math.max(minLeft, Math.min(left, maxRight));
1637
+ const clampedTop = Math.max(minTop, Math.min(top, maxBottom));
1638
+ const clampedRight = Math.max(minLeft, Math.min(left + width, maxRight));
1639
+ const clampedBottom = Math.max(minTop, Math.min(top + height, maxBottom));
1640
+ overlay.style.left = `${clampedLeft}px`;
1641
+ overlay.style.top = `${clampedTop}px`;
1642
+ overlay.style.width = `${Math.max(0, clampedRight - clampedLeft)}px`;
1643
+ overlay.style.height = `${Math.max(0, clampedBottom - clampedTop)}px`;
1644
+ };
1645
+ const onPointerMove = (e) => {
1646
+ if (e.pointerId !== pointerId)
1647
+ return;
1648
+ // Host removed from the DOM mid-drag (e.g. server diff replaced it).
1649
+ // Without this, the overlay would be left orphaned under the parent
1650
+ // because the host's cleanup never runs.
1651
+ if (!el.isConnected) {
1652
+ finalize(null, false);
1653
+ return;
1654
+ }
1655
+ updateOverlay(e);
1656
+ };
1657
+ const onPointerUp = (e) => {
1658
+ if (e.pointerId !== pointerId)
1659
+ return;
1660
+ if (!el.isConnected) {
1661
+ finalize(null, false);
1662
+ return;
1663
+ }
1664
+ finalize(e, true);
1665
+ };
1666
+ const onPointerCancel = (e) => {
1667
+ if (e.pointerId !== pointerId)
1668
+ return;
1669
+ finalize(e, false);
1670
+ };
1671
+ // lostpointercapture handles the rare case where the platform yanks
1672
+ // capture (OS gesture, another setPointerCapture call). Guard on
1673
+ // pointerId — another code path could call setPointerCapture for a
1674
+ // DIFFERENT pointer on the same element, and we mustn't cancel
1675
+ // our in-progress drag because of an unrelated release.
1676
+ const onLostCapture = (e) => {
1677
+ if (e.pointerId === pointerId)
1678
+ finalize(null, false);
1679
+ };
1680
+ el.addEventListener("pointerdown", onPointerDown);
1681
+ el.addEventListener("pointermove", onPointerMove);
1682
+ el.addEventListener("pointerup", onPointerUp);
1683
+ el.addEventListener("pointercancel", onPointerCancel);
1684
+ el.addEventListener("lostpointercapture", onLostCapture);
1685
+ el.addEventListener("dragstart", onDragStart);
1686
+ const cleanup = () => {
1687
+ el.removeEventListener("pointerdown", onPointerDown);
1688
+ el.removeEventListener("pointermove", onPointerMove);
1689
+ el.removeEventListener("pointerup", onPointerUp);
1690
+ el.removeEventListener("pointercancel", onPointerCancel);
1691
+ el.removeEventListener("lostpointercapture", onLostCapture);
1692
+ el.removeEventListener("pointerleave", onPointerLeaveCancel);
1693
+ el.removeEventListener("dragstart", onDragStart);
1694
+ finalize(null, false);
1695
+ areaSelectArmed.delete(el);
1696
+ };
1697
+ return {
1698
+ action,
1699
+ cleanup,
1700
+ updateSend: (s) => {
1701
+ send = s;
1702
+ },
1703
+ };
1704
+ }
1705
+ function clampRange(n, lo, hi) {
1706
+ if (!Number.isFinite(n) || n < lo)
1707
+ return lo;
1708
+ if (n > hi)
1709
+ return hi;
1710
+ return n;
1711
+ }
861
1712
  //# sourceMappingURL=directives.js.map