@livetemplate/client 0.11.5 → 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.
- package/dist/dom/directives.d.ts +72 -0
- package/dist/dom/directives.d.ts.map +1 -1
- package/dist/dom/directives.js +464 -0
- package/dist/dom/directives.js.map +1 -1
- package/dist/livetemplate-client.browser.js +4 -4
- package/dist/livetemplate-client.browser.js.map +3 -3
- package/dist/livetemplate-client.d.ts.map +1 -1
- package/dist/livetemplate-client.js +7 -0
- package/dist/livetemplate-client.js.map +1 -1
- package/dist/tests/directives.test.js +692 -0
- package/dist/tests/directives.test.js.map +1 -1
- package/package.json +1 -1
package/dist/dom/directives.d.ts
CHANGED
|
@@ -153,4 +153,76 @@ export declare function setupToastClickOutside(): void;
|
|
|
153
153
|
* also logs a console.warn so the divergence is visible.
|
|
154
154
|
*/
|
|
155
155
|
export declare function handleShadowRootHydration(rootElement: Element): void;
|
|
156
|
+
/**
|
|
157
|
+
* Apply area-select directives. `lvt-fx:area-select="<actionName>"` on
|
|
158
|
+
* an element (typically an `<img>` inside a positioned parent) lets
|
|
159
|
+
* the user drag a rectangle locally — a `<div>` overlay tracks the
|
|
160
|
+
* gesture in real time without a server round-trip — and on
|
|
161
|
+
* `pointerup` dispatches a single livetemplate action with the final
|
|
162
|
+
* `{x, y, w, h}` as 0..1 fractions of the element's rendered bounding
|
|
163
|
+
* rect. The image's intrinsic dimensions don't matter for the
|
|
164
|
+
* fractions: any uniform scale (zoom, responsive layout) preserves
|
|
165
|
+
* the fraction. The consumer scales to pixels using the natural size
|
|
166
|
+
* if it needs them.
|
|
167
|
+
*
|
|
168
|
+
* Contract:
|
|
169
|
+
* - Host's `parentElement` must establish a positioning context
|
|
170
|
+
* (`position: relative` / `absolute` / `fixed`). The overlay is
|
|
171
|
+
* `position: absolute` inside that parent so it follows the host
|
|
172
|
+
* on scroll / reflow.
|
|
173
|
+
* - Consumers usually pair this with `touch-action: none` on the
|
|
174
|
+
* host so iOS Safari doesn't interpret the drag as a pinch/scroll.
|
|
175
|
+
* - `<img>` and other natively-draggable hosts work automatically:
|
|
176
|
+
* the directive calls `preventDefault()` on `dragstart` so the
|
|
177
|
+
* browser's native drag (which would otherwise steal the gesture)
|
|
178
|
+
* is suppressed.
|
|
179
|
+
* - On pointer-cancel (e.g. system gesture, app switch), the overlay
|
|
180
|
+
* is removed and no action is dispatched — same effect as cancelling
|
|
181
|
+
* a click on `mouseleave`.
|
|
182
|
+
* - Drags smaller than `MIN_AREA_FRACTION` in BOTH dimensions are
|
|
183
|
+
* dropped — a click on the host still fires normal `click`
|
|
184
|
+
* handlers via the compatibility mouse events.
|
|
185
|
+
* - For text-bearing hosts, set `user-select: none` (the directive
|
|
186
|
+
* deliberately does NOT call `preventDefault()` on `pointerdown`
|
|
187
|
+
* so click handlers still receive the gesture; that means the
|
|
188
|
+
* browser's default text-selection-on-drag behaviour also fires
|
|
189
|
+
* unless the host opts out via CSS).
|
|
190
|
+
* - The overlay uses `z-index: var(--lvt-area-select-z-index, 9999)`.
|
|
191
|
+
* 9999 is high enough for most use cases but can collide with
|
|
192
|
+
* portals / modals / drawers that also sit at a high z-index.
|
|
193
|
+
* Set `--lvt-area-select-z-index` on the host (or any ancestor)
|
|
194
|
+
* to override. Color + fill follow the same pattern via
|
|
195
|
+
* `--lvt-area-select-color` and `--lvt-area-select-fill`.
|
|
196
|
+
* - **No keyboard equivalent.** Pointer-only by design (a keyboard-
|
|
197
|
+
* selected rectangle requires a different UX — focus + arrow keys
|
|
198
|
+
* to position + arrow keys to size). Consumers needing a11y for
|
|
199
|
+
* area selection should provide a parallel form-based affordance.
|
|
200
|
+
*
|
|
201
|
+
* Idempotent across renders: an element re-armed with the same action
|
|
202
|
+
* keeps its existing listeners. A different action causes a tear-down
|
|
203
|
+
* and re-arm. Disconnected elements (and elements whose attribute was
|
|
204
|
+
* cleared by a server diff) get their listeners cleaned up by the
|
|
205
|
+
* sweep at the top of every call — we use a regular Map (not WeakMap)
|
|
206
|
+
* specifically so the sweep can iterate.
|
|
207
|
+
*
|
|
208
|
+
* Module-level singleton: `areaSelectArmed` is shared across all
|
|
209
|
+
* LiveTemplateClient instances in the same window. If two clients
|
|
210
|
+
* ever arm the same element with different actions, the second wins
|
|
211
|
+
* and the first client's send() is orphaned. Single-client use is
|
|
212
|
+
* unaffected.
|
|
213
|
+
*/
|
|
214
|
+
export declare function handleAreaSelectDirectives(rootElement: Element, send: (message: {
|
|
215
|
+
action: string;
|
|
216
|
+
data: Record<string, unknown>;
|
|
217
|
+
}) => void): void;
|
|
218
|
+
/**
|
|
219
|
+
* Cancel area-select listeners for every armed element under root.
|
|
220
|
+
* Mirrors teardownAutoClickTimers: meant for the client's disconnect /
|
|
221
|
+
* destroy lifecycle so the module-level singleton doesn't outlive a
|
|
222
|
+
* client that was torn down without a subsequent
|
|
223
|
+
* handleAreaSelectDirectives call (e.g. network error closed the
|
|
224
|
+
* socket while an element was armed). Without this, a SPA that mounts
|
|
225
|
+
* + tears down livetemplate trees would leak listeners across mounts.
|
|
226
|
+
*/
|
|
227
|
+
export declare function teardownAreaSelectForRoot(rootElement: Element): void;
|
|
156
228
|
//# sourceMappingURL=directives.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"directives.d.ts","sourceRoot":"","sources":["../../dom/directives.ts"],"names":[],"mappings":"AA4CA;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iCAAiC,IAAI,IAAI,CAKxD;AAsBD;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,OAAO,EAAE,YAAY,CAAC,EAAE,OAAO,GAAG,IAAI,CA0CvF;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAWrE;AAED;;GAEG;AACH,wBAAgB,4BAA4B,CAC1C,WAAW,EAAE,OAAO,EACpB,SAAS,EAAE,MAAM,EACjB,UAAU,CAAC,EAAE,MAAM,GAClB,IAAI,CAiBN;AAiLD;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAiBpE;AAED;;;GAGG;AACH,wBAAgB,4BAA4B,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAUvE;AAaD,wBAAgB,sBAAsB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAMjE;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CAG9C;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CA8FpE;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAMpE;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAQlE;AAwCD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CA4BhE;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,IAAI,IAAI,CAW7C;AAkFD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAyGpE"}
|
|
1
|
+
{"version":3,"file":"directives.d.ts","sourceRoot":"","sources":["../../dom/directives.ts"],"names":[],"mappings":"AA4CA;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iCAAiC,IAAI,IAAI,CAKxD;AAsBD;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,OAAO,EAAE,YAAY,CAAC,EAAE,OAAO,GAAG,IAAI,CA0CvF;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAWrE;AAED;;GAEG;AACH,wBAAgB,4BAA4B,CAC1C,WAAW,EAAE,OAAO,EACpB,SAAS,EAAE,MAAM,EACjB,UAAU,CAAC,EAAE,MAAM,GAClB,IAAI,CAiBN;AAiLD;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAiBpE;AAED;;;GAGG;AACH,wBAAgB,4BAA4B,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAUvE;AAaD,wBAAgB,sBAAsB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAMjE;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CAG9C;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CA8FpE;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAMpE;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAQlE;AAwCD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CA4BhE;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,IAAI,IAAI,CAW7C;AAkFD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAyGpE;AA4CD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDG;AACH,wBAAgB,0BAA0B,CACxC,WAAW,EAAE,OAAO,EACpB,IAAI,EAAE,CAAC,OAAO,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,KAAK,IAAI,GACzE,IAAI,CAyCN;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAQpE"}
|
package/dist/dom/directives.js
CHANGED
|
@@ -14,6 +14,8 @@ 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;
|
|
17
19
|
const reactive_attributes_1 = require("./reactive-attributes");
|
|
18
20
|
// ─── Trigger parsing for lvt-fx: attributes ─────────────────────────────────
|
|
19
21
|
const FX_LIFECYCLE_SET = new Set(["pending", "success", "error", "done"]);
|
|
@@ -858,4 +860,466 @@ function handleShadowRootHydration(rootElement) {
|
|
|
858
860
|
tpl.remove();
|
|
859
861
|
}
|
|
860
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
|
+
}
|
|
861
1325
|
//# sourceMappingURL=directives.js.map
|