Translucent_Relief/JavaScript/canvas_editor.js

1171 lines
62 KiB
JavaScript
Raw Normal View History

2026-08-18 16:06:28 +08:00
 function drawSource() {
syncActiveConfig();
if (state.imageMode === 'whole') {
renderConfig(state.globalConfig);
state.sourcePixels = state.globalConfig.pixels;
} else {
state.bladeConfigs.forEach( (config) => {
if (config.image || config.editDirty)
renderConfig(config);
else
config.pixels = null;
}
);
}
drawMaskPreview();
}
function modelToCanvas(x, y, canvas) {
var bounds = modelBounds();
return [(y - bounds.yMin) / (bounds.yMax - bounds.yMin) * canvas.width, (x - bounds.xMin) / (bounds.xMax - bounds.xMin) * canvas.height];
}
function appendBladePath(ctx, angle, canvas) {
FAN.outline.forEach( (point, index) => {
var world = rotatePoint(point[0], point[1], angle);
var p = modelToCanvas(world[0], world[1], canvas);
if (index === 0)
ctx.moveTo(p[0], p[1]);
else
ctx.lineTo(p[0], p[1]);
}
);
ctx.closePath();
}
function currentReliefMargin() {
return ui.reliefMargin ? Number(ui.reliefMargin.value) : FAN.margin;
}
function reliefHalfAt(radius, margin=currentReliefMargin()) {
return Math.max(.25, widthAt(radius) + (FAN.edgeOverlap || 0) - margin);
}
function reliefRadiusAt(index, count) {
if (!FAN.headArc || count < 4) {
return FAN.rMin + (FAN.rMax - FAN.rMin) * index / (count - 1);
}
var arc = FAN.headArc;
var startAngle = Math.acos(clamp((arc.startRadius - arc.centerRadius) / arc.radius, -1, 1));
var endAngle = Math.acos(clamp((FAN.rMax - arc.centerRadius) / arc.radius, -1, 1));
var headSegments = clamp(Math.max(24, Math.round((count - 1) * .15)), 2, count - 3);
var bodySegments = count - 1 - headSegments;
if (index <= bodySegments) {
return FAN.rMin + (arc.startRadius - FAN.rMin) * index / bodySegments;
}
var headProgress = (index - bodySegments) / (count - 1 - bodySegments);
var angle = startAngle + (endAngle - startAngle) * headProgress;
return arc.centerRadius + arc.radius * Math.cos(angle);
}
function rootCapTriangleCount() {
return FAN.rootInnerUpper.length * 8 + 4;
}
function outerReliefTipRadius() {
return FAN.profile[FAN.profile.length - 1][0] + (FAN.tipOverlap || 0);
}
function rootReliefPolygon(margin=currentReliefMargin()) {
var insetUpper = FAN.rootInnerUpper.map( ([radius,half]) => [radius, Math.max(.25, half - margin)]);
var joinHalf = reliefHalfAt(FAN.rMin, margin);
return [[FAN.rMin, -joinHalf], ...insetUpper.slice().reverse().map( ([radius,half]) => [radius, -half]), [FAN.rootTipRadius, 0], ...insetUpper, [FAN.rMin, joinHalf]];
}
function pointInPolygon2D(x, y, polygon) {
var inside = false;
for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) {
const [xi,yi] = polygon[index];
const [xj,yj] = polygon[previous];
var crosses = (yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi;
if (crosses)
inside = !inside;
}
return inside;
}
function appendReliefPath(ctx, angle, canvas) {
var steps = 96;
var margin = currentReliefMargin();
var pointIndex = 0;
for (let index = 0; index < steps; index++) {
var radius = reliefRadiusAt(index, steps);
var half = reliefHalfAt(radius, margin);
var world = rotatePoint(-radius, half, angle);
var point = modelToCanvas(world[0], world[1], canvas);
if (pointIndex++ === 0)
ctx.moveTo(point[0], point[1]);
else
ctx.lineTo(point[0], point[1]);
}
for (let index = steps - 1; index >= 0; index--) {
var radius = reliefRadiusAt(index, steps);
var half = reliefHalfAt(radius, margin);
var world = rotatePoint(-radius, -half, angle);
var point = modelToCanvas(world[0], world[1], canvas);
ctx.lineTo(point[0], point[1]);
}
var rootPolygon = rootReliefPolygon(margin);
for (let index = 1; index < rootPolygon.length; index++) {
const [radius,localY] = rootPolygon[index];
var world = rotatePoint(-radius, localY, angle);
var point = modelToCanvas(world[0], world[1], canvas);
ctx.lineTo(point[0], point[1]);
}
ctx.closePath();
}
function drawBladePath(ctx, angle, canvas) {
ctx.beginPath();
appendBladePath(ctx, angle, canvas);
}
function ensureMaskLookup(canvas) {
var margin = currentReliefMargin();
if (state.maskLookup && state.maskLookup.width === canvas.width && state.maskLookup.height === canvas.height && state.maskLookup.margin === margin) {
return state.maskLookup;
}
var length = canvas.width * canvas.height;
var blade = new Int8Array(length);
blade.fill(-1);
var u = new Float32Array(length);
var v = new Float32Array(length);
var bounds = modelBounds();
var modelW = bounds.xMax - bounds.xMin;
var modelH = bounds.yMax - bounds.yMin;
var rootPolygon = rootReliefPolygon(margin);
for (let py = 0; py < canvas.height; py++) {
var worldX = bounds.xMin + (py + .5) / canvas.height * modelW;
for (let px = 0; px < canvas.width; px++) {
var worldY = bounds.yMin + (px + .5) / canvas.width * modelH;
var out = py * canvas.width + px;
for (let bladeIndex = 0; bladeIndex < FAN.angles.length; bladeIndex++) {
var local = rotatePoint(worldX, worldY, -FAN.angles[bladeIndex]);
var radius = -local[0];
if (radius < FAN.rootMinRadius || radius > FAN.rMax)
continue;
var half = reliefHalfAt(radius, margin);
var inMainPanel = radius >= FAN.rMin && Math.abs(local[1]) <= half;
var inRootCap = radius < FAN.rMin && pointInPolygon2D(radius, local[1], rootPolygon);
if (!inMainPanel && !inRootCap)
continue;
blade[out] = bladeIndex;
u[out] = clamp((radius - FAN.rMin) / (FAN.rMax - FAN.rMin), 0, 1);
v[out] = clamp((half - local[1]) / (2 * half), 0, 1);
break;
}
}
}
state.maskLookup = {
width: canvas.width,
height: canvas.height,
margin,
blade,
u,
v
};
return state.maskLookup;
}
function drawWholeMask(ctx, canvas) {
var pattern = document.createElement('canvas');
pattern.width = canvas.width;
pattern.height = canvas.height;
pattern.getContext('2d').drawImage(ui.sourceCanvas, 0, 0, pattern.width, pattern.height);
ctx.save();
ctx.beginPath();
FAN.angles.forEach( (angle) => appendReliefPath(ctx, angle, canvas));
ctx.clip('nonzero');
ctx.drawImage(pattern, 0, 0);
ctx.restore();
}
function drawIndividualMask(ctx, canvas) {
var lookup = ensureMaskLookup(canvas);
var output = ctx.createImageData(canvas.width, canvas.height);
var data = output.data;
for (let i = 0; i < lookup.blade.length; i++) {
var out = i * 4;
var bladeIndex = lookup.blade[i];
if (bladeIndex < 0) {
data[out] = 213;
data[out + 1] = 226;
data[out + 2] = 224;
data[out + 3] = 255;
continue;
}
var pixels = state.bladeConfigs[bladeIndex].pixels;
if (!pixels) {
data[out] = 244;
data[out + 1] = 244;
data[out + 2] = 238;
data[out + 3] = 255;
continue;
}
var sx = clamp(Math.round(lookup.u[i] * (pixels.width - 1)), 0, pixels.width - 1);
var sy = clamp(Math.round(lookup.v[i] * (pixels.height - 1)), 0, pixels.height - 1);
var source = (sy * pixels.width + sx) * 4;
data[out] = pixels.data[source];
data[out + 1] = pixels.data[source + 1];
data[out + 2] = pixels.data[source + 2];
data[out + 3] = 255;
}
ctx.putImageData(output, 0, 0);
}
function drawMaskPreview() {
var canvas = ui.maskCanvas;
var ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#d5e2e0';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (state.imageMode === 'whole')
drawWholeMask(ctx, canvas);
else
drawIndividualMask(ctx, canvas);
ctx.save();
ctx.lineJoin = 'round';
FAN.angles.forEach( (angle, bladeIndex) => {
drawBladePath(ctx, angle, canvas);
var selected = state.imageMode === 'blade' && bladeIndex === state.selectedBlade;
ctx.strokeStyle = selected ? '#c64c3f' : 'rgba(16,38,50,.68)';
ctx.lineWidth = selected ? 3 : 1.25;
ctx.stroke();
}
);
var pivot = modelToCanvas(0, 0, canvas);
ctx.beginPath();
ctx.arc(pivot[0], pivot[1], canvas.width * .035, 0, Math.PI * 2);
ctx.fillStyle = '#244f59';
ctx.fill();
ctx.beginPath();
ctx.arc(pivot[0], pivot[1], canvas.width * .022, 0, Math.PI * 2);
ctx.fillStyle = '#b3c9c5';
ctx.fill();
ctx.restore();
drawEditorObjectOverlay(ctx, canvas);
}
function pointerOnActiveEditCanvas(event) {
var rect = ui.maskCanvas.getBoundingClientRect();
var px = clamp((event.clientX - rect.left) / rect.width * ui.maskCanvas.width, 0, ui.maskCanvas.width - .001);
var py = clamp((event.clientY - rect.top) / rect.height * ui.maskCanvas.height, 0, ui.maskCanvas.height - .001);
var config = activeConfig();
var lookup = ensureMaskLookup(ui.maskCanvas);
var index = Math.floor(py) * ui.maskCanvas.width + Math.floor(px);
if (lookup.blade[index] < 0)
return null;
if (state.imageMode === 'whole') {
return [px / ui.maskCanvas.width * config.editCanvas.width, py / ui.maskCanvas.height * config.editCanvas.height];
}
if (lookup.blade[index] !== state.selectedBlade)
return null;
return [lookup.u[index] * (config.editCanvas.width - 1), lookup.v[index] * (config.editCanvas.height - 1)];
}
function pointerOnTextEditCanvas(event) {
var rect = ui.maskCanvas.getBoundingClientRect();
var screenX = event.clientX - rect.left;
var screenY = event.clientY - rect.top;
if (screenX < 0 || screenY < 0 || screenX > rect.width || screenY > rect.height)
return null;
var px = screenX / rect.width * ui.maskCanvas.width;
var py = screenY / rect.height * ui.maskCanvas.height;
var config = activeConfig();
if (state.imageMode === 'whole') {
return [px / ui.maskCanvas.width * config.editCanvas.width, py / ui.maskCanvas.height * config.editCanvas.height];
}
var bounds = modelBounds();
var worldX = bounds.xMin + py / ui.maskCanvas.height * (bounds.xMax - bounds.xMin);
var worldY = bounds.yMin + px / ui.maskCanvas.width * (bounds.yMax - bounds.yMin);
var local = rotatePoint(worldX, worldY, -FAN.angles[state.selectedBlade]);
var radius = -local[0];
var half = reliefHalfAt(clamp(radius, FAN.rMin, FAN.rMax));
var u = (radius - FAN.rMin) / (FAN.rMax - FAN.rMin);
var v = (half - local[1]) / Math.max(.5, 2 * half);
return [u * (config.editCanvas.width - 1), v * (config.editCanvas.height - 1)];
}
function editPointToMask(config, point, canvas=ui.maskCanvas) {
if (state.imageMode === 'whole') {
return [point[0] / config.editCanvas.width * canvas.width, point[1] / config.editCanvas.height * canvas.height];
}
var u = point[0] / Math.max(1, config.editCanvas.width - 1);
var v = point[1] / Math.max(1, config.editCanvas.height - 1);
var radius = FAN.rMin + u * (FAN.rMax - FAN.rMin);
var half = reliefHalfAt(clamp(radius, FAN.rMin, FAN.rMax));
var localY = half - v * 2 * half;
var world = rotatePoint(-radius, localY, FAN.angles[state.selectedBlade]);
return modelToCanvas(world[0], world[1], canvas);
}
function vectorObjectBounds(config, object) {
var minX, minY, maxX, maxY;
if (object.type === 'text') {
var ctx = config.editCanvas.getContext('2d');
var metrics = textLayoutMetrics(ctx, object);
var vertical = object.fontSize * .72 + Math.abs(metrics.sagitta);
minX = object.x - metrics.total / 2 - object.fontSize * .2;
maxX = object.x + metrics.total / 2 + object.fontSize * .2;
minY = object.y - vertical - object.fontSize * .2;
maxY = object.y + object.fontSize * .65 + Math.abs(metrics.sagitta) * .12;
} else if (object.type === 'spline') {
var points = object.points || [];
if (!points.length)
return null;
minX = Math.min(...points.map( (point) => point[0]));
minY = Math.min(...points.map( (point) => point[1]));
maxX = Math.max(...points.map( (point) => point[0]));
maxY = Math.max(...points.map( (point) => point[1]));
} else {
minX = Math.min(object.x1, object.x2);
minY = Math.min(object.y1, object.y2);
maxX = Math.max(object.x1, object.x2);
maxY = Math.max(object.y1, object.y2);
}
var pad = Math.max(5, object.lineWidth || object.fontSize * .08 || 5);
return {
minX: minX - pad,
minY: minY - pad,
maxX: maxX + pad,
maxY: maxY + pad
};
}
function objectControlPoints(object, bounds) {
if (object.type === 'spline')
return object.points || [];
if (object.type === 'text')
return [[object.x, object.y]];
if (['triangle', 'polygon', 'star', 'diamond'].includes(object.type)) {
var count = object.type === 'triangle' ? 3 : object.type === 'polygon' ? (object.sides || 6) : object.type === 'star' ? 5 : 4;
return regularShapePoints(object, count);
}
return [[bounds.minX, bounds.minY], [bounds.maxX, bounds.minY], [bounds.maxX, bounds.maxY], [bounds.minX, bounds.maxY]];
}
function drawEditorObjectOverlay(ctx, canvas) {
var config = activeConfig();
var object = state.editorTool === 'select' ? selectedVectorObject(config) : null;
if (object) {
var bounds = vectorObjectBounds(config, object);
if (bounds) {
var corners = [[bounds.minX, bounds.minY], [bounds.maxX, bounds.minY], [bounds.maxX, bounds.maxY], [bounds.minX, bounds.maxY]].map( (point) => editPointToMask(config, point, canvas));
ctx.save();
ctx.strokeStyle = 'rgba(198,76,63,.96)';
ctx.fillStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 4]);
ctx.beginPath();
tracePoints(ctx, corners);
ctx.stroke();
ctx.setLineDash([]);
objectControlPoints(object, bounds).forEach( (point) => {
var screen = editPointToMask(config, point, canvas);
ctx.beginPath();
ctx.arc(screen[0], screen[1], 3.5, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
);
ctx.restore();
}
}
var preview = state.editorPreview;
if (preview && preview.config === config && preview.object) {
var point = preview.object.type === 'text' ? [preview.object.x, preview.object.y] : preview.object.type === 'spline' ? preview.object.points[preview.object.points.length - 1] : [preview.object.x2, preview.object.y2];
if (point) {
var screen = editPointToMask(config, point, canvas);
ctx.save();
ctx.strokeStyle = 'rgba(198,76,63,.96)';
ctx.fillStyle = 'rgba(255,255,255,.84)';
ctx.lineWidth = 1.25;
ctx.beginPath();
ctx.arc(screen[0], screen[1], 5, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(screen[0] - 9, screen[1]);
ctx.lineTo(screen[0] + 9, screen[1]);
ctx.moveTo(screen[0], screen[1] - 9);
ctx.lineTo(screen[0], screen[1] + 9);
ctx.stroke();
ctx.restore();
}
}
}
function hitTestVectorObject(config, point) {
var scale = config.editCanvas.width / ui.maskCanvas.width;
var tolerance = 10 * scale;
for (let index = config.vectorObjects.length - 1; index >= 0; index--) {
var object = config.vectorObjects[index];
var bounds = vectorObjectBounds(config, object);
if (!bounds)
continue;
if (point[0] >= bounds.minX - tolerance && point[0] <= bounds.maxX + tolerance && point[1] >= bounds.minY - tolerance && point[1] <= bounds.maxY + tolerance)
return object;
}
return null;
}
function translateVectorObject(object, dx, dy) {
if (object.type === 'text') {
object.x += dx;
object.y += dy;
} else if (object.type === 'spline') {
object.points = object.points.map( (point) => [point[0] + dx, point[1] + dy]);
} else {
object.x1 += dx;
object.y1 += dy;
object.x2 += dx;
object.y2 += dy;
}
}
function cloneVectorObjects(objects) {
return objects.map( (object) => JSON.parse(JSON.stringify(object)));
}
function updateConfigEditDirty(config) {
config.editDirty = Boolean(config.paintDirty || config.vectorObjects.length);
}
function currentTextFont(key=state.textFont) {
return TEXT_FONTS[key] || TEXT_FONTS.xingkai;
}
function textLayoutMetrics(ctx, object) {
var font = currentTextFont(object.fontKey);
ctx.font = `${font.weight} ${object.fontSize}px ${font.family}`;
var characters = Array.from(object.value || '');
var spacing = object.fontSize * .045;
var widths = characters.map( (character) => Math.max(1, ctx.measureText(character).width));
var total = widths.reduce( (sum, width) => sum + width, 0) + Math.max(0, characters.length - 1) * spacing;
var sagitta = object.arc / 100 * Math.max(object.fontSize * 1.7, total * .22);
return {
characters,
widths,
spacing,
total: Math.max(1, total),
sagitta
};
}
function drawCurvedText(ctx, object) {
var font = currentTextFont(object.fontKey);
var metrics = textLayoutMetrics(ctx, object);
var cursor = -metrics.total / 2;
ctx.save();
ctx.fillStyle = object.color;
ctx.strokeStyle = object.color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineJoin = 'round';
ctx.lineWidth = Math.max(1, object.fontSize * .085);
ctx.font = `${font.weight} ${object.fontSize}px ${font.family}`;
metrics.characters.forEach( (character, index) => {
var center = cursor + metrics.widths[index] / 2;
var normalized = center / metrics.total;
var yOffset = -metrics.sagitta * (1 - 4 * normalized * normalized);
var tangent = 8 * metrics.sagitta * normalized / metrics.total;
ctx.save();
ctx.translate(object.x + center, object.y + yOffset);
ctx.rotate(Math.atan(tangent));
if (object.textStyle === 'outline')
ctx.strokeText(character, 0, 0);
else
ctx.fillText(character, 0, 0);
ctx.restore();
cursor += metrics.widths[index] + metrics.spacing;
}
);
ctx.restore();
}
function regularShapePoints(object, count, innerRatio=1) {
var cx = (object.x1 + object.x2) / 2;
var cy = (object.y1 + object.y2) / 2;
var rx = Math.max(1, Math.abs(object.x2 - object.x1) / 2);
var ry = Math.max(1, Math.abs(object.y2 - object.y1) / 2);
var points = [];
var total = innerRatio < 1 ? count * 2 : count;
for (let index = 0; index < total; index++) {
var angle = -Math.PI / 2 + index / total * Math.PI * 2;
var ratio = innerRatio < 1 && index % 2 ? innerRatio : 1;
points.push([cx + Math.cos(angle) * rx * ratio, cy + Math.sin(angle) * ry * ratio]);
}
return points;
}
function tracePoints(ctx, points, close=true) {
if (!points.length)
return;
ctx.moveTo(points[0][0], points[0][1]);
for (let index = 1; index < points.length; index++)
ctx.lineTo(points[index][0], points[index][1]);
if (close)
ctx.closePath();
}
function traceSpline(ctx, points, tension=.55) {
if (!points.length)
return;
ctx.moveTo(points[0][0], points[0][1]);
if (points.length === 1)
return;
if (points.length === 2) {
ctx.lineTo(points[1][0], points[1][1]);
return;
}
var factor = clamp(tension, 0, 1) / 6;
for (let index = 0; index < points.length - 1; index++) {
var p0 = points[Math.max(0, index - 1)];
var p1 = points[index];
var p2 = points[index + 1];
var p3 = points[Math.min(points.length - 1, index + 2)];
var cp1 = [p1[0] + (p2[0] - p0[0]) * factor, p1[1] + (p2[1] - p0[1]) * factor];
var cp2 = [p2[0] - (p3[0] - p1[0]) * factor, p2[1] - (p3[1] - p1[1]) * factor];
ctx.bezierCurveTo(cp1[0], cp1[1], cp2[0], cp2[1], p2[0], p2[1]);
}
}
function drawVectorObject(ctx, object) {
if (!object)
return;
if (object.type === 'text') {
drawCurvedText(ctx, object);
return;
}
ctx.save();
ctx.strokeStyle = object.color;
ctx.fillStyle = object.color;
ctx.lineWidth = Math.max(1, object.lineWidth);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
if (object.type === 'line') {
ctx.moveTo(object.x1, object.y1);
ctx.lineTo(object.x2, object.y2);
} else if (object.type === 'ellipse') {
var cx = (object.x1 + object.x2) / 2
, cy = (object.y1 + object.y2) / 2;
ctx.ellipse(cx, cy, Math.max(1, Math.abs(object.x2 - object.x1) / 2), Math.max(1, Math.abs(object.y2 - object.y1) / 2), 0, 0, Math.PI * 2);
} else if (object.type === 'rect') {
ctx.rect(Math.min(object.x1, object.x2), Math.min(object.y1, object.y2), Math.abs(object.x2 - object.x1), Math.abs(object.y2 - object.y1));
} else if (object.type === 'triangle') {
tracePoints(ctx, regularShapePoints(object, 3));
} else if (object.type === 'polygon') {
tracePoints(ctx, regularShapePoints(object, object.sides || 6));
} else if (object.type === 'star') {
tracePoints(ctx, regularShapePoints(object, 5, .43));
} else if (object.type === 'diamond') {
tracePoints(ctx, regularShapePoints(object, 4));
} else if (object.type === 'arrow') {
var dx = object.x2 - object.x1
, dy = object.y2 - object.y1;
var length = Math.max(1, Math.hypot(dx, dy));
var ux = dx / length
, uy = dy / length;
var head = Math.min(length * .42, Math.max(object.lineWidth * 5, 14));
var wing = head * .55;
ctx.moveTo(object.x1, object.y1);
ctx.lineTo(object.x2, object.y2);
ctx.moveTo(object.x2, object.y2);
ctx.lineTo(object.x2 - ux * head - uy * wing, object.y2 - uy * head + ux * wing);
ctx.moveTo(object.x2, object.y2);
ctx.lineTo(object.x2 - ux * head + uy * wing, object.y2 - uy * head - ux * wing);
} else if (object.type === 'spline') {
traceSpline(ctx, object.points || [], object.tension);
}
ctx.stroke();
ctx.restore();
}
function renderEditorComposite(config, previewObject=null) {
var ctx = config.editCanvas.getContext('2d');
ctx.clearRect(0, 0, config.editCanvas.width, config.editCanvas.height);
if (config.paintDirty)
ctx.drawImage(config.paintCanvas, 0, 0);
config.vectorObjects.forEach( (object) => drawVectorObject(ctx, object));
if (previewObject)
drawVectorObject(ctx, previewObject);
}
function refreshCommittedEditor(config, redrawMask=true) {
renderEditorComposite(config);
updateConfigEditDirty(config);
renderConfig(config);
if (state.imageMode === 'whole')
state.sourcePixels = config.pixels;
if (redrawMask)
drawMaskPreview();
}
function renderEditorPreview(config, previewObject) {
state.editorPreview = previewObject ? {
config,
object: previewObject
} : null;
renderEditorComposite(config, previewObject);
renderConfig(config, Boolean(previewObject));
if (state.imageMode === 'whole')
state.sourcePixels = config.pixels;
drawMaskPreview();
renderEditorComposite(config);
renderConfig(config);
if (state.imageMode === 'whole')
state.sourcePixels = config.pixels;
}
function clearEditorPreview(redrawMask=true) {
var preview = state.editorPreview;
state.editorPreview = null;
if (!preview)
return;
refreshCommittedEditor(preview.config, redrawMask);
}
function pushEditHistory(config) {
var ctx = config.paintCanvas.getContext('2d', {
willReadFrequently: true
});
config.editHistory.push({
paintPixels: ctx.getImageData(0, 0, config.paintCanvas.width, config.paintCanvas.height),
paintDirty: config.paintDirty,
vectorObjects: cloneVectorObjects(config.vectorObjects),
nextVectorId: config.nextVectorId,
selectedObjectId: config.selectedObjectId
});
if (config.editHistory.length > 20)
config.editHistory.shift();
}
function previewEditorChange(config) {
refreshCommittedEditor(config, true);
}
function paintEditorSegment(config, from, to, erasing=false) {
if (!from || !to)
return;
var canvas = config.paintCanvas;
var ctx = canvas.getContext('2d');
var scale = canvas.width / ui.maskCanvas.width;
ctx.save();
ctx.globalCompositeOperation = erasing ? 'destination-out' : 'source-over';
ctx.strokeStyle = ui.editorColor.value;
ctx.lineWidth = Math.max(1, Number(ui.brushSize.value) * scale);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
var distance = Math.hypot(to[0] - from[0], to[1] - from[1]);
if (distance < .01) {
ctx.fillStyle = erasing ? '#000' : ui.editorColor.value;
ctx.beginPath();
ctx.arc(to[0], to[1], ctx.lineWidth / 2, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.beginPath();
ctx.moveTo(from[0], from[1]);
ctx.lineTo(to[0], to[1]);
ctx.stroke();
}
ctx.restore();
config.paintDirty = true;
previewEditorChange(config);
}
function createTextObject(config, point) {
var value = ui.textValue.value.trim();
if (!value)
return null;
var scale = config.editCanvas.width / ui.maskCanvas.width;
return {
id: null,
type: 'text',
x: point[0],
y: point[1],
value,
fontKey: state.textFont,
textStyle: state.textStyle,
fontSize: Math.max(8, Number(ui.textSize.value) * scale),
arc: Number(ui.textArc.value),
color: ui.editorColor.value
};
}
function createShapeObject(config, type, start, end, points=null) {
var scale = config.editCanvas.width / ui.maskCanvas.width;
var shapeEnd = end;
var ratioMode = RATIO_SHAPE_TOOLS.includes(type) ? state.shapeRatioMode : 'free';
if (ratioMode === 'regular') {
var dx = end[0] - start[0]
, dy = end[1] - start[1];
var side = Math.max(Math.abs(dx), Math.abs(dy), 1);
shapeEnd = [start[0] + (dx < 0 ? -side : side), start[1] + (dy < 0 ? -side : side)];
}
var object = {
id: null,
type,
color: ui.editorColor.value,
lineWidth: Math.max(1, Number(ui.brushSize.value) * scale),
ratioMode,
x1: start[0],
y1: start[1],
x2: shapeEnd[0],
y2: shapeEnd[1]
};
if (type === 'polygon')
object.sides = Number(ui.polygonSides.value);
if (type === 'spline') {
object.points = (points || [start, end]).map( (point) => [point[0], point[1]]);
object.tension = Number(ui.splineTension.value) / 100;
}
return object;
}
function commitVectorObject(config, object) {
if (!object)
return false;
object.id = config.nextVectorId++;
config.vectorObjects.push(object);
config.selectedObjectId = object.id;
state.editorPreview = null;
refreshCommittedEditor(config, true);
updateSelectionUi();
scheduleRebuild();
return true;
}
function placeEditorText(config, point) {
var object = createTextObject(config, point);
if (!object) {
ui.status.textContent = '请先输入要添加的文字。';
return false;
}
return commitVectorObject(config, object);
}
function finishEditorStroke() {
if (!state.editorDrawing)
return;
state.editorDrawing = false;
state.editorLastPoint = null;
state.editorPointerId = null;
scheduleRebuild();
}
function selectedVectorObject(config=activeConfig()) {
return config.vectorObjects.find( (object) => object.id === config.selectedObjectId) || null;
}
function updateSelectionUi() {
var object = selectedVectorObject();
var selected = Boolean(object);
ui.selectionInfo.hidden = !selected;
ui.deleteSelected.disabled = !selected;
ui.floatDeleteSelected.disabled = !selected;
if (selected)
ui.selectionLabel.textContent = `已选中${vectorObjectDisplayName(object)},可在扇面中拖动位置`;
}
function vectorObjectDisplayName(object) {
if (!object || !RATIO_SHAPE_TOOLS.includes(object.type))
return VECTOR_TOOL_LABELS[object?.type] || '对象';
var regular = object.ratioMode === 'regular';
var names = {
ellipse: regular ? '正圆' : '椭圆',
rect: regular ? '正方形' : '矩形',
triangle: regular ? '正三角形' : '自由三角形',
polygon: regular ? `${object.sides || 6}边形` : `自由比例${object.sides || 6}边形`,
star: regular ? '正星形' : '自由比例星形',
diamond: regular ? '正菱形' : '自由比例菱形'
};
return names[object.type];
}
function deleteSelectedObject() {
var config = activeConfig();
var index = config.vectorObjects.findIndex( (object) => object.id === config.selectedObjectId);
if (index < 0)
return;
pushEditHistory(config);
config.vectorObjects.splice(index, 1);
config.selectedObjectId = null;
refreshCommittedEditor(config, true);
updateSelectionUi();
scheduleRebuild();
}
function undoEditorChange() {
var config = activeConfig();
var previous = config.editHistory.pop();
if (!previous) {
ui.status.textContent = '当前图片没有可撤销的编辑。';
return;
}
var ctx = config.paintCanvas.getContext('2d');
ctx.clearRect(0, 0, config.paintCanvas.width, config.paintCanvas.height);
ctx.putImageData(previous.paintPixels, 0, 0);
config.paintDirty = previous.paintDirty;
config.vectorObjects = cloneVectorObjects(previous.vectorObjects);
config.nextVectorId = previous.nextVectorId;
config.selectedObjectId = previous.selectedObjectId;
refreshCommittedEditor(config, true);
updateSelectionUi();
scheduleRebuild();
}
function clearEditorChanges() {
var config = activeConfig();
if (!config.editDirty)
return;
pushEditHistory(config);
config.paintCanvas.getContext('2d').clearRect(0, 0, config.paintCanvas.width, config.paintCanvas.height);
config.paintDirty = false;
config.vectorObjects = [];
config.selectedObjectId = null;
refreshCommittedEditor(config, true);
updateSelectionUi();
scheduleRebuild();
}
function shapeRatioDisplayName(tool=state.editorTool, mode=state.shapeRatioMode) {
var regular = mode === 'regular';
var names = {
ellipse: regular ? '正圆' : '椭圆',
rect: regular ? '正方形' : '矩形',
triangle: regular ? '正三角形' : '自由比例三角形',
polygon: regular ? '正多边形' : '自由比例多边形',
star: regular ? '正星形' : '自由比例星形',
diamond: regular ? '正菱形' : '自由比例菱形'
};
return names[tool] || VECTOR_TOOL_LABELS[tool] || '几何图形';
}
function setShapeRatioMode(mode) {
state.shapeRatioMode = mode === 'free' ? 'free' : 'regular';
document.querySelectorAll('[data-shape-ratio]').forEach( (button) => {
var active = button.dataset.shapeRatio === state.shapeRatioMode;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
}
);
ui.floatShapeRatio.value = state.shapeRatioMode;
ui.shapeRatioHint.textContent = `${shapeRatioDisplayName()} · ${state.shapeRatioMode === 'regular' ? '等比例' : '可拉伸'}`;
if (RATIO_SHAPE_TOOLS.includes(state.editorTool)) {
ui.editorHint.textContent = `${shapeRatioDisplayName()}:按住拖动绘制;可随时切换“正图形”与“自由比例”。`;
ui.adjustHint.textContent = `${shapeRatioDisplayName()} · 按住拖动`;
}
}
function setEditorTool(tool) {
clearEditorPreview(false);
state.editorTool = tool;
document.querySelectorAll('[data-editor-tool]').forEach( (button) => {
var active = button.dataset.editorTool === tool;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
}
);
var messages = {
move: '移动模式:直接拖动图片,滚轮缩放;上方滑杆或按钮可旋转。',
select: '选择模式:单击文字或几何,再拖动到新位置;选中文字后可继续修改内容、字体和弧度。',
brush: '画笔模式:圆圈就是实际笔画大小;按住拖动绘制,单击可落一个圆点。',
eraser: '橡皮模式:虚线圆圈就是擦除范围;擦除画笔内容,文字和几何请用“选择”后删除。',
text: '文字模式:扇面中显示的就是最终排版;锚点可移到叶片外侧,预览会保留,单击确认放置。',
line: '直线:在扇面内按住拖动,松开完成;之后可用“选择”移动。',
ellipse: '圆形:选择“正圆”或“自由比例椭圆”,再按住拖动绘制。',
rect: '方形:选择“正方形”或“自由比例矩形”,再按住拖动绘制。',
triangle: '三角:选择“正三角形”或“自由比例三角形”,再按住拖动绘制。',
polygon: '多边形:设置边数,并选择“正多边形”或“自由比例多边形”。',
star: '星形:选择“正星形”或“自由比例星形”,再按住拖动绘制。',
diamond: '菱形:选择“正菱形”或“自由比例菱形”,再按住拖动绘制。',
arrow: '箭头:从起点拖向箭头方向,松开完成。',
spline: '样条:按住自由绘制,系统自动拟合平滑曲线。'
};
ui.editorHint.textContent = messages[tool];
ui.adjustHint.textContent = tool === 'move' ? '拖动图片 · 滚轮缩放' : tool === 'select' ? '单击选择 · 拖动位置' : tool === 'text' ? '最终效果预览 · 单击放置' : VECTOR_SHAPE_TOOLS.includes(tool) ? `${VECTOR_TOOL_LABELS[tool]} · 按住拖动` : `${tool === 'brush' ? '画笔' : '橡皮'}圆圈 · 按住拖动`;
ui.maskWrap.classList.toggle('is-editing', tool !== 'move');
ui.maskWrap.classList.toggle('is-selecting', tool === 'select');
var shapeTool = VECTOR_SHAPE_TOOLS.includes(tool);
var ratioShapeTool = RATIO_SHAPE_TOOLS.includes(tool);
var selectedText = tool === 'select' && selectedVectorObject()?.type === 'text';
ui.brushEditorOptions.hidden = !(tool === 'brush' || tool === 'eraser' || shapeTool);
ui.shapeEditorOptions.hidden = !(ratioShapeTool || tool === 'spline');
ui.shapeRatioControl.hidden = !ratioShapeTool;
ui.polygonSidesControl.hidden = tool !== 'polygon';
ui.splineTensionControl.hidden = tool !== 'spline';
ui.textEditorOptions.hidden = !(tool === 'text' || selectedText);
ui.editorColorControl.hidden = !(tool === 'brush' || tool === 'text' || shapeTool || selectedText);
updateFloatingToolContext();
syncBrushCursorMode();
setShapeRatioMode(state.shapeRatioMode);
updateSelectionUi();
drawMaskPreview();
refreshEditorPointerPreview();
}
function hideBrushCursor() {
ui.brushCursor.hidden = true;
}
function hideTextPlacementPreview() {
ui.textPlacementPreview.hidden = true;
if (state.editorPreview?.object?.type === 'text')
clearEditorPreview(true);
}
function hideEditorPointerPreviews() {
hideBrushCursor();
hideTextPlacementPreview();
if (state.editorPreview)
clearEditorPreview(true);
state.editorHoverX = null;
state.editorHoverY = null;
}
function refreshBrushCursorSize() {
var rect = ui.maskCanvas.getBoundingClientRect();
if (!rect.width)
return;
var diameter = clamp(Number(ui.brushSize.value) * rect.width / ui.maskCanvas.width, 4, 180);
ui.brushCursor.style.width = `${diameter}px`;
ui.brushCursor.style.height = `${diameter}px`;
ui.brushCursor.style.setProperty('--cursor-color', state.editorTool === 'eraser' ? '#c64c3f' : ui.editorColor.value);
ui.brushCursor.dataset.label = `${state.editorTool === 'eraser' ? '橡皮' : '画笔'} ${ui.brushSize.value}px`;
}
function syncBrushCursorMode() {
var active = state.editorTool === 'brush' || state.editorTool === 'eraser';
ui.maskWrap.classList.toggle('has-brush-cursor', active);
ui.maskWrap.classList.toggle('has-text-preview', state.editorTool === 'text');
ui.brushCursor.classList.toggle('is-eraser', state.editorTool === 'eraser');
refreshBrushCursorSize();
if (!active)
hideBrushCursor();
if (state.editorTool !== 'text')
hideTextPlacementPreview();
}
function updateBrushCursor(event) {
if (state.editorTool !== 'brush' && state.editorTool !== 'eraser') {
hideBrushCursor();
return;
}
var rect = ui.maskCanvas.getBoundingClientRect();
var x = event.clientX - rect.left;
var y = event.clientY - rect.top;
if (x < 0 || y < 0 || x > rect.width || y > rect.height || !pointerOnActiveEditCanvas(event)) {
hideBrushCursor();
return;
}
refreshBrushCursorSize();
ui.brushCursor.style.left = `${x}px`;
ui.brushCursor.style.top = `${y}px`;
ui.brushCursor.hidden = false;
}
function textPreviewAngle() {
if (state.imageMode !== 'blade')
return 0;
var angle = FAN.angles[state.selectedBlade];
var root = rotatePoint(-FAN.rMin, 0, angle);
var tip = rotatePoint(-FAN.rMax, 0, angle);
var rootCanvas = modelToCanvas(root[0], root[1], ui.maskCanvas);
var tipCanvas = modelToCanvas(tip[0], tip[1], ui.maskCanvas);
return Math.atan2(tipCanvas[1] - rootCanvas[1], tipCanvas[0] - rootCanvas[0]) * 180 / Math.PI;
}
function updateTextPlacementPreview(event) {
if (state.editorTool !== 'text') {
hideTextPlacementPreview();
return;
}
var value = ui.textValue.value.trim();
var point = value ? pointerOnTextEditCanvas(event) : null;
if (!point) {
hideTextPlacementPreview();
return;
}
ui.textPlacementPreview.hidden = true;
renderEditorPreview(activeConfig(), createTextObject(activeConfig(), point));
}
function updateEditorPointerPreview(event) {
state.editorHoverX = event.clientX;
state.editorHoverY = event.clientY;
updateBrushCursor(event);
updateTextPlacementPreview(event);
}
function refreshEditorPointerPreview() {
if (state.editorDrawing || state.editorObjectDragging)
return;
if (state.editorHoverX === null || state.editorHoverY === null)
return;
updateEditorPointerPreview({
clientX: state.editorHoverX,
clientY: state.editorHoverY
});
}
function setTextFont(key) {
if (!TEXT_FONTS[key])
return;
state.textFont = key;
ui.textFontChips.querySelectorAll('[data-text-font]').forEach( (button) => {
var active = button.dataset.textFont === key;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
}
);
ui.floatTextFont.value = key;
var font = currentTextFont();
if (document.fonts?.load)
document.fonts.load(`${font.weight} 24px ${font.family}`).then(refreshEditorPointerPreview).catch( () => {}
);
applyTextControlsToSelected();
refreshEditorPointerPreview();
}
function setTextStyle(style) {
state.textStyle = style === 'outline' ? 'outline' : 'fill';
ui.textStyle.value = state.textStyle;
ui.floatTextStyle.value = state.textStyle;
applyTextControlsToSelected();
refreshEditorPointerPreview();
}
function setTextArc(value) {
state.textArc = clamp(Number(value), -100, 100);
ui.textArc.value = state.textArc;
ui.floatTextArc.value = state.textArc;
ui.floatTextArcValue.textContent = `${state.textArc}%`;
updateOutput(ui.textArc, '%');
applyTextControlsToSelected();
refreshEditorPointerPreview();
}
function applyTextControlsToSelected() {
if (state.editorTool !== 'select')
return;
var config = activeConfig();
var object = selectedVectorObject(config);
if (!object || object.type !== 'text')
return;
var scale = config.editCanvas.width / ui.maskCanvas.width;
object.value = ui.textValue.value.trim() || object.value;
object.fontKey = state.textFont;
object.textStyle = state.textStyle;
object.fontSize = Math.max(8, Number(ui.textSize.value) * scale);
object.arc = Number(ui.textArc.value);
object.color = ui.editorColor.value;
refreshCommittedEditor(config, true);
scheduleRebuild();
}
function syncTextControlsFromSelected(object) {
if (!object || object.type !== 'text')
return;
var config = activeConfig();
var scale = config.editCanvas.width / ui.maskCanvas.width;
ui.textValue.value = object.value;
ui.floatTextValue.value = object.value;
ui.textSize.value = Math.round(object.fontSize / scale);
ui.floatTextSize.value = ui.textSize.value;
ui.floatTextSizeValue.textContent = ui.textSize.value;
updateOutput(ui.textSize, ' px');
ui.editorColor.value = object.color;
ui.floatEditorColor.value = object.color;
state.textFont = object.fontKey;
state.textStyle = object.textStyle;
setTextArc(object.arc);
ui.textFontChips.querySelectorAll('[data-text-font]').forEach( (button) => {
var active = button.dataset.textFont === state.textFont;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
}
);
ui.floatTextFont.value = state.textFont;
ui.textStyle.value = state.textStyle;
ui.floatTextStyle.value = state.textStyle;
}
function updateFloatingTransformStatus() {
ui.floatTransformValue.textContent = `${ui.imageScale.value}% · ${ui.imageRotation.value}°`;
ui.floatImageRotation.value = ui.imageRotation.value;
ui.floatImageRotationValue.textContent = `${ui.imageRotation.value}°`;
}
function syncFloatingEditorControls() {
ui.floatBrushSize.value = ui.brushSize.value;
ui.floatBrushSizeValue.textContent = ui.brushSize.value;
ui.floatTextValue.value = ui.textValue.value;
ui.floatTextFont.value = state.textFont;
ui.floatTextStyle.value = state.textStyle;
ui.floatTextSize.value = ui.textSize.value;
ui.floatTextSizeValue.textContent = ui.textSize.value;
ui.floatTextArc.value = ui.textArc.value;
ui.floatTextArcValue.textContent = `${ui.textArc.value}%`;
ui.floatPolygonSides.value = ui.polygonSides.value;
ui.floatPolygonSidesValue.textContent = ui.polygonSides.value;
ui.floatSplineTension.value = ui.splineTension.value;
ui.floatSplineTensionValue.textContent = `${ui.splineTension.value}%`;
ui.floatShapeRatio.value = state.shapeRatioMode;
ui.floatEditorColor.value = ui.editorColor.value;
updateFloatingTransformStatus();
updateFloatingToolContext();
}
function updateFloatingToolContext() {
var isBrush = state.editorTool === 'brush';
var isEraser = state.editorTool === 'eraser';
var isText = state.editorTool === 'text';
var isShape = VECTOR_SHAPE_TOOLS.includes(state.editorTool);
var selectedText = state.editorTool === 'select' && selectedVectorObject()?.type === 'text';
ui.floatToolContext.hidden = !(isBrush || isEraser || isText || isShape || selectedText);
ui.floatBrushControl.hidden = !(isBrush || isEraser || isShape);
ui.floatTextControl.hidden = !(isText || selectedText);
ui.floatTextFontControl.hidden = !(isText || selectedText);
ui.floatTextStyleControl.hidden = !(isText || selectedText);
ui.floatTextSizeControl.hidden = !(isText || selectedText);
ui.floatTextArcControl.hidden = !(isText || selectedText);
ui.floatPolygonSidesControl.hidden = state.editorTool !== 'polygon';
ui.floatSplineTensionControl.hidden = state.editorTool !== 'spline';
ui.floatShapeRatioControl.hidden = !RATIO_SHAPE_TOOLS.includes(state.editorTool);
ui.floatColorControl.hidden = !(isBrush || isText || isShape || selectedText);
}
function fitExpandedMask() {
if (!state.maskExpanded) {
ui.maskWrap.style.removeProperty('width');
refreshBrushCursorSize();
return;
}
var aspect = ui.maskCanvas.width / ui.maskCanvas.height;
var width = Math.max(300, Math.min(1000, window.innerWidth - 36, (window.innerHeight - 116) * aspect));
ui.maskWrap.style.width = `${width}px`;
refreshBrushCursorSize();
}
function setMaskExpanded(expanded) {
hideEditorPointerPreviews();
state.maskExpanded = Boolean(expanded);
ui.maskWrap.classList.toggle('is-expanded', state.maskExpanded);
ui.maskBackdrop.hidden = !state.maskExpanded;
ui.maskFloatingToolbar.hidden = !state.maskExpanded;
ui.expandMask.setAttribute('aria-expanded', String(state.maskExpanded));
ui.expandMask.textContent = state.maskExpanded ? '× 退出放大' : '⛶ 放大编辑';
ui.expandMask.title = state.maskExpanded ? '退出放大编辑Esc' : '放大扇面编辑区';
document.body.classList.toggle('mask-expanded', state.maskExpanded);
syncFloatingEditorControls();
fitExpandedMask();
if (state.maskExpanded)
requestAnimationFrame( () => ui.maskFloatingToolbar.querySelector('[data-editor-tool].active')?.focus({
preventScroll: true
}));
}