1377 lines
42 KiB
JavaScript
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;
|