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;