1171 lines
62 KiB
JavaScript
1171 lines
62 KiB
JavaScript
|
|
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
|
|||
|
|
}));
|
|||
|
|
}
|