@livetemplate/client 0.11.4 → 0.11.6

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.
@@ -13,6 +13,9 @@ exports.handleHighlightDirectives = handleHighlightDirectives;
13
13
  exports.handleAnimateDirectives = handleAnimateDirectives;
14
14
  exports.handleToastDirectives = handleToastDirectives;
15
15
  exports.setupToastClickOutside = setupToastClickOutside;
16
+ exports.handleShadowRootHydration = handleShadowRootHydration;
17
+ exports.handleAreaSelectDirectives = handleAreaSelectDirectives;
18
+ exports.teardownAreaSelectForRoot = teardownAreaSelectForRoot;
16
19
  const reactive_attributes_1 = require("./reactive-attributes");
17
20
  // ─── Trigger parsing for lvt-fx: attributes ─────────────────────────────────
18
21
  const FX_LIFECYCLE_SET = new Set(["pending", "success", "error", "done"]);
@@ -700,4 +703,623 @@ function createToastElement(msg) {
700
703
  }
701
704
  return el;
702
705
  }
706
+ // closedShadowRoots tracks shadow roots created in "closed" mode. The
707
+ // platform makes them unreachable via `parent.shadowRoot` (it returns
708
+ // null) — closed mode's whole point is that the host's normal DOM API
709
+ // can't see them. On a re-render, without this side channel, the code
710
+ // would call attachShadow a second time on the same host, throw
711
+ // NotSupportedError, hit the catch, and silently drop the new content.
712
+ // Open roots are reachable via parent.shadowRoot, so they don't need
713
+ // the map.
714
+ //
715
+ // Module-scoped on purpose: WeakMap keys are garbage-collected with
716
+ // their hosts, so detached elements don't leak. A function-scoped map
717
+ // would forget closed roots across renders and the bug would return.
718
+ const closedShadowRoots = new WeakMap();
719
+ /**
720
+ * Activate Declarative Shadow DOM for `<template shadowrootmode>` elements
721
+ * that the client inserted via DOM APIs (innerHTML setter, morphdom's
722
+ * createElement+appendChild path). The HTML parser activates declarative
723
+ * shadow roots only at parse time or via setHTMLUnsafe / parseHTMLUnsafe;
724
+ * a `<template shadowrootmode>` set via `.innerHTML = ...` is parked as a
725
+ * plain template with content but no attached shadow root. This sweep
726
+ * closes that gap so server-emitted shadow roots survive a client
727
+ * re-render.
728
+ *
729
+ * For each matching template found under rootElement:
730
+ * - attach a shadow root on the parent (open by default; "closed" when
731
+ * shadowrootmode="closed");
732
+ * - move the template's content into the shadow root (replaceChildren
733
+ * accepts a DocumentFragment and re-parents its children atomically,
734
+ * so re-renders cleanly reset prior shadow content);
735
+ * - remove the template.
736
+ *
737
+ * Hosts that can't accept a shadow root (a small fixed set: <input>,
738
+ * <textarea>, void elements, etc.) silently drop the template — better
739
+ * than an unhandled exception that kills the render.
740
+ *
741
+ * Closed-mode roots are tracked in a module-level WeakMap so re-renders
742
+ * can locate them (parent.shadowRoot returns null for closed roots).
743
+ *
744
+ * Idempotent: a re-run with no remaining templates is one qsa walk and
745
+ * an early return (sub-millisecond on hundreds-of-rows pages).
746
+ *
747
+ * Known limitations:
748
+ *
749
+ * - Nested DSD is inert on EVERY render, not just re-renders. A
750
+ * `<template>`'s content lives in a DocumentFragment (`tpl.content`),
751
+ * not in the light DOM, and `querySelectorAll` does not descend into
752
+ * that fragment. So a `<template shadowrootmode>` nested inside
753
+ * another `<template>` is never in the qsa result. Once the outer
754
+ * shadow has been attached, the inner template ends up behind a
755
+ * shadow boundary — still unreachable. The fix would be a recursive
756
+ * sweep per new shadow root from within this loop.
757
+ *
758
+ * - Shadow-root options (`delegatesFocus`, `clonable`, `serializable`,
759
+ * even `mode`) are fixed at first attach. A re-render that toggles
760
+ * `shadowrootdelegatesfocus` on a host that already has a shadow root
761
+ * won't change the existing root's focus behaviour — re-attach isn't
762
+ * possible. Matches the HTML parser, which would have made the same
763
+ * one-shot decision; if the server needs to flip these flags, it
764
+ * needs to swap the host element entirely. The mode-mismatch case
765
+ * also logs a console.warn so the divergence is visible.
766
+ */
767
+ function handleShadowRootHydration(rootElement) {
768
+ // Single qsa for both the empty-fast-path and the actual work — a
769
+ // leading querySelector check would double-walk the tree when
770
+ // templates are present. NodeList from querySelectorAll is static
771
+ // (not live), so removing templates inside the loop doesn't disturb
772
+ // iteration; no Array.from copy needed.
773
+ // The selector guarantees <template> elements, so the typed qsa
774
+ // overload removes the `as HTMLTemplateElement` cast inside the loop.
775
+ const templates = rootElement.querySelectorAll("template[shadowrootmode]");
776
+ if (templates.length === 0)
777
+ return;
778
+ for (const tpl of templates) {
779
+ // qsa on an Element always returns descendants with a parentElement,
780
+ // so !parent should be unreachable today. Kept as a defensive guard
781
+ // in case a future caller passes a DocumentFragment-rooted tree
782
+ // where the matched template could be a fragment's direct child.
783
+ const parent = tpl.parentElement;
784
+ if (!parent) {
785
+ tpl.remove();
786
+ continue;
787
+ }
788
+ const modeAttr = tpl.getAttribute("shadowrootmode");
789
+ // Align with the HTML parser: only "open" and "closed" trigger
790
+ // activation. A typo like shadowrootmode="opne" was previously
791
+ // left in place "so the author can inspect" — but on every
792
+ // subsequent render the qsa would re-find it, defeating the
793
+ // fast-path advertised in the docblock. Remove it AND log a
794
+ // console.warn so authors actually see the typo (the next morphdom
795
+ // pass would overwrite it anyway).
796
+ if (modeAttr !== "open" && modeAttr !== "closed") {
797
+ console.warn(`livetemplate: invalid shadowrootmode=${JSON.stringify(modeAttr)}; ` +
798
+ `expected "open" or "closed". Template removed.`, tpl);
799
+ tpl.remove();
800
+ continue;
801
+ }
802
+ const mode = modeAttr;
803
+ // For open roots, parent.shadowRoot is the reachable handle. For
804
+ // closed roots, the platform returns null on purpose — consult the
805
+ // WeakMap that we populated when we first attached the root.
806
+ let shadow = parent.shadowRoot ?? closedShadowRoots.get(parent);
807
+ // If the server flips shadowrootmode on a re-render (e.g. open →
808
+ // closed), attachShadow can't be called a second time — the existing
809
+ // mode silently wins. Warn so the author notices the mistake instead
810
+ // of debugging mysterious focus/encapsulation behaviour later.
811
+ if (shadow && shadow.mode !== modeAttr) {
812
+ console.warn(`livetemplate: shadowrootmode changed from "${shadow.mode}" to "${modeAttr}" ` +
813
+ `on re-render — mode is fixed at first attach and cannot be changed.`, parent);
814
+ }
815
+ if (!shadow) {
816
+ try {
817
+ // Forward all Declarative Shadow DOM attributes so the hydrated
818
+ // root matches the one the HTML parser would build natively:
819
+ // - shadowrootdelegatesfocus → delegatesFocus
820
+ // - shadowrootclonable → clonable (Chrome 124+)
821
+ // - shadowrootserializable → serializable (Chrome 125+)
822
+ // Unknown flags from older runtimes are silently ignored by
823
+ // attachShadow, so we don't need a feature-detect.
824
+ shadow = parent.attachShadow({
825
+ mode,
826
+ delegatesFocus: tpl.hasAttribute("shadowrootdelegatesfocus"),
827
+ clonable: tpl.hasAttribute("shadowrootclonable"),
828
+ serializable: tpl.hasAttribute("shadowrootserializable"),
829
+ });
830
+ if (mode === "closed") {
831
+ closedShadowRoots.set(parent, shadow);
832
+ }
833
+ }
834
+ catch (e) {
835
+ // attachShadow throws DOMException for hosts that can't accept
836
+ // one (void elements, <input>, <textarea>, custom elements that
837
+ // declared a different mode, etc.). Drop the template so it
838
+ // doesn't keep tripping this hook on every render, AND warn so
839
+ // a developer accidentally putting shadow content on an invalid
840
+ // host gets a console signal rather than a mysteriously empty
841
+ // preview.
842
+ //
843
+ // Anything OTHER than a DOMException is a real bug (typo in the
844
+ // options object, runtime fault); re-raise so it surfaces in the
845
+ // console instead of getting silently masked as "unsupported
846
+ // host".
847
+ if (!(e instanceof DOMException))
848
+ throw e;
849
+ console.warn(`livetemplate: attachShadow rejected on <${parent.tagName.toLowerCase()}> ` +
850
+ `(${e.name}: ${e.message}). Template removed.`, parent);
851
+ tpl.remove();
852
+ continue;
853
+ }
854
+ }
855
+ // Pass the DocumentFragment directly — replaceChildren moves its
856
+ // children into the shadow root in one atomic platform call. Avoids
857
+ // both the spread (which could hit call-stack argument limits on
858
+ // very large NodeLists) and the intermediate Array.from allocation.
859
+ shadow.replaceChildren(tpl.content);
860
+ tpl.remove();
861
+ }
862
+ }
863
+ // areaSelectArmed tracks the cleanup callback for every element that
864
+ // currently has a `lvt-fx:area-select` handler attached. Map (not
865
+ // WeakMap) because the sweep needs to iterate to detect elements whose
866
+ // attribute was removed by a server diff — without iteration those
867
+ // elements would keep their listeners and silently dispatch the old
868
+ // action on subsequent drags. Detached elements are cleaned up via
869
+ // the same sweep (isConnected check).
870
+ const areaSelectArmed = new Map();
871
+ // areaSelectWarnedParents dedupes the "parent not positioned" dev-warn
872
+ // so a user who repeatedly drags on a mis-configured element gets a
873
+ // single console message instead of one per pointerdown. WeakSet so
874
+ // detached parents don't leak.
875
+ //
876
+ // Known limitation: once a parent is in the set, the warn never fires
877
+ // again on that DOM node — even if the developer subsequently adds
878
+ // `position: relative` to fix the issue. The WeakSet is per-object
879
+ // (different DOM node = different entry), so re-mounting the parent
880
+ // resets the dedupe; in-place CSS fixes do not. Fine in practice
881
+ // (the user already saw the warn once, on the broken render).
882
+ const areaSelectWarnedParents = new WeakSet();
883
+ // MIN_AREA_FRACTION filters accidental click-style gestures where the
884
+ // user meant to click, not drag. 2% of the element's rendered size is
885
+ // big enough to be intentional on touch + mouse but small enough that
886
+ // anyone seriously trying to annotate a tiny region can still do it.
887
+ const MIN_AREA_FRACTION = 0.02;
888
+ /**
889
+ * Apply area-select directives. `lvt-fx:area-select="<actionName>"` on
890
+ * an element (typically an `<img>` inside a positioned parent) lets
891
+ * the user drag a rectangle locally — a `<div>` overlay tracks the
892
+ * gesture in real time without a server round-trip — and on
893
+ * `pointerup` dispatches a single livetemplate action with the final
894
+ * `{x, y, w, h}` as 0..1 fractions of the element's rendered bounding
895
+ * rect. The image's intrinsic dimensions don't matter for the
896
+ * fractions: any uniform scale (zoom, responsive layout) preserves
897
+ * the fraction. The consumer scales to pixels using the natural size
898
+ * if it needs them.
899
+ *
900
+ * Contract:
901
+ * - Host's `parentElement` must establish a positioning context
902
+ * (`position: relative` / `absolute` / `fixed`). The overlay is
903
+ * `position: absolute` inside that parent so it follows the host
904
+ * on scroll / reflow.
905
+ * - Consumers usually pair this with `touch-action: none` on the
906
+ * host so iOS Safari doesn't interpret the drag as a pinch/scroll.
907
+ * - `<img>` and other natively-draggable hosts work automatically:
908
+ * the directive calls `preventDefault()` on `dragstart` so the
909
+ * browser's native drag (which would otherwise steal the gesture)
910
+ * is suppressed.
911
+ * - On pointer-cancel (e.g. system gesture, app switch), the overlay
912
+ * is removed and no action is dispatched — same effect as cancelling
913
+ * a click on `mouseleave`.
914
+ * - Drags smaller than `MIN_AREA_FRACTION` in BOTH dimensions are
915
+ * dropped — a click on the host still fires normal `click`
916
+ * handlers via the compatibility mouse events.
917
+ * - For text-bearing hosts, set `user-select: none` (the directive
918
+ * deliberately does NOT call `preventDefault()` on `pointerdown`
919
+ * so click handlers still receive the gesture; that means the
920
+ * browser's default text-selection-on-drag behaviour also fires
921
+ * unless the host opts out via CSS).
922
+ * - The overlay uses `z-index: var(--lvt-area-select-z-index, 9999)`.
923
+ * 9999 is high enough for most use cases but can collide with
924
+ * portals / modals / drawers that also sit at a high z-index.
925
+ * Set `--lvt-area-select-z-index` on the host (or any ancestor)
926
+ * to override. Color + fill follow the same pattern via
927
+ * `--lvt-area-select-color` and `--lvt-area-select-fill`.
928
+ * - **No keyboard equivalent.** Pointer-only by design (a keyboard-
929
+ * selected rectangle requires a different UX — focus + arrow keys
930
+ * to position + arrow keys to size). Consumers needing a11y for
931
+ * area selection should provide a parallel form-based affordance.
932
+ *
933
+ * Idempotent across renders: an element re-armed with the same action
934
+ * keeps its existing listeners. A different action causes a tear-down
935
+ * and re-arm. Disconnected elements (and elements whose attribute was
936
+ * cleared by a server diff) get their listeners cleaned up by the
937
+ * sweep at the top of every call — we use a regular Map (not WeakMap)
938
+ * specifically so the sweep can iterate.
939
+ *
940
+ * Module-level singleton: `areaSelectArmed` is shared across all
941
+ * LiveTemplateClient instances in the same window. If two clients
942
+ * ever arm the same element with different actions, the second wins
943
+ * and the first client's send() is orphaned. Single-client use is
944
+ * unaffected.
945
+ */
946
+ function handleAreaSelectDirectives(rootElement, send) {
947
+ // Sweep stale entries before processing the current match set:
948
+ // disconnected elements AND elements where the attribute was
949
+ // removed by a server diff. Without this, a previously-armed
950
+ // element whose lvt-fx:area-select was cleared would keep its
951
+ // listeners and silently dispatch the old action on subsequent
952
+ // drags. Iterate via Array.from so cleanup()'s delete() doesn't
953
+ // disturb the iterator.
954
+ for (const [element, entry] of Array.from(areaSelectArmed)) {
955
+ if (!element.isConnected || !element.hasAttribute("lvt-fx:area-select")) {
956
+ entry.cleanup();
957
+ }
958
+ }
959
+ const matches = rootElement.querySelectorAll("[lvt-fx\\:area-select]");
960
+ if (matches.length === 0)
961
+ return;
962
+ for (const el of matches) {
963
+ const action = el.getAttribute("lvt-fx:area-select");
964
+ // Empty attribute → consumer almost certainly typoed; warn and
965
+ // skip rather than dispatching to a blank action name.
966
+ if (!action) {
967
+ console.warn(`lvt-fx:area-select requires an action name, got: ${JSON.stringify(action)}`);
968
+ continue;
969
+ }
970
+ const existing = areaSelectArmed.get(el);
971
+ if (existing && existing.action === action) {
972
+ // Idempotent re-arm: keep the listeners + WeakMap entry, but
973
+ // update the captured send so a subsequent drag dispatches
974
+ // through the latest callback (e.g. after a WebSocket
975
+ // reconnect rebuilt the transport).
976
+ existing.updateSend(send);
977
+ continue;
978
+ }
979
+ if (existing)
980
+ existing.cleanup();
981
+ areaSelectArmed.set(el, attachAreaSelect(el, action, send));
982
+ }
983
+ }
984
+ /**
985
+ * Cancel area-select listeners for every armed element under root.
986
+ * Mirrors teardownAutoClickTimers: meant for the client's disconnect /
987
+ * destroy lifecycle so the module-level singleton doesn't outlive a
988
+ * client that was torn down without a subsequent
989
+ * handleAreaSelectDirectives call (e.g. network error closed the
990
+ * socket while an element was armed). Without this, a SPA that mounts
991
+ * + tears down livetemplate trees would leak listeners across mounts.
992
+ */
993
+ function teardownAreaSelectForRoot(rootElement) {
994
+ // `contains` returns true for the node itself, so this also handles
995
+ // the (today-impossible) case of rootElement being armed directly.
996
+ for (const [element, entry] of Array.from(areaSelectArmed)) {
997
+ if (rootElement.contains(element)) {
998
+ entry.cleanup();
999
+ }
1000
+ }
1001
+ }
1002
+ // attachAreaSelect captures `send` in a mutable local so the
1003
+ // idempotent re-arm path (same element, same action) can swap it via
1004
+ // the returned `updateSend` callback without tearing down + rebuilding
1005
+ // listeners. Listeners reference the closure-captured `send` variable
1006
+ // directly, so reassigning it propagates instantly. This guards
1007
+ // against the stale-closure trap a caller would hit if their `send`
1008
+ // reference changed across renders — e.g. a reconnect rebuilt the
1009
+ // transport.
1010
+ function attachAreaSelect(el, action, initialSend) {
1011
+ let send = initialSend;
1012
+ let overlay = null;
1013
+ let startClientX = 0;
1014
+ let startClientY = 0;
1015
+ let pointerId = -1;
1016
+ // Capture the parent at pointerdown time so a server diff that moves
1017
+ // the host to a NEW parent mid-drag doesn't split the drag across
1018
+ // two positioning contexts. updateOverlay positions against this
1019
+ // cached parent for the lifetime of the gesture; the overlay itself
1020
+ // stays a child of the parent we appended it to (overlay removal
1021
+ // uses overlay.parentElement, which is independent).
1022
+ let dragParent = null;
1023
+ // Cache the host's rect at pointerdown — startClientX/Y are captured
1024
+ // in the SAME frame, so the start corner is meaningful only against
1025
+ // the rect that existed then. If a server diff repositions the host
1026
+ // mid-drag, finalize would otherwise clamp the (old-coord-system)
1027
+ // startClientX against the new rect and silently produce wrong
1028
+ // fractions. Anchoring to the start-rect keeps the dispatched
1029
+ // rectangle pinned to the visual region the user actually dragged.
1030
+ let startRect = null;
1031
+ const removeOverlay = () => {
1032
+ if (overlay) {
1033
+ // Element.remove() is a no-op if the node isn't in the DOM,
1034
+ // so we don't need the parent-null guard the older two-step
1035
+ // pattern needed.
1036
+ overlay.remove();
1037
+ }
1038
+ overlay = null;
1039
+ };
1040
+ const finalize = (e, dispatch) => {
1041
+ if (pointerId === -1)
1042
+ return;
1043
+ // CRITICAL ORDER: reset pointerId + dragParent + startRect BEFORE
1044
+ // calling releasePointerCapture. Chromium fires lostpointercapture
1045
+ // SYNCHRONOUSLY during releasePointerCapture, which lands in
1046
+ // onLostCapture → finalize(null, false). Without the early reset,
1047
+ // the nested finalize sees pointerId still matching and runs to
1048
+ // completion (clearing startRect), then the outer finalize
1049
+ // resumes with startRect == null and silently drops the
1050
+ // dispatched action. Resetting first makes the nested call
1051
+ // return at the `pointerId === -1` guard, leaving outer state
1052
+ // intact.
1053
+ const capturedPointerId = pointerId;
1054
+ const rect = startRect;
1055
+ pointerId = -1;
1056
+ dragParent = null;
1057
+ startRect = null;
1058
+ try {
1059
+ el.releasePointerCapture(capturedPointerId);
1060
+ }
1061
+ catch {
1062
+ // Capture may already be gone (e.g. pointercancel) — ignore.
1063
+ }
1064
+ // Remove the per-gesture pointerleave fallback so a NEXT drag
1065
+ // doesn't inherit a stale listener from this one. {once: true}
1066
+ // only auto-removes if it fires; a stuck drag never fired it.
1067
+ el.removeEventListener("pointerleave", onPointerLeaveCancel);
1068
+ if (!dispatch || !e || !rect) {
1069
+ removeOverlay();
1070
+ return;
1071
+ }
1072
+ if (rect.width <= 0 || rect.height <= 0) {
1073
+ removeOverlay();
1074
+ return;
1075
+ }
1076
+ // Clamp the two corners to the rect BEFORE computing fractions so
1077
+ // a drag that escapes the element still yields a rectangle inside
1078
+ // it (x ∈ [0,1], w ∈ [0,1-x]). Otherwise a far-off-rect endpoint
1079
+ // would push w past 1 even with x already > 0.
1080
+ const rectRight = rect.left + rect.width;
1081
+ const rectBottom = rect.top + rect.height;
1082
+ const x0 = clampRange(Math.min(startClientX, e.clientX), rect.left, rectRight);
1083
+ const y0 = clampRange(Math.min(startClientY, e.clientY), rect.top, rectBottom);
1084
+ const x1 = clampRange(Math.max(startClientX, e.clientX), rect.left, rectRight);
1085
+ const y1 = clampRange(Math.max(startClientY, e.clientY), rect.top, rectBottom);
1086
+ const x = (x0 - rect.left) / rect.width;
1087
+ const y = (y0 - rect.top) / rect.height;
1088
+ const w = (x1 - x0) / rect.width;
1089
+ const h = (y1 - y0) / rect.height;
1090
+ removeOverlay();
1091
+ // Reject zero-area rectangles outright. The MIN_AREA_FRACTION
1092
+ // check below uses `&&` (drop only when BOTH dims are small) so
1093
+ // a wide-but-thin selection is preserved — but a literal
1094
+ // 60%×0 (or 0×60%) collapses to no region, can't be rendered
1095
+ // sensibly, and would divide by zero in any pixel-space
1096
+ // conversion downstream. Drop independently of the threshold.
1097
+ if (w <= 0 || h <= 0)
1098
+ return;
1099
+ // Drop when BOTH dimensions are below the threshold (intentional
1100
+ // `&&` — NOT `||`). A wide-but-thin drag (e.g. an underline across
1101
+ // an annotated row) or a tall-but-thin drag (e.g. a vertical
1102
+ // highlight) is a real selection in this directive's contract,
1103
+ // not an accidental click. `||` would drop those legitimate
1104
+ // gestures. The click-vs-drag boundary lives in "the rect has
1105
+ // basically no area" — that's both dims below the threshold.
1106
+ if (w < MIN_AREA_FRACTION && h < MIN_AREA_FRACTION) {
1107
+ // Treat as a click, not a drag. Don't dispatch; let normal click
1108
+ // handlers (if any) run via the platform.
1109
+ return;
1110
+ }
1111
+ send({ action, data: { x, y, w, h } });
1112
+ };
1113
+ const onPointerLeaveCancel = (e) => {
1114
+ // Fallback for the rare case where setPointerCapture failed: without
1115
+ // capture, pointermove + pointerup stop arriving once the pointer
1116
+ // leaves the host, freezing the overlay. Treating pointerleave as
1117
+ // a cancel keeps the overlay from getting stuck on screen.
1118
+ // Guard on pointerId — in multi-touch, a SECONDARY pointer's
1119
+ // leave shouldn't cancel the primary drag.
1120
+ if (e.pointerId !== pointerId)
1121
+ return;
1122
+ finalize(null, false);
1123
+ };
1124
+ // Chromium fires `dragstart` on an <img> after the first mousemove
1125
+ // following mousedown, yanking the gesture away from pointer events
1126
+ // before pointerup arrives — the overlay flashes and capture is
1127
+ // lost. preventDefault on dragstart suppresses the native image
1128
+ // drag without breaking pointer events. Cheap to attach on every
1129
+ // element type (non-img hosts simply never fire dragstart).
1130
+ const onDragStart = (e) => e.preventDefault();
1131
+ const onPointerDown = (e) => {
1132
+ // Only primary button (left mouse / single touch / pen tip). Modifier
1133
+ // keys passed through so the server-side handler can decide what to
1134
+ // do with them via subsequent renders.
1135
+ if (!e.isPrimary || e.button !== 0)
1136
+ return;
1137
+ // Re-entrancy guard: if a prior drag never finished (e.g. capture
1138
+ // failed silently, then pointer left the element with no pointerup
1139
+ // ever delivered), the closed-over pointerId variable would still
1140
+ // hold the stale id. Cancel the prior drag — removing its overlay
1141
+ // and listeners — before starting a fresh one.
1142
+ if (pointerId !== -1)
1143
+ finalize(null, false);
1144
+ const parent = el.parentElement;
1145
+ if (!parent)
1146
+ return; // overlay needs a positioned container
1147
+ // Dev-time check: if the parent doesn't establish a positioning
1148
+ // context, the overlay's `position: absolute` will resolve against
1149
+ // the nearest positioned ANCESTOR — a distant element with no
1150
+ // visible relationship to the host. Result: overlay paints in
1151
+ // the wrong place with no error, just a confusing visual.
1152
+ // Check against the positive list of positioned values; the
1153
+ // default "static" and an unset/empty value both fail it (jsdom
1154
+ // returns "" for unset position). Dedupe via WeakSet so a user
1155
+ // dragging repeatedly on the same mis-configured parent gets ONE
1156
+ // console message, not one per pointerdown.
1157
+ if (!areaSelectWarnedParents.has(parent)) {
1158
+ const parentPos = window.getComputedStyle(parent).position;
1159
+ if (parentPos !== "relative" &&
1160
+ parentPos !== "absolute" &&
1161
+ parentPos !== "fixed" &&
1162
+ parentPos !== "sticky") {
1163
+ console.warn("lvt-fx:area-select: parentElement has no positioning context; the drag overlay will be mis-positioned. " +
1164
+ "Add position:relative (or absolute/fixed/sticky) to the parent.", parent);
1165
+ areaSelectWarnedParents.add(parent);
1166
+ }
1167
+ }
1168
+ startClientX = e.clientX;
1169
+ startClientY = e.clientY;
1170
+ pointerId = e.pointerId;
1171
+ dragParent = parent;
1172
+ startRect = el.getBoundingClientRect();
1173
+ let captureOk = false;
1174
+ try {
1175
+ el.setPointerCapture(pointerId);
1176
+ captureOk = true;
1177
+ }
1178
+ catch {
1179
+ // Capture failure is non-fatal — without it, leaving the element
1180
+ // mid-drag will lose pointermove. Fall back to pointerleave as
1181
+ // the cancel signal so the overlay can't get stuck.
1182
+ }
1183
+ if (!captureOk) {
1184
+ el.addEventListener("pointerleave", onPointerLeaveCancel, { once: true });
1185
+ }
1186
+ overlay = document.createElement("div");
1187
+ overlay.className = "lvt-area-select-overlay";
1188
+ overlay.setAttribute("aria-hidden", "true");
1189
+ // Inline styles so the directive doesn't depend on a CSS class
1190
+ // shipped by the consumer. Consumers can override via the class
1191
+ // selector if they want a different look.
1192
+ overlay.style.cssText =
1193
+ "position:absolute;pointer-events:none;border:2px solid var(--lvt-area-select-color,#4cc2ff);" +
1194
+ "background:var(--lvt-area-select-fill,rgba(76,194,255,0.18));box-sizing:border-box;" +
1195
+ "z-index:var(--lvt-area-select-z-index,9999);";
1196
+ parent.appendChild(overlay);
1197
+ updateOverlay(e);
1198
+ // NOT calling e.preventDefault() here: doing so on pointerdown
1199
+ // suppresses the compatibility mouse events (mousedown → mouseup
1200
+ // → click), so a small-rect drag (which finalize() treats as a
1201
+ // click) would never reach the host's click handlers. The
1202
+ // directive's contract promises clicks still bubble. Text-
1203
+ // selection during drag is the consumer's responsibility — set
1204
+ // `user-select: none` on the host (the contract docs this).
1205
+ };
1206
+ const updateOverlay = (e) => {
1207
+ if (!overlay)
1208
+ return;
1209
+ // Use the parent captured at pointerdown — if a server diff
1210
+ // moved `el` to a new parent mid-drag, re-fetching el.parentElement
1211
+ // here would compute against the new container while the overlay
1212
+ // lives in the old, paint at the wrong place for the rest of the
1213
+ // gesture.
1214
+ const parent = dragParent;
1215
+ if (!parent)
1216
+ return;
1217
+ const elRect = el.getBoundingClientRect();
1218
+ const parentRect = parent.getBoundingClientRect();
1219
+ // Convert viewport coords (clientX/Y) to position:absolute CSS
1220
+ // offsets inside the parent. Three corrections, all subtracted
1221
+ // from / added to the same way for every value we compute:
1222
+ //
1223
+ // 1. parentRect.left/top — getBoundingClientRect is in viewport
1224
+ // coords; CSS offsets are relative to the parent's box.
1225
+ // 2. parent.clientLeft/Top — position:absolute is measured from
1226
+ // the padding box; getBoundingClientRect returns the border
1227
+ // box. A parent with a CSS border would otherwise shift the
1228
+ // overlay by the border width.
1229
+ // 3. parent.scrollLeft/Top — when the parent is scrolled, an
1230
+ // element at viewport_x = parentRect.left has CSS_left =
1231
+ // parent.scrollLeft (not 0). Without adding scroll back in,
1232
+ // the overlay paints offset by the scroll amount.
1233
+ const borderL = parent.clientLeft;
1234
+ const borderT = parent.clientTop;
1235
+ const scrollL = parent.scrollLeft;
1236
+ const scrollT = parent.scrollTop;
1237
+ const toCSSLeft = (vx) => vx - parentRect.left - borderL + scrollL;
1238
+ const toCSSTop = (vy) => vy - parentRect.top - borderT + scrollT;
1239
+ const left = toCSSLeft(Math.min(startClientX, e.clientX));
1240
+ const top = toCSSTop(Math.min(startClientY, e.clientY));
1241
+ const width = Math.abs(e.clientX - startClientX);
1242
+ const height = Math.abs(e.clientY - startClientY);
1243
+ // Clamp to the host's rendered rect (in the same CSS coord space)
1244
+ // so a drag that runs off the edge doesn't paint outside the host.
1245
+ const minLeft = toCSSLeft(elRect.left);
1246
+ const minTop = toCSSTop(elRect.top);
1247
+ const maxRight = minLeft + elRect.width;
1248
+ const maxBottom = minTop + elRect.height;
1249
+ const clampedLeft = Math.max(minLeft, Math.min(left, maxRight));
1250
+ const clampedTop = Math.max(minTop, Math.min(top, maxBottom));
1251
+ const clampedRight = Math.max(minLeft, Math.min(left + width, maxRight));
1252
+ const clampedBottom = Math.max(minTop, Math.min(top + height, maxBottom));
1253
+ overlay.style.left = `${clampedLeft}px`;
1254
+ overlay.style.top = `${clampedTop}px`;
1255
+ overlay.style.width = `${Math.max(0, clampedRight - clampedLeft)}px`;
1256
+ overlay.style.height = `${Math.max(0, clampedBottom - clampedTop)}px`;
1257
+ };
1258
+ const onPointerMove = (e) => {
1259
+ if (e.pointerId !== pointerId)
1260
+ return;
1261
+ // Host removed from the DOM mid-drag (e.g. server diff replaced it).
1262
+ // Without this, the overlay would be left orphaned under the parent
1263
+ // because the host's cleanup never runs.
1264
+ if (!el.isConnected) {
1265
+ finalize(null, false);
1266
+ return;
1267
+ }
1268
+ updateOverlay(e);
1269
+ };
1270
+ const onPointerUp = (e) => {
1271
+ if (e.pointerId !== pointerId)
1272
+ return;
1273
+ if (!el.isConnected) {
1274
+ finalize(null, false);
1275
+ return;
1276
+ }
1277
+ finalize(e, true);
1278
+ };
1279
+ const onPointerCancel = (e) => {
1280
+ if (e.pointerId !== pointerId)
1281
+ return;
1282
+ finalize(e, false);
1283
+ };
1284
+ // lostpointercapture handles the rare case where the platform yanks
1285
+ // capture (OS gesture, another setPointerCapture call). Guard on
1286
+ // pointerId — another code path could call setPointerCapture for a
1287
+ // DIFFERENT pointer on the same element, and we mustn't cancel
1288
+ // our in-progress drag because of an unrelated release.
1289
+ const onLostCapture = (e) => {
1290
+ if (e.pointerId === pointerId)
1291
+ finalize(null, false);
1292
+ };
1293
+ el.addEventListener("pointerdown", onPointerDown);
1294
+ el.addEventListener("pointermove", onPointerMove);
1295
+ el.addEventListener("pointerup", onPointerUp);
1296
+ el.addEventListener("pointercancel", onPointerCancel);
1297
+ el.addEventListener("lostpointercapture", onLostCapture);
1298
+ el.addEventListener("dragstart", onDragStart);
1299
+ const cleanup = () => {
1300
+ el.removeEventListener("pointerdown", onPointerDown);
1301
+ el.removeEventListener("pointermove", onPointerMove);
1302
+ el.removeEventListener("pointerup", onPointerUp);
1303
+ el.removeEventListener("pointercancel", onPointerCancel);
1304
+ el.removeEventListener("lostpointercapture", onLostCapture);
1305
+ el.removeEventListener("pointerleave", onPointerLeaveCancel);
1306
+ el.removeEventListener("dragstart", onDragStart);
1307
+ finalize(null, false);
1308
+ areaSelectArmed.delete(el);
1309
+ };
1310
+ return {
1311
+ action,
1312
+ cleanup,
1313
+ updateSend: (s) => {
1314
+ send = s;
1315
+ },
1316
+ };
1317
+ }
1318
+ function clampRange(n, lo, hi) {
1319
+ if (!Number.isFinite(n) || n < lo)
1320
+ return lo;
1321
+ if (n > hi)
1322
+ return hi;
1323
+ return n;
1324
+ }
703
1325
  //# sourceMappingURL=directives.js.map