1171 lines
62 KiB
JavaScript
Raw Permalink 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.

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
}));
}