Prevent cancelled drags from committing moves
All checks were successful
Deploy Sokko G / deploy (push) Successful in 19s

This commit is contained in:
Shinuwa 2026-09-13 08:37:16 +02:00
parent e6e08b458c
commit 6bcde57e88
6 changed files with 148 additions and 12 deletions

View file

@ -43,3 +43,4 @@
{"id":"int-224b7820169f57116ea4fd87a50560f1","kind":"field_change","created_at":"2026-09-12T21:26:21.88315065Z","actor":"Shinuwa","issue_id":"sokko-g-bhy","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented selective images/imageAnnotation hydration with local retry, preserved unread values in atomic snapshots, and serialized complete exports without populating the UI cache. No schema change; timer/calendar remain eager. check:full passed including 52 E2E; four production scenarios profiled alone, five reloads each. 40-media baseline 120.68 to 2.78 MiB JS heap, 610.1 to 445.4 ms median ready. Reports in .local/performance; docs updated. Opened media remain cached until reload. No git commit or push performed."}}
{"id":"int-4036248c53312cfaafae4ee37bb145bd","kind":"field_change","created_at":"2026-09-12T21:26:40.724321767Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
{"id":"int-37bf322d7d6ae9d06e033d9f16b7f057","kind":"field_change","created_at":"2026-09-12T21:33:50.250082802Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
{"id":"int-cc87d51843f7ee62beb6445939aa30d5","kind":"field_change","created_at":"2026-09-13T06:35:52.804580049Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}

View file

@ -33,6 +33,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Pour l'arbre d'objectifs, vérifier la sélection après suppression/import et le retour à Base quand le nœud sélectionné devient une étape sans onglet Contenu.
- Vérifier le mode une colonne et deux colonnes.
- Vérifier que l'ajout, la suppression et la réorganisation respectent `moduleOrder.one` et `moduleOrder.two`.
- Pour les déplacements par pointeur, vérifier les dépôts avant/après, le rejet des cibles interdites et l'annulation navigateur (`pointercancel`) sans modification de l'ordre ; le démontage doit retirer les listeners actifs.
- Vérifier le quota de stockage si l'outil manipule des données lourdes.
## Page Jeu

View file

@ -0,0 +1,48 @@
// Rôle : expose les hooks réels de formulaire et déplacement sous StrictMode pour les tests navigateur.
import { createElement as h, StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { useDraftForm } from "../../website/src/hooks/useDraftForm.js";
import { usePointerReorder } from "../../website/src/hooks/usePointerReorder.js";
export function mountFormReorderProbe(element, kind) {
const events = [];
const root = createRoot(element);
function Form() {
const form = useDraftForm({ title: "Initial", detail: "Kept" });
return h("form", { onSubmit: (event) => {
form.handleSubmit(event, (values, { reset }) => {
events.push({ ...values });
if (!values.title.trim()) return "Required";
reset();
});
events.push({ prevented: event.defaultPrevented });
} },
h("input", form.getFieldProps("title", { "aria-label": "Title", onChange: () => events.push("changed") })),
h("input", form.getFieldProps("detail", { "aria-label": "Detail" })),
h("p", { role: "alert" }, form.error),
h("button", { type: "submit" }, "Submit"),
h("button", { type: "button", onClick: () => form.updateValues({ title: "Patched" }) }, "Patch"),
h("button", { type: "button", onClick: () => form.reset({ title: "Replacement", detail: "New" }) }, "Replace"));
}
function Reorder({ revision }) {
const reorder = usePointerReorder({
targetSelector: "[data-probe-item]",
getTargetId: (element) => element.dataset.probeItem,
canDropOn: (element) => element.dataset.probeItem !== "blocked",
onMove: (source, target, placement) => events.push({ source, target, placement, revision })
});
return h("section", null,
h("output", { "aria-label": "Dragging", style: { display: "block", height: 24 } }, reorder.draggingId),
...["a", "b", "blocked"].map((id) => h("div", {
key: id, "data-probe-item": id,
style: { height: 100, width: 300, border: "1px solid", margin: 10 }
}, h("button", { type: "button", onPointerDown: (event) => reorder.startDrag(event, id) }, id))));
}
const Component = kind === "form" ? Form : Reorder;
function update(revision = 0) {
flushSync(() => root.render(h(StrictMode, null, h(Component, { revision }))));
}
update();
return { events, update, unmount: () => root.unmount() };
}

92
e2e/form-reorder.spec.js Normal file
View file

@ -0,0 +1,92 @@
// Rôle : vérifie les brouillons de formulaire et le cycle de déplacement par pointeur sans assertions de code source.
/* global window, document */
import { test, expect } from "@playwright/test";
import { fileURLToPath, URL } from "node:url";
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/form-reorder-probe.js", import.meta.url))}`;
async function setup(page, kind) {
await page.goto("/");
await page.evaluate(async ({ url, kind }) => {
const { mountFormReorderProbe } = await import(url);
document.getElementById("app").style.display = "none";
const element = document.createElement("div");
document.body.prepend(element);
window.interactionProbe = mountFormReorderProbe(element, kind);
}, { url: probeUrl, kind });
}
test("draft forms retain edits, report validation, prevent navigation and reset after success", async ({ page }) => {
await setup(page, "form");
const title = page.getByRole("textbox", { name: "Title", exact: true });
const detail = page.getByRole("textbox", { name: "Detail", exact: true });
await title.fill("");
await page.getByRole("button", { name: "Submit", exact: true }).click();
await expect(page.getByRole("alert")).toHaveText("Required");
await title.fill("Draft");
await expect(page.getByRole("alert")).toBeEmpty();
await page.evaluate(() => window.interactionProbe.update(1));
await expect(title).toHaveValue("Draft");
await expect(detail).toHaveValue("Kept");
await page.getByRole("button", { name: "Patch", exact: true }).click();
await expect(title).toHaveValue("Patched");
await expect(detail).toHaveValue("Kept");
await page.getByRole("button", { name: "Replace", exact: true }).click();
await expect(title).toHaveValue("Replacement");
await expect(detail).toHaveValue("New");
await title.press("Enter");
await expect(title).toHaveValue("Initial");
await expect(detail).toHaveValue("Kept");
expect(await page.evaluate(() => window.interactionProbe.events)).toEqual([
"changed", { title: "", detail: "Kept" }, { prevented: true },
"changed", { title: "Replacement", detail: "New" }, { prevented: true }
]);
});
async function start(page) {
await page.getByRole("button", { name: "a", exact: true }).hover();
await page.mouse.down();
await expect(page.getByLabel("Dragging")).toHaveText("a");
}
test("pointer drops resolve before and after using the latest callback and reject invalid targets", async ({ page }) => {
await setup(page, "reorder");
await page.getByRole("button", { name: "a", exact: true }).hover();
await page.mouse.down({ button: "right" });
await expect(page.getByLabel("Dragging")).toBeEmpty();
await page.mouse.up({ button: "right" });
const target = await page.locator('[data-probe-item="b"]').boundingBox();
for (const [offset, placement] of [[10, "before"], [90, "after"]]) {
await start(page);
await page.evaluate(() => window.interactionProbe.update(1));
await page.mouse.move(target.x + 200, target.y + offset);
await page.mouse.up();
await expect(page.getByLabel("Dragging")).toBeEmpty();
expect(await page.evaluate(() => window.interactionProbe.events.at(-1))).toEqual({ source: "a", target: "b", placement, revision: 1 });
}
await start(page);
await page.locator('[data-probe-item="blocked"]').hover();
await page.mouse.up();
await start(page);
await page.mouse.up();
expect(await page.evaluate(() => window.interactionProbe.events.length)).toBe(2);
});
test("pointer cancellation never commits a drop and unmount removes active listeners", async ({ page }) => {
await setup(page, "reorder");
const target = await page.locator('[data-probe-item="b"]').boundingBox();
await start(page);
await page.mouse.move(target.x + 200, target.y + 50);
await page.getByRole("button", { name: "a", exact: true }).dispatchEvent("pointercancel", { clientX: target.x + 200, clientY: target.y + 50, pointerId: 1 });
await expect(page.getByLabel("Dragging")).toBeEmpty();
await page.mouse.up();
expect(await page.evaluate(() => window.interactionProbe.events)).toEqual([]);
await start(page);
await page.evaluate(() => {
const remainingTarget = document.querySelector('[data-probe-item="b"]').cloneNode(true);
window.interactionProbe.unmount();
document.body.prepend(remainingTarget);
});
await page.locator('[data-probe-item="b"]').hover();
await page.mouse.up();
expect(await page.evaluate(() => window.interactionProbe.events)).toEqual([]);
});

View file

@ -29,9 +29,7 @@ test("toolbox cards and pages retain their public UI conventions", async () => {
const storageQuota = await readFile("website/src/components/StorageQuota.jsx", "utf8");
const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8");
const guidedTooltipTour = await readFile("website/src/components/GuidedTooltipTour.jsx", "utf8");
const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8");
const groupedReorderHook = await readFile("website/src/hooks/useGroupedReorder.js", "utf8");
const draftFormHook = await readFile("website/src/hooks/useDraftForm.js", "utf8");
assert.match(toolboxActions, /creationTemplate/);
assert.match(toolboxActions, /type: "memos"/);
@ -101,20 +99,12 @@ test("toolbox cards and pages retain their public UI conventions", async () => {
assert.match(storageQuota, /role="progressbar"/);
assert.match(importButton, /export function ImportButton/);
assert.match(importButton, /accept="application\/json"/);
assert.match(reorderHook, /export function usePointerReorder/);
assert.match(reorderHook, /setPointerCapture/);
assert.match(reorderHook, /elementsFromPoint/);
assert.match(reorderHook, /elementFromPoint/);
assert.match(groupedReorderHook, /export function useGroupedReorder/);
assert.match(groupedReorderHook, /usePointerReorder/);
assert.match(groupedReorderHook, /export function getGroupedEntries/);
assert.match(groupedReorderHook, /export function applyGroupedReorderOperation/);
assert.match(groupedReorderHook, /data-reorder-orientation/);
assert.match(groupedReorderHook, /operation\.sourceParentId === operation\.targetParentId/);
assert.match(draftFormHook, /export function useDraftForm/);
assert.match(draftFormHook, /getFieldProps/);
assert.match(draftFormHook, /handleSubmit/);
assert.match(draftFormHook, /preventDefault/);
assert.match(toolboxCoverEditor, /image\/webp/);
assert.match(toolboxCoverEditor, /OUTPUT_WIDTH = 620/);
assert.match(toolboxCoverEditor, /OUTPUT_HEIGHT = 248/);

View file

@ -50,17 +50,21 @@ export function usePointerReorder({
const { onMove } = optionsRef.current;
const target = getDropTarget(event);
if (target.id) onMove?.(draggingId, target.id, target.placement);
handlePointerCancel();
}
function handlePointerCancel() {
setDraggingId("");
setDropTarget({ id: "", placement: "before" });
}
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp, { once: true });
window.addEventListener("pointercancel", handlePointerUp, { once: true });
window.addEventListener("pointercancel", handlePointerCancel, { once: true });
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerCancel);
};
}, [draggingId, targetSelector]);