diff --git a/index.html b/index.html
new file mode 100644
index 0000000..569e0df
--- /dev/null
+++ b/index.html
@@ -0,0 +1,235 @@
+
+
+
+
+
+ draw
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/main.js b/main.js
new file mode 100644
index 0000000..a85a41e
--- /dev/null
+++ b/main.js
@@ -0,0 +1,1376 @@
+import { getStroke } from "perfect-freehand";
+
+const canvas = document.getElementById("canvas");
+const ctx = canvas.getContext("2d");
+
+const hud = document.getElementById("hud");
+const hudTop = document.querySelector(".hud-top");
+const hudDragHandle = document.getElementById("hudDragHandle");
+const hudHoverGap = document.getElementById("hudHoverGap");
+
+const toggleTheme = document.getElementById("toggleTheme");
+const cycleCursor = document.getElementById("cycleCursor");
+const cursorDot = document.getElementById("cursorDot");
+
+const size = document.getElementById("size");
+const sizeRange = document.getElementById("sizeRange");
+const thinning = document.getElementById("thinning");
+const thinningRange = document.getElementById("thinningRange");
+const smoothing = document.getElementById("smoothing");
+const smoothingRange = document.getElementById("smoothingRange");
+const streamline = document.getElementById("streamline");
+const streamlineRange = document.getElementById("streamlineRange");
+const pressureMode = document.getElementById("pressureMode");
+const eraser = document.getElementById("eraser");
+const strokeColor = document.getElementById("strokeColor");
+const brushPreview = document.getElementById("brushPreview");
+const colorA = document.getElementById("colorA");
+const colorB = document.getElementById("colorB");
+const gradientMix = document.getElementById("gradientMix");
+const gradientMixRange = document.getElementById("gradientMixRange");
+const gradientMode = document.getElementById("gradientMode");
+
+const guidesOn = document.getElementById("guidesOn");
+const guidesMode = document.getElementById("guidesMode");
+const guidesSpacing = document.getElementById("guidesSpacing");
+const guidesSpacingRange = document.getElementById("guidesSpacingRange");
+const guidesOpacity = document.getElementById("guidesOpacity");
+const guidesOpacityRange = document.getElementById("guidesOpacityRange");
+const guidesSnap = document.getElementById("guidesSnap");
+const resetGuides = document.getElementById("resetGuides");
+
+const clearBtn = document.getElementById("clear");
+const undoBtn = document.getElementById("undo");
+const redoBtn = document.getElementById("redo");
+const saveBtn = document.getElementById("save");
+
+const gradientToggle = document.getElementById("gradientToggle");
+const gradientPanel = document.getElementById("gradientPanel");
+const guidesToggle = document.getElementById("guidesToggle");
+const guidesPanel = document.getElementById("guidesPanel");
+
+const HUD_POS_KEY = "pfh-hud-pos";
+const SETTINGS_KEY = "pfh-settings";
+const THEME_KEY = "pfh-theme";
+const UI_KEY = "pfh-ui";
+const CURSOR_KEY = "pfh-cursor";
+
+const INTERACTIVE = 'button,input,select,textarea,label,[role="button"],[tabindex]';
+const HOVER_PAD = 18;
+const EXPAND_DELAY = 10;
+const COLLAPSE_DELAY = 10;
+const AUTO_COLLAPSE_DELAY = 4800;
+const SNAP_PAD = 26;
+const VIEWPORT_PAD = 8;
+const LONG_PRESS_DELAY = 220;
+const LONG_PRESS_MOVE_TOLERANCE = 8;
+const COLLAPSE_GRACE_MS = 180;
+const cursorOrder = ["default", "crosshair", "dot"];
+
+let dpr = window.devicePixelRatio || 1;
+let drawing = false;
+let currentStroke = [];
+let strokes = [];
+let redoStack = [];
+
+let draggingHud = false;
+let dragOffsetX = 0;
+let dragOffsetY = 0;
+let dragPointerId = null;
+
+let uiTimer = 0;
+let hoverTimer = 0;
+
+let togglePressTimer = 0;
+let togglePressActive = false;
+let togglePressMoved = false;
+let toggleDragStartedFromButton = false;
+let toggleSuppressClick = false;
+let togglePointerId = null;
+let toggleStartX = 0;
+let toggleStartY = 0;
+
+let pointerInsideHud = false;
+let collapseLockUntil = 0;
+
+let lastDotX = 0;
+let lastDotY = 0;
+let dotRAF = 0;
+let canvasScale = 1;
+let dotStyleRAF = 0;
+let colorPickerActive = false;
+
+const cursorIcons = cycleCursor ? cycleCursor.querySelectorAll(".cursor-ic") : [];
+
+function clamp(v, min, max) {
+ return Math.max(min, Math.min(max, v));
+}
+
+function clearTimer(t) {
+ return t && clearTimeout(t), 0;
+}
+
+function now() {
+ return performance.now();
+}
+
+function lockCollapse(ms = COLLAPSE_GRACE_MS) {
+ collapseLockUntil = now() + ms;
+}
+
+function syncRange(el) {
+ const min = Number(el.min || 0);
+ const max = Number(el.max || 100);
+ const val = Number(el.value);
+ const pct = ((val - min) / (max - min)) * 100;
+ el.style.setProperty("--pct", `${pct}%`);
+}
+
+function isDark() {
+ return document.body.dataset.theme === "dark";
+}
+
+function getCursorMode() {
+ return document.body.dataset.cursor || localStorage.getItem(CURSOR_KEY) || "crosshair";
+}
+
+function updateCursorIcons() {
+ if (!cycleCursor || !cursorIcons.length) return;
+ const mode = getCursorMode();
+ cursorIcons.forEach(i => i.classList.remove("active"));
+ const active = cycleCursor.querySelector(`.cursor-${mode}`);
+ if (active) active.classList.add("active");
+}
+
+function applyCursorDotVisibility() {
+ if (!cursorDot) return;
+ cursorDot.style.opacity = document.body.dataset.cursor === "dot" && !colorPickerActive ? "1" : "0";
+}
+
+function setCursorMode(mode) {
+ document.body.dataset.cursor = mode;
+ localStorage.setItem(CURSOR_KEY, mode);
+ updateCursorIcons();
+ applyCursorDotVisibility();
+ syncDotStyle();
+}
+
+function cycleCursorMode() {
+ const current = getCursorMode();
+ const idx = cursorOrder.indexOf(current);
+ setCursorMode(cursorOrder[(idx + 1) % cursorOrder.length]);
+}
+
+function measureCanvasScale() {
+ const rect = canvas.getBoundingClientRect();
+ canvasScale = rect.width ? canvas.width / (rect.width * dpr) : 1;
+}
+
+function updateCursorDotStyle() {
+ if (!cursorDot) return;
+ measureCanvasScale();
+ const s = Number(size.value) || 10;
+ const dotSize = Math.max(6, Math.min(160, s / canvasScale));
+ document.documentElement.style.setProperty("--cursor-dot-size", `${dotSize}px`);
+ const dotColor = eraser.checked
+ ? (isDark() ? "rgba(255,255,255,.32)" : "rgba(0,0,0,.22)")
+ : (colorA.value || strokeColor.value || "#000");
+ document.documentElement.style.setProperty("--cursor-dot-color", dotColor);
+}
+
+function syncDotStyle() {
+ if (dotStyleRAF) return;
+ dotStyleRAF = requestAnimationFrame(() => {
+ dotStyleRAF = 0;
+ updateCursorDotStyle();
+ applyCursorDotVisibility();
+ updateBrushPreview();
+ });
+}
+
+function setDotPos() {
+ dotRAF = 0;
+ if (cursorDot) {
+ cursorDot.style.transform = `translate3d(${lastDotX}px,${lastDotY}px,0)`;
+ }
+}
+
+function queueDotPos(x, y) {
+ lastDotX = x;
+ lastDotY = y;
+ if (!dotRAF) dotRAF = requestAnimationFrame(setDotPos);
+}
+
+function saveSettings() {
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify({
+ size: size.value,
+ thinning: thinning.value,
+ smoothing: smoothing.value,
+ streamline: streamline.value,
+ pressureMode: pressureMode.value,
+ eraser: eraser.checked,
+ strokeColor: strokeColor.value,
+ colorA: colorA.value,
+ colorB: colorB.value,
+ gradientMix: gradientMix.value,
+ gradientMode: gradientMode.value,
+ guidesOn: guidesOn.checked,
+ guidesMode: guidesMode.value,
+ guidesSpacing: guidesSpacing.value,
+ guidesOpacity: guidesOpacity.value,
+ guidesSnap: guidesSnap.checked
+ }));
+}
+
+function restoreSettings() {
+ try {
+ const s = JSON.parse(localStorage.getItem(SETTINGS_KEY) || "null");
+ if (!s) return;
+
+ if (s.size != null) { size.value = s.size; sizeRange.value = s.size; }
+ if (s.thinning != null) { thinning.value = s.thinning; thinningRange.value = s.thinning; }
+ if (s.smoothing != null) { smoothing.value = s.smoothing; smoothingRange.value = s.smoothing; }
+ if (s.streamline != null) { streamline.value = s.streamline; streamlineRange.value = s.streamline; }
+ if (s.pressureMode != null) pressureMode.value = s.pressureMode;
+ if (s.eraser != null) eraser.checked = !!s.eraser;
+ if (s.strokeColor) strokeColor.value = s.strokeColor;
+ if (s.colorA) colorA.value = s.colorA;
+ if (s.colorB) colorB.value = s.colorB;
+ if (s.gradientMix != null) { gradientMix.value = s.gradientMix; gradientMixRange.value = s.gradientMix; }
+ if (s.gradientMode) gradientMode.value = s.gradientMode;
+ if (s.guidesOn != null) guidesOn.checked = !!s.guidesOn;
+ if (s.guidesMode) guidesMode.value = s.guidesMode;
+ if (s.guidesSpacing != null) { guidesSpacing.value = s.guidesSpacing; guidesSpacingRange.value = s.guidesSpacing; }
+ if (s.guidesOpacity != null) { guidesOpacity.value = s.guidesOpacity; guidesOpacityRange.value = s.guidesOpacity; }
+ if (s.guidesSnap != null) guidesSnap.checked = !!s.guidesSnap;
+ } catch {}
+}
+
+function bindPair(rangeEl, numberEl) {
+ const sync = src => {
+ if (rangeEl.value !== src.value) rangeEl.value = src.value;
+ if (numberEl.value !== src.value) numberEl.value = src.value;
+ syncRange(rangeEl);
+ syncRange(numberEl);
+ saveSettings();
+ syncDotStyle();
+ };
+
+ rangeEl.addEventListener("input", () => {
+ sync(rangeEl);
+ redraw();
+ });
+ numberEl.addEventListener("input", () => {
+ sync(numberEl);
+ redraw();
+ });
+
+ syncRange(rangeEl);
+ syncRange(numberEl);
+}
+
+function setTheme(theme) {
+ document.body.dataset.theme = theme === "dark" ? "dark" : "light";
+ localStorage.setItem(THEME_KEY, document.body.dataset.theme);
+ redraw();
+ syncDotStyle();
+ updateBrushPreview();
+}
+
+function toggleThemeNow() {
+ setTheme(isDark() ? "light" : "dark");
+}
+
+function resizeCanvas() {
+ const rect = canvas.getBoundingClientRect();
+ dpr = window.devicePixelRatio || 1;
+ canvas.width = Math.round(rect.width * dpr);
+ canvas.height = Math.round(rect.height * dpr);
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ measureCanvasScale();
+ syncDotStyle();
+ redraw();
+}
+
+function getPoint(ev) {
+ const rect = canvas.getBoundingClientRect();
+ return {
+ x: ev.clientX - rect.left,
+ y: ev.clientY - rect.top,
+ pressure: ev.pressure && ev.pressure > 0 ? ev.pressure : 0.5
+ };
+}
+
+function simulatedPressure(points, index) {
+ if (index === 0) return 0.5;
+ const start = Math.max(0, index - 3);
+ let total = 0;
+ let count = 0;
+ for (let i = start + 1; i <= index; i++) {
+ const p1 = points[i - 1];
+ const p2 = points[i];
+ if (p1 && p2) {
+ total += Math.hypot(p2.x - p1.x, p2.y - p1.y);
+ count++;
+ }
+ }
+ const avg = total / Math.max(1, count);
+ const pressure = 1 - Math.min(1, avg / 30);
+ return Math.max(0.2, Math.min(1, Math.pow(pressure, 1.25)));
+}
+
+function hexToRgb(hex) {
+ const s = (hex || "#000000").replace("#", "");
+ const full = s.length === 3 ? s.split("").map(ch => ch + ch).join("") : s;
+ const n = parseInt(full, 16);
+ return { r: n >> 16 & 255, g: n >> 8 & 255, b: n & 255 };
+}
+
+function rgbToHex(r, g, b) {
+ return "#" + [r, g, b].map(v => v.toString(16).padStart(2, "0")).join("");
+}
+
+function mixColors(a, b, t) {
+ const c1 = hexToRgb(a);
+ const c2 = hexToRgb(b);
+ const u = clamp(Number(t) || 0, 0, 1);
+ return rgbToHex(
+ Math.round(c1.r + (c2.r - c1.r) * u),
+ Math.round(c1.g + (c2.g - c1.g) * u),
+ Math.round(c1.b + (c2.b - c1.b) * u)
+ );
+}
+
+function brushState() {
+ return {
+ size: Number(size.value),
+ thinning: Number(thinning.value),
+ smoothing: Number(smoothing.value),
+ streamline: Number(streamline.value),
+ pressureMode: pressureMode.value,
+ colorA: colorA.value,
+ colorB: colorB.value,
+ gradientMix: Number(gradientMix.value),
+ gradientMode: gradientMode.value,
+ erase: eraser.checked
+ };
+}
+
+function drawStroke(stroke, preview = false, targetCtx = ctx) {
+ const points = stroke && stroke.points ? stroke.points : stroke;
+ const brush = stroke && stroke.brush ? stroke.brush : brushState();
+ const filtered = points && points.length ? points.filter(p => p && Number.isFinite(p.x) && Number.isFinite(p.y)) : [];
+ if (!filtered.length) return;
+
+ const outline = getStroke(
+ filtered.map((p, i) => [p.x, p.y, brush.pressureMode === "pointer" ? (p.pressure ?? 0.5) : simulatedPressure(filtered, i)]),
+ {
+ size: Number(brush.size),
+ thinning: Number(brush.thinning),
+ smoothing: Number(brush.smoothing),
+ streamline: Number(brush.streamline),
+ simulatePressure: false
+ }
+ );
+
+ if (!outline.length) return;
+
+ targetCtx.save();
+ targetCtx.globalCompositeOperation = brush.erase ? "destination-out" : "source-over";
+
+ if (brush.erase) {
+ targetCtx.fillStyle = "#000";
+ targetCtx.beginPath();
+ targetCtx.moveTo(outline[0][0], outline[0][1]);
+ outline.forEach(pt => targetCtx.lineTo(pt[0], pt[1]));
+ targetCtx.closePath();
+ targetCtx.fill();
+ targetCtx.restore();
+ return;
+ }
+
+ if (brush.gradientMode === "along" && outline.length > 1) {
+ for (let i = 0; i < outline.length - 1; i++) {
+ const t = i / (outline.length - 2 || 1);
+ const c = mixColors(brush.colorA, brush.colorB, clamp(brush.gradientMix + t * (1 - brush.gradientMix), 0, 1));
+ targetCtx.fillStyle = c;
+ targetCtx.beginPath();
+ targetCtx.moveTo(outline[i][0], outline[i][1]);
+ targetCtx.lineTo(outline[i + 1][0], outline[i + 1][1]);
+ const next = outline[i + 2] || outline[i + 1];
+ const cur = outline[i + 1];
+ targetCtx.lineTo(cur[0] + (next[0] - cur[0]) * 0.25, cur[1] + (next[1] - cur[1]) * 0.25);
+ targetCtx.closePath();
+ targetCtx.fill();
+ }
+ } else {
+ targetCtx.fillStyle = brush.gradientMode === "fixed"
+ ? mixColors(brush.colorA, brush.colorB, brush.gradientMix)
+ : mixColors(brush.colorA, brush.colorB, 0.5);
+ targetCtx.beginPath();
+ targetCtx.moveTo(outline[0][0], outline[0][1]);
+ outline.forEach(pt => targetCtx.lineTo(pt[0], pt[1]));
+ targetCtx.closePath();
+ targetCtx.fill();
+ }
+
+ targetCtx.restore();
+}
+
+function drawGuides() {
+ if (!guidesOn.checked) return;
+ const rect = canvas.getBoundingClientRect();
+ const mode = guidesMode.value;
+ const opacity = clamp(Number(guidesOpacity.value) || 0, 0.01, 1);
+ const rgb = isDark() ? "255,255,255" : "0,0,0";
+
+ ctx.save();
+ ctx.globalCompositeOperation = "source-over";
+ ctx.lineWidth = 1;
+ ctx.strokeStyle = `rgba(${rgb},${opacity})`;
+ ctx.setLineDash([6, 6]);
+
+ const spacing = Math.max(4, Number(guidesSpacing.value) || 80);
+
+ if (mode === "crosshair") {
+ ctx.beginPath();
+ ctx.moveTo(rect.width / 2, 0);
+ ctx.lineTo(rect.width / 2, rect.height);
+ ctx.moveTo(0, rect.height / 2);
+ ctx.lineTo(rect.width, rect.height / 2);
+ ctx.stroke();
+ } else if (mode === "grid") {
+ for (let x = spacing; x < rect.width; x += spacing) {
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, rect.height);
+ ctx.stroke();
+ }
+ for (let y = spacing; y < rect.height; y += spacing) {
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(rect.width, y);
+ ctx.stroke();
+ }
+ } else {
+ for (let x = spacing; x < rect.width; x += spacing) {
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, rect.height);
+ ctx.stroke();
+ }
+ for (let y = spacing; y < rect.height; y += spacing) {
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(rect.width, y);
+ ctx.stroke();
+ }
+ }
+
+ ctx.restore();
+}
+
+function redraw() {
+ const rect = canvas.getBoundingClientRect();
+ const bg = isDark() ? "#111" : "#fff";
+ ctx.clearRect(0, 0, rect.width, rect.height);
+ ctx.fillStyle = bg;
+ ctx.fillRect(0, 0, rect.width, rect.height);
+ strokes.forEach(s => drawStroke(s, false, ctx));
+ if (drawing && currentStroke.length) {
+ drawStroke({ points: currentStroke, brush: brushState() }, true, ctx);
+ }
+ drawGuides();
+}
+
+function collapseUi() {
+ if (!hud.classList.contains("compact")) {
+ hud.classList.add("compact");
+ localStorage.setItem(UI_KEY, "compact");
+ }
+}
+
+function expandUi() {
+ if (hud.classList.contains("compact")) {
+ hud.classList.remove("compact");
+ localStorage.setItem(UI_KEY, "expanded");
+ }
+}
+
+function hudHasFocus() {
+ const active = document.activeElement;
+ return !!active && hud.contains(active);
+}
+
+function canAutoHide() {
+ return !draggingHud &&
+ !drawing &&
+ !pointerInsideHud &&
+ !hud.matches(":hover") &&
+ !hudHoverGap.matches(":hover") &&
+ !hudHasFocus() &&
+ now() > collapseLockUntil;
+}
+
+function scheduleAutoCollapse(ms = AUTO_COLLAPSE_DELAY) {
+ uiTimer = clearTimer(uiTimer);
+ uiTimer = setTimeout(() => {
+ if (canAutoHide()) collapseUi();
+ }, ms);
+}
+
+function restoreUiState() {
+ if (localStorage.getItem(UI_KEY) === "compact") collapseUi();
+ else expandUi();
+}
+
+function restoreHudPosition() {
+ try {
+ const pos = JSON.parse(localStorage.getItem(HUD_POS_KEY) || "null");
+ if (pos) {
+ hud.style.left = `${pos.x}px`;
+ hud.style.top = `${pos.y}px`;
+ }
+ } catch {}
+}
+
+function saveHudPosition() {
+ const rect = hud.getBoundingClientRect();
+ localStorage.setItem(HUD_POS_KEY, JSON.stringify({ x: rect.left, y: rect.top }));
+}
+
+function clampHudToViewport() {
+ const rect = hud.getBoundingClientRect();
+ const w = window.innerWidth;
+ const h = window.innerHeight;
+ hud.style.left = `${clamp(rect.left, VIEWPORT_PAD, w - rect.width - VIEWPORT_PAD)}px`;
+ hud.style.top = `${clamp(rect.top, VIEWPORT_PAD, h - rect.height - VIEWPORT_PAD)}px`;
+}
+
+function snapToCornerIfNear() {
+ const rect = hud.getBoundingClientRect();
+ const w = window.innerWidth;
+ const h = window.innerHeight;
+ const nearLeft = rect.left < SNAP_PAD;
+ const nearTop = rect.top < SNAP_PAD;
+ const nearRight = w - rect.right < SNAP_PAD;
+ const nearBottom = h - rect.bottom < SNAP_PAD;
+ if (!(nearLeft || nearTop || nearRight || nearBottom)) return false;
+
+ const x = nearLeft ? VIEWPORT_PAD : nearRight ? Math.max(VIEWPORT_PAD, w - rect.width - VIEWPORT_PAD) : rect.left;
+ const y = nearTop ? VIEWPORT_PAD : nearBottom ? Math.max(VIEWPORT_PAD, h - rect.height - VIEWPORT_PAD) : rect.top;
+ hud.style.left = `${x}px`;
+ hud.style.top = `${y}px`;
+ collapseUi();
+ saveHudPosition();
+ return true;
+}
+
+function commitStroke() {
+ if (!currentStroke.length) return;
+ strokes.push({ points: currentStroke.slice(), brush: brushState() });
+ currentStroke = [];
+ redoStack = [];
+ redraw();
+ collapseUi();
+ scheduleAutoCollapse(900);
+}
+
+function stopDrawing() {
+ if (drawing) {
+ drawing = false;
+ commitStroke();
+ }
+}
+
+function pointerNearHandle(x, y, pad = HOVER_PAD) {
+ const rect = hudDragHandle.getBoundingClientRect();
+ return x >= rect.left - pad && x <= rect.right + pad && y >= rect.top - pad && y <= rect.bottom + pad;
+}
+
+function hoverExpandIfNeeded(x, y) {
+ if (!hud.classList.contains("compact")) return;
+ hoverTimer = clearTimer(hoverTimer);
+ if (pointerNearHandle(x, y)) {
+ hoverTimer = setTimeout(() => {
+ if (!draggingHud && !drawing && hud.classList.contains("compact")) expandUi();
+ }, EXPAND_DELAY);
+ }
+}
+
+function hoverCollapseIfNeeded() {
+ if (hud.classList.contains("compact")) return;
+ hoverTimer = clearTimer(hoverTimer);
+ hoverTimer = setTimeout(() => {
+ if (canAutoHide()) collapseUi();
+ }, COLLAPSE_DELAY);
+}
+
+function savePNG() {
+ const rect = canvas.getBoundingClientRect();
+ const out = document.createElement("canvas");
+ out.width = Math.round(rect.width * dpr);
+ out.height = Math.round(rect.height * dpr);
+ const octx = out.getContext("2d");
+ const bg = isDark() ? "#111" : "#fff";
+ octx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ octx.fillStyle = bg;
+ octx.fillRect(0, 0, rect.width, rect.height);
+ referenceManager?.drawToExport(octx);
+ strokes.forEach(s => drawStroke(s, false, octx));
+ const a = document.createElement("a");
+ a.download = "draw.png";
+ a.href = out.toDataURL("image/png");
+ a.click();
+}
+
+function beginHudDrag(x, y, pointerId = null) {
+ draggingHud = true;
+ dragPointerId = pointerId;
+ hud.classList.add("dragging");
+ const rect = hud.getBoundingClientRect();
+ dragOffsetX = x - rect.left;
+ dragOffsetY = y - rect.top;
+ uiTimer = clearTimer(uiTimer);
+ hoverTimer = clearTimer(hoverTimer);
+ lockCollapse();
+}
+
+function moveHudDrag(x, y) {
+ if (!draggingHud) return;
+ hud.style.left = `${Math.max(0, x - dragOffsetX)}px`;
+ hud.style.top = `${Math.max(0, y - dragOffsetY)}px`;
+ if (x < SNAP_PAD || y < SNAP_PAD || x > window.innerWidth - SNAP_PAD || y > window.innerHeight - SNAP_PAD) {
+ snapToCornerIfNear();
+ }
+}
+
+function stopHudDrag() {
+ if (!draggingHud) return;
+ draggingHud = false;
+ dragPointerId = null;
+ hud.classList.remove("dragging");
+ clampHudToViewport();
+ if (!snapToCornerIfNear()) saveHudPosition();
+ lockCollapse();
+ scheduleAutoCollapse(900);
+}
+
+function clearToggleLongPress() {
+ togglePressTimer = clearTimer(togglePressTimer);
+ togglePressActive = false;
+ togglePointerId = null;
+}
+
+function resetToggleSuppressionSoon() {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ toggleSuppressClick = false;
+ toggleDragStartedFromButton = false;
+ });
+ });
+}
+
+function startToggleLongPress(ev) {
+ togglePressActive = true;
+ togglePressMoved = false;
+ toggleSuppressClick = false;
+ toggleDragStartedFromButton = false;
+ togglePointerId = ev.pointerId;
+ toggleStartX = ev.clientX;
+ toggleStartY = ev.clientY;
+ lockCollapse();
+ togglePressTimer = clearTimer(togglePressTimer);
+ togglePressTimer = setTimeout(() => {
+ if (togglePressActive && !togglePressMoved && !draggingHud) {
+ toggleDragStartedFromButton = true;
+ toggleSuppressClick = true;
+ expandUi();
+ beginHudDrag(ev.clientX, ev.clientY, ev.pointerId);
+ }
+ }, LONG_PRESS_DELAY);
+}
+
+function keepHudOpen() {
+ pointerInsideHud = true;
+ lockCollapse();
+ uiTimer = clearTimer(uiTimer);
+ expandUi();
+}
+
+function handleGlobalPointerMove(ev) {
+ if (document.body.dataset.cursor === "dot") queueDotPos(ev.clientX, ev.clientY);
+
+ if (draggingHud) {
+ if (dragPointerId == null || ev.pointerId === dragPointerId) moveHudDrag(ev.clientX, ev.clientY);
+ } else if (hud.classList.contains("compact")) {
+ hoverExpandIfNeeded(ev.clientX, ev.clientY);
+ } else if (hud.matches(":hover") || hudHoverGap.matches(":hover") || hudHasFocus()) {
+ // keep open
+ } else {
+ hoverCollapseIfNeeded();
+ }
+}
+
+function handleGlobalPointerEnd(ev) {
+ if (togglePressActive && togglePointerId === ev.pointerId) clearToggleLongPress();
+ if (draggingHud && (dragPointerId == null || dragPointerId === ev.pointerId)) {
+ stopHudDrag();
+ if (toggleDragStartedFromButton) resetToggleSuppressionSoon();
+ }
+}
+
+function setPanel(panel, toggle, open, key) {
+ if (!panel || !toggle) return;
+ panel.classList.toggle("open", open);
+ toggle.textContent = open ? "hide" : "show";
+ localStorage.setItem(key, open ? "open" : "closed");
+}
+
+
+/* reference manager */
+const referenceManager = (() => {
+ const refLayer = document.getElementById("refLayer");
+ const refUpload = document.getElementById("refUpload");
+ const addRefBtn = document.getElementById("addRef");
+ if (!refLayer) return null;
+
+ const state = {
+ refs: [],
+ activeId: null,
+ seq: 1,
+ menu: null,
+ ui: {}
+ };
+
+ function getCanvasCenter() {
+ const r = canvas.getBoundingClientRect();
+ return { x: r.width / 2, y: r.height / 2 };
+ }
+
+ function normalizeZ() {
+ state.refs.sort((a, b) => a.z - b.z);
+ state.refs.forEach((r, i) => {
+ r.z = i;
+ r.item.style.zIndex = String(i);
+ });
+ }
+
+ function syncRefDom(ref) {
+ ref.item.style.left = `${ref.x - ref.w / 2}px`;
+ ref.item.style.top = `${ref.y - ref.h / 2}px`;
+ ref.item.style.width = `${ref.w}px`;
+ ref.item.style.height = `${ref.h}px`;
+ ref.item.style.opacity = ref.opacity;
+ ref.item.style.zIndex = String(ref.z);
+ const sx = ref.flipX ? -1 : 1;
+ const sy = ref.flipY ? -1 : 1;
+ ref.img.style.transform = `rotate(${ref.rotation}deg) scale(${sx}, ${sy})`;
+ ref.img.style.transformOrigin = "center center";
+ }
+
+ function clearFadeTimer(ref) {
+ if (ref?._fadeTimer) {
+ clearTimeout(ref._fadeTimer);
+ ref._fadeTimer = 0;
+ }
+ }
+
+ function flashSelected(ref) {
+ if (!ref?.item) return;
+ ref.item.classList.remove("ref-fade");
+ ref.item.classList.add("ref-selected");
+ }
+
+ function scheduleFade(ref) {
+ if (!ref?.item) return;
+ clearFadeTimer(ref);
+ ref._fadeTimer = setTimeout(() => {
+ if (state.activeId === ref.id) return;
+ ref.item.classList.add("ref-fade");
+ ref.item.classList.remove("ref-selected");
+ }, 3000);
+ }
+
+ function getActiveRef() {
+ return state.refs.find(r => r.id === state.activeId) || null;
+ }
+
+ function syncMenu(ref) {
+ if (!state.menu || !ref) return;
+ state.ui.opacity.value = ref.opacity;
+ state.ui.rotate.value = ref.rotation;
+ state.ui.flipX.textContent = ref.flipX ? "flip x: on" : "flip x";
+ state.ui.flipY.textContent = ref.flipY ? "flip y: on" : "flip y";
+ }
+
+ function setActive(ref) {
+ const prev = getActiveRef();
+ state.activeId = ref?.id ?? null;
+
+ state.refs.forEach(r => {
+ if (r.id === state.activeId) {
+ clearFadeTimer(r);
+ r.item.classList.remove("ref-fade");
+ r.item.classList.add("ref-selected");
+ } else if (r.item.classList.contains("ref-selected")) {
+ scheduleFade(r);
+ }
+ });
+
+ if (prev && prev.id !== state.activeId) scheduleFade(prev);
+ if (ref) syncMenu(ref);
+ }
+
+ function showMenu(ref, x, y) {
+ if (!state.menu) return;
+ setActive(ref);
+ state.menu.style.display = "block";
+ state.menu.style.left = `${x}px`;
+ state.menu.style.top = `${y}px`;
+ syncMenu(ref);
+ }
+
+ function hideMenu() {
+ if (state.menu) state.menu.style.display = "none";
+ }
+
+ function bringToFront(ref) {
+ ref.z = Math.max(-1, ...state.refs.map(r => r.z)) + 1;
+ normalizeZ();
+ syncRefDom(ref);
+ }
+
+ function sendToBack(ref) {
+ ref.z = Math.min(1e9, ...state.refs.map(r => r.z)) - 1;
+ normalizeZ();
+ syncRefDom(ref);
+ }
+
+ function moveUp(ref) {
+ const sorted = [...state.refs].sort((a, b) => a.z - b.z);
+ const idx = sorted.findIndex(r => r.id === ref.id);
+ if (idx >= 0 && idx < sorted.length - 1) {
+ [sorted[idx].z, sorted[idx + 1].z] = [sorted[idx + 1].z, sorted[idx].z];
+ normalizeZ();
+ }
+ }
+
+ function moveDown(ref) {
+ const sorted = [...state.refs].sort((a, b) => a.z - b.z);
+ const idx = sorted.findIndex(r => r.id === ref.id);
+ if (idx > 0) {
+ [sorted[idx].z, sorted[idx - 1].z] = [sorted[idx - 1].z, sorted[idx].z];
+ normalizeZ();
+ }
+ }
+
+ function createReference({ src, x, y, w, h, opacity = 1, rotation = 0, flipX = false, flipY = false, z = state.refs.length }) {
+ const id = state.seq++;
+ const item = document.createElement("div");
+ item.className = "ref-item";
+ const img = document.createElement("img");
+ img.src = src;
+ img.alt = "reference";
+ const handle = document.createElement("div");
+ handle.className = "ref-handle";
+ item.appendChild(img);
+ item.appendChild(handle);
+ refLayer.appendChild(item);
+
+ const ref = { id, src, x, y, w, h, opacity, rotation, flipX, flipY, z, item, img, handle, _fadeTimer: 0 };
+ state.refs.push(ref);
+ normalizeZ();
+ syncRefDom(ref);
+ bindInteractions(ref);
+ flashSelected(ref);
+ return ref;
+ }
+
+ function bindInteractions(ref) {
+ let drag = null;
+ let resize = null;
+
+ const activate = () => {
+ setActive(ref);
+ bringToFront(ref);
+ };
+
+ ref.item.addEventListener("pointerdown", (e) => {
+ if (e.target === ref.handle) return;
+ activate();
+ ref.item.classList.add("active-dragging");
+ ref.item.setPointerCapture(e.pointerId);
+ drag = { id: e.pointerId, startX: e.clientX, startY: e.clientY, x: ref.x, y: ref.y };
+ });
+
+ ref.handle.addEventListener("pointerdown", (e) => {
+ e.stopPropagation();
+ activate();
+ ref.item.setPointerCapture(e.pointerId);
+ resize = { id: e.pointerId, startX: e.clientX, startY: e.clientY, w: ref.w, h: ref.h, ratio: ref.h / ref.w };
+ });
+
+ ref.item.addEventListener("contextmenu", (e) => {
+ e.preventDefault();
+ showMenu(ref, e.clientX, e.clientY);
+ });
+
+ ref.item.addEventListener("wheel", (e) => {
+ if (!e.shiftKey) return;
+ e.preventDefault();
+ ref.rotation += Math.sign(e.deltaY) * 5;
+ syncRefDom(ref);
+ syncMenu(ref);
+ }, { passive: false });
+
+ window.addEventListener("pointermove", (e) => {
+ if (drag && e.pointerId === drag.id) {
+ ref.x = drag.x + (e.clientX - drag.startX);
+ ref.y = drag.y + (e.clientY - drag.startY);
+ syncRefDom(ref);
+ }
+ if (resize && e.pointerId === resize.id) {
+ const dw = e.clientX - resize.startX;
+ ref.w = Math.max(40, resize.w + dw);
+ ref.h = ref.w * resize.ratio;
+ syncRefDom(ref);
+ }
+ });
+
+ window.addEventListener("pointerup", (e) => {
+ if (drag && e.pointerId === drag.id) {
+ drag = null;
+ ref.item.classList.remove("active-dragging");
+ }
+ if (resize && e.pointerId === resize.id) resize = null;
+ });
+
+ window.addEventListener("pointercancel", (e) => {
+ if (drag && e.pointerId === drag.id) {
+ drag = null;
+ ref.item.classList.remove("active-dragging");
+ }
+ if (resize && e.pointerId === resize.id) resize = null;
+ });
+ }
+
+ function removeRef(ref) {
+ clearFadeTimer(ref);
+ ref.item.remove();
+ state.refs = state.refs.filter(r => r.id !== ref.id);
+ if (state.activeId === ref.id) state.activeId = null;
+ normalizeZ();
+ hideMenu();
+ }
+
+ function duplicateRef(ref) {
+ const clone = createReference({
+ src: ref.src,
+ x: ref.x + 20,
+ y: ref.y + 20,
+ w: ref.w,
+ h: ref.h,
+ opacity: ref.opacity,
+ rotation: ref.rotation,
+ flipX: ref.flipX,
+ flipY: ref.flipY,
+ z: ref.z + 1
+ });
+ setActive(clone);
+ bringToFront(clone);
+ }
+
+ async function addFiles(files) {
+ const list = [...files].filter(f => f.type && f.type.startsWith("image/"));
+ const center = getCanvasCenter();
+ for (const file of list) {
+ const src = URL.createObjectURL(file);
+ const img = new Image();
+ img.onload = () => {
+ const maxW = Math.min(canvas.getBoundingClientRect().width * 0.45, img.naturalWidth || 300);
+ const ratio = (img.naturalHeight || 200) / (img.naturalWidth || 300);
+ const w = Math.max(120, maxW);
+ const h = Math.max(80, w * ratio);
+ createReference({ src, x: center.x, y: center.y, w, h, opacity: 1, rotation: 0, flipX: false, flipY: false });
+ };
+ img.src = src;
+ }
+ }
+
+ function drawToExport(exportCtx) {
+ [...state.refs].sort((a, b) => a.z - b.z).forEach(ref => {
+ if (!ref.img.complete) return;
+ exportCtx.save();
+ exportCtx.globalAlpha = ref.opacity;
+ exportCtx.translate(ref.x, ref.y);
+ exportCtx.rotate((ref.rotation * Math.PI) / 180);
+ exportCtx.scale(ref.flipX ? -1 : 1, ref.flipY ? -1 : 1);
+ exportCtx.drawImage(ref.img, -ref.w / 2, -ref.h / 2, ref.w, ref.h);
+ exportCtx.restore();
+ });
+ }
+
+ function init() {
+ if (addRefBtn && refUpload) {
+ addRefBtn.addEventListener("click", () => refUpload.click());
+ refUpload.addEventListener("change", async e => {
+ if (e.target.files?.length) await addFiles(e.target.files);
+ refUpload.value = "";
+ });
+ }
+
+ window.addEventListener("dragover", e => e.preventDefault());
+ window.addEventListener("drop", async e => {
+ e.preventDefault();
+ if (e.dataTransfer?.files?.length) await addFiles(e.dataTransfer.files);
+ });
+
+ window.addEventListener("keydown", e => {
+ if (e.key === "Escape") hideMenu();
+ });
+ }
+
+ init();
+
+ return {
+ createReference,
+ addFiles,
+ drawToExport,
+ get refs() { return state.refs; },
+ get active() { return getActiveRef(); },
+ setActive,
+ hideMenu,
+ bringToFront,
+ sendToBack,
+ moveUp,
+ moveDown
+ };
+})();
+
+/* init UI toggles */
+function setPanelState(panel, toggle, open, key) {
+ if (!panel || !toggle) return;
+ panel.classList.toggle("open", open);
+ toggle.textContent = open ? "hide" : "show";
+ localStorage.setItem(key, open ? "open" : "closed");
+}
+
+gradientToggle?.addEventListener("click", () => {
+ setPanelState(gradientPanel, gradientToggle, !gradientPanel.classList.contains("open"), "pfh-gradient-panel");
+});
+
+guidesToggle?.addEventListener("click", () => {
+ setPanelState(guidesPanel, guidesToggle, !guidesPanel.classList.contains("open"), "pfh-guides-panel");
+});
+
+setPanelState(gradientPanel, gradientToggle, localStorage.getItem("pfh-gradient-panel") === "open", "pfh-gradient-panel");
+setPanelState(guidesPanel, guidesToggle, localStorage.getItem("pfh-guides-panel") === "open", "pfh-guides-panel");
+
+/* brush preview */
+function updateBrushPreview() {
+ if (!brushPreview) return;
+ const color = eraser.checked
+ ? (isDark() ? "rgba(255,255,255,.32)" : "rgba(0,0,0,.22)")
+ : (strokeColor?.value || "#000000");
+ brushPreview.style.background = color;
+ brushPreview.style.height = `${Math.max(2, Math.min(16, Number(size?.value || sizeRange?.value || 12) / 2))}px`;
+}
+
+/* bootstrap */
+bindPair(sizeRange, size);
+bindPair(thinningRange, thinning);
+bindPair(smoothingRange, smoothing);
+bindPair(streamlineRange, streamline);
+bindPair(gradientMixRange, gradientMix);
+
+restoreSettings();
+document.body.dataset.theme = localStorage.getItem(THEME_KEY) || "dark";
+setCursorMode(localStorage.getItem(CURSOR_KEY) || "crosshair");
+restoreHudPosition();
+restoreUiState();
+resizeCanvas();
+redraw();
+syncDotStyle();
+updateCursorIcons();
+updateBrushPreview();
+
+/* canvas draw events */
+canvas.addEventListener("pointerdown", ev => {
+ drawing = true;
+ currentStroke = [];
+ const p = getPoint(ev);
+ if (p) currentStroke.push(p);
+ canvas.setPointerCapture(ev.pointerId);
+ redraw();
+ uiTimer = clearTimer(uiTimer);
+ hoverTimer = clearTimer(hoverTimer);
+});
+canvas.addEventListener("pointermove", ev => {
+ if (!drawing) return;
+ const p = getPoint(ev);
+ if (p) currentStroke.push(p);
+ redraw();
+ scheduleAutoCollapse(1600);
+});
+canvas.addEventListener("pointerup", stopDrawing);
+canvas.addEventListener("pointercancel", stopDrawing);
+canvas.addEventListener("pointerleave", stopDrawing);
+
+/* footer actions */
+clearBtn?.addEventListener("click", () => {
+ strokes = [];
+ redoStack = [];
+ currentStroke = [];
+ redraw();
+ collapseUi();
+ scheduleAutoCollapse();
+});
+undoBtn?.addEventListener("click", () => {
+ if (strokes.length) {
+ redoStack.push(strokes.pop());
+ redraw();
+ scheduleAutoCollapse();
+ }
+});
+redoBtn?.addEventListener("click", () => {
+ if (redoStack.length) {
+ strokes.push(redoStack.pop());
+ redraw();
+ scheduleAutoCollapse();
+ }
+});
+saveBtn?.addEventListener("click", savePNG);
+
+/* reset guides */
+resetGuides?.addEventListener("click", () => {
+ guidesOn.checked = true;
+ guidesMode.value = "crosshair";
+ guidesSpacing.value = 80;
+ guidesSpacingRange.value = 80;
+ guidesOpacity.value = 0.18;
+ guidesOpacityRange.value = 0.18;
+ guidesSnap.checked = false;
+ saveSettings();
+ redraw();
+});
+
+/* toggle buttons */
+toggleTheme?.addEventListener("click", ev => {
+ if (toggleSuppressClick || toggleDragStartedFromButton || draggingHud) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ return;
+ }
+ toggleThemeNow();
+});
+cycleCursor?.addEventListener("click", ev => {
+ ev.preventDefault();
+ cycleCursorMode();
+});
+
+/* brush color visibility */
+size?.addEventListener("input", syncDotStyle, { passive: true });
+size?.addEventListener("change", syncDotStyle);
+sizeRange?.addEventListener("input", syncDotStyle, { passive: true });
+sizeRange?.addEventListener("change", syncDotStyle);
+eraser?.addEventListener("input", syncDotStyle, { passive: true });
+eraser?.addEventListener("change", syncDotStyle);
+
+strokeColor?.addEventListener("pointerdown", () => { colorPickerActive = true; applyCursorDotVisibility(); }, { passive: true });
+strokeColor?.addEventListener("focus", () => { colorPickerActive = true; applyCursorDotVisibility(); });
+strokeColor?.addEventListener("input", syncDotStyle, { passive: true });
+strokeColor?.addEventListener("change", () => { syncDotStyle(); colorPickerActive = false; applyCursorDotVisibility(); });
+strokeColor?.addEventListener("blur", () => { syncDotStyle(); colorPickerActive = false; applyCursorDotVisibility(); });
+window.addEventListener("pointerup", () => {
+ if (colorPickerActive) {
+ syncDotStyle();
+ colorPickerActive = false;
+ applyCursorDotVisibility();
+ }
+}, { passive: true });
+
+/* global settings redraw hooks */
+[size, thinning, smoothing, streamline, pressureMode, eraser, strokeColor, colorA, colorB, gradientMix, gradientMode, guidesOn, guidesMode, guidesSpacing, guidesOpacity, guidesSnap].forEach(el => {
+ if (!el) return;
+ el.addEventListener("input", () => {
+ redraw();
+ saveSettings();
+ scheduleAutoCollapse();
+ });
+ el.addEventListener("change", () => {
+ redraw();
+ saveSettings();
+ scheduleAutoCollapse();
+ });
+});
+
+/* hud interactions */
+hud?.addEventListener("pointerdown", keepHudOpen);
+hud?.addEventListener("focusin", keepHudOpen);
+hud?.addEventListener("input", keepHudOpen);
+hud?.addEventListener("change", keepHudOpen);
+hud?.addEventListener("focusout", () => { lockCollapse(); scheduleAutoCollapse(); });
+hud?.addEventListener("pointerenter", () => { pointerInsideHud = true; uiTimer = clearTimer(uiTimer); lockCollapse(); });
+hud?.addEventListener("pointerleave", () => { pointerInsideHud = false; lockCollapse(); scheduleAutoCollapse(220); });
+hudTop?.addEventListener("pointerleave", () => lockCollapse());
+toggleTheme?.addEventListener("pointerleave", () => lockCollapse());
+hudHoverGap?.addEventListener("pointerenter", () => { pointerInsideHud = true; lockCollapse(); });
+hudHoverGap?.addEventListener("pointerleave", () => { pointerInsideHud = false; lockCollapse(); });
+
+window.addEventListener("pointerleave", applyCursorDotVisibility);
+window.addEventListener("pointerenter", applyCursorDotVisibility);
+
+/* keys */
+document.addEventListener("keydown", ev => {
+ if (!(ev.ctrlKey || ev.metaKey || ev.altKey) && ev.key.toLowerCase() === "c") {
+ ev.preventDefault();
+ cycleCursorMode();
+ return;
+ }
+
+ const mod = ev.ctrlKey || ev.metaKey;
+ if (mod && ev.key.toLowerCase() === "s") {
+ ev.preventDefault();
+ savePNG();
+ return;
+ }
+ if (mod && ev.key.toLowerCase() === "z" && !ev.shiftKey) {
+ ev.preventDefault();
+ undoBtn?.click();
+ return;
+ }
+ if (mod && (ev.key.toLowerCase() === "y" || (ev.shiftKey && ev.key.toLowerCase() === "z"))) {
+ ev.preventDefault();
+ redoBtn?.click();
+ return;
+ }
+ if (ev.key === "Escape") {
+ if (hud.classList.contains("compact")) expandUi();
+ else collapseUi();
+ return;
+ }
+
+ if (document.activeElement === hudDragHandle || document.activeElement === hud) {
+ const step = ev.shiftKey ? 24 : 12;
+ const rect = hud.getBoundingClientRect();
+ let x = rect.left;
+ let y = rect.top;
+ let moved = false;
+
+ if (ev.key === "ArrowLeft") { x -= step; moved = true; }
+ if (ev.key === "ArrowRight") { x += step; moved = true; }
+ if (ev.key === "ArrowUp") { y -= step; moved = true; }
+ if (ev.key === "ArrowDown") { y += step; moved = true; }
+
+ if (moved) {
+ ev.preventDefault();
+ hud.style.left = `${clamp(x, VIEWPORT_PAD, window.innerWidth - rect.width - VIEWPORT_PAD)}px`;
+ hud.style.top = `${clamp(y, VIEWPORT_PAD, window.innerHeight - rect.height - VIEWPORT_PAD)}px`;
+ saveHudPosition();
+ lockCollapse();
+ scheduleAutoCollapse(900);
+ }
+
+ if (ev.key === "Enter" || ev.key === " ") {
+ ev.preventDefault();
+ if (hud.classList.contains("compact")) expandUi();
+ else collapseUi();
+ }
+ }
+});
+
+/* hud drag wiring */
+hudTop?.addEventListener("pointerdown", ev => {
+ if (ev.button !== undefined && ev.button !== 0) return;
+ if (toggleTheme?.contains(ev.target)) return;
+ if (cycleCursor?.contains(ev.target)) return;
+ if (ev.target.closest(INTERACTIVE)) return;
+ beginHudDrag(ev.clientX, ev.clientY, ev.pointerId);
+});
+hudDragHandle?.addEventListener("pointerdown", ev => {
+ if (ev.button !== undefined && ev.button !== 0) return;
+ if (toggleTheme?.contains(ev.target)) return;
+ if (cycleCursor?.contains(ev.target)) return;
+ beginHudDrag(ev.clientX, ev.clientY, ev.pointerId);
+ try { hudDragHandle.setPointerCapture(ev.pointerId); } catch {}
+});
+
+toggleTheme?.addEventListener("pointerdown", ev => {
+ if (ev.button !== undefined && ev.button !== 0) return;
+ startToggleLongPress(ev);
+});
+toggleTheme?.addEventListener("pointermove", ev => {
+ if (draggingHud) {
+ if (dragPointerId === ev.pointerId) moveHudDrag(ev.clientX, ev.clientY);
+ return;
+ }
+ if (!togglePressActive || togglePointerId !== ev.pointerId) return;
+ const dx = ev.clientX - toggleStartX;
+ const dy = ev.clientY - toggleStartY;
+ if (Math.hypot(dx, dy) > LONG_PRESS_MOVE_TOLERANCE) {
+ togglePressMoved = true;
+ togglePressTimer = clearTimer(togglePressTimer);
+ }
+});
+toggleTheme?.addEventListener("pointerup", ev => {
+ if (togglePointerId !== null && ev.pointerId === togglePointerId) {
+ clearToggleLongPress();
+ if (toggleDragStartedFromButton) {
+ stopHudDrag();
+ resetToggleSuppressionSoon();
+ }
+ }
+});
+toggleTheme?.addEventListener("pointercancel", ev => {
+ if (togglePointerId !== null && ev.pointerId === togglePointerId) {
+ clearToggleLongPress();
+ if (toggleDragStartedFromButton || draggingHud) {
+ stopHudDrag();
+ resetToggleSuppressionSoon();
+ }
+ }
+});
+toggleTheme?.addEventListener("contextmenu", ev => {
+ if (togglePressActive || draggingHud) ev.preventDefault();
+});
+
+hudHoverGap?.addEventListener("pointerenter", () => {
+ if (!hud.classList.contains("compact")) return;
+ const rect = hudDragHandle.getBoundingClientRect();
+ hoverExpandIfNeeded(rect.left + 1, rect.top + 1);
+});
+
+window.addEventListener("pointermove", handleGlobalPointerMove, { passive: true });
+window.addEventListener("pointerup", handleGlobalPointerEnd);
+window.addEventListener("pointercancel", handleGlobalPointerEnd);
+window.addEventListener("pointerdown", ev => {
+ if (hud.classList.contains("compact")) hoverExpandIfNeeded(ev.clientX, ev.clientY);
+ if (document.body.dataset.cursor === "dot") queueDotPos(ev.clientX, ev.clientY);
+}, { passive: true });
+
+window.addEventListener("resize", () => {
+ resizeCanvas();
+ clampHudToViewport();
+ syncDotStyle();
+});
+window.addEventListener("orientationchange", () => {
+ resizeCanvas();
+ clampHudToViewport();
+ syncDotStyle();
+});
+window.addEventListener("blur", () => {
+ clearToggleLongPress();
+ if (draggingHud) stopHudDrag();
+ pointerInsideHud = false;
+ lockCollapse();
+ scheduleAutoCollapse(500);
+ applyCursorDotVisibility();
+});
+
+setTimeout(() => {
+ if (!strokes.length) scheduleAutoCollapse(900);
+}, 50);
+
+/* reference export hook exposed globally */
+window.referenceManager = referenceManager;
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..490e07c
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,938 @@
+{
+ "name": "perfect-freehand-demo",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "perfect-freehand-demo",
+ "version": "1.0.0",
+ "dependencies": {
+ "perfect-freehand": "^1.2.2"
+ },
+ "devDependencies": {
+ "vite": "^5.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.15",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/perfect-freehand": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.3.tgz",
+ "integrity": "sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA=="
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true
+ },
+ "node_modules/postcss": {
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..c56e0fb
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,12 @@
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ server: {
+ host: '0.0.0.0',
+ port: 80
+ },
+ preview: {
+ host: '0.0.0.0',
+ port: 80
+ }
+})