1a6308aba7
A test and code-analysis round of 0.2.72 to 0.2.76 (suites at every tag, sanitizers, ESP32 and GitHub builds, host harnesses on both boards, the editor in a browser, best practice checked against ESPHome, LVGL, Home Assistant and Vue) found these, fixed here with Max's choices for the product questions: - Firmware 0.2.65: the second hand of a single analog clock moves again; on a page that is one full on/off tile the waking push also switches it; on the Guition a flick is never a tap and a finger that slides off a tile doesn't tap it; Previous/Next react to a firm press; the same Go to page tile may sit on several pages; a layout the screen has no memory for is refused instead of crashing; 64-bit redraw mask for 48 tiles; Montserrat 14 no longer built. - App: Save refuses a layout too long for one message or too big for eight pages; an offline screen keeps its firmware version from the device registry; HA refusals reach the editor; 128 KB requests; changelog out of the live stream; cached hashed assets; tini as init and a clean stop; changelog parsing without asterisks or crashes. - Editor: clicking the open screen keeps unsaved edits, edits during a save stay unsaved, a Page row and phone dragging to other pages, repeated page tiles, limits from the server, HA's error messages, a type-checked build. - tools/check.sh + GitHub Actions + a CYD flash budget; docs match 48 tiles and the new editor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
164 lines
9.4 KiB
TypeScript
164 lines
9.4 KiB
TypeScript
// Pointer-based drag & drop, mouse and touch, from the library into the mockup and between
|
|
// cells. Touch starts after a short hold so the page still scrolls. While dragging, the
|
|
// mockup already shows where everything ends up; the drop confirms exactly that, and a
|
|
// drop off the grid changes nothing. A finished drag never doubles as a click.
|
|
import type { Directive } from "vue";
|
|
import { arrange, newTile } from "./model/layout";
|
|
import { markDirty, placeTile, state } from "./store";
|
|
import type { Tile } from "./types";
|
|
|
|
export type DragSource = { kind: "tile"; tile: Tile } | { kind: "entity"; id: string };
|
|
type Drag = {
|
|
source: DragSource | null; element: HTMLElement | null; ghost: HTMLElement | null; timer: number;
|
|
start: { x: number; y: number } | null; offset: { x: number; y: number }; pointerId: number | null;
|
|
suppressUntil: number; last: { x: number; y: number } | null; scroller: number; target: number | null;
|
|
};
|
|
const drag: Drag = { source: null, element: null, ghost: null, timer: 0, start: null, offset: { x: 0, y: 0 }, pointerId: null, suppressUntil: 0, last: null, scroller: 0, target: null };
|
|
|
|
export const vDrag: Directive<HTMLElement, DragSource> = {
|
|
mounted(element, binding) {
|
|
(element as any).__dragSource = binding.value;
|
|
element.addEventListener("pointerdown", (e: PointerEvent) => {
|
|
const source = (element as any).__dragSource as DragSource;
|
|
if (e.button !== 0 || (element as HTMLButtonElement).disabled || (e.target as HTMLElement).closest(".remove")) return;
|
|
// No text selection while the mouse drags; touch keeps its default so the page can scroll.
|
|
if (e.pointerType !== "touch") e.preventDefault();
|
|
Object.assign(drag, { source, element, start: { x: e.clientX, y: e.clientY }, pointerId: e.pointerId });
|
|
// A fast flick may leave the card before its first move event: keep the pointer until the drag begins.
|
|
try { element.setPointerCapture(e.pointerId); } catch {}
|
|
clearTimeout(drag.timer);
|
|
if (e.pointerType === "touch") drag.timer = window.setTimeout(() => beginDrag(e), 260);
|
|
});
|
|
element.addEventListener("pointermove", (e: PointerEvent) => {
|
|
if (state.drag.active || !drag.start || drag.element !== element) return;
|
|
const distance = Math.hypot(e.clientX - drag.start.x, e.clientY - drag.start.y);
|
|
if (e.pointerType === "touch") { if (distance > 10) { clearTimeout(drag.timer); drag.start = null; } return; }
|
|
if (distance >= 6) beginDrag(e);
|
|
});
|
|
const cancel = () => { if (drag.element === element && !state.drag.active) { clearTimeout(drag.timer); drag.start = null; } };
|
|
element.addEventListener("pointerup", cancel);
|
|
element.addEventListener("pointercancel", cancel);
|
|
},
|
|
updated(element, binding) {
|
|
(element as any).__dragSource = binding.value;
|
|
},
|
|
};
|
|
|
|
function beginDrag(e: PointerEvent) {
|
|
if (state.drag.active || !drag.start || !drag.source || !drag.element) return;
|
|
state.drag.active = true;
|
|
state.drag.moving = drag.source.kind === "tile" ? drag.source.tile : newTile(drag.source.id);
|
|
state.drag.preview = null;
|
|
drag.target = null;
|
|
getSelection()?.removeAllRanges();
|
|
const rect = drag.element.getBoundingClientRect();
|
|
const ghost = drag.element.cloneNode(true) as HTMLElement;
|
|
ghost.classList.add("drag-ghost");
|
|
ghost.style.width = `${rect.width}px`;
|
|
ghost.style.height = `${rect.height}px`;
|
|
drag.offset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
document.body.append(ghost);
|
|
drag.ghost = ghost;
|
|
// The mockup re-renders while hovering, so the pointer is followed on the document, not the card.
|
|
document.addEventListener("pointermove", moveDrag);
|
|
document.addEventListener("pointerup", finishDrag);
|
|
document.addEventListener("pointercancel", finishDrag);
|
|
try { drag.element.releasePointerCapture(drag.pointerId!); } catch {}
|
|
try { document.documentElement.setPointerCapture(drag.pointerId!); } catch {}
|
|
document.addEventListener("touchmove", blockScroll, { passive: false });
|
|
// Near the edges the pages scroll along, so every page can be reached.
|
|
drag.scroller = window.setInterval(() => {
|
|
if (!drag.last) return;
|
|
const { x, y } = dragScrollers(document);
|
|
const dx = x ? edgeStep(drag.last.x, visibleSpan(x, "x")) : 0;
|
|
const dy = y ? edgeStep(drag.last.y, visibleSpan(y, "y")) : 0;
|
|
if (dx) x!.scrollBy(dx, 0);
|
|
if (dy) y!.scrollBy(0, dy);
|
|
if (dx || dy) setTarget(slotAt(drag.last.x, drag.last.y));
|
|
}, 16);
|
|
setTarget(-1);
|
|
moveDrag(e);
|
|
}
|
|
// True when the element scrolls along that axis: more content than room, and an overflow that lets it scroll.
|
|
export function scrollsAlong(element: Element | null, axis: "x" | "y") {
|
|
if (!element) return false;
|
|
const more = axis === "x" ? element.scrollWidth > element.clientWidth : element.scrollHeight > element.clientHeight;
|
|
const overflow = getComputedStyle(element)[axis === "x" ? "overflowX" : "overflowY"];
|
|
return more && (overflow === "auto" || overflow === "scroll");
|
|
}
|
|
// What a drag near an edge scrolls, per axis (app 0.2.78). A wide window scrolls the canvas both ways. At 960 px and
|
|
// narrower (a phone, also in the Home Assistant app) the canvas grows with its content: the row of pages scrolls
|
|
// sideways and the page itself up and down, so a tile can still reach page 2 and beyond.
|
|
export function dragScrollers(doc: Document): { x: Element | null; y: Element | null } {
|
|
const pages = doc.querySelector(".pages"), canvas = doc.querySelector(".canvas");
|
|
return {
|
|
x: scrollsAlong(pages, "x") ? pages : canvas,
|
|
y: scrollsAlong(canvas, "y") ? canvas : doc.scrollingElement,
|
|
};
|
|
}
|
|
// The part of a scroller on screen along one axis; the page itself is the whole window.
|
|
function visibleSpan(element: Element, axis: "x" | "y"): [number, number] {
|
|
const view = axis === "x" ? window.innerWidth : window.innerHeight;
|
|
if (element === element.ownerDocument.scrollingElement) return [0, view];
|
|
const r = element.getBoundingClientRect();
|
|
return axis === "x" ? [Math.max(r.left, 0), Math.min(r.right, view)] : [Math.max(r.top, 0), Math.min(r.bottom, view)];
|
|
}
|
|
// Within 60 px of either end the scroller moves 12 px per step towards that end.
|
|
export function edgeStep(position: number, [start, end]: [number, number]) {
|
|
return position < start + 60 ? -12 : position > end - 60 ? 12 : 0;
|
|
}
|
|
function blockScroll(e: TouchEvent) { if (state.drag.active) e.preventDefault(); }
|
|
function finishDrag(e: PointerEvent) { if (e.pointerId === drag.pointerId) endDrag(e.type === "pointerup"); }
|
|
function moveDrag(e: PointerEvent) {
|
|
if (!drag.ghost || e.pointerId !== drag.pointerId) return;
|
|
drag.last = { x: e.clientX, y: e.clientY };
|
|
drag.ghost.style.transform = `translate(${e.clientX - drag.offset.x}px, ${e.clientY - drag.offset.y}px)`;
|
|
setTarget(slotAt(e.clientX, e.clientY));
|
|
}
|
|
// The cell under the pointer: the nearest card or empty cell (the gaps between them count
|
|
// too); on a wide card the left or right half decides. -1 away from the mockup.
|
|
function slotAt(x: number, y: number) {
|
|
let best: { cell: HTMLElement; r: DOMRect } | null = null, nearest = Infinity;
|
|
for (const cell of document.querySelectorAll<HTMLElement>(".pages [data-slot]")) {
|
|
const r = cell.getBoundingClientRect();
|
|
const distance = Math.hypot(Math.max(r.left - x, 0, x - r.right), Math.max(r.top - y, 0, y - r.bottom));
|
|
if (distance < nearest) { nearest = distance; best = { cell, r }; }
|
|
}
|
|
if (!best || nearest > 16) return -1;
|
|
let slot = Number(best.cell.dataset.slot);
|
|
if (best.cell.classList.contains("wide") && x > (best.r.left + best.r.right) / 2) slot += 1;
|
|
return slot;
|
|
}
|
|
function setTarget(slot: number) {
|
|
if (drag.target === slot || !state.layout || !state.drag.moving) return;
|
|
drag.target = slot;
|
|
// Off the grid: a tile from the grid shows where it came from; a new one shows nowhere yet.
|
|
state.drag.preview = slot >= 0 ? arrange(state.layout.tiles, state.drag.moving, slot) : null;
|
|
}
|
|
function endDrag(drop: boolean) {
|
|
const preview = state.drag.preview, moving = state.drag.moving;
|
|
document.removeEventListener("pointermove", moveDrag);
|
|
document.removeEventListener("pointerup", finishDrag);
|
|
document.removeEventListener("pointercancel", finishDrag);
|
|
document.removeEventListener("touchmove", blockScroll);
|
|
try { document.documentElement.releasePointerCapture(drag.pointerId!); } catch {}
|
|
drag.ghost?.remove();
|
|
Object.assign(drag, { ghost: null, suppressUntil: Date.now() + 400, last: null, start: null, target: null, element: null, source: null });
|
|
clearInterval(drag.scroller);
|
|
state.drag.active = false;
|
|
state.drag.preview = null;
|
|
state.drag.moving = null;
|
|
if (drop && preview && moving && state.layout) {
|
|
const before = state.layout.tiles.map((t) => `${t.entity}@${t.slot}`).join();
|
|
// By the tile itself, not its entity: a screen can have several tiles that go to the same page (firmware 0.2.65).
|
|
const added = !state.layout.tiles.includes(moving);
|
|
for (const { tile, slot } of preview) { tile.slot = slot; if (!state.layout.tiles.includes(tile)) state.layout.tiles.push(tile); }
|
|
state.layout.tiles.sort((a, b) => a.slot - b.slot);
|
|
if (state.layout.tiles.map((t) => `${t.entity}@${t.slot}`).join() !== before) markDirty();
|
|
// Placing goes through the same rules as a click; a new tile also asks for its capabilities.
|
|
if (added) placeTile(moving, moving.slot);
|
|
}
|
|
}
|
|
window.addEventListener("click", (e) => { if (Date.now() < drag.suppressUntil) { e.stopPropagation(); e.preventDefault(); } }, true);
|
|
export const dragSuppressed = () => Date.now() < drag.suppressUntil;
|