Files
draw/main.js
2026-06-30 03:23:00 +00:00

1377 lines
42 KiB
JavaScript

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;