@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.
- package/dist/dom/directives.d.ts +153 -0
- package/dist/dom/directives.d.ts.map +1 -1
- package/dist/dom/directives.js +851 -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 +30 -0
- package/dist/livetemplate-client.js.map +1 -1
- package/dist/tests/directives.test.js +1091 -0
- package/dist/tests/directives.test.js.map +1 -1
- package/package.json +1 -1
|
@@ -900,4 +900,1095 @@ describe("handleShadowRootHydration", () => {
|
|
|
900
900
|
});
|
|
901
901
|
});
|
|
902
902
|
});
|
|
903
|
+
describe("handleAreaSelectDirectives", () => {
|
|
904
|
+
// The module-level areaSelectArmed map is cleared lazily — the sweep
|
|
905
|
+
// only fires when handleAreaSelectDirectives runs. Without an explicit
|
|
906
|
+
// afterEach, tests that don't call it would inherit armed elements
|
|
907
|
+
// from prior tests. teardownAreaSelectForRoot(document.body) wipes
|
|
908
|
+
// every entry so each test gets a fresh slate.
|
|
909
|
+
afterEach(() => {
|
|
910
|
+
(0, directives_1.teardownAreaSelectForRoot)(document.body);
|
|
911
|
+
});
|
|
912
|
+
// jsdom-friendly helper: configure the target element so it has a
|
|
913
|
+
// non-trivial bounding rect (jsdom returns zeros by default) and a
|
|
914
|
+
// positioned parent so the overlay has somewhere to land. Returns
|
|
915
|
+
// [target, parent] for the assertions.
|
|
916
|
+
function mountTarget(targetTag, attrs, rect) {
|
|
917
|
+
document.body.innerHTML = `
|
|
918
|
+
<div id="parent" style="position:relative;">
|
|
919
|
+
<${targetTag} id="target"></${targetTag}>
|
|
920
|
+
</div>
|
|
921
|
+
`;
|
|
922
|
+
const target = document.getElementById("target");
|
|
923
|
+
const parent = document.getElementById("parent");
|
|
924
|
+
for (const [k, v] of Object.entries(attrs))
|
|
925
|
+
target.setAttribute(k, v);
|
|
926
|
+
target.getBoundingClientRect = jest.fn(() => ({
|
|
927
|
+
x: rect.left,
|
|
928
|
+
y: rect.top,
|
|
929
|
+
left: rect.left,
|
|
930
|
+
top: rect.top,
|
|
931
|
+
right: rect.left + rect.width,
|
|
932
|
+
bottom: rect.top + rect.height,
|
|
933
|
+
width: rect.width,
|
|
934
|
+
height: rect.height,
|
|
935
|
+
toJSON: () => ({}),
|
|
936
|
+
}));
|
|
937
|
+
parent.getBoundingClientRect = jest.fn(() => ({
|
|
938
|
+
x: rect.left,
|
|
939
|
+
y: rect.top,
|
|
940
|
+
left: rect.left,
|
|
941
|
+
top: rect.top,
|
|
942
|
+
right: rect.left + rect.width,
|
|
943
|
+
bottom: rect.top + rect.height,
|
|
944
|
+
width: rect.width,
|
|
945
|
+
height: rect.height,
|
|
946
|
+
toJSON: () => ({}),
|
|
947
|
+
}));
|
|
948
|
+
// jsdom doesn't implement pointer-capture; stub so the directive's
|
|
949
|
+
// try/catch around it doesn't matter, but the test still asserts
|
|
950
|
+
// the contract.
|
|
951
|
+
target.setPointerCapture = jest.fn();
|
|
952
|
+
target.releasePointerCapture = jest.fn();
|
|
953
|
+
return [target, parent];
|
|
954
|
+
}
|
|
955
|
+
function dispatchPointer(el, type, clientX, clientY, pointerId = 1) {
|
|
956
|
+
const e = new MouseEvent(type, { clientX, clientY, button: 0, bubbles: true });
|
|
957
|
+
// PointerEvent isn't fully supported in jsdom but the directive
|
|
958
|
+
// only reads pointerId / isPrimary / button / clientX / clientY.
|
|
959
|
+
Object.defineProperty(e, "pointerId", { value: pointerId });
|
|
960
|
+
Object.defineProperty(e, "isPrimary", { value: true });
|
|
961
|
+
el.dispatchEvent(e);
|
|
962
|
+
}
|
|
963
|
+
beforeEach(() => {
|
|
964
|
+
document.body.innerHTML = "";
|
|
965
|
+
jest.spyOn(console, "warn").mockImplementation(() => { });
|
|
966
|
+
});
|
|
967
|
+
afterEach(() => {
|
|
968
|
+
jest.restoreAllMocks();
|
|
969
|
+
});
|
|
970
|
+
it("dispatches the action with 0..1 fraction coords on pointerup", () => {
|
|
971
|
+
const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 100, top: 50, width: 200, height: 200 });
|
|
972
|
+
const send = jest.fn();
|
|
973
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
974
|
+
// Drag from (120, 80) → (220, 150) inside the rect.
|
|
975
|
+
// x = (120 - 100) / 200 = 0.10
|
|
976
|
+
// y = (80 - 50) / 200 = 0.15
|
|
977
|
+
// w = (220 - 120) / 200 = 0.50
|
|
978
|
+
// h = (150 - 80) / 200 = 0.35
|
|
979
|
+
dispatchPointer(target, "pointerdown", 120, 80);
|
|
980
|
+
dispatchPointer(target, "pointermove", 220, 150);
|
|
981
|
+
dispatchPointer(target, "pointerup", 220, 150);
|
|
982
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
983
|
+
const msg = send.mock.calls[0][0];
|
|
984
|
+
expect(msg.action).toBe("selectImageArea");
|
|
985
|
+
expect(msg.data.x).toBeCloseTo(0.10, 5);
|
|
986
|
+
expect(msg.data.y).toBeCloseTo(0.15, 5);
|
|
987
|
+
expect(msg.data.w).toBeCloseTo(0.50, 5);
|
|
988
|
+
expect(msg.data.h).toBeCloseTo(0.35, 5);
|
|
989
|
+
});
|
|
990
|
+
it("filters drags smaller than the min-fraction threshold", () => {
|
|
991
|
+
const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 1000, height: 1000 });
|
|
992
|
+
const send = jest.fn();
|
|
993
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
994
|
+
// Drag is 10×10 px on a 1000×1000 rect → 1% fraction in both dims.
|
|
995
|
+
// 1% < MIN_AREA_FRACTION (2%) → must drop.
|
|
996
|
+
dispatchPointer(target, "pointerdown", 100, 100);
|
|
997
|
+
dispatchPointer(target, "pointermove", 110, 110);
|
|
998
|
+
dispatchPointer(target, "pointerup", 110, 110);
|
|
999
|
+
expect(send).not.toHaveBeenCalled();
|
|
1000
|
+
// Overlay should have been cleaned up after the failed drag.
|
|
1001
|
+
expect(document.querySelectorAll(".lvt-area-select-overlay").length).toBe(0);
|
|
1002
|
+
});
|
|
1003
|
+
it("paints an overlay during the drag and removes it on release", () => {
|
|
1004
|
+
const [, parent] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
|
|
1005
|
+
const target = parent.querySelector("img");
|
|
1006
|
+
const send = jest.fn();
|
|
1007
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1008
|
+
dispatchPointer(target, "pointerdown", 10, 10);
|
|
1009
|
+
expect(parent.querySelector(".lvt-area-select-overlay")).not.toBeNull();
|
|
1010
|
+
dispatchPointer(target, "pointermove", 60, 70);
|
|
1011
|
+
const overlay = parent.querySelector(".lvt-area-select-overlay");
|
|
1012
|
+
expect(overlay.style.width).toBe("50px");
|
|
1013
|
+
expect(overlay.style.height).toBe("60px");
|
|
1014
|
+
dispatchPointer(target, "pointerup", 60, 70);
|
|
1015
|
+
expect(parent.querySelector(".lvt-area-select-overlay")).toBeNull();
|
|
1016
|
+
});
|
|
1017
|
+
it("pointercancel removes the overlay and does NOT dispatch", () => {
|
|
1018
|
+
const [, parent] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
|
|
1019
|
+
const target = parent.querySelector("img");
|
|
1020
|
+
const send = jest.fn();
|
|
1021
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1022
|
+
dispatchPointer(target, "pointerdown", 10, 10);
|
|
1023
|
+
dispatchPointer(target, "pointermove", 60, 60);
|
|
1024
|
+
dispatchPointer(target, "pointercancel", 60, 60);
|
|
1025
|
+
expect(send).not.toHaveBeenCalled();
|
|
1026
|
+
expect(parent.querySelector(".lvt-area-select-overlay")).toBeNull();
|
|
1027
|
+
});
|
|
1028
|
+
it("is idempotent across renders for the same action", () => {
|
|
1029
|
+
const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
|
|
1030
|
+
const send = jest.fn();
|
|
1031
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1032
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1033
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1034
|
+
dispatchPointer(target, "pointerdown", 10, 10);
|
|
1035
|
+
dispatchPointer(target, "pointermove", 100, 100);
|
|
1036
|
+
dispatchPointer(target, "pointerup", 100, 100);
|
|
1037
|
+
// Listeners must NOT have been duplicated by repeated calls.
|
|
1038
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
1039
|
+
});
|
|
1040
|
+
it("re-arms with new action when the attribute value changes", () => {
|
|
1041
|
+
const [target] = mountTarget("img", { "lvt-fx:area-select": "first" }, { left: 0, top: 0, width: 200, height: 200 });
|
|
1042
|
+
const send = jest.fn();
|
|
1043
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1044
|
+
target.setAttribute("lvt-fx:area-select", "second");
|
|
1045
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1046
|
+
dispatchPointer(target, "pointerdown", 10, 10);
|
|
1047
|
+
dispatchPointer(target, "pointermove", 100, 100);
|
|
1048
|
+
dispatchPointer(target, "pointerup", 100, 100);
|
|
1049
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
1050
|
+
expect(send.mock.calls[0][0].action).toBe("second");
|
|
1051
|
+
});
|
|
1052
|
+
it("warns and skips when the attribute value is empty", () => {
|
|
1053
|
+
const warn = console.warn;
|
|
1054
|
+
mountTarget("img", { "lvt-fx:area-select": "" }, { left: 0, top: 0, width: 200, height: 200 });
|
|
1055
|
+
const send = jest.fn();
|
|
1056
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1057
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("requires an action name"));
|
|
1058
|
+
});
|
|
1059
|
+
it("clamps coords to 0..1 when the drag escapes the element rect", () => {
|
|
1060
|
+
const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 100, top: 100, width: 200, height: 200 });
|
|
1061
|
+
const send = jest.fn();
|
|
1062
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1063
|
+
// Drag starts inside but ends far below-right of the rect.
|
|
1064
|
+
dispatchPointer(target, "pointerdown", 150, 150);
|
|
1065
|
+
dispatchPointer(target, "pointerup", 10000, 10000);
|
|
1066
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
1067
|
+
const data = send.mock.calls[0][0].data;
|
|
1068
|
+
expect(data.x).toBeGreaterThanOrEqual(0);
|
|
1069
|
+
expect(data.x).toBeLessThanOrEqual(1);
|
|
1070
|
+
expect(data.x + data.w).toBeLessThanOrEqual(1);
|
|
1071
|
+
expect(data.y + data.h).toBeLessThanOrEqual(1);
|
|
1072
|
+
});
|
|
1073
|
+
it("fast-path returns when no matching elements", () => {
|
|
1074
|
+
document.body.innerHTML = `<div><p>nothing here</p></div>`;
|
|
1075
|
+
const send = jest.fn();
|
|
1076
|
+
// Should not throw, should not dispatch.
|
|
1077
|
+
expect(() => (0, directives_1.handleAreaSelectDirectives)(document.body, send)).not.toThrow();
|
|
1078
|
+
expect(send).not.toHaveBeenCalled();
|
|
1079
|
+
});
|
|
1080
|
+
it("removes the overlay when the host is detached mid-drag", () => {
|
|
1081
|
+
const [target, parent] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
|
|
1082
|
+
const send = jest.fn();
|
|
1083
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1084
|
+
dispatchPointer(target, "pointerdown", 10, 10);
|
|
1085
|
+
expect(parent.querySelector(".lvt-area-select-overlay")).not.toBeNull();
|
|
1086
|
+
// Simulate a server diff replacing the host element.
|
|
1087
|
+
target.remove();
|
|
1088
|
+
// A late pointermove (jsdom dispatches to the detached element)
|
|
1089
|
+
// must clean up the overlay rather than leave it orphaned under
|
|
1090
|
+
// the parent.
|
|
1091
|
+
dispatchPointer(target, "pointermove", 60, 70);
|
|
1092
|
+
expect(parent.querySelector(".lvt-area-select-overlay")).toBeNull();
|
|
1093
|
+
});
|
|
1094
|
+
it("suppresses native <img> drag via dragstart preventDefault", () => {
|
|
1095
|
+
const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
|
|
1096
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, jest.fn());
|
|
1097
|
+
// Without the dragstart listener, Chromium would call default-
|
|
1098
|
+
// action (start a native image drag) and steal the gesture from
|
|
1099
|
+
// pointer events. The directive must call preventDefault on
|
|
1100
|
+
// dragstart so pointermove + pointerup keep arriving.
|
|
1101
|
+
const drag = new Event("dragstart", { bubbles: true, cancelable: true });
|
|
1102
|
+
target.dispatchEvent(drag);
|
|
1103
|
+
expect(drag.defaultPrevented).toBe(true);
|
|
1104
|
+
});
|
|
1105
|
+
it("positions overlay correctly when target is offset inside its parent", () => {
|
|
1106
|
+
// Parent at (0,0), target at (50, 25) — exercises the
|
|
1107
|
+
// border-box-to-padding-box offset math with a real gap.
|
|
1108
|
+
document.body.innerHTML = `
|
|
1109
|
+
<div id="parent" style="position:relative;">
|
|
1110
|
+
<img id="target" lvt-fx:area-select="selectImageArea">
|
|
1111
|
+
</div>
|
|
1112
|
+
`;
|
|
1113
|
+
const target = document.getElementById("target");
|
|
1114
|
+
const parent = document.getElementById("parent");
|
|
1115
|
+
target.getBoundingClientRect = jest.fn(() => ({
|
|
1116
|
+
x: 50, y: 25, left: 50, top: 25, right: 150, bottom: 125,
|
|
1117
|
+
width: 100, height: 100, toJSON: () => ({}),
|
|
1118
|
+
}));
|
|
1119
|
+
parent.getBoundingClientRect = jest.fn(() => ({
|
|
1120
|
+
x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200,
|
|
1121
|
+
width: 200, height: 200, toJSON: () => ({}),
|
|
1122
|
+
}));
|
|
1123
|
+
target.setPointerCapture = jest.fn();
|
|
1124
|
+
target.releasePointerCapture = jest.fn();
|
|
1125
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, jest.fn());
|
|
1126
|
+
// Drag from (80, 50) → (120, 90) — inside the target's 100×100 rect.
|
|
1127
|
+
// Relative to the parent (and after subtracting clientLeft/clientTop=0
|
|
1128
|
+
// for a borderless parent), the overlay should sit at left=80, top=50.
|
|
1129
|
+
dispatchPointer(target, "pointerdown", 80, 50);
|
|
1130
|
+
dispatchPointer(target, "pointermove", 120, 90);
|
|
1131
|
+
const overlay = parent.querySelector(".lvt-area-select-overlay");
|
|
1132
|
+
expect(overlay).not.toBeNull();
|
|
1133
|
+
expect(overlay.style.left).toBe("80px");
|
|
1134
|
+
expect(overlay.style.top).toBe("50px");
|
|
1135
|
+
expect(overlay.style.width).toBe("40px");
|
|
1136
|
+
expect(overlay.style.height).toBe("40px");
|
|
1137
|
+
});
|
|
1138
|
+
it("pointerleave for a different pointerId does NOT cancel our capture-fallback drag", () => {
|
|
1139
|
+
// When setPointerCapture fails the directive attaches a
|
|
1140
|
+
// pointerleave fallback so the drag can clean up. In a multi-
|
|
1141
|
+
// touch scenario, a SECONDARY pointer leaving the element fires
|
|
1142
|
+
// pointerleave too — must not be mistaken for our pointer
|
|
1143
|
+
// leaving.
|
|
1144
|
+
const [, parent] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
|
|
1145
|
+
const target = parent.querySelector("img");
|
|
1146
|
+
// Force capture failure so the pointerleave fallback is attached.
|
|
1147
|
+
target.setPointerCapture = jest.fn(() => {
|
|
1148
|
+
throw new DOMException("no capture", "InvalidStateError");
|
|
1149
|
+
});
|
|
1150
|
+
const send = jest.fn();
|
|
1151
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1152
|
+
// Primary drag with pointerId=1.
|
|
1153
|
+
dispatchPointer(target, "pointerdown", 10, 10, 1);
|
|
1154
|
+
dispatchPointer(target, "pointermove", 80, 80, 1);
|
|
1155
|
+
// Secondary pointer (id=42) leaves the host. Must NOT cancel
|
|
1156
|
+
// our id=1 drag.
|
|
1157
|
+
dispatchPointer(target, "pointerleave", 80, 80, 42);
|
|
1158
|
+
// Primary drag still alive — pointerup completes it normally.
|
|
1159
|
+
dispatchPointer(target, "pointerup", 100, 100, 1);
|
|
1160
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
1161
|
+
});
|
|
1162
|
+
it("lostpointercapture for a different pointerId does NOT cancel our drag", () => {
|
|
1163
|
+
// Another code path could call setPointerCapture on the same
|
|
1164
|
+
// element with a different pointerId and later release it,
|
|
1165
|
+
// firing lostpointercapture on the host. Our handler must not
|
|
1166
|
+
// mistake that for OUR drag being canceled.
|
|
1167
|
+
const [, parent] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
|
|
1168
|
+
const target = parent.querySelector("img");
|
|
1169
|
+
const send = jest.fn();
|
|
1170
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1171
|
+
// Our drag starts with pointerId=1.
|
|
1172
|
+
dispatchPointer(target, "pointerdown", 10, 10, 1);
|
|
1173
|
+
dispatchPointer(target, "pointermove", 80, 80, 1);
|
|
1174
|
+
// Unrelated lostpointercapture for pointerId=42 — must be ignored.
|
|
1175
|
+
dispatchPointer(target, "lostpointercapture", 80, 80, 42);
|
|
1176
|
+
// Our drag is still alive — pointerup dispatches normally.
|
|
1177
|
+
dispatchPointer(target, "pointerup", 100, 100, 1);
|
|
1178
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
1179
|
+
});
|
|
1180
|
+
it("releasePointerCapture firing lostpointercapture synchronously does not drop the dispatch", () => {
|
|
1181
|
+
// Chromium fires lostpointercapture SYNCHRONOUSLY during
|
|
1182
|
+
// releasePointerCapture. Without the early state-reset in
|
|
1183
|
+
// finalize, the nested lostpointercapture handler would see
|
|
1184
|
+
// pointerId still matching, run a nested finalize that clears
|
|
1185
|
+
// startRect, then the outer finalize would resume with rect=null
|
|
1186
|
+
// and silently drop the dispatched action.
|
|
1187
|
+
const [, parent] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
|
|
1188
|
+
const target = parent.querySelector("img");
|
|
1189
|
+
// Make releasePointerCapture fire lostpointercapture synchronously
|
|
1190
|
+
// — the real Chromium behaviour that wasn't covered by jest.fn().
|
|
1191
|
+
target.releasePointerCapture = jest.fn((pid) => {
|
|
1192
|
+
target.dispatchEvent(Object.assign(new MouseEvent("lostpointercapture", { bubbles: false }), {
|
|
1193
|
+
pointerId: pid,
|
|
1194
|
+
}));
|
|
1195
|
+
});
|
|
1196
|
+
const send = jest.fn();
|
|
1197
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1198
|
+
dispatchPointer(target, "pointerdown", 20, 20);
|
|
1199
|
+
dispatchPointer(target, "pointermove", 80, 80);
|
|
1200
|
+
dispatchPointer(target, "pointerup", 80, 80);
|
|
1201
|
+
// The action must still dispatch — the early state-reset prevents
|
|
1202
|
+
// the nested lostpointercapture from re-entering finalize and
|
|
1203
|
+
// clearing the rect mid-call.
|
|
1204
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
1205
|
+
});
|
|
1206
|
+
it("uses pointerdown-time rect for fractions even if host moves between mousemoves", () => {
|
|
1207
|
+
// If a server diff repositions the host mid-drag, the rect
|
|
1208
|
+
// captured AT POINTERUP would clamp startClientX (which was
|
|
1209
|
+
// captured against the OLD position) into the wrong place.
|
|
1210
|
+
// The dispatched fractions must reflect the original drag,
|
|
1211
|
+
// measured against the rect that existed at pointerdown.
|
|
1212
|
+
document.body.innerHTML = `
|
|
1213
|
+
<div id="parent" style="position:relative;"><img id="target" lvt-fx:area-select="selectImageArea"></div>
|
|
1214
|
+
`;
|
|
1215
|
+
const parent = document.getElementById("parent");
|
|
1216
|
+
const target = document.getElementById("target");
|
|
1217
|
+
// Rect call counter: first call (pointerdown) returns the OLD
|
|
1218
|
+
// rect, subsequent calls return a NEW rect 500px away.
|
|
1219
|
+
let rectCalls = 0;
|
|
1220
|
+
target.getBoundingClientRect = jest.fn(() => {
|
|
1221
|
+
rectCalls++;
|
|
1222
|
+
if (rectCalls === 1) {
|
|
1223
|
+
return {
|
|
1224
|
+
x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200,
|
|
1225
|
+
width: 200, height: 200, toJSON: () => ({}),
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
return {
|
|
1229
|
+
x: 500, y: 500, left: 500, top: 500, right: 700, bottom: 700,
|
|
1230
|
+
width: 200, height: 200, toJSON: () => ({}),
|
|
1231
|
+
};
|
|
1232
|
+
});
|
|
1233
|
+
parent.getBoundingClientRect = jest.fn(() => ({
|
|
1234
|
+
x: 0, y: 0, left: 0, top: 0, right: 1000, bottom: 1000,
|
|
1235
|
+
width: 1000, height: 1000, toJSON: () => ({}),
|
|
1236
|
+
}));
|
|
1237
|
+
target.setPointerCapture = jest.fn();
|
|
1238
|
+
target.releasePointerCapture = jest.fn();
|
|
1239
|
+
const send = jest.fn();
|
|
1240
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1241
|
+
// Drag from (40, 60) → (140, 160) inside the OLD rect at (0..200).
|
|
1242
|
+
// OLD-rect fractions: x=40/200=0.2, y=60/200=0.3, w=100/200=0.5,
|
|
1243
|
+
// h=100/200=0.5. With the new (500..700) rect at finalize time,
|
|
1244
|
+
// clamping (40,60) into that rect would silently shift the start.
|
|
1245
|
+
dispatchPointer(target, "pointerdown", 40, 60);
|
|
1246
|
+
dispatchPointer(target, "pointermove", 90, 110);
|
|
1247
|
+
dispatchPointer(target, "pointerup", 140, 160);
|
|
1248
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
1249
|
+
const data = send.mock.calls[0][0].data;
|
|
1250
|
+
expect(data.x).toBeCloseTo(0.2, 5);
|
|
1251
|
+
expect(data.y).toBeCloseTo(0.3, 5);
|
|
1252
|
+
expect(data.w).toBeCloseTo(0.5, 5);
|
|
1253
|
+
expect(data.h).toBeCloseTo(0.5, 5);
|
|
1254
|
+
});
|
|
1255
|
+
it("uses the pointerdown-time parent even if host moves between mousemoves", () => {
|
|
1256
|
+
// Two positioned parents at known offsets. The host starts under
|
|
1257
|
+
// the first; we begin a drag, then synthetically re-parent the
|
|
1258
|
+
// host into the second container mid-drag. Without the parent-
|
|
1259
|
+
// capture fix, updateOverlay would refetch el.parentElement and
|
|
1260
|
+
// compute against the SECOND parent while the overlay still
|
|
1261
|
+
// lives in the FIRST — visual mis-positioning for the rest of
|
|
1262
|
+
// the drag. With the fix, the overlay tracks the FIRST parent.
|
|
1263
|
+
document.body.innerHTML = `
|
|
1264
|
+
<div id="p1" style="position:relative;"></div>
|
|
1265
|
+
<div id="p2" style="position:relative;"></div>
|
|
1266
|
+
`;
|
|
1267
|
+
const p1 = document.getElementById("p1");
|
|
1268
|
+
const p2 = document.getElementById("p2");
|
|
1269
|
+
const target = document.createElement("img");
|
|
1270
|
+
target.setAttribute("lvt-fx:area-select", "selectImageArea");
|
|
1271
|
+
p1.appendChild(target);
|
|
1272
|
+
target.getBoundingClientRect = jest.fn(() => ({
|
|
1273
|
+
x: 10, y: 10, left: 10, top: 10, right: 110, bottom: 110,
|
|
1274
|
+
width: 100, height: 100, toJSON: () => ({}),
|
|
1275
|
+
}));
|
|
1276
|
+
p1.getBoundingClientRect = jest.fn(() => ({
|
|
1277
|
+
x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200,
|
|
1278
|
+
width: 200, height: 200, toJSON: () => ({}),
|
|
1279
|
+
}));
|
|
1280
|
+
p2.getBoundingClientRect = jest.fn(() => ({
|
|
1281
|
+
x: 500, y: 500, left: 500, top: 500, right: 700, bottom: 700,
|
|
1282
|
+
width: 200, height: 200, toJSON: () => ({}),
|
|
1283
|
+
}));
|
|
1284
|
+
target.setPointerCapture = jest.fn();
|
|
1285
|
+
target.releasePointerCapture = jest.fn();
|
|
1286
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, jest.fn());
|
|
1287
|
+
dispatchPointer(target, "pointerdown", 30, 30);
|
|
1288
|
+
// Server diff moves the host to p2.
|
|
1289
|
+
p2.appendChild(target);
|
|
1290
|
+
dispatchPointer(target, "pointermove", 80, 60);
|
|
1291
|
+
// Overlay stays in p1 (where pointerdown attached it). Position is
|
|
1292
|
+
// computed against p1's rect (cached at pointerdown), not p2's.
|
|
1293
|
+
const overlayInP1 = p1.querySelector(".lvt-area-select-overlay");
|
|
1294
|
+
const overlayInP2 = p2.querySelector(".lvt-area-select-overlay");
|
|
1295
|
+
expect(overlayInP1).not.toBeNull();
|
|
1296
|
+
expect(overlayInP2).toBeNull();
|
|
1297
|
+
// pointerdown at (30,30), move to (80,60) → 50×30 against p1
|
|
1298
|
+
// at (0,0). If updateOverlay had re-fetched el.parentElement
|
|
1299
|
+
// and used p2 (at 500,500), left would be -470 instead of 30.
|
|
1300
|
+
expect(overlayInP1.style.left).toBe("30px");
|
|
1301
|
+
});
|
|
1302
|
+
it("positions overlay correctly when parent is scrolled", () => {
|
|
1303
|
+
// For a scrolled positioned parent: an element at viewport_x =
|
|
1304
|
+
// parentRect.left actually has CSS_left = parent.scrollLeft (the
|
|
1305
|
+
// browser is scrolled, so what's at viewport-x-0 is parent-x-100
|
|
1306
|
+
// for scrollLeft=100). Without adding scrollLeft/scrollTop back
|
|
1307
|
+
// into the CSS coords, the overlay paints displaced by the scroll
|
|
1308
|
+
// amount.
|
|
1309
|
+
document.body.innerHTML = `
|
|
1310
|
+
<div id="parent" style="position:relative;"><img id="target" lvt-fx:area-select="selectImageArea"></div>
|
|
1311
|
+
`;
|
|
1312
|
+
const parent = document.getElementById("parent");
|
|
1313
|
+
const target = document.getElementById("target");
|
|
1314
|
+
target.getBoundingClientRect = jest.fn(() => ({
|
|
1315
|
+
x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200,
|
|
1316
|
+
width: 200, height: 200, toJSON: () => ({}),
|
|
1317
|
+
}));
|
|
1318
|
+
parent.getBoundingClientRect = jest.fn(() => ({
|
|
1319
|
+
x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200,
|
|
1320
|
+
width: 200, height: 200, toJSON: () => ({}),
|
|
1321
|
+
}));
|
|
1322
|
+
// jsdom: scrollLeft/Top are mutable properties; just assign.
|
|
1323
|
+
Object.defineProperty(parent, "scrollLeft", { value: 100, configurable: true });
|
|
1324
|
+
Object.defineProperty(parent, "scrollTop", { value: 50, configurable: true });
|
|
1325
|
+
target.setPointerCapture = jest.fn();
|
|
1326
|
+
target.releasePointerCapture = jest.fn();
|
|
1327
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, jest.fn());
|
|
1328
|
+
// Drag from viewport (30, 40) to (90, 100). With the scroll
|
|
1329
|
+
// correction the overlay's CSS left should be
|
|
1330
|
+
// 30 - 0 - 0 + 100 = 130 and CSS top should be
|
|
1331
|
+
// 40 - 0 - 0 + 50 = 90. Without it, left=30 / top=40 (the bug).
|
|
1332
|
+
dispatchPointer(target, "pointerdown", 30, 40);
|
|
1333
|
+
dispatchPointer(target, "pointermove", 90, 100);
|
|
1334
|
+
const overlay = parent.querySelector(".lvt-area-select-overlay");
|
|
1335
|
+
expect(overlay).not.toBeNull();
|
|
1336
|
+
expect(overlay.style.left).toBe("130px");
|
|
1337
|
+
expect(overlay.style.top).toBe("90px");
|
|
1338
|
+
expect(overlay.style.width).toBe("60px");
|
|
1339
|
+
expect(overlay.style.height).toBe("60px");
|
|
1340
|
+
});
|
|
1341
|
+
it("warns when the parent's computed position is `static`", () => {
|
|
1342
|
+
// Forgetting position:relative on the parent silently mis-paints
|
|
1343
|
+
// the overlay against the nearest positioned ancestor. A
|
|
1344
|
+
// dev-time warn gives the author a chance to spot the mistake.
|
|
1345
|
+
const warn = console.warn;
|
|
1346
|
+
document.body.innerHTML = `
|
|
1347
|
+
<div id="static-parent">
|
|
1348
|
+
<img id="target" lvt-fx:area-select="selectImageArea">
|
|
1349
|
+
</div>
|
|
1350
|
+
`;
|
|
1351
|
+
const target = document.getElementById("target");
|
|
1352
|
+
target.getBoundingClientRect = jest.fn(() => ({
|
|
1353
|
+
x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200,
|
|
1354
|
+
width: 200, height: 200, toJSON: () => ({}),
|
|
1355
|
+
}));
|
|
1356
|
+
target.setPointerCapture = jest.fn();
|
|
1357
|
+
target.releasePointerCapture = jest.fn();
|
|
1358
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, jest.fn());
|
|
1359
|
+
dispatchPointer(target, "pointerdown", 10, 10);
|
|
1360
|
+
const warnings = warn.mock.calls.filter((args) => typeof args[0] === "string" && args[0].includes("parentElement has no positioning context"));
|
|
1361
|
+
expect(warnings.length).toBe(1);
|
|
1362
|
+
});
|
|
1363
|
+
it("dedupes the static-parent warn across repeated drags", () => {
|
|
1364
|
+
// Without the WeakSet dedupe, a user repeatedly dragging on the
|
|
1365
|
+
// same mis-configured element would spam console.warn (and
|
|
1366
|
+
// re-run getComputedStyle, a style-recalc trigger) once per
|
|
1367
|
+
// pointerdown.
|
|
1368
|
+
const warn = console.warn;
|
|
1369
|
+
document.body.innerHTML = `
|
|
1370
|
+
<div id="static-parent">
|
|
1371
|
+
<img id="target" lvt-fx:area-select="selectImageArea">
|
|
1372
|
+
</div>
|
|
1373
|
+
`;
|
|
1374
|
+
const target = document.getElementById("target");
|
|
1375
|
+
target.getBoundingClientRect = jest.fn(() => ({
|
|
1376
|
+
x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200,
|
|
1377
|
+
width: 200, height: 200, toJSON: () => ({}),
|
|
1378
|
+
}));
|
|
1379
|
+
target.setPointerCapture = jest.fn();
|
|
1380
|
+
target.releasePointerCapture = jest.fn();
|
|
1381
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, jest.fn());
|
|
1382
|
+
// Three drags on the same mis-configured parent — only the FIRST
|
|
1383
|
+
// should warn.
|
|
1384
|
+
for (let i = 0; i < 3; i++) {
|
|
1385
|
+
dispatchPointer(target, "pointerdown", 10, 10);
|
|
1386
|
+
dispatchPointer(target, "pointerup", 60, 60);
|
|
1387
|
+
}
|
|
1388
|
+
const warnings = warn.mock.calls.filter((args) => typeof args[0] === "string" && args[0].includes("parentElement has no positioning context"));
|
|
1389
|
+
expect(warnings.length).toBe(1);
|
|
1390
|
+
});
|
|
1391
|
+
it("pointercancel cancels the drag without dispatching", () => {
|
|
1392
|
+
// pointercancel fires on system gestures (OS-level swipe, app
|
|
1393
|
+
// switch). Like lostpointercapture / pointerleave, it must remove
|
|
1394
|
+
// the overlay and NOT dispatch — same contract from the user's
|
|
1395
|
+
// perspective.
|
|
1396
|
+
const [, parent] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
|
|
1397
|
+
const target = parent.querySelector("img");
|
|
1398
|
+
const send = jest.fn();
|
|
1399
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1400
|
+
dispatchPointer(target, "pointerdown", 10, 10);
|
|
1401
|
+
dispatchPointer(target, "pointermove", 80, 80);
|
|
1402
|
+
dispatchPointer(target, "pointercancel", 80, 80);
|
|
1403
|
+
expect(send).not.toHaveBeenCalled();
|
|
1404
|
+
expect(parent.querySelector(".lvt-area-select-overlay")).toBeNull();
|
|
1405
|
+
});
|
|
1406
|
+
it("rejects zero-area rectangles even if the threshold check would pass", () => {
|
|
1407
|
+
// The MIN_AREA_FRACTION check uses && so a wide-but-thin drag is
|
|
1408
|
+
// a legit selection — but a literal 60% × 0% drag has no area,
|
|
1409
|
+
// can't render sensibly, and would divide by zero downstream.
|
|
1410
|
+
// Drop it independently of the threshold.
|
|
1411
|
+
const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
|
|
1412
|
+
const send = jest.fn();
|
|
1413
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1414
|
+
// Drag from (40,100) to (160,100) — w=60% of 200, h=0%.
|
|
1415
|
+
dispatchPointer(target, "pointerdown", 40, 100);
|
|
1416
|
+
dispatchPointer(target, "pointermove", 160, 100);
|
|
1417
|
+
dispatchPointer(target, "pointerup", 160, 100);
|
|
1418
|
+
expect(send).not.toHaveBeenCalled();
|
|
1419
|
+
});
|
|
1420
|
+
it("teardownAreaSelectForRoot cancels armed elements under root", () => {
|
|
1421
|
+
// For the disconnect / destroy lifecycle: if a client tears down
|
|
1422
|
+
// without a subsequent handleAreaSelectDirectives call, the
|
|
1423
|
+
// module-level singleton would otherwise leak listeners.
|
|
1424
|
+
document.body.innerHTML = `
|
|
1425
|
+
<div id="root">
|
|
1426
|
+
<div id="parent" style="position:relative;">
|
|
1427
|
+
<img id="target" lvt-fx:area-select="selectImageArea">
|
|
1428
|
+
</div>
|
|
1429
|
+
</div>
|
|
1430
|
+
<div id="outside-parent" style="position:relative;">
|
|
1431
|
+
<img id="outside-target" lvt-fx:area-select="otherAction">
|
|
1432
|
+
</div>
|
|
1433
|
+
`;
|
|
1434
|
+
const root = document.getElementById("root");
|
|
1435
|
+
const target = document.getElementById("target");
|
|
1436
|
+
const outside = document.getElementById("outside-target");
|
|
1437
|
+
for (const el of [target, outside]) {
|
|
1438
|
+
el.getBoundingClientRect = jest.fn(() => ({
|
|
1439
|
+
x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200,
|
|
1440
|
+
width: 200, height: 200, toJSON: () => ({}),
|
|
1441
|
+
}));
|
|
1442
|
+
el.parentElement.getBoundingClientRect = jest.fn(() => ({
|
|
1443
|
+
x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200,
|
|
1444
|
+
width: 200, height: 200, toJSON: () => ({}),
|
|
1445
|
+
}));
|
|
1446
|
+
el.setPointerCapture = jest.fn();
|
|
1447
|
+
el.releasePointerCapture = jest.fn();
|
|
1448
|
+
}
|
|
1449
|
+
const send = jest.fn();
|
|
1450
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1451
|
+
(0, directives_1.teardownAreaSelectForRoot)(root);
|
|
1452
|
+
// The target inside root must NOT dispatch after teardown.
|
|
1453
|
+
dispatchPointer(target, "pointerdown", 10, 10);
|
|
1454
|
+
dispatchPointer(target, "pointermove", 100, 100);
|
|
1455
|
+
dispatchPointer(target, "pointerup", 100, 100);
|
|
1456
|
+
expect(send).not.toHaveBeenCalled();
|
|
1457
|
+
// The target outside root must still work.
|
|
1458
|
+
dispatchPointer(outside, "pointerdown", 10, 10);
|
|
1459
|
+
dispatchPointer(outside, "pointermove", 100, 100);
|
|
1460
|
+
dispatchPointer(outside, "pointerup", 100, 100);
|
|
1461
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
1462
|
+
expect(send.mock.calls[0][0].action).toBe("otherAction");
|
|
1463
|
+
(0, directives_1.teardownAreaSelectForRoot)(document.body); // clean up for next test
|
|
1464
|
+
});
|
|
1465
|
+
it("does not preventDefault on pointerdown — clicks still bubble", () => {
|
|
1466
|
+
// The contract promises a small-rect drag (treated as a click)
|
|
1467
|
+
// still reaches the host's click handlers. Calling
|
|
1468
|
+
// preventDefault on pointerdown would suppress the compatibility
|
|
1469
|
+
// mouse events that fire click — so the directive must NOT do
|
|
1470
|
+
// that.
|
|
1471
|
+
const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
|
|
1472
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, jest.fn());
|
|
1473
|
+
const down = new MouseEvent("pointerdown", {
|
|
1474
|
+
clientX: 50, clientY: 50, button: 0, bubbles: true, cancelable: true,
|
|
1475
|
+
});
|
|
1476
|
+
Object.defineProperty(down, "pointerId", { value: 1 });
|
|
1477
|
+
Object.defineProperty(down, "isPrimary", { value: true });
|
|
1478
|
+
target.dispatchEvent(down);
|
|
1479
|
+
expect(down.defaultPrevented).toBe(false);
|
|
1480
|
+
});
|
|
1481
|
+
it("stale pointerleave listener does not survive into the next gesture", () => {
|
|
1482
|
+
// The bug Claude flagged: when setPointerCapture fails on drag N,
|
|
1483
|
+
// the directive registers a pointerleave fallback. If drag N
|
|
1484
|
+
// gets stuck (pointer never leaves so the fallback never fires
|
|
1485
|
+
// and no pointerup arrives), and the user starts drag N+1, the
|
|
1486
|
+
// re-entrancy guard finalizes drag N but the STALE pointerleave
|
|
1487
|
+
// listener from N would still be attached. If capture SUCCEEDS
|
|
1488
|
+
// on drag N+1 (no new pointerleave registered), the stale one
|
|
1489
|
+
// from N would still fire if the pointer ever leaves — and
|
|
1490
|
+
// incorrectly cancel drag N+1. finalize() must remove the
|
|
1491
|
+
// pointerleave listener so it can't outlive its own gesture.
|
|
1492
|
+
const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
|
|
1493
|
+
// Capture FAILS only on the first call (drag N), succeeds after.
|
|
1494
|
+
let captureCalls = 0;
|
|
1495
|
+
target.setPointerCapture = jest.fn(() => {
|
|
1496
|
+
captureCalls++;
|
|
1497
|
+
if (captureCalls === 1) {
|
|
1498
|
+
throw new DOMException("no capture", "InvalidStateError");
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
const send = jest.fn();
|
|
1502
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1503
|
+
// Drag N: pointerdown attaches the pointerleave fallback, but
|
|
1504
|
+
// user starts drag and never releases (simulates a "stuck"
|
|
1505
|
+
// drag). Do NOT pointerup.
|
|
1506
|
+
dispatchPointer(target, "pointerdown", 10, 10);
|
|
1507
|
+
dispatchPointer(target, "pointermove", 80, 80);
|
|
1508
|
+
// Drag N+1 starts. Re-entrancy guard finalizes drag N. WITHOUT
|
|
1509
|
+
// the fix, drag N's pointerleave listener stays attached.
|
|
1510
|
+
// Capture succeeds this time so no NEW pointerleave is attached.
|
|
1511
|
+
dispatchPointer(target, "pointerdown", 20, 20);
|
|
1512
|
+
dispatchPointer(target, "pointermove", 100, 100);
|
|
1513
|
+
// Fire pointerleave. With the fix, no pointerleave handler is
|
|
1514
|
+
// attached → no-op, drag N+1 continues. WITHOUT the fix, the
|
|
1515
|
+
// stale listener from N would call finalize and cancel.
|
|
1516
|
+
target.dispatchEvent(new MouseEvent("pointerleave", { bubbles: false }));
|
|
1517
|
+
dispatchPointer(target, "pointerup", 100, 100);
|
|
1518
|
+
// Drag N+1 should have dispatched normally — the stale listener
|
|
1519
|
+
// must NOT have cancelled it.
|
|
1520
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
1521
|
+
});
|
|
1522
|
+
it("idempotent re-arm picks up the latest send callback (no stale closure)", () => {
|
|
1523
|
+
const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
|
|
1524
|
+
const firstSend = jest.fn();
|
|
1525
|
+
const secondSend = jest.fn();
|
|
1526
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, firstSend);
|
|
1527
|
+
// A subsequent render passes a different send (e.g. after a WS
|
|
1528
|
+
// reconnect rebuilt the transport). The idempotent path keeps
|
|
1529
|
+
// the listeners but MUST swap the captured send so the next
|
|
1530
|
+
// drag dispatches through the latest callback.
|
|
1531
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, secondSend);
|
|
1532
|
+
dispatchPointer(target, "pointerdown", 10, 10);
|
|
1533
|
+
dispatchPointer(target, "pointermove", 100, 100);
|
|
1534
|
+
dispatchPointer(target, "pointerup", 100, 100);
|
|
1535
|
+
expect(firstSend).not.toHaveBeenCalled();
|
|
1536
|
+
expect(secondSend).toHaveBeenCalledTimes(1);
|
|
1537
|
+
});
|
|
1538
|
+
it("lostpointercapture cancels the drag without dispatching", () => {
|
|
1539
|
+
const [, parent] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
|
|
1540
|
+
const target = parent.querySelector("img");
|
|
1541
|
+
const send = jest.fn();
|
|
1542
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1543
|
+
dispatchPointer(target, "pointerdown", 10, 10);
|
|
1544
|
+
dispatchPointer(target, "pointermove", 80, 80);
|
|
1545
|
+
// Platform yanks capture (OS gesture, another setPointerCapture
|
|
1546
|
+
// call elsewhere). lostpointercapture should cancel like
|
|
1547
|
+
// pointercancel — overlay removed, no action dispatched.
|
|
1548
|
+
dispatchPointer(target, "lostpointercapture", 80, 80);
|
|
1549
|
+
expect(send).not.toHaveBeenCalled();
|
|
1550
|
+
expect(parent.querySelector(".lvt-area-select-overlay")).toBeNull();
|
|
1551
|
+
});
|
|
1552
|
+
it("cleans up armed elements whose attribute was removed by a server diff", () => {
|
|
1553
|
+
const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
|
|
1554
|
+
const send = jest.fn();
|
|
1555
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1556
|
+
// Server diff removes the attribute. The element stays in the DOM
|
|
1557
|
+
// (host alive, but no longer wants area-select). The next
|
|
1558
|
+
// handleAreaSelectDirectives pass MUST cancel the listeners — a
|
|
1559
|
+
// subsequent drag must not dispatch the old action.
|
|
1560
|
+
target.removeAttribute("lvt-fx:area-select");
|
|
1561
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1562
|
+
dispatchPointer(target, "pointerdown", 10, 10);
|
|
1563
|
+
dispatchPointer(target, "pointermove", 100, 100);
|
|
1564
|
+
dispatchPointer(target, "pointerup", 100, 100);
|
|
1565
|
+
expect(send).not.toHaveBeenCalled();
|
|
1566
|
+
});
|
|
1567
|
+
it("recovers from a stuck drag on the next pointerdown (re-entrancy guard)", () => {
|
|
1568
|
+
const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
|
|
1569
|
+
const send = jest.fn();
|
|
1570
|
+
(0, directives_1.handleAreaSelectDirectives)(document.body, send);
|
|
1571
|
+
// First drag: pointerdown but no pointerup (simulates a captured
|
|
1572
|
+
// pointer that never released — what happens when capture silently
|
|
1573
|
+
// fails and pointer leaves the element).
|
|
1574
|
+
dispatchPointer(target, "pointerdown", 10, 10);
|
|
1575
|
+
dispatchPointer(target, "pointermove", 60, 60);
|
|
1576
|
+
// No pointerup. The drag is "stuck".
|
|
1577
|
+
// Second drag starts. Without the re-entrancy guard, the first
|
|
1578
|
+
// overlay would be orphaned. With the guard, the directive cancels
|
|
1579
|
+
// the stuck drag before starting the new one, and the new drag
|
|
1580
|
+
// completes normally.
|
|
1581
|
+
dispatchPointer(target, "pointerdown", 100, 100);
|
|
1582
|
+
dispatchPointer(target, "pointermove", 150, 150);
|
|
1583
|
+
dispatchPointer(target, "pointerup", 150, 150);
|
|
1584
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
1585
|
+
// The dispatched coords must come from the SECOND drag, not the
|
|
1586
|
+
// first. (Start=100, End=150 → x=0.5, w=0.25 on the 200-wide rect.)
|
|
1587
|
+
const data = send.mock.calls[0][0].data;
|
|
1588
|
+
expect(data.x).toBeCloseTo(0.50, 5);
|
|
1589
|
+
expect(data.w).toBeCloseTo(0.25, 5);
|
|
1590
|
+
// Exactly one overlay at most over the whole sequence (the second
|
|
1591
|
+
// drag's) — never two.
|
|
1592
|
+
expect(document.querySelectorAll(".lvt-area-select-overlay").length).toBe(0);
|
|
1593
|
+
});
|
|
1594
|
+
});
|
|
1595
|
+
describe("handleURLHashDirective", () => {
|
|
1596
|
+
// The directive is a module-level singleton (a Map of armed elements
|
|
1597
|
+
// plus a single window-level hashchange listener), so every test
|
|
1598
|
+
// must tear down to avoid bleed between cases.
|
|
1599
|
+
afterEach(() => {
|
|
1600
|
+
(0, directives_1.teardownURLHashForRoot)(document.body);
|
|
1601
|
+
document.body.innerHTML = "";
|
|
1602
|
+
// Body persists across tests (innerHTML only resets descendants);
|
|
1603
|
+
// wipe attributes the previous test set on body itself.
|
|
1604
|
+
document.body.removeAttribute("lvt-fx:url-hash");
|
|
1605
|
+
document.body.removeAttribute("data-lvt-url-hash");
|
|
1606
|
+
// Reset URL hash without touching history (the directive uses
|
|
1607
|
+
// pushState/replaceState; jsdom keeps them isolated per test).
|
|
1608
|
+
window.history.replaceState(null, "", window.location.pathname);
|
|
1609
|
+
// The unencoded-hash warning dedupe Set is module-level and
|
|
1610
|
+
// outlives a single test; reset so a hash value reused across
|
|
1611
|
+
// tests still warns.
|
|
1612
|
+
(0, directives_1.__resetURLHashUnencodedWarnedForTesting)();
|
|
1613
|
+
jest.restoreAllMocks();
|
|
1614
|
+
});
|
|
1615
|
+
function mountBody(dataHash, action = "setURLHash") {
|
|
1616
|
+
const body = document.body;
|
|
1617
|
+
body.setAttribute("lvt-fx:url-hash", action);
|
|
1618
|
+
body.setAttribute("data-lvt-url-hash", dataHash);
|
|
1619
|
+
return body;
|
|
1620
|
+
}
|
|
1621
|
+
it("on first arm with empty location.hash and non-empty data-attr, mirrors data-attr into location.hash", () => {
|
|
1622
|
+
mountBody("README.md:L4");
|
|
1623
|
+
const send = jest.fn();
|
|
1624
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1625
|
+
expect(window.location.hash).toBe("#README.md:L4");
|
|
1626
|
+
// No dispatch because the URL didn't drive the change — the server
|
|
1627
|
+
// already knew the state (the data-attr came FROM the server).
|
|
1628
|
+
expect(send).not.toHaveBeenCalled();
|
|
1629
|
+
});
|
|
1630
|
+
it("on first arm with empty URL, uses replaceState (not pushState) — Back must not loop", () => {
|
|
1631
|
+
// Bug pinned: when initial URL is empty AND server has a hash,
|
|
1632
|
+
// the mirror step uses pushState by the path-comparison rule
|
|
1633
|
+
// ("" → "README.md" is a path change). That leaves a history
|
|
1634
|
+
// entry where Back lands the user on `url-without-hash`, which
|
|
1635
|
+
// re-arms the directive, which pushes the same hash again. Loop.
|
|
1636
|
+
// Fix: empty currentLocation → always replaceState.
|
|
1637
|
+
const lengthBefore = window.history.length;
|
|
1638
|
+
mountBody("README.md:L4");
|
|
1639
|
+
const send = jest.fn();
|
|
1640
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1641
|
+
expect(window.location.hash).toBe("#README.md:L4");
|
|
1642
|
+
expect(window.history.length).toBe(lengthBefore);
|
|
1643
|
+
});
|
|
1644
|
+
it("on first arm with non-empty location.hash differing from data-attr, dispatches the action with the URL hash", () => {
|
|
1645
|
+
window.history.replaceState(null, "", "#README.md:L4");
|
|
1646
|
+
mountBody(""); // server hasn't seen the hash yet
|
|
1647
|
+
const send = jest.fn();
|
|
1648
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1649
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
1650
|
+
expect(send.mock.calls[0][0]).toEqual({
|
|
1651
|
+
action: "setURLHash",
|
|
1652
|
+
data: { hash: "README.md:L4" },
|
|
1653
|
+
});
|
|
1654
|
+
// The URL is not rewritten — the server's next render will produce
|
|
1655
|
+
// the canonical data-attr, and we'll converge then.
|
|
1656
|
+
expect(window.location.hash).toBe("#README.md:L4");
|
|
1657
|
+
});
|
|
1658
|
+
it("on first arm with empty location.hash and empty data-attr, no-op (no dispatch, no URL write)", () => {
|
|
1659
|
+
mountBody("");
|
|
1660
|
+
const send = jest.fn();
|
|
1661
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1662
|
+
expect(send).not.toHaveBeenCalled();
|
|
1663
|
+
expect(window.location.hash).toBe("");
|
|
1664
|
+
});
|
|
1665
|
+
it("mirrors data-attr change to location.hash via replaceState when path component is unchanged", () => {
|
|
1666
|
+
mountBody("README.md:L4");
|
|
1667
|
+
const send = jest.fn();
|
|
1668
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1669
|
+
expect(window.location.hash).toBe("#README.md:L4");
|
|
1670
|
+
const lengthBefore = window.history.length;
|
|
1671
|
+
// Server re-render: same file, different line.
|
|
1672
|
+
document.body.setAttribute("data-lvt-url-hash", "README.md:L8");
|
|
1673
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1674
|
+
expect(window.location.hash).toBe("#README.md:L8");
|
|
1675
|
+
// replaceState keeps history depth flat: jsdom's history.length
|
|
1676
|
+
// increments only on pushState, not replaceState.
|
|
1677
|
+
expect(window.history.length).toBe(lengthBefore);
|
|
1678
|
+
});
|
|
1679
|
+
it("mirrors data-attr change to location.hash via pushState when path component changes", () => {
|
|
1680
|
+
mountBody("README.md:L4");
|
|
1681
|
+
const send = jest.fn();
|
|
1682
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1683
|
+
const lengthBefore = window.history.length;
|
|
1684
|
+
// Server re-render: different file.
|
|
1685
|
+
document.body.setAttribute("data-lvt-url-hash", "OTHER.md:L1");
|
|
1686
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1687
|
+
expect(window.location.hash).toBe("#OTHER.md:L1");
|
|
1688
|
+
expect(window.history.length).toBe(lengthBefore + 1);
|
|
1689
|
+
});
|
|
1690
|
+
it("on hashchange (user clicks a permalink), dispatches the action with the new hash", () => {
|
|
1691
|
+
mountBody("README.md:L4");
|
|
1692
|
+
const send = jest.fn();
|
|
1693
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1694
|
+
send.mockClear();
|
|
1695
|
+
// Simulate a user-driven hash change: set location.hash AND
|
|
1696
|
+
// synchronously fire the hashchange event. (jsdom queues
|
|
1697
|
+
// hashchange asynchronously when you assign location.hash, so we
|
|
1698
|
+
// dispatch manually to keep the test deterministic — same pattern
|
|
1699
|
+
// as area-select's synthetic pointer events.)
|
|
1700
|
+
window.history.replaceState(null, "", "#OTHER.md:L2");
|
|
1701
|
+
window.dispatchEvent(new Event("hashchange"));
|
|
1702
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
1703
|
+
expect(send.mock.calls[0][0]).toEqual({
|
|
1704
|
+
action: "setURLHash",
|
|
1705
|
+
data: { hash: "OTHER.md:L2" },
|
|
1706
|
+
});
|
|
1707
|
+
});
|
|
1708
|
+
it("idempotent re-arm with the same action does NOT add history entries", () => {
|
|
1709
|
+
mountBody("README.md:L4");
|
|
1710
|
+
const send = jest.fn();
|
|
1711
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1712
|
+
const lengthAfterArm = window.history.length;
|
|
1713
|
+
// Re-call with no data-attr change.
|
|
1714
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1715
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1716
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1717
|
+
expect(window.history.length).toBe(lengthAfterArm);
|
|
1718
|
+
expect(window.location.hash).toBe("#README.md:L4");
|
|
1719
|
+
});
|
|
1720
|
+
it("updateSend swaps the captured transport so a reconnect rebuilds dispatching", () => {
|
|
1721
|
+
mountBody("README.md:L4");
|
|
1722
|
+
const firstSend = jest.fn();
|
|
1723
|
+
(0, directives_1.handleURLHashDirective)(document.body, firstSend);
|
|
1724
|
+
firstSend.mockClear();
|
|
1725
|
+
// Re-arm with a NEW send (simulating a reconnect that rebuilt the
|
|
1726
|
+
// transport).
|
|
1727
|
+
const secondSend = jest.fn();
|
|
1728
|
+
(0, directives_1.handleURLHashDirective)(document.body, secondSend);
|
|
1729
|
+
// hashchange now should route through the second send only.
|
|
1730
|
+
window.history.replaceState(null, "", "#OTHER.md:L1");
|
|
1731
|
+
window.dispatchEvent(new Event("hashchange"));
|
|
1732
|
+
expect(firstSend).not.toHaveBeenCalled();
|
|
1733
|
+
expect(secondSend).toHaveBeenCalledTimes(1);
|
|
1734
|
+
expect(secondSend.mock.calls[0][0].data).toEqual({ hash: "OTHER.md:L1" });
|
|
1735
|
+
});
|
|
1736
|
+
it("teardown removes the armed element AND its hashchange listener", () => {
|
|
1737
|
+
mountBody("README.md:L4");
|
|
1738
|
+
const send = jest.fn();
|
|
1739
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1740
|
+
expect(window.location.hash).toBe("#README.md:L4");
|
|
1741
|
+
(0, directives_1.teardownURLHashForRoot)(document.body);
|
|
1742
|
+
// After teardown, a hashchange does NOT dispatch — the window
|
|
1743
|
+
// listener was removed when the armed map emptied.
|
|
1744
|
+
window.history.replaceState(null, "", "#OTHER.md:L1");
|
|
1745
|
+
window.dispatchEvent(new Event("hashchange"));
|
|
1746
|
+
expect(send).not.toHaveBeenCalled();
|
|
1747
|
+
});
|
|
1748
|
+
it("teardown via a descendant root cleans up a body-armed entry", () => {
|
|
1749
|
+
// Pin the body-ancestor branch of teardownURLHashForRoot: in
|
|
1750
|
+
// production, livetemplate calls teardown with the wrapper div
|
|
1751
|
+
// (inside body) as the root, but the directive lives on body.
|
|
1752
|
+
// The teardown must clean up the body entry too — otherwise
|
|
1753
|
+
// disconnect/reconnect cycles leak listeners.
|
|
1754
|
+
mountBody("README.md:L4");
|
|
1755
|
+
const wrapper = document.createElement("div");
|
|
1756
|
+
document.body.appendChild(wrapper);
|
|
1757
|
+
const send = jest.fn();
|
|
1758
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1759
|
+
(0, directives_1.teardownURLHashForRoot)(wrapper);
|
|
1760
|
+
window.history.replaceState(null, "", "#OTHER.md:L1");
|
|
1761
|
+
window.dispatchEvent(new Event("hashchange"));
|
|
1762
|
+
expect(send).not.toHaveBeenCalled();
|
|
1763
|
+
});
|
|
1764
|
+
it("sweep cleans up entries whose attribute was removed by a server diff", () => {
|
|
1765
|
+
mountBody("README.md:L4");
|
|
1766
|
+
const send = jest.fn();
|
|
1767
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1768
|
+
// Server diff removed the directive.
|
|
1769
|
+
document.body.removeAttribute("lvt-fx:url-hash");
|
|
1770
|
+
document.body.removeAttribute("data-lvt-url-hash");
|
|
1771
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1772
|
+
// The window listener should be gone now too — no dispatch.
|
|
1773
|
+
window.history.replaceState(null, "", "#OTHER.md:L1");
|
|
1774
|
+
window.dispatchEvent(new Event("hashchange"));
|
|
1775
|
+
expect(send).not.toHaveBeenCalled();
|
|
1776
|
+
});
|
|
1777
|
+
it("warns and skips when lvt-fx:url-hash is present but empty", () => {
|
|
1778
|
+
const warn = jest.spyOn(console, "warn").mockImplementation(() => { });
|
|
1779
|
+
document.body.setAttribute("lvt-fx:url-hash", "");
|
|
1780
|
+
const send = jest.fn();
|
|
1781
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1782
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("lvt-fx:url-hash requires an action name"));
|
|
1783
|
+
expect(send).not.toHaveBeenCalled();
|
|
1784
|
+
});
|
|
1785
|
+
it("ignores plain element-id hashes on initial load (no dispatch, no URL clobber)", () => {
|
|
1786
|
+
// Anchors like `#hero` (no `:`, no `/`, no `.`) belong to native
|
|
1787
|
+
// anchor / dialog / popover machinery — the directive must NOT
|
|
1788
|
+
// dispatch for them or it would race against setupHashLink.
|
|
1789
|
+
window.history.replaceState(null, "", "#hero");
|
|
1790
|
+
mountBody("");
|
|
1791
|
+
const send = jest.fn();
|
|
1792
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1793
|
+
expect(send).not.toHaveBeenCalled();
|
|
1794
|
+
expect(window.location.hash).toBe("#hero");
|
|
1795
|
+
});
|
|
1796
|
+
it("on initial load with non-deep-link hash AND non-empty server data-attr, leaves the URL alone", () => {
|
|
1797
|
+
// The browser navigated to `#hero` (a popover id, say). The
|
|
1798
|
+
// server happens to have selected a default file, so the data-
|
|
1799
|
+
// attr is non-empty. Before the fix, the else-branch mirrored
|
|
1800
|
+
// the server's hash into the URL and silently closed the
|
|
1801
|
+
// popover. After the fix, the URL is left alone — popover
|
|
1802
|
+
// wins.
|
|
1803
|
+
window.history.replaceState(null, "", "#hero");
|
|
1804
|
+
mountBody("README.md");
|
|
1805
|
+
const send = jest.fn();
|
|
1806
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1807
|
+
expect(send).not.toHaveBeenCalled();
|
|
1808
|
+
expect(window.location.hash).toBe("#hero");
|
|
1809
|
+
});
|
|
1810
|
+
it("server changing selection while a non-deep-link URL hash is open does NOT clobber", () => {
|
|
1811
|
+
// Round-8 bot edge case: case (b) leaves the URL on #hero and
|
|
1812
|
+
// seeds currentDataHash = dataHash. If the server then pushes a
|
|
1813
|
+
// DIFFERENT selection (rare in prereview — server state changes
|
|
1814
|
+
// only on user action — but possible via cross-tab sync or a
|
|
1815
|
+
// server-driven update), the mirror's path-comparison would
|
|
1816
|
+
// have written the new file's hash and clobbered #hero. Fixed
|
|
1817
|
+
// by generalising the non-deep-link-URL guard to cover any
|
|
1818
|
+
// dataHash, not just empty.
|
|
1819
|
+
window.history.replaceState(null, "", "#hero");
|
|
1820
|
+
mountBody("README.md");
|
|
1821
|
+
const send = jest.fn();
|
|
1822
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1823
|
+
expect(window.location.hash).toBe("#hero");
|
|
1824
|
+
// Server pushes a different selection — URL must NOT change.
|
|
1825
|
+
document.body.setAttribute("data-lvt-url-hash", "OTHER.md");
|
|
1826
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1827
|
+
expect(window.location.hash).toBe("#hero");
|
|
1828
|
+
});
|
|
1829
|
+
it("server clearing the data-attr DOES clear a deep-link URL hash (deliberate, symmetric)", () => {
|
|
1830
|
+
// Symmetric to "non-deep-link is never clobbered": if the URL
|
|
1831
|
+
// is a deep-link we own AND the server transitions to
|
|
1832
|
+
// no-selection, the URL should clear. Pin this as deliberate
|
|
1833
|
+
// so a future refactor doesn't accidentally make all
|
|
1834
|
+
// empty-dataHash mirrors no-op.
|
|
1835
|
+
mountBody("README.md:L4");
|
|
1836
|
+
const send = jest.fn();
|
|
1837
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1838
|
+
expect(window.location.hash).toBe("#README.md:L4");
|
|
1839
|
+
document.body.setAttribute("data-lvt-url-hash", "");
|
|
1840
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1841
|
+
expect(window.location.hash).toBe("");
|
|
1842
|
+
});
|
|
1843
|
+
it("deselection (deep-link → empty) uses pushState — Back returns to the selection (deliberate)", () => {
|
|
1844
|
+
// Pin the path-comparison branch's behavior for the deselect
|
|
1845
|
+
// case: server transitions `data-lvt-url-hash` from a selected
|
|
1846
|
+
// file to "", path comparison says oldPath !== newPath (one is
|
|
1847
|
+
// empty), the mirror uses pushState. That leaves a history
|
|
1848
|
+
// entry so Back returns the user to their prior selection.
|
|
1849
|
+
// Intentional — matches the file-switch UX.
|
|
1850
|
+
mountBody("README.md:L4");
|
|
1851
|
+
const send = jest.fn();
|
|
1852
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1853
|
+
expect(window.location.hash).toBe("#README.md:L4");
|
|
1854
|
+
const lengthBeforeDeselect = window.history.length;
|
|
1855
|
+
document.body.setAttribute("data-lvt-url-hash", "");
|
|
1856
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1857
|
+
expect(window.location.hash).toBe("");
|
|
1858
|
+
expect(window.history.length).toBe(lengthBeforeDeselect + 1);
|
|
1859
|
+
});
|
|
1860
|
+
it("server clearing the data-attr does NOT wipe a non-deep-link URL hash", () => {
|
|
1861
|
+
// Server first has README.md selected → URL becomes
|
|
1862
|
+
// `#README.md`. Then the user opens a popover whose id is
|
|
1863
|
+
// `#hero` (URL is now `#hero`). Then the server transitions to
|
|
1864
|
+
// no-selection (e.g. ClearSelection) and renders data-attr="".
|
|
1865
|
+
// The directive must not clobber the popover hash.
|
|
1866
|
+
mountBody("README.md");
|
|
1867
|
+
const send = jest.fn();
|
|
1868
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1869
|
+
expect(window.location.hash).toBe("#README.md");
|
|
1870
|
+
// User navigates to a popover-shaped hash (simulated).
|
|
1871
|
+
window.history.replaceState(null, "", "#hero");
|
|
1872
|
+
// Server clears state → empty data-attr.
|
|
1873
|
+
document.body.setAttribute("data-lvt-url-hash", "");
|
|
1874
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1875
|
+
expect(window.location.hash).toBe("#hero");
|
|
1876
|
+
});
|
|
1877
|
+
it("case-(b) + user clears URL: URL stays empty while server keeps its selection (deliberate divergence)", () => {
|
|
1878
|
+
// Load with a popover-shaped hash, server has a file selected.
|
|
1879
|
+
// Case (b) seeds entry.currentDataHash with the server's value.
|
|
1880
|
+
window.history.replaceState(null, "", "#hero");
|
|
1881
|
+
mountBody("README.md");
|
|
1882
|
+
const send = jest.fn();
|
|
1883
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1884
|
+
expect(window.location.hash).toBe("#hero");
|
|
1885
|
+
// User clears the URL bar.
|
|
1886
|
+
window.history.replaceState(null, "", window.location.pathname);
|
|
1887
|
+
window.dispatchEvent(new Event("hashchange"));
|
|
1888
|
+
expect(send).not.toHaveBeenCalled();
|
|
1889
|
+
// Server re-renders with the SAME selection (data-attr unchanged).
|
|
1890
|
+
// The early-exit guard (currentDataHash === dataHash) means we
|
|
1891
|
+
// never mirror — URL stays empty, server stays on README.md.
|
|
1892
|
+
// This divergence is intentional: case (b) said "the URL isn't
|
|
1893
|
+
// ours, leave it alone", and a user clearing the URL doesn't
|
|
1894
|
+
// change that — they're still navigating outside our turf.
|
|
1895
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1896
|
+
expect(window.location.hash).toBe("");
|
|
1897
|
+
expect(send).not.toHaveBeenCalled();
|
|
1898
|
+
// Convergence only happens when the user takes an in-app action
|
|
1899
|
+
// that changes server state (e.g. SelectFile to OTHER.md): the
|
|
1900
|
+
// data-attr now differs from currentDataHash and the mirror
|
|
1901
|
+
// step writes the new hash.
|
|
1902
|
+
document.body.setAttribute("data-lvt-url-hash", "OTHER.md");
|
|
1903
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1904
|
+
expect(window.location.hash).toBe("#OTHER.md");
|
|
1905
|
+
});
|
|
1906
|
+
it("user clearing the URL hash (hashchange to empty) does NOT dispatch — server stays source of truth", () => {
|
|
1907
|
+
// The directive treats the server as source of truth for
|
|
1908
|
+
// "what's selected". An empty location.hash is "user navigated
|
|
1909
|
+
// away from the hash" but not "deselect". Deselect happens via
|
|
1910
|
+
// in-app affordances that flow through the server, which then
|
|
1911
|
+
// emits an empty data-attr.
|
|
1912
|
+
mountBody("README.md:L4");
|
|
1913
|
+
const send = jest.fn();
|
|
1914
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1915
|
+
send.mockClear();
|
|
1916
|
+
// User clears the URL bar.
|
|
1917
|
+
window.history.replaceState(null, "", window.location.pathname);
|
|
1918
|
+
window.dispatchEvent(new Event("hashchange"));
|
|
1919
|
+
expect(send).not.toHaveBeenCalled();
|
|
1920
|
+
});
|
|
1921
|
+
it("ignores plain element-id hashes on hashchange", () => {
|
|
1922
|
+
mountBody("README.md:L4");
|
|
1923
|
+
const send = jest.fn();
|
|
1924
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1925
|
+
send.mockClear();
|
|
1926
|
+
// User clicks an HTML anchor link (e.g. inside the TOC overlay).
|
|
1927
|
+
window.history.replaceState(null, "", "#some-section");
|
|
1928
|
+
window.dispatchEvent(new Event("hashchange"));
|
|
1929
|
+
expect(send).not.toHaveBeenCalled();
|
|
1930
|
+
});
|
|
1931
|
+
it("preserves history.state across the mirror write (doesn't clobber co-tenant SPA state)", () => {
|
|
1932
|
+
// Co-tenant code stores something in history.state — e.g. scroll
|
|
1933
|
+
// position or modal flag. Our directive's push/replaceState must
|
|
1934
|
+
// pass that state through, not overwrite with null.
|
|
1935
|
+
window.history.replaceState({ scroll: 42, modal: "open" }, "", window.location.pathname);
|
|
1936
|
+
mountBody("README.md:L4");
|
|
1937
|
+
const send = jest.fn();
|
|
1938
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1939
|
+
expect(window.location.hash).toBe("#README.md:L4");
|
|
1940
|
+
expect(window.history.state).toEqual({ scroll: 42, modal: "open" });
|
|
1941
|
+
// Same on a path-change pushState path.
|
|
1942
|
+
document.body.setAttribute("data-lvt-url-hash", "OTHER.md");
|
|
1943
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1944
|
+
expect(window.location.hash).toBe("#OTHER.md");
|
|
1945
|
+
expect(window.history.state).toEqual({ scroll: 42, modal: "open" });
|
|
1946
|
+
});
|
|
1947
|
+
it("warns when data-lvt-url-hash contains characters that should be percent-encoded", () => {
|
|
1948
|
+
const warn = jest.spyOn(console, "warn").mockImplementation(() => { });
|
|
1949
|
+
// Note: jsdom's location.hash setter percent-encodes spaces, so
|
|
1950
|
+
// we mount the directive with a value containing a literal space
|
|
1951
|
+
// and expect a warn before the directive writes the URL.
|
|
1952
|
+
mountBody("path with space.md");
|
|
1953
|
+
(0, directives_1.handleURLHashDirective)(document.body, jest.fn());
|
|
1954
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("should be percent-encoded"));
|
|
1955
|
+
});
|
|
1956
|
+
it.each([
|
|
1957
|
+
["bracket [", "path[v1].md"],
|
|
1958
|
+
["bracket ]", "list]item.md"],
|
|
1959
|
+
["raw percent", "50%off.md"],
|
|
1960
|
+
["raw quote", `name".md`],
|
|
1961
|
+
["raw less-than", "x<y.md"],
|
|
1962
|
+
])("warns on %s", (_label, hash) => {
|
|
1963
|
+
const warn = jest.spyOn(console, "warn").mockImplementation(() => { });
|
|
1964
|
+
mountBody(hash);
|
|
1965
|
+
(0, directives_1.handleURLHashDirective)(document.body, jest.fn());
|
|
1966
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("should be percent-encoded"));
|
|
1967
|
+
});
|
|
1968
|
+
it("valid percent-encoded sequence does NOT warn", () => {
|
|
1969
|
+
// `%20` is a properly percent-encoded space — no warn.
|
|
1970
|
+
const warn = jest.spyOn(console, "warn").mockImplementation(() => { });
|
|
1971
|
+
mountBody("path%20with%20space.md");
|
|
1972
|
+
(0, directives_1.handleURLHashDirective)(document.body, jest.fn());
|
|
1973
|
+
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("should be percent-encoded"));
|
|
1974
|
+
});
|
|
1975
|
+
it("data-attr update unchanged from last mirror is a no-op (no history pollution)", () => {
|
|
1976
|
+
mountBody("README.md:L4");
|
|
1977
|
+
const send = jest.fn();
|
|
1978
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1979
|
+
// User clicks a permalink anchor → location.hash changes to the
|
|
1980
|
+
// same value the data-attr already had. The hashchange dispatch
|
|
1981
|
+
// updates currentDataHash to the same value; subsequent renders
|
|
1982
|
+
// with the same data-attr should still no-op (no extra history
|
|
1983
|
+
// entries when the server echoes back the same hash).
|
|
1984
|
+
window.history.replaceState(null, "", "#OTHER.md:L9");
|
|
1985
|
+
window.dispatchEvent(new Event("hashchange"));
|
|
1986
|
+
const lengthBefore = window.history.length;
|
|
1987
|
+
send.mockClear();
|
|
1988
|
+
document.body.setAttribute("data-lvt-url-hash", "OTHER.md:L9");
|
|
1989
|
+
(0, directives_1.handleURLHashDirective)(document.body, send);
|
|
1990
|
+
expect(window.history.length).toBe(lengthBefore);
|
|
1991
|
+
expect(window.location.hash).toBe("#OTHER.md:L9");
|
|
1992
|
+
});
|
|
1993
|
+
});
|
|
903
1994
|
//# sourceMappingURL=directives.test.js.map
|