Files
matrix-screen-controller/核桃派软件源代码/app/static/views/canvas.js
T

586 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
announce,
createLocalState,
registerWorkspace,
setActiveButton,
} from "../core.js";
import { refreshColorTrigger, setColorValue } from "../color-picker.js";
import { hexToRgb, rgbToHex } from "../color-math.js";
import {
BRUSH_MODE_NORMAL,
BRUSH_MODE_PIXEL,
PixelUndoHistory,
brushModeSpec,
brushStampPoints,
clampCanvasView,
clampFloatingButton,
linePoints,
normalizeBrushSize,
restoreCanvasToolState,
zoomCanvasView,
} from "../canvas-tools-model.js";
import { setControlReady, setControlUnavailable } from "../interaction-feedback.js";
import {
boardPoint,
commitPixelChanges,
fillPixelLayer,
getBoardOverlay,
getInteractionMode,
getPixelBuffer,
renderPixelDraft,
} from "../scene-board.js";
const canvas = getBoardOverlay();
const localState = createLocalState("canvas-tools", 2);
const previousLocalState = createLocalState("canvas-tools", 1).load(null);
const legacyState = createLocalState("canvas", 3).load(null)
|| createLocalState("canvas", 2).load(null)
|| {};
const toolButtons = Array.from(document.querySelectorAll("[data-canvas-tool]"));
const paintColor = document.getElementById("paint-color");
const backgroundColor = document.getElementById("background-color");
const brushSizeInput = document.getElementById("brush-size");
const brushSizePresets = Array.from(document.querySelectorAll("[data-brush-size]"));
const pixelEditToggle = document.getElementById("pixel-edit-toggle");
const pixelEditStatus = document.getElementById("pixel-edit-status");
const pixelGrid = document.getElementById("pixel-grid");
const boardStage = document.getElementById("board-stage");
const canvasWorkbench = document.querySelector(".canvas-workbench");
const startEditButton = document.getElementById("start-canvas-edit");
const assistive = document.getElementById("canvas-edit-assistive");
const assistiveToggle = document.getElementById("canvas-assistive-toggle");
const assistiveMenu = document.getElementById("canvas-assistive-menu");
const editorModeHint = document.getElementById("canvas-edit-mode-hint");
const undoButton = document.getElementById("undo-canvas-edit");
const exitEditButton = document.getElementById("exit-canvas-edit");
const moveViewButton = document.getElementById("move-canvas-view");
const continueEditButton = document.getElementById("continue-canvas-edit");
const savedTools = restoreCanvasToolState(localState.load(null), previousLocalState || legacyState);
let tool = savedTools.tool;
let previousDrawingTool = tool;
let pixelEditEnabled = savedTools.pixelEditEnabled;
let normalBrushSize = savedTools.normalBrushSize;
let pixelBrushSize = savedTools.pixelBrushSize;
let drawing = false;
let drawingPointerId = null;
let lastPoint = null;
let drawingBefore = null;
let editorActive = false;
let editorMode = "draw";
let view = { scale: 1, x: 0, y: 0 };
let viewGesture = null;
let assistiveDrag = null;
let suppressAssistiveClick = false;
let assistivePosition = { x: 36, y: window.innerHeight / 2 };
const viewPointers = new Map();
const undoHistory = new PixelUndoHistory();
paintColor.value = savedTools.paintColor || paintColor.value;
backgroundColor.value = savedTools.backgroundColor || backgroundColor.value;
function brushMode() {
return pixelEditEnabled ? BRUSH_MODE_PIXEL : BRUSH_MODE_NORMAL;
}
function currentBrushSize() {
return pixelEditEnabled ? pixelBrushSize : normalBrushSize;
}
function setPixel(x, y, rgb) {
if (x < 0 || x > 63 || y < 0 || y > 63) return;
const offset = (y * 64 + x) * 3;
const pixels = getPixelBuffer();
pixels[offset] = rgb.r;
pixels[offset + 1] = rgb.g;
pixels[offset + 2] = rgb.b;
}
function activeRgb() {
return tool === "eraser" ? hexToRgb(backgroundColor.value) : hexToRgb(paintColor.value);
}
function stampBrush(centerX, centerY, rgb) {
brushStampPoints(centerX, centerY, currentBrushSize(), brushMode())
.forEach((point) => setPixel(point.x, point.y, rgb));
}
function paintLine(from, to) {
const rgb = activeRgb();
linePoints(from, to).forEach((point) => stampBrush(point.x, point.y, rgb));
}
function paintEvent(event) {
const samples = typeof event.getCoalescedEvents === "function" ? event.getCoalescedEvents() : [event];
for (const sample of samples.length ? samples : [event]) {
const point = boardPoint(sample);
paintLine(lastPoint || point, point);
lastPoint = point;
}
renderPixelDraft();
}
function pixelSnapshot() {
return Uint8Array.from(getPixelBuffer());
}
function updateUndoControl() {
if (undoHistory.size > 0) setControlReady(undoButton);
else setControlUnavailable(undoButton, "还没有可以撤销的像素操作。");
}
function clearUndoHistory() {
undoHistory.clear();
updateUndoControl();
}
function recordPixelOperation(before) {
const recorded = undoHistory.record(before, getPixelBuffer());
updateUndoControl();
return recorded;
}
function viewportBounds() {
return {
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
baseSize: Math.max(1, boardStage.offsetWidth),
minVisible: 48,
};
}
function applyCanvasView(next = view) {
view = clampCanvasView(next, viewportBounds());
boardStage.style.setProperty("--canvas-view-scale", String(view.scale));
boardStage.style.setProperty("--canvas-view-x", `${view.x}px`);
boardStage.style.setProperty("--canvas-view-y", `${view.y}px`);
}
function resetCanvasView() {
view = { scale: 1, x: 0, y: 0 };
applyCanvasView();
}
function applyAssistivePosition(next = assistivePosition) {
assistivePosition = clampFloatingButton(next, {
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
size: 56,
inset: 8,
});
assistive.style.setProperty("--assistive-x", `${assistivePosition.x}px`);
assistive.style.setProperty("--assistive-y", `${assistivePosition.y}px`);
assistive.dataset.menuHorizontal = assistivePosition.x > window.innerWidth / 2 ? "left" : "right";
assistive.dataset.menuVertical = assistivePosition.y > window.innerHeight / 2 ? "up" : "down";
}
function resetAssistivePosition() {
applyAssistivePosition({ x: 36, y: window.innerHeight / 2 });
}
function closeAssistiveMenu({ restoreFocus = false } = {}) {
assistiveMenu.hidden = true;
assistiveToggle.setAttribute("aria-expanded", "false");
assistiveToggle.setAttribute("aria-label", "打开画布编辑菜单");
if (restoreFocus && editorActive) assistiveToggle.focus();
}
function openAssistiveMenu() {
applyAssistivePosition();
assistiveMenu.hidden = false;
assistiveToggle.setAttribute("aria-expanded", "true");
assistiveToggle.setAttribute("aria-label", "关闭画布编辑菜单");
const firstAvailable = assistiveMenu.querySelector('button:not([aria-disabled="true"])');
firstAvailable?.focus();
}
function toggleAssistiveMenu() {
if (assistiveMenu.hidden) openAssistiveMenu();
else closeAssistiveMenu({ restoreFocus: true });
}
function updateEditorModeControls() {
const moving = editorMode === "move";
if (editorActive) document.body.dataset.canvasEditorMode = editorMode;
else delete document.body.dataset.canvasEditorMode;
editorModeHint.textContent = moving
? "移动模式:单指拖动画布,双指捏合缩放;桌面可使用滚轮缩放。"
: "绘画模式:直接在画布上绘制。";
if (moving) {
setControlUnavailable(moveViewButton, "当前已经是移动画布视线模式。");
setControlReady(continueEditButton);
} else {
setControlReady(moveViewButton);
setControlUnavailable(continueEditButton, "当前已经是绘画模式。");
}
updateUndoControl();
}
function resetViewGesture() {
const points = Array.from(viewPointers.values());
if (points.length >= 2) {
const [first, second] = points;
viewGesture = {
kind: "pinch",
startView: { ...view },
startMidpoint: { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 },
startDistance: Math.max(1, Math.hypot(second.x - first.x, second.y - first.y)),
};
} else if (points.length === 1) {
viewGesture = { kind: "pan", startView: { ...view }, startPoint: { ...points[0] } };
} else {
viewGesture = null;
}
}
function setEditorMode(mode) {
if (!editorActive) return;
if (mode === "move") finishDrawing();
editorMode = mode === "move" ? "move" : "draw";
viewPointers.clear();
viewGesture = null;
updateEditorModeControls();
closeAssistiveMenu({ restoreFocus: true });
announce(editorMode === "move"
? "已进入移动画布视线模式:单指拖动,双指缩放"
: "已继续编辑画布");
}
function enterCanvasEditor() {
if (editorActive || getInteractionMode() !== "pixels") return;
editorActive = true;
editorMode = "draw";
canvas.dataset.canvasEditing = "true";
document.body.classList.add("canvas-edit-active");
assistive.hidden = false;
closeAssistiveMenu();
updateEditorModeControls();
window.requestAnimationFrame(() => {
resetCanvasView();
resetAssistivePosition();
assistiveToggle.focus();
});
announce("已进入全屏画布编辑;左侧悬浮按钮可撤销、移动视线或退出");
}
function exitCanvasEditor({ returnToTools = true } = {}) {
if (!editorActive) return;
finishDrawing();
editorActive = false;
editorMode = "draw";
viewPointers.clear();
viewGesture = null;
closeAssistiveMenu();
assistive.hidden = true;
delete canvas.dataset.canvasEditing;
document.body.classList.remove("canvas-edit-active");
delete document.body.dataset.canvasEditorMode;
boardStage.style.removeProperty("--canvas-view-scale");
boardStage.style.removeProperty("--canvas-view-x");
boardStage.style.removeProperty("--canvas-view-y");
view = { scale: 1, x: 0, y: 0 };
if (returnToTools) {
window.requestAnimationFrame(() => {
canvasWorkbench.scrollIntoView({ block: "start", behavior: "smooth" });
startEditButton.focus({ preventScroll: true });
});
}
announce("已退出画布编辑,可以重新选择颜色和画笔工具");
}
function undoPixelOperation() {
const previous = undoHistory.undo();
if (!previous) {
updateUndoControl();
return;
}
getPixelBuffer().set(previous);
commitPixelChanges("pixel-undo");
updateUndoControl();
announce("已撤销上一个像素操作");
}
function persistTools() {
localState.save({
paintColor: paintColor.value,
backgroundColor: backgroundColor.value,
tool,
pixelEditEnabled,
normalBrushSize,
pixelBrushSize,
});
}
function renderBrushSize() {
const mode = brushMode();
const brushSize = currentBrushSize();
const spec = brushModeSpec(mode);
brushSizeInput.min = String(spec.min);
brushSizeInput.max = String(spec.max);
brushSizeInput.value = String(brushSize);
document.getElementById("brush-size-value").textContent = pixelEditEnabled
? `${brushSize} × ${brushSize}`
: `${brushSize} px`;
const preview = document.getElementById("brush-size-preview");
const previewSize = Math.max(4, brushSize * 2);
preview.style.width = `${previewSize}px`;
preview.style.height = `${previewSize}px`;
preview.dataset.shape = pixelEditEnabled ? "square" : "round";
brushSizePresets.forEach((button, index) => {
const value = spec.presets[index];
button.dataset.brushSize = String(value);
button.textContent = String(value);
});
setActiveButton(
brushSizePresets,
brushSizePresets.find((button) => Number(button.dataset.brushSize) === brushSize),
);
pixelEditToggle.setAttribute("aria-pressed", String(pixelEditEnabled));
pixelEditToggle.textContent = pixelEditEnabled ? "退出单像素编辑" : "开启单像素编辑";
pixelEditStatus.hidden = !pixelEditEnabled;
pixelEditStatus.textContent = pixelEditEnabled
? `画布已按 64×64 方形像素分格,当前笔尖为 ${brushSize}×${brushSize}。`
: "";
}
function setBrushSize(value) {
const normalized = normalizeBrushSize(value, brushMode());
if (pixelEditEnabled) pixelBrushSize = normalized;
else normalBrushSize = normalized;
renderBrushSize();
persistTools();
}
function updatePixelGridVisibility() {
pixelGrid.hidden = !(pixelEditEnabled && getInteractionMode() === "pixels");
}
function togglePixelEditMode() {
pixelEditEnabled = !pixelEditEnabled;
renderBrushSize();
updatePixelGridVisibility();
persistTools();
announce(pixelEditEnabled
? `已进入单像素编辑模式:画布已按 64×64 方形像素分格,笔尖为 ${pixelBrushSize}×${pixelBrushSize} 正方形。`
: `已退出单像素编辑模式:恢复 ${normalBrushSize} px 圆形笔尖。`);
}
function selectTool(button) {
tool = button.dataset.canvasTool;
if (tool !== "eyedropper") previousDrawingTool = tool;
setActiveButton(toolButtons, button);
persistTools();
}
toolButtons.forEach((button) => button.addEventListener("click", () => selectTool(button)));
canvas.addEventListener("pointerdown", (event) => {
if (!editorActive || getInteractionMode() !== "pixels") return;
if (editorMode === "move") {
event.preventDefault();
viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
canvas.setPointerCapture(event.pointerId);
resetViewGesture();
return;
}
if (drawing) return;
if (tool === "eyedropper") {
const point = boardPoint(event);
const offset = (point.y * 64 + point.x) * 3;
const pixels = getPixelBuffer();
setColorValue(paintColor, rgbToHex({
r: pixels[offset],
g: pixels[offset + 1],
b: pixels[offset + 2],
}), { recordHistory: true });
tool = previousDrawingTool;
setActiveButton(toolButtons, toolButtons.find((button) => button.dataset.canvasTool === tool));
persistTools();
announce(`已吸取像素层 (${point.x}, ${point.y}) 的颜色`);
return;
}
event.preventDefault();
drawing = true;
drawingPointerId = event.pointerId;
drawingBefore = pixelSnapshot();
lastPoint = boardPoint(event);
canvas.setPointerCapture(event.pointerId);
paintEvent(event);
});
canvas.addEventListener("pointermove", (event) => {
if (editorActive && editorMode === "move" && viewPointers.has(event.pointerId)) {
event.preventDefault();
viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
const points = Array.from(viewPointers.values());
if (viewGesture?.kind === "pinch" && points.length >= 2) {
const [first, second] = points;
const midpoint = { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 };
const distance = Math.max(1, Math.hypot(second.x - first.x, second.y - first.y));
const scaled = zoomCanvasView(
viewGesture.startView,
viewGesture.startView.scale * (distance / viewGesture.startDistance),
viewGesture.startMidpoint,
viewportBounds(),
);
applyCanvasView({
...scaled,
x: scaled.x + midpoint.x - viewGesture.startMidpoint.x,
y: scaled.y + midpoint.y - viewGesture.startMidpoint.y,
});
} else if (viewGesture?.kind === "pan" && points.length === 1) {
applyCanvasView({
...viewGesture.startView,
x: viewGesture.startView.x + points[0].x - viewGesture.startPoint.x,
y: viewGesture.startView.y + points[0].y - viewGesture.startPoint.y,
});
}
return;
}
if (drawing && drawingPointerId === event.pointerId && editorActive
&& editorMode === "draw" && getInteractionMode() === "pixels") paintEvent(event);
});
function finishDrawing() {
if (!drawing) return;
drawing = false;
drawingPointerId = null;
lastPoint = null;
const changed = recordPixelOperation(drawingBefore);
drawingBefore = null;
if (changed) commitPixelChanges("pixel-draw");
persistTools();
}
function finishCanvasPointer(event) {
if (viewPointers.delete(event.pointerId)) {
resetViewGesture();
return;
}
if (drawingPointerId === event.pointerId) finishDrawing();
}
canvas.addEventListener("pointerup", finishCanvasPointer);
canvas.addEventListener("pointercancel", finishCanvasPointer);
canvas.addEventListener("lostpointercapture", finishCanvasPointer);
canvas.addEventListener("wheel", (event) => {
if (!editorActive || editorMode !== "move") return;
event.preventDefault();
const factor = Math.exp(-event.deltaY * 0.002);
applyCanvasView(zoomCanvasView(view, view.scale * factor, {
x: event.clientX,
y: event.clientY,
}, viewportBounds()));
}, { passive: false });
paintColor.addEventListener("input", persistTools);
backgroundColor.addEventListener("input", persistTools);
brushSizeInput.addEventListener("input", () => setBrushSize(brushSizeInput.value));
brushSizePresets.forEach((button) => button.addEventListener("click", () => setBrushSize(button.dataset.brushSize)));
pixelEditToggle.addEventListener("click", togglePixelEditMode);
document.getElementById("fill-canvas").addEventListener("click", () => {
const before = pixelSnapshot();
const rgb = hexToRgb(backgroundColor.value);
fillPixelLayer([rgb.r, rgb.g, rgb.b]);
recordPixelOperation(before);
announce("背景颜色已填充到像素层,文字元素保持不变");
});
document.getElementById("clear-canvas").addEventListener("click", () => {
const before = pixelSnapshot();
fillPixelLayer([0, 0, 0]);
recordPixelOperation(before);
announce("像素层已清空,文字元素保持不变");
});
startEditButton.addEventListener("click", enterCanvasEditor);
undoButton.addEventListener("click", undoPixelOperation);
exitEditButton.addEventListener("click", () => exitCanvasEditor());
moveViewButton.addEventListener("click", () => setEditorMode("move"));
continueEditButton.addEventListener("click", () => setEditorMode("draw"));
assistiveToggle.addEventListener("pointerdown", (event) => {
if (!editorActive) return;
event.preventDefault();
event.stopPropagation();
assistiveDrag = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
origin: { ...assistivePosition },
moved: false,
};
assistiveToggle.setPointerCapture(event.pointerId);
});
assistiveToggle.addEventListener("pointermove", (event) => {
if (!assistiveDrag || assistiveDrag.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
const dx = event.clientX - assistiveDrag.startX;
const dy = event.clientY - assistiveDrag.startY;
if (Math.hypot(dx, dy) > 6) assistiveDrag.moved = true;
if (assistiveDrag.moved) {
closeAssistiveMenu();
applyAssistivePosition({ x: assistiveDrag.origin.x + dx, y: assistiveDrag.origin.y + dy });
}
});
function finishAssistiveDrag(event) {
if (!assistiveDrag || assistiveDrag.pointerId !== event.pointerId) return;
suppressAssistiveClick = assistiveDrag.moved;
assistiveDrag = null;
event.stopPropagation();
}
assistiveToggle.addEventListener("pointerup", finishAssistiveDrag);
assistiveToggle.addEventListener("pointercancel", finishAssistiveDrag);
assistiveToggle.addEventListener("lostpointercapture", finishAssistiveDrag);
assistiveToggle.addEventListener("click", () => {
if (suppressAssistiveClick) {
suppressAssistiveClick = false;
return;
}
toggleAssistiveMenu();
});
document.addEventListener("keydown", (event) => {
if (!editorActive || event.key !== "Escape") return;
event.preventDefault();
if (!assistiveMenu.hidden) closeAssistiveMenu({ restoreFocus: true });
else exitCanvasEditor();
});
window.addEventListener("resize", () => {
if (!editorActive) return;
applyCanvasView();
applyAssistivePosition();
});
document.addEventListener("matrix:composition-applied", clearUndoHistory);
document.addEventListener("matrix:canvas-history-checkpoint", clearUndoHistory);
document.addEventListener("matrix:scene-replaced", clearUndoHistory);
refreshColorTrigger(paintColor);
refreshColorTrigger(backgroundColor);
setActiveButton(toolButtons, toolButtons.find((button) => button.dataset.canvasTool === tool));
renderBrushSize();
persistTools();
updateEditorModeControls();
registerWorkspace({
id: "canvas",
title: "像素画布",
elementId: "workspace-canvas",
order: 30,
persistence: "local",
capabilities: ["scene-template-save"],
onEnter() {
renderPixelDraft();
updatePixelGridVisibility();
},
onLeave() {
exitCanvasEditor({ returnToTools: false });
finishDrawing();
pixelGrid.hidden = true;
},
});