mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-30 12:02:22 +02:00
Squash Odysseus development history
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
/** First-class adjustment-layer rendering and retained metadata helpers. */
|
||||
import { applyAdjustment } from './fx/pixel-pass.js';
|
||||
import { defaultAdjParams } from './layer-helpers.js';
|
||||
import { renderWithLayerMasks } from './composite-helpers.js';
|
||||
|
||||
const TYPES = new Set([
|
||||
'brightness-contrast',
|
||||
'exposure',
|
||||
'white-balance',
|
||||
'hue-saturation',
|
||||
'vibrance',
|
||||
'black-white',
|
||||
'shadows-highlights',
|
||||
'levels',
|
||||
'curves',
|
||||
'color-balance',
|
||||
'selective-color',
|
||||
'gradient-map',
|
||||
]);
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
export function normalizeAdjustmentData(value) {
|
||||
const type = TYPES.has(value?.type) ? value.type : 'levels';
|
||||
const defaults = defaultAdjParams(type);
|
||||
const params = value?.params && typeof value.params === 'object'
|
||||
? { ...clone(defaults), ...clone(value.params) }
|
||||
: clone(defaults);
|
||||
if (type === 'levels') {
|
||||
params.channels = {
|
||||
...clone(defaults.channels),
|
||||
...(params.channels || {}),
|
||||
};
|
||||
for (const name of Object.keys(defaults.channels)) {
|
||||
params.channels[name] = { ...clone(defaults.channels[name]), ...(params.channels[name] || {}) };
|
||||
}
|
||||
}
|
||||
if (type === 'curves') {
|
||||
params.points = {
|
||||
...clone(defaults.points),
|
||||
...(params.points || {}),
|
||||
};
|
||||
}
|
||||
if (type === 'selective-color') {
|
||||
params.ranges = {
|
||||
...clone(defaults.ranges),
|
||||
...(params.ranges || {}),
|
||||
};
|
||||
for (const name of Object.keys(defaults.ranges)) {
|
||||
params.ranges[name] = { ...clone(defaults.ranges[name]), ...(params.ranges[name] || {}) };
|
||||
}
|
||||
}
|
||||
return { type, params };
|
||||
}
|
||||
|
||||
export function adjustmentSpec(layer) {
|
||||
if (layer?._stagedAdj) return normalizeAdjustmentData(layer._stagedAdj);
|
||||
return normalizeAdjustmentData(layer?.adjustment);
|
||||
}
|
||||
|
||||
function documentCanvas(width, height) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the already-rendered content under an adjustment layer with its
|
||||
* adjusted equivalent. Opacity, blend mode, masks, clipping, and group scope
|
||||
* are applied by drawing back into the current stack target.
|
||||
*/
|
||||
export function drawAdjustmentLayer(target, editorState, layer, clippingBase, renderLayer) {
|
||||
if (!layer?.visible || layer.kind !== 'adjustment' || !target?.canvas) return false;
|
||||
// During comparison the underlying stack is already on the target. Leaving
|
||||
// it untouched shows the true before state without creating a history entry.
|
||||
if (layer._adjCompare) return true;
|
||||
const width = editorState.imgWidth;
|
||||
const height = editorState.imgHeight;
|
||||
// Large live previews use a bounded working surface. The staged layer is
|
||||
// marked only while its popup is open; committed rendering stays full-size
|
||||
// so export and reopen remain pixel-accurate.
|
||||
const maxPreviewEdge = 1400;
|
||||
const previewScale = layer._adjPreview
|
||||
? Math.min(1, maxPreviewEdge / Math.max(width, height))
|
||||
: 1;
|
||||
const sourceWidth = Math.max(1, Math.round(width * previewScale));
|
||||
const sourceHeight = Math.max(1, Math.round(height * previewScale));
|
||||
const source = documentCanvas(sourceWidth, sourceHeight);
|
||||
source.getContext('2d').drawImage(target.canvas, 0, 0, sourceWidth, sourceHeight);
|
||||
let adjusted = applyAdjustment(source, adjustmentSpec(layer));
|
||||
if (previewScale !== 1) {
|
||||
const expanded = documentCanvas(width, height);
|
||||
expanded.getContext('2d').drawImage(adjusted, 0, 0, width, height);
|
||||
adjusted = expanded;
|
||||
}
|
||||
adjusted = renderWithLayerMasks(adjusted, layer, { x: 0, y: 0 });
|
||||
|
||||
if (layer.clipped && clippingBase) {
|
||||
const clipped = documentCanvas(width, height);
|
||||
const clippedCtx = clipped.getContext('2d');
|
||||
clippedCtx.drawImage(adjusted, 0, 0);
|
||||
clippedCtx.globalCompositeOperation = 'destination-in';
|
||||
const offset = editorState.layerOffsets.get(clippingBase.id) || { x: 0, y: 0 };
|
||||
clippedCtx.drawImage(renderLayer(clippingBase), offset.x, offset.y);
|
||||
clippedCtx.globalCompositeOperation = 'source-over';
|
||||
adjusted = clipped;
|
||||
}
|
||||
|
||||
target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1;
|
||||
target.globalCompositeOperation = layer.blendMode || 'source-over';
|
||||
target.drawImage(adjusted, 0, 0);
|
||||
target.globalAlpha = 1;
|
||||
target.globalCompositeOperation = 'source-over';
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Async adjustment rendering for live previews and large documents. */
|
||||
export async function drawAdjustmentLayerAsync(target, editorState, layer, clippingBase, renderLayer, shouldContinue = () => true) {
|
||||
if (!layer?.visible || layer.kind !== 'adjustment' || !target?.canvas || !shouldContinue()) return false;
|
||||
if (layer._adjCompare) return true;
|
||||
const width = editorState.imgWidth;
|
||||
const height = editorState.imgHeight;
|
||||
const maxPreviewEdge = 1400;
|
||||
const previewScale = layer._adjPreview
|
||||
? Math.min(1, maxPreviewEdge / Math.max(width, height))
|
||||
: 1;
|
||||
const sourceWidth = Math.max(1, Math.round(width * previewScale));
|
||||
const sourceHeight = Math.max(1, Math.round(height * previewScale));
|
||||
const source = documentCanvas(sourceWidth, sourceHeight);
|
||||
source.getContext('2d').drawImage(target.canvas, 0, 0, sourceWidth, sourceHeight);
|
||||
let adjusted = await renderAdjustmentAsync(source, adjustmentSpec(layer), shouldContinue);
|
||||
if (!shouldContinue() || !adjusted) return false;
|
||||
if (previewScale !== 1) {
|
||||
if (!shouldContinue()) return false;
|
||||
const expanded = documentCanvas(width, height);
|
||||
expanded.getContext('2d').drawImage(adjusted, 0, 0, width, height);
|
||||
adjusted = expanded;
|
||||
}
|
||||
if (!shouldContinue()) return false;
|
||||
adjusted = renderWithLayerMasks(adjusted, layer, { x: 0, y: 0 });
|
||||
if (!shouldContinue()) return false;
|
||||
if (layer.clipped && clippingBase) {
|
||||
const clipped = documentCanvas(width, height);
|
||||
const clippedCtx = clipped.getContext('2d');
|
||||
clippedCtx.drawImage(adjusted, 0, 0);
|
||||
clippedCtx.globalCompositeOperation = 'destination-in';
|
||||
const offset = editorState.layerOffsets.get(clippingBase.id) || { x: 0, y: 0 };
|
||||
const baseOutput = await renderLayer(clippingBase);
|
||||
if (!shouldContinue() || !baseOutput) return false;
|
||||
clippedCtx.drawImage(baseOutput, offset.x, offset.y);
|
||||
clippedCtx.globalCompositeOperation = 'source-over';
|
||||
adjusted = clipped;
|
||||
}
|
||||
target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1;
|
||||
target.globalCompositeOperation = layer.blendMode || 'source-over';
|
||||
target.drawImage(adjusted, 0, 0);
|
||||
target.globalAlpha = 1;
|
||||
target.globalCompositeOperation = 'source-over';
|
||||
return true;
|
||||
}
|
||||
|
||||
async function renderAdjustmentAsync(source, adjustment, shouldContinue) {
|
||||
if (typeof Worker === 'undefined' || typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap !== 'function') {
|
||||
return applyAdjustment(source, adjustment);
|
||||
}
|
||||
let sourceBitmap;
|
||||
try {
|
||||
sourceBitmap = await createImageBitmap(source);
|
||||
} catch {
|
||||
return applyAdjustment(source, adjustment);
|
||||
}
|
||||
if (!shouldContinue()) {
|
||||
sourceBitmap.close?.();
|
||||
return null;
|
||||
}
|
||||
let worker;
|
||||
try {
|
||||
worker = new Worker(new URL('./adjustments-worker.js', import.meta.url), { type: 'module' });
|
||||
} catch {
|
||||
return shouldContinue() ? applyAdjustment(source, adjustment) : null;
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = result => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
worker.terminate();
|
||||
sourceBitmap.close?.();
|
||||
resolve(result);
|
||||
};
|
||||
worker.onmessage = event => {
|
||||
const { bitmap, error } = event.data || {};
|
||||
if (error || !bitmap || !shouldContinue()) {
|
||||
bitmap?.close?.();
|
||||
finish(shouldContinue() ? applyAdjustment(source, adjustment) : null);
|
||||
return;
|
||||
}
|
||||
const output = documentCanvas(source.width, source.height);
|
||||
output.getContext('2d').drawImage(bitmap, 0, 0);
|
||||
bitmap.close?.();
|
||||
finish(output);
|
||||
};
|
||||
// A stale worker must not trigger a full-resolution fallback. The caller
|
||||
// will discard it, and doing the work here makes slider scrubbing worse.
|
||||
worker.onerror = () => finish(shouldContinue() ? applyAdjustment(source, adjustment) : null);
|
||||
worker.postMessage({ source: sourceBitmap, adjustment }, [sourceBitmap]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/* Worker adapter for the shared pure adjustment pixel pass. */
|
||||
|
||||
let applyAdjustment;
|
||||
|
||||
async function render(source, adjustment) {
|
||||
if (!applyAdjustment) {
|
||||
// pixel-pass only needs document.createElement('canvas'), so provide the
|
||||
// smallest worker-local adapter instead of maintaining a second renderer.
|
||||
self.document = { createElement: () => new OffscreenCanvas(source.width, source.height) };
|
||||
({ applyAdjustment } = await import('./fx/pixel-pass.js'));
|
||||
}
|
||||
const input = new OffscreenCanvas(source.width, source.height);
|
||||
input.getContext('2d').drawImage(source, 0, 0);
|
||||
return applyAdjustment(input, adjustment);
|
||||
}
|
||||
|
||||
self.onmessage = async event => {
|
||||
try {
|
||||
const { source, adjustment } = event.data;
|
||||
const output = await render(source, adjustment);
|
||||
const bitmap = output.transferToImageBitmap();
|
||||
self.postMessage({ bitmap }, [bitmap]);
|
||||
} catch (error) {
|
||||
self.postMessage({ error: String(error?.message || error) });
|
||||
}
|
||||
};
|
||||
@@ -45,7 +45,7 @@ import { state } from './state.js';
|
||||
export function wireInpaintButtons({
|
||||
buildMergedMaskCanvas, dilateMask, applyInpaintFeather,
|
||||
getSelectedAIEndpoint, ensureActiveMaskLayer,
|
||||
saveState, createLayer, composite, renderLayerPanel,
|
||||
saveState, createLayer, composite, flatten, renderLayerPanel,
|
||||
spinnerModule, uiModule,
|
||||
}) {
|
||||
// Shared inpaint runner — used by Generate, Remove, and Outpaint.
|
||||
@@ -110,16 +110,7 @@ export function wireInpaintButtons({
|
||||
} catch (_) { /* overlay is decorative */ }
|
||||
try {
|
||||
// Flatten current image.
|
||||
const flatCanvas = document.createElement('canvas');
|
||||
flatCanvas.width = state.imgWidth; flatCanvas.height = state.imgHeight;
|
||||
const flatCtx = flatCanvas.getContext('2d');
|
||||
for (const layer of state.layers) {
|
||||
if (!layer.visible) continue;
|
||||
flatCtx.globalAlpha = layer.opacity;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
flatCtx.drawImage(layer.canvas, off.x, off.y);
|
||||
}
|
||||
flatCtx.globalAlpha = 1;
|
||||
const flatCanvas = flatten();
|
||||
// Dilate the user's brush mask before sending to the model.
|
||||
// The AI fills a small buffer zone around the brush, so the
|
||||
// post-gen Edge feather slider has AI content to fade INTO
|
||||
@@ -136,6 +127,10 @@ export function wireInpaintButtons({
|
||||
const dilatedMask = dilateMask(mergedMask, padPx);
|
||||
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
|
||||
const maskB64 = dilatedMask.toDataURL('image/png').split(',')[1];
|
||||
const baseSnap = document.createElement('canvas');
|
||||
baseSnap.width = state.imgWidth;
|
||||
baseSnap.height = state.imgHeight;
|
||||
baseSnap.getContext('2d').drawImage(flatCanvas, 0, 0);
|
||||
const res = await fetch('/api/image/inpaint', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -180,7 +175,7 @@ export function wireInpaintButtons({
|
||||
maskSnap.width = state.maskCanvas.width;
|
||||
maskSnap.height = state.maskCanvas.height;
|
||||
maskSnap.getContext('2d').drawImage(state.maskCanvas, 0, 0);
|
||||
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, padPx };
|
||||
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
|
||||
// Apply initial alpha = hard mask (no feather, no edge shift).
|
||||
applyInpaintFeather(resultLayer, 0, 0);
|
||||
state.layers.push(resultLayer);
|
||||
@@ -192,7 +187,9 @@ export function wireInpaintButtons({
|
||||
// on each sub-row's eye icon.
|
||||
for (const ly of state.layers) {
|
||||
if (!ly.masks || !ly.masks.length) continue;
|
||||
for (const mk of ly.masks) mk.visible = false;
|
||||
for (const mk of ly.masks) {
|
||||
if (mk.mode !== 'layer') mk.visible = false;
|
||||
}
|
||||
}
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
@@ -216,7 +213,9 @@ export function wireInpaintButtons({
|
||||
const eRow = document.getElementById('ge-inpaint-edgestroke-row');
|
||||
const eSlider = document.getElementById('ge-edgestroke-slider');
|
||||
const eLabel = document.getElementById('ge-edgestroke-label');
|
||||
const autoRow = document.getElementById('ge-inpaint-automatch-row');
|
||||
if (eRow) eRow.style.display = '';
|
||||
if (autoRow) autoRow.style.display = '';
|
||||
if (eSlider) {
|
||||
eSlider.max = String(padPx);
|
||||
eSlider.min = String(-padPx);
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { sortModelIds } from '../modelSort.js';
|
||||
|
||||
// Heuristic classifier on a model id + endpoint name. A model can be:
|
||||
// - gen: text-to-image generation
|
||||
@@ -106,7 +107,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
for (const ep of endpoints) {
|
||||
if (!ep.is_enabled) continue;
|
||||
const hasListedModels = Array.isArray(ep.models) && ep.models.length;
|
||||
const models = hasListedModels ? ep.models : [''];
|
||||
const models = hasListedModels ? sortModelIds(ep.models) : [''];
|
||||
const isImageEndpoint = (ep.model_type || '').toLowerCase() === 'image';
|
||||
// Image/inpaint endpoints can be called by URL even when their
|
||||
// /models cache is still empty, so don't strand a freshly served
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
* @returns {{ buildSelectionHintMask: () => string | null }}
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { selectionMaskToDocument } from './selection-mask.js';
|
||||
|
||||
export function wireRembgAndSharpen({
|
||||
applyImageTool, openCookbookForDependency,
|
||||
@@ -69,9 +70,9 @@ export function wireRembgAndSharpen({
|
||||
// which to hide after a successful cutout.
|
||||
const prevVisible = state.layers.filter(l => l.visible).map(l => l.id);
|
||||
await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
|
||||
// applyImageTool finishes after fetch but the new layer is added
|
||||
// inside img.onload (one tick later). Poll for up to 60 frames
|
||||
// (~1s) for the new layer to appear before we auto-hide.
|
||||
// applyImageTool resolves after the returned image is decoded and the
|
||||
// new layer is inserted. Keep a short poll for defensive compatibility
|
||||
// with alternate runners that may still commit on the next frame.
|
||||
let frames = 0;
|
||||
while (state.layers.length <= before && frames < 60) {
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
@@ -204,9 +205,13 @@ export function wireRembgAndSharpen({
|
||||
const w = state.imgWidth, h = state.imgHeight;
|
||||
if (state.wandMask && state.wandLayerId) {
|
||||
const off = state.layerOffsets.get(state.wandLayerId) || { x: 0, y: 0 };
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
c.getContext('2d').drawImage(state.wandMask, off.x, off.y);
|
||||
const c = selectionMaskToDocument(
|
||||
state.wandMask,
|
||||
state.wandMaskSpace || 'layer',
|
||||
off,
|
||||
w,
|
||||
h,
|
||||
);
|
||||
return c.toDataURL('image/png').split(',')[1];
|
||||
}
|
||||
if (state.lassoPoints.length >= 3 && !state.lassoActive) {
|
||||
|
||||
@@ -92,8 +92,13 @@ export function createApplyImageTool({
|
||||
if (data.error) throw new Error(data.error);
|
||||
if (!data.image) throw new Error('No image returned');
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4)
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = () => reject(new Error('Failed to decode result image'));
|
||||
img.src = 'data:image/png;base64,' + data.image;
|
||||
});
|
||||
if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4)
|
||||
{
|
||||
saveState();
|
||||
const layer = createLayer(layerName, state.imgWidth, state.imgHeight);
|
||||
layer.ctx.drawImage(img, 0, 0);
|
||||
@@ -102,9 +107,7 @@ export function createApplyImageTool({
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
if (uiModule) uiModule.showToast(layerName + ' complete', 4500);
|
||||
};
|
||||
img.onerror = () => { if (uiModule) uiModule.showToast('Failed to load result', 6000); };
|
||||
img.src = 'data:image/png;base64,' + data.image;
|
||||
}
|
||||
} catch (e) {
|
||||
// Detect known failure modes and surface an action-toast.
|
||||
const msg = (e?.message || '').toLowerCase();
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
* renderLayerPanel: () => void,
|
||||
* spinnerModule: object,
|
||||
* uiModule: object,
|
||||
* openAdjustmentLayer: (type: string, anchor: HTMLElement) => void,
|
||||
* }} deps
|
||||
*
|
||||
* @returns {{ addEmptyLayer: () => void }}
|
||||
@@ -34,7 +35,7 @@ import { state } from './state.js';
|
||||
export function wireAIToolsMisc({
|
||||
apiBase, buildLayerBodyMask, buildSeamMask, applyImageTool,
|
||||
flatten, saveState, fitZoom, composite, createLayer, renderLayerPanel,
|
||||
spinnerModule, uiModule,
|
||||
spinnerModule, uiModule, openAdjustmentLayer,
|
||||
}) {
|
||||
// ── Harmonize sliders — Color match + Seam fix ──
|
||||
const harmColorPrev = document.getElementById('ge-harmonize-color-preview');
|
||||
@@ -210,7 +211,52 @@ export function wireAIToolsMisc({
|
||||
renderLayerPanel();
|
||||
composite();
|
||||
}
|
||||
document.getElementById('ge-add-layer')?.addEventListener('click', addEmptyLayer);
|
||||
const addButton = document.getElementById('ge-add-layer');
|
||||
addButton?.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
document.querySelector('.ge-add-layer-menu')?.remove();
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'ge-add-layer-menu ge-frosted';
|
||||
menu.innerHTML = `
|
||||
<button type="button" data-layer-kind="raster"><span class="ge-add-layer-symbol">+</span><span>Pixel Layer</span></button>
|
||||
<span class="ge-add-layer-divider"></span>
|
||||
<button type="button" data-adjustment-type="levels"><span class="ge-add-layer-symbol">▥</span><span>Levels</span></button>
|
||||
<button type="button" data-adjustment-type="curves"><span class="ge-add-layer-symbol">⌁</span><span>Curves</span></button>
|
||||
<button type="button" data-adjustment-type="exposure"><span class="ge-add-layer-symbol">☼</span><span>Exposure</span></button>
|
||||
<button type="button" data-adjustment-type="white-balance"><span class="ge-add-layer-symbol">◑</span><span>White Balance</span></button>
|
||||
<button type="button" data-adjustment-type="brightness-contrast"><span class="ge-add-layer-symbol">◐</span><span>Brightness / Contrast</span></button>
|
||||
<button type="button" data-adjustment-type="hue-saturation"><span class="ge-add-layer-symbol">◉</span><span>Hue / Saturation</span></button>
|
||||
<button type="button" data-adjustment-type="vibrance"><span class="ge-add-layer-symbol">⌁</span><span>Vibrance</span></button>
|
||||
<button type="button" data-adjustment-type="black-white"><span class="ge-add-layer-symbol">◐</span><span>Black & White</span></button>
|
||||
<button type="button" data-adjustment-type="shadows-highlights"><span class="ge-add-layer-symbol">◐</span><span>Shadows / Highlights</span></button>
|
||||
<button type="button" data-adjustment-type="color-balance"><span class="ge-add-layer-symbol">◒</span><span>Color Balance</span></button>
|
||||
<button type="button" data-adjustment-type="selective-color"><span class="ge-add-layer-symbol">◎</span><span>Selective Color</span></button>
|
||||
<button type="button" data-adjustment-type="gradient-map"><span class="ge-add-layer-symbol">▰</span><span>Gradient Map</span></button>
|
||||
`;
|
||||
document.body.appendChild(menu);
|
||||
const rect = addButton.getBoundingClientRect();
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
menu.style.left = `${Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, rect.right - menuRect.width))}px`;
|
||||
menu.style.top = `${Math.max(8, Math.min(window.innerHeight - menuRect.height - 8, rect.bottom + 5))}px`;
|
||||
const close = () => {
|
||||
menu.remove();
|
||||
document.removeEventListener('pointerdown', away, true);
|
||||
};
|
||||
const away = pointerEvent => {
|
||||
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== addButton) close();
|
||||
};
|
||||
requestAnimationFrame(() => document.addEventListener('pointerdown', away, true));
|
||||
menu.addEventListener('click', clickEvent => {
|
||||
const button = clickEvent.target.closest('button');
|
||||
if (!button) return;
|
||||
clickEvent.preventDefault();
|
||||
clickEvent.stopPropagation();
|
||||
if (button.dataset.layerKind === 'raster') addEmptyLayer();
|
||||
else if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, addButton);
|
||||
close();
|
||||
});
|
||||
});
|
||||
|
||||
return { addEmptyLayer };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Device-independent brush sampling. Rendering stays in stroke-pipeline.js;
|
||||
* this module only turns irregular pointer input into evenly spaced samples.
|
||||
*/
|
||||
|
||||
export function normalisePressure(value) {
|
||||
const pressure = Number(value);
|
||||
if (!Number.isFinite(pressure) || pressure <= 0) return 1;
|
||||
return Math.max(0.01, Math.min(1, pressure));
|
||||
}
|
||||
|
||||
export function pressureValue(base, pressure, enabled, minimum = 0.08) {
|
||||
if (!enabled) return base;
|
||||
return base * Math.max(minimum, normalisePressure(pressure));
|
||||
}
|
||||
|
||||
export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit }) {
|
||||
let emitted = null;
|
||||
let filtered = null;
|
||||
let remainder = 0;
|
||||
|
||||
const output = (sample) => {
|
||||
emitted = { ...sample };
|
||||
emit(emitted);
|
||||
};
|
||||
|
||||
const begin = (sample) => {
|
||||
emitted = null;
|
||||
filtered = { ...sample, pressure: normalisePressure(sample.pressure) };
|
||||
remainder = 0;
|
||||
output(filtered);
|
||||
};
|
||||
|
||||
const add = (sample) => {
|
||||
const raw = { ...sample, pressure: normalisePressure(sample.pressure) };
|
||||
if (!filtered || !emitted) return begin(raw);
|
||||
const smoothing = Math.max(0, Math.min(0.95, Number(getSmoothing()) || 0));
|
||||
const response = 1 - smoothing;
|
||||
filtered = {
|
||||
x: filtered.x + (raw.x - filtered.x) * response,
|
||||
y: filtered.y + (raw.y - filtered.y) * response,
|
||||
pressure: filtered.pressure + (raw.pressure - filtered.pressure) * response,
|
||||
};
|
||||
const start = { ...emitted };
|
||||
const dx = filtered.x - start.x;
|
||||
const dy = filtered.y - start.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
if (!distance) return;
|
||||
const diameter = Math.max(1, Number(getDiameter()) || 1);
|
||||
const spacing = Math.max(0.5, diameter * Math.max(0.01, Number(getSpacing()) || 0.01));
|
||||
let travelled = spacing - remainder;
|
||||
while (travelled <= distance) {
|
||||
const t = travelled / distance;
|
||||
output({
|
||||
x: start.x + dx * t,
|
||||
y: start.y + dy * t,
|
||||
pressure: start.pressure + (filtered.pressure - start.pressure) * t,
|
||||
});
|
||||
travelled += spacing;
|
||||
}
|
||||
remainder = Math.max(0, distance - (travelled - spacing));
|
||||
};
|
||||
|
||||
const end = (sample) => {
|
||||
if (sample && emitted) {
|
||||
const finalSample = { ...sample, pressure: normalisePressure(sample.pressure) };
|
||||
if (Math.hypot(finalSample.x - emitted.x, finalSample.y - emitted.y) > 0.25) output(finalSample);
|
||||
}
|
||||
emitted = null;
|
||||
filtered = null;
|
||||
remainder = 0;
|
||||
};
|
||||
|
||||
return { begin, add, end };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
const STORAGE_KEY = 'odysseus.editor.brush-presets.v1';
|
||||
|
||||
const BUILT_INS = [
|
||||
{ id: 'soft-round', name: 'Soft Round', size: 80, opacity: 100, flow: 35, softness: 100, spacing: 12, smoothing: 25, blendMode: 'source-over' },
|
||||
{ id: 'hard-round', name: 'Hard Round', size: 24, opacity: 100, flow: 100, softness: 0, spacing: 18, smoothing: 15, blendMode: 'source-over' },
|
||||
{ id: 'detail', name: 'Detail', size: 6, opacity: 100, flow: 70, softness: 20, spacing: 10, smoothing: 55, blendMode: 'source-over' },
|
||||
];
|
||||
|
||||
export function loadBrushPresets(storage = globalThis.localStorage) {
|
||||
let custom = [];
|
||||
try {
|
||||
const parsed = JSON.parse(storage?.getItem(STORAGE_KEY) || '[]');
|
||||
if (Array.isArray(parsed)) custom = parsed.filter(item => item && typeof item.name === 'string');
|
||||
} catch {}
|
||||
return [...BUILT_INS.map(item => ({ ...item, builtIn: true })), ...custom];
|
||||
}
|
||||
|
||||
export function saveCustomBrushPresets(presets, storage = globalThis.localStorage) {
|
||||
const custom = (presets || []).filter(item => item && !item.builtIn);
|
||||
storage?.setItem(STORAGE_KEY, JSON.stringify(custom));
|
||||
}
|
||||
|
||||
export function captureBrushPreset(state, name) {
|
||||
return {
|
||||
id: `custom-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
name: String(name || 'Brush').trim() || 'Brush',
|
||||
size: state.brushSize,
|
||||
opacity: state.brushOpacity,
|
||||
flow: state.brushFlow,
|
||||
softness: state.brushSoftness,
|
||||
spacing: state.brushSpacing,
|
||||
smoothing: state.brushSmoothing,
|
||||
blendMode: state.brushBlendMode,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyBrushPreset(state, preset) {
|
||||
if (!preset) return;
|
||||
state.brushSize = preset.size;
|
||||
state.brushOpacity = preset.opacity;
|
||||
state.brushFlow = preset.flow;
|
||||
state.brushSoftness = preset.softness;
|
||||
state.brushSpacing = preset.spacing;
|
||||
state.brushSmoothing = preset.smoothing;
|
||||
state.brushBlendMode = preset.blendMode || 'source-over';
|
||||
}
|
||||
|
||||
Vendored
+304
-2
@@ -12,6 +12,15 @@
|
||||
export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
const brushSliderValue = Math.round(Math.log(Math.max(1, brushSize)) / Math.log(800) * 1000);
|
||||
return `
|
||||
<div class="ge-layer-geometry-section" id="ge-layer-geometry-section">
|
||||
<div class="ge-section-title"><span>Position</span><span id="ge-layer-geometry-name"></span></div>
|
||||
<div class="ge-layer-geometry-grid">
|
||||
<label><span>X</span><input id="ge-layer-x" type="number" step="1" inputmode="numeric" /></label>
|
||||
<label><span>Y</span><input id="ge-layer-y" type="number" step="1" inputmode="numeric" /></label>
|
||||
<label><span>W</span><input id="ge-layer-width" type="number" readonly title="Edit width with Transform" /></label>
|
||||
<label><span>H</span><input id="ge-layer-height" type="number" readonly title="Edit height with Transform" /></label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ge-brush-controls">
|
||||
<div class="ge-control-row" id="ge-color-row">
|
||||
<label>Color</label>
|
||||
@@ -20,9 +29,204 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<div class="ge-control-row">
|
||||
<label>Size <span class="ge-size-label">${brushSize}px</span></label>
|
||||
<input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" />
|
||||
</div>
|
||||
<div class="ge-section-title">Stroke</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<label>Spacing <span id="ge-brush-spacing-label">15%</span></label>
|
||||
<input type="range" id="ge-brush-spacing" min="1" max="100" value="15" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<label>Smoothing <span id="ge-brush-smoothing-label">25%</span></label>
|
||||
<input type="range" id="ge-brush-smoothing" min="0" max="95" value="25" />
|
||||
</div>
|
||||
<div class="ge-control-row">
|
||||
<label for="ge-brush-blend">Blend</label>
|
||||
<select id="ge-brush-blend">
|
||||
<option value="source-over">Normal</option><option value="multiply">Multiply</option>
|
||||
<option value="screen">Screen</option><option value="overlay">Overlay</option>
|
||||
<option value="soft-light">Soft Light</option><option value="color">Color</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ge-control-row ge-brush-pressure-row">
|
||||
<span class="ge-pressure-option" title="Pen pressure changes brush size"><span>Size</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-size" checked /><span class="toggle-slider"></span></label></span>
|
||||
<span class="ge-pressure-option" title="Pen pressure changes opacity"><span>Opacity</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-opacity" /><span class="toggle-slider"></span></label></span>
|
||||
<span class="ge-pressure-option" title="Pen pressure changes flow"><span>Flow</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-flow" checked /><span class="toggle-slider"></span></label></span>
|
||||
</div>
|
||||
<div class="ge-control-row ge-brush-preset-row">
|
||||
<select id="ge-brush-preset" aria-label="Brush preset"></select>
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-brush-preset-save" title="Save current brush preset">Save</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-brush-preset-delete" title="Delete selected preset" aria-label="Delete selected preset">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-gradient-section" id="ge-gradient-section" style="display:none;">
|
||||
<div class="ge-section-title">Gradient</div>
|
||||
<div class="ge-control-row"><label for="ge-gradient-type">Type</label><select id="ge-gradient-type"><option value="linear-gradient">Linear</option><option value="radial-gradient">Radial</option></select></div>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field"><span>Start</span><input type="color" class="ge-color-picker" id="ge-gradient-start" value="${color}" /></label>
|
||||
<label class="ge-text-field"><span>End</span><input type="color" class="ge-color-picker" id="ge-gradient-end" value="#ffffff" /></label>
|
||||
</div>
|
||||
<div class="ge-control-row ge-gradient-mid-row">
|
||||
<span class="ge-gradient-mid-toggle"><span>Midpoint</span><label class="toggle-switch"><input type="checkbox" id="ge-gradient-mid-enabled" /><span class="toggle-slider"></span></label></span>
|
||||
<input type="color" class="ge-color-picker" id="ge-gradient-mid" value="#808080" title="Midpoint color" disabled />
|
||||
<input type="range" id="ge-gradient-mid-position" min="1" max="99" value="50" title="Midpoint position" disabled />
|
||||
<span id="ge-gradient-mid-position-label">50%</span>
|
||||
</div>
|
||||
<div class="ge-gradient-extra-stops" id="ge-gradient-extra-stops"></div>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-gradient-add-stop" id="ge-gradient-add-stop">Add stop</button>
|
||||
<div class="ge-control-row"><label for="ge-gradient-end-alpha">End opacity <span id="ge-gradient-end-alpha-label">100%</span></label><input type="range" id="ge-gradient-end-alpha" min="0" max="100" value="100" /></div>
|
||||
<div class="ge-control-row"><label for="ge-gradient-opacity">Opacity <span id="ge-gradient-opacity-label">100%</span></label><input type="range" id="ge-gradient-opacity" min="0" max="100" value="100" /></div>
|
||||
<p class="ge-section-hint">Drag across the active layer. Switch tools or press Esc to cancel.</p>
|
||||
</div>
|
||||
<div class="ge-eraser-section" id="ge-eyedropper-section" style="display:none;">
|
||||
<div class="ge-section-title">Eyedropper</div>
|
||||
<div class="ge-eyedropper-live" id="ge-eyedropper-live" aria-live="polite">
|
||||
<canvas class="ge-eyedropper-loupe" id="ge-eyedropper-loupe" width="84" height="84" aria-hidden="true"></canvas>
|
||||
<span class="ge-eyedropper-live-swatch" id="ge-eyedropper-live-swatch" aria-hidden="true"></span>
|
||||
<span class="ge-eyedropper-live-values">
|
||||
<code id="ge-eyedropper-live-value">Move over the canvas</code>
|
||||
<span id="ge-eyedropper-live-rgb">RGB --</span>
|
||||
<span id="ge-eyedropper-live-hsl">HSL --</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-control-row"><label for="ge-eyedropper-sample">Sample</label><select id="ge-eyedropper-sample"><option value="composite">All layers</option><option value="layer">Active layer</option></select></div>
|
||||
</div>
|
||||
<div class="ge-text-section" id="ge-text-section" style="display:none;">
|
||||
<textarea id="ge-text-content" rows="3" placeholder="Type text..." aria-label="Text content"></textarea>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field ge-text-font-field"><span>Font</span>
|
||||
<select id="ge-text-font">
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Impact">Impact</option>
|
||||
<option value="system-ui">System</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="ge-text-field ge-text-size-field"><span>Size</span>
|
||||
<input id="ge-text-size" type="number" min="1" max="2000" value="48" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="ge-control-row ge-text-format-row">
|
||||
<input type="color" class="ge-color-picker" id="ge-text-color" value="${color}" title="Text color" aria-label="Text color" />
|
||||
<button type="button" class="ge-text-toggle" id="ge-text-bold" title="Bold" aria-pressed="false"><strong>B</strong></button>
|
||||
<button type="button" class="ge-text-toggle" id="ge-text-italic" title="Italic" aria-pressed="false"><em>I</em></button>
|
||||
<span class="ge-text-format-sep"></span>
|
||||
<button type="button" class="ge-text-toggle active" data-text-align="left" title="Align left" aria-pressed="true">⇤</button>
|
||||
<button type="button" class="ge-text-toggle" data-text-align="center" title="Align center" aria-pressed="false">↔</button>
|
||||
<button type="button" class="ge-text-toggle" data-text-align="right" title="Align right" aria-pressed="false">⇥</button>
|
||||
</div>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field"><span>Line</span>
|
||||
<input id="ge-text-line-height" type="number" min="0.5" max="5" step="0.1" value="1.2" />
|
||||
</label>
|
||||
<label class="ge-text-field"><span>Spacing</span>
|
||||
<input id="ge-text-letter-spacing" type="number" min="-100" max="500" step="0.5" value="0" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field"><span>Frame</span>
|
||||
<input id="ge-text-frame-width" type="number" min="1" max="10000" step="1" value="320" />
|
||||
</label>
|
||||
<label class="ge-text-field"><span>Height</span>
|
||||
<input id="ge-text-frame-height" type="number" min="0" max="10000" step="1" value="0" />
|
||||
</label>
|
||||
<label class="ge-text-field"><span>Vertical</span>
|
||||
<select id="ge-text-vertical-align"><option value="top">Top</option><option value="middle">Middle</option><option value="bottom">Bottom</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field"><span>Stroke</span>
|
||||
<input id="ge-text-stroke-width" type="number" min="0" max="100" step="1" value="0" />
|
||||
</label>
|
||||
<input type="color" class="ge-color-picker" id="ge-text-stroke-color" value="#000000" title="Stroke color" aria-label="Stroke color" />
|
||||
</div>
|
||||
<label class="ge-text-auto-width-option"><span>Auto width</span><span class="toggle-switch"><input type="checkbox" id="ge-text-auto-width" /><span class="toggle-slider"></span></span></label>
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-text-rasterize">Rasterize</button>
|
||||
</div>
|
||||
<div class="ge-shape-section" id="ge-shape-section" style="display:none;">
|
||||
<div class="ge-section-title">Shape</div>
|
||||
<div class="ge-shape-types" role="group" aria-label="Shape type">
|
||||
<button type="button" class="ge-text-toggle active" data-shape-type="rectangle" title="Rectangle" aria-pressed="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="1"/></svg></button>
|
||||
<button type="button" class="ge-text-toggle" data-shape-type="ellipse" title="Ellipse" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="12" rx="8" ry="7"/></svg></button>
|
||||
<button type="button" class="ge-text-toggle" data-shape-type="line" title="Line" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="19" x2="20" y2="5"/></svg></button>
|
||||
<button type="button" class="ge-text-toggle" data-shape-type="polygon" title="Polygon" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12 3 9 7-3.5 11h-11L3 10Z"/></svg></button>
|
||||
</div>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field"><span>Fill</span><input type="color" class="ge-color-picker" id="ge-shape-fill" value="${color}" /></label>
|
||||
<label class="ge-text-field"><span>Stroke</span><input type="color" class="ge-color-picker" id="ge-shape-stroke" value="#111111" /></label>
|
||||
</div>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field"><span>Fill type</span>
|
||||
<select id="ge-shape-fill-type"><option value="solid">Solid</option><option value="linear-gradient">Linear gradient</option></select>
|
||||
</label>
|
||||
<label class="ge-text-field ge-shape-gradient-angle-field" hidden><span>Angle</span><input id="ge-shape-gradient-angle" type="number" min="-36000" max="36000" step="1" value="0" /></label>
|
||||
</div>
|
||||
<div class="ge-text-grid ge-shape-gradient-fields" hidden>
|
||||
<label class="ge-text-field"><span>Start</span><input type="color" class="ge-color-picker" id="ge-shape-gradient-start" value="#ffffff" /></label>
|
||||
<label class="ge-text-field"><span>End</span><input type="color" class="ge-color-picker" id="ge-shape-gradient-end" value="#000000" /></label>
|
||||
<label class="ge-text-field"><span>Midpoint</span><input type="color" class="ge-color-picker" id="ge-shape-gradient-mid" value="#808080" /></label>
|
||||
<label class="ge-text-field"><span>Position</span><input id="ge-shape-gradient-mid-position" type="number" min="1" max="99" step="1" value="50" /></label>
|
||||
<label class="ge-text-field ge-shape-gradient-mid-enabled"><span>Use midpoint</span><input id="ge-shape-gradient-mid-enabled" type="checkbox" /></label>
|
||||
</div>
|
||||
<div class="ge-shape-gradient-extra-stops" id="ge-shape-gradient-extra-stops"></div>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-shape-gradient-add-stop" id="ge-shape-gradient-add-stop">Add stop</button>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field"><span>Width</span><input id="ge-shape-stroke-width" type="number" min="0" max="500" step="1" value="2" /></label>
|
||||
<label class="ge-text-field"><span>Radius</span><input id="ge-shape-radius" type="number" min="0" max="5000" step="1" value="0" /></label>
|
||||
</div>
|
||||
<label class="ge-text-field ge-shape-sides-field" hidden><span>Sides</span><input id="ge-shape-sides" type="number" min="3" max="24" step="1" value="5" /></label>
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-shape-rasterize">Rasterize</button>
|
||||
</div>
|
||||
<div class="ge-marquee-section" id="ge-marquee-section" style="display:none;">
|
||||
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-marquee-shape-btn active" data-marquee-shape="rectangle" title="Rectangular marquee" aria-pressed="true">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14"/></svg>
|
||||
</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-marquee-shape-btn" data-marquee-shape="ellipse" title="Elliptical marquee" aria-pressed="false">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="12" rx="8" ry="7"/></svg>
|
||||
</button>
|
||||
<span style="flex:1"></span>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="New selection">New</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
|
||||
</div>
|
||||
<div class="ge-marquee-constraint-row">
|
||||
<label for="ge-marquee-constraint">Style</label>
|
||||
<select id="ge-marquee-constraint" title="Marquee sizing style">
|
||||
<option value="free">Free</option>
|
||||
<option value="ratio">Fixed ratio</option>
|
||||
<option value="size">Fixed size</option>
|
||||
</select>
|
||||
<div class="ge-marquee-dimensions" hidden>
|
||||
<label>W <input type="number" id="ge-marquee-width" min="1" step="1" value="1" inputmode="decimal" /></label>
|
||||
<label>H <input type="number" id="ge-marquee-height" min="1" step="1" value="1" inputmode="decimal" /></label>
|
||||
<button type="button" class="ge-icon-btn" id="ge-marquee-swap" title="Swap width and height" aria-label="Swap width and height">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 3 4 4-4 4"/><path d="M4 7h16"/><path d="m8 21-4-4 4-4"/><path d="M20 17H4"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
|
||||
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-marquee-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-clear" title="Clear selection">Clear</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-invert" title="Invert selection">Invert</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-delete" title="Erase selected pixels">Erase</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-copy" title="Copy selection to a new layer">Copy Layer</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-mask" title="Add selection to mask">To Mask</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-lasso-section" id="ge-lasso-section" style="display:none;">
|
||||
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next lasso combines with the current selection.">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="New selection">New</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-feather" style="display:none;">
|
||||
<span class="ge-eraser-preview" id="ge-lasso-feather-preview" aria-hidden="true"></span>
|
||||
<label>Feather <span id="ge-lasso-feather-label">0px</span></label>
|
||||
@@ -50,6 +254,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
|
||||
To Mask
|
||||
</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
|
||||
</div>
|
||||
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Draw a freehand selection. Esc to cancel.</p>
|
||||
</div>
|
||||
@@ -58,6 +263,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection on each click">New</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection (Shift)">+ Add</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection (Alt)">− Subtract</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-wand-tol-preview" aria-hidden="true"></span>
|
||||
@@ -99,9 +305,41 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
|
||||
To Mask
|
||||
</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
|
||||
</div>
|
||||
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click a region to select similar pixels. Shift+click to add, Alt+click to subtract. Esc to clear.</p>
|
||||
</div>
|
||||
<div class="ge-sam-section" id="ge-sam-section" style="display:none;">
|
||||
<div class="ge-section-title ge-section-title-with-help"><span>SAM</span><span class="ge-section-help" tabindex="0" role="img" aria-label="SAM selection help" title="Click an object for visual SAM selection, or type a neutral object label and use Find. The text is only used to locate a region before SAM creates the mask.">?</span></div>
|
||||
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next SAM selection combines with the current selection. Shift / Alt held during a click override this for one click.">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+ Add</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">− Subtract</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
|
||||
</div>
|
||||
<div class="ge-control-row" style="display:flex;gap:6px;align-items:center;min-width:0;">
|
||||
<input type="text" class="ge-inpaint-prompt" id="ge-sam-query" placeholder="Object to select..." style="flex:1 1 auto;min-width:0;" />
|
||||
<button class="ge-btn ge-btn-sm ge-btn-ai" id="ge-sam-find" style="height:28px;display:inline-flex;align-items:center;gap:5px;" title="Find object and create a SAM mask">
|
||||
<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>
|
||||
Find
|
||||
</button>
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
|
||||
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-sam-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-clear" title="Clear the selection">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
||||
Clear
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-mask" title="Add selection to the inpaint mask">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
|
||||
To Mask
|
||||
</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
|
||||
</div>
|
||||
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click an object, or type a neutral object label. Shift adds, Alt subtracts.</p>
|
||||
</div>
|
||||
<div class="ge-inpaint-section" id="ge-inpaint-section" style="display:none;">
|
||||
<div class="ge-inpaint-popover-head" data-inpaint-drag>
|
||||
<div class="ge-section-title ge-section-title-with-help ge-inpaint-popover-title"><span>INPAINT</span><span class="ge-section-help" tabindex="0" role="img" aria-label="How inpaint works" title="Brush the area you want the AI to redraw — the red preview marks the mask region. Use Paint to add, Erase to subtract (or hold Ctrl+Alt to flip for one stroke). Generate fills with what your prompt describes; Remove fills with the surrounding background.">?</span></div>
|
||||
@@ -189,12 +427,29 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<label>Edge stroke <span id="ge-edgestroke-label">0px</span></label>
|
||||
<input type="range" id="ge-edgestroke-slider" min="-80" max="80" value="0" title="Expand (+) or contract (−) the inpaint layer's edge before feathering. Uses the AI buffer generated around your brush." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" id="ge-inpaint-automatch-row" style="display:none;margin-top:6px;">
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel ge-btn-ai" id="ge-inpaint-automatch" style="width:100%;justify-content:center;" title="Match the latest inpaint result to the surrounding colour and lighting using an adjustment layer.">
|
||||
<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>
|
||||
Auto match color
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-eraser-section" id="ge-clone-section" style="display:none;">
|
||||
<div class="ge-section-title ge-section-title-with-help"><span>Clone</span><span class="ge-section-help" tabindex="0" role="img" aria-label="How clone works" title="Alt-click (desktop) or double-tap (mobile) somewhere on the canvas to set the sample source. Then drag elsewhere to clone those pixels onto the active layer. The source point moves with your brush so the offset stays constant. Size / Opacity / Flow / Softness come from the Brush panel.">?</span></div>
|
||||
<p class="ge-section-hint" style="margin-top:0;">
|
||||
<strong class="ge-clone-hint-desktop">Alt-click</strong><strong class="ge-clone-hint-mobile">Double-tap</strong> to set source · drag to paint
|
||||
</p>
|
||||
<div class="ge-clone-source-status" id="ge-clone-source-status" aria-live="polite">
|
||||
<span id="ge-clone-source-label">No source selected</span>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-clone-source-clear" title="Clear sampled source" aria-label="Clear sampled source">×</button>
|
||||
</div>
|
||||
<div class="ge-control-row">
|
||||
<label for="ge-clone-sample-mode">Sample</label>
|
||||
<select id="ge-clone-sample-mode">
|
||||
<option value="active-layer">Active layer</option>
|
||||
<option value="composite">All visible layers</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-clone-preview-opacity" aria-hidden="true"></span>
|
||||
<label>Opacity <span id="ge-clone-opacity-label">100%</span></label>
|
||||
@@ -212,7 +467,12 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-eraser-section" id="ge-brush-section" style="display:none;">
|
||||
<div class="ge-section-title">Brush</div>
|
||||
<div class="ge-section-title"><span>Brush</span></div>
|
||||
<div class="ge-control-row ge-eraser-row" id="ge-smudge-strength-row" style="display:none;">
|
||||
<span class="ge-eraser-preview" id="ge-smudge-preview-strength" aria-hidden="true"></span>
|
||||
<label>Strength <span id="ge-smudge-strength-label">65%</span></label>
|
||||
<input type="range" id="ge-smudge-strength" min="5" max="100" value="65" title="How strongly Smudge carries sampled pixels into the stroke." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-brush-preview-opacity" aria-hidden="true"></span>
|
||||
<label>Opacity <span id="ge-brush-opacity-label">100%</span></label>
|
||||
@@ -361,6 +621,48 @@ export function layerPanelHTML() {
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-flatten" title="Flatten copy (keeps originals)" aria-label="Flatten copy">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 L4 6 L4 18 L12 22 L20 18 L20 6 Z"/><path d="M12 2 L12 22"/><path d="M4 6 L20 6"/><path d="M4 18 L20 18"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-select-all-layers" title="Select all layers" aria-label="Select all layers">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="13" height="13" rx="1"/><path d="M4 16H3V3h13v1"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-group-selected" title="Group selected layers" aria-label="Group selected layers">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h7l2 2h9v10H3z"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-add-layer" title="Add empty layer">+ Add</button>
|
||||
</div><div class="ge-layers-list" id="ge-layers-list"></div>`;
|
||||
</div>
|
||||
<div class="ge-layer-blend-row">
|
||||
<label for="ge-layer-blend">Blend</label>
|
||||
<select id="ge-layer-blend" title="Active layer blend mode">
|
||||
<option value="source-over">Normal</option>
|
||||
<option value="multiply">Multiply</option>
|
||||
<option value="screen">Screen</option>
|
||||
<option value="overlay">Overlay</option>
|
||||
<option value="soft-light">Soft Light</option>
|
||||
<option value="hard-light">Hard Light</option>
|
||||
<option value="darken">Darken</option>
|
||||
<option value="lighten">Lighten</option>
|
||||
<option value="color-dodge">Color Dodge</option>
|
||||
<option value="color-burn">Color Burn</option>
|
||||
<option value="difference">Difference</option>
|
||||
<option value="exclusion">Exclusion</option>
|
||||
<option value="hue">Hue</option>
|
||||
<option value="saturation">Saturation</option>
|
||||
<option value="color">Color</option>
|
||||
<option value="luminosity">Luminosity</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ge-layer-selection-bar" id="ge-layer-selection-bar" hidden>
|
||||
<span id="ge-layer-selection-count">2 layers</span>
|
||||
<button class="ge-icon-btn" id="ge-selected-visibility" title="Toggle selected visibility" aria-label="Toggle selected visibility">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
<button class="ge-icon-btn" id="ge-selected-lock" title="Toggle selected lock" aria-label="Toggle selected lock">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
|
||||
</button>
|
||||
<button class="ge-icon-btn" id="ge-selected-align" title="Align or distribute selected layers" aria-label="Align or distribute selected layers">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 5h16M7 12h10M4 19h16"/><path d="M12 3v18"/></svg>
|
||||
</button>
|
||||
<button class="ge-icon-btn danger" id="ge-selected-delete" title="Delete selected layers" aria-label="Delete selected layers">×</button>
|
||||
</div>
|
||||
<div class="ge-layers-list" id="ge-layers-list"></div>
|
||||
<div class="ge-layer-tools" id="ge-layer-tools" aria-label="Selected layer tools"></div>`;
|
||||
}
|
||||
|
||||
@@ -22,14 +22,15 @@ export function shortcutsPopupHTML() {
|
||||
<div class="ge-shortcuts-col">
|
||||
<h5>Tools</h5>
|
||||
<div><kbd>V</kbd> Move</div>
|
||||
<div><kbd>T</kbd> Transform</div>
|
||||
<div><kbd>H</kbd> Hand <span style="opacity:0.5">(hold Space temporarily)</span></div>
|
||||
<div><kbd>Arrow</kbd> Move 1 px <span style="opacity:0.5">(Shift = 10 px)</span></div>
|
||||
<div><kbd>T</kbd> Text</div>
|
||||
<div><kbd>B</kbd> Brush</div>
|
||||
<div><kbd>E</kbd> Eraser</div>
|
||||
<div><kbd>K</kbd> Clone Stamp <span style="opacity:0.5">(Alt-click = set source)</span></div>
|
||||
<div><kbd>L</kbd> Lasso</div>
|
||||
<div><kbd>W</kbd> Wand</div>
|
||||
<div><kbd>M</kbd> Inpaint</div>
|
||||
<div><kbd>E</kbd> Eraser</div>
|
||||
<div><kbd>C</kbd> Crop</div>
|
||||
<div><kbd>S</kbd> Sharpen</div>
|
||||
</div>
|
||||
@@ -40,6 +41,7 @@ export function shortcutsPopupHTML() {
|
||||
<div><kbd>Ctrl</kbd>+<kbd>S</kbd> Save</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> Save to Gallery</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>J</kbd> New Layer</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>G</kbd> Clipping Mask</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>T</kbd> Free Transform</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>T</kbd> Canvas size…</div>
|
||||
</div>
|
||||
@@ -89,8 +91,19 @@ export function historyPanelHTML(historyIcon) {
|
||||
export function canvasSizePromptHTML() {
|
||||
return `
|
||||
<div class="modal-content ge-canvas-prompt">
|
||||
<div class="modal-header"><h4 id="ge-canvas-prompt-title">New canvas</h4></div>
|
||||
<div class="modal-header"><h4 id="ge-canvas-prompt-title">New project</h4></div>
|
||||
<div class="modal-body">
|
||||
<div class="ge-canvas-prompt-section-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M3 9h18M9 3v18"></path></svg><span>Resolution templates</span></div>
|
||||
<div class="ge-canvas-preset-grid" role="group" aria-label="Resolution templates">
|
||||
<button type="button" class="ge-canvas-preset" data-canvas-preset="1024x1024"><svg class="ge-canvas-preset-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="1"></rect></svg><span>Square</span><strong>1024 × 1024</strong></button>
|
||||
<button type="button" class="ge-canvas-preset" data-canvas-preset="1920x1080"><svg class="ge-canvas-preset-icon" width="14" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="2" y="5" width="20" height="14" rx="1"></rect></svg><span>Landscape</span><strong>1920 × 1080</strong></button>
|
||||
<button type="button" class="ge-canvas-preset" data-canvas-preset="1080x1920"><svg class="ge-canvas-preset-icon" width="10" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="1"></rect></svg><span>Story</span><strong>1080 × 1920</strong></button>
|
||||
<button type="button" class="ge-canvas-preset" data-canvas-preset="1080x1350"><svg class="ge-canvas-preset-icon" width="11" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="5" y="3" width="14" height="18" rx="1"></rect></svg><span>Portrait</span><strong>1080 × 1350</strong></button>
|
||||
<button type="button" class="ge-canvas-preset" data-canvas-preset="2480x3508"><svg class="ge-canvas-preset-icon" width="11" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M6 2h9l3 3v17H6zM15 2v4h4"></path></svg><span>A4</span><strong>2480 × 3508</strong></button>
|
||||
<button type="button" class="ge-canvas-preset" data-canvas-preset="3840x2160"><svg class="ge-canvas-preset-icon" width="14" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="1"></rect><path d="M8 22h8M12 20v2"></path></svg><span>4K</span><strong>3840 × 2160</strong></button>
|
||||
</div>
|
||||
<div class="ge-canvas-prompt-section-label ge-canvas-prompt-custom-label">Custom size</div>
|
||||
<label class="ge-canvas-units-field"><span class="ge-canvas-control-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16v16H4z"></path><path d="M8 4v16M16 4v16M4 8h16M4 16h16"></path></svg><span>Units</span></span><select id="ge-canvas-prompt-units"><option value="px">Pixels</option><option value="percent">Percent</option></select></label>
|
||||
<div class="ge-canvas-prompt-row">
|
||||
<label class="ge-canvas-prompt-field">
|
||||
<span>Width</span>
|
||||
@@ -102,9 +115,19 @@ export function canvasSizePromptHTML() {
|
||||
<input type="text" id="ge-canvas-prompt-h" inputmode="numeric" value="1024">
|
||||
</label>
|
||||
</div>
|
||||
<p class="ge-canvas-prompt-hint">Pixels, or type a ratio like 3x5 / 16:9 in either field.</p>
|
||||
<div class="ge-canvas-anchor-options">
|
||||
<div class="ge-canvas-anchor-label">Anchor</div>
|
||||
<div class="ge-canvas-anchor-grid" role="group" aria-label="Canvas anchor">
|
||||
${[['0,0','Top left'],['0.5,0','Top'],['1,0','Top right'],['0,0.5','Left'],['0.5,0.5','Center'],['1,0.5','Right'],['0,1','Bottom left'],['0.5,1','Bottom'],['1,1','Bottom right']].map(([value, label], index) => `<button type="button" class="ge-canvas-anchor${index === 0 ? ' active' : ''}" data-canvas-anchor="${value}" title="${label}" aria-label="${label}"></button>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-canvas-resize-options">
|
||||
<label class="ge-canvas-lock-option"><input type="checkbox" id="ge-canvas-prompt-lock" /> <span>Keep proportions</span></label>
|
||||
<label class="ge-canvas-interpolation-field"><span class="ge-canvas-control-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18 9 13l3 3 8-8"></path><path d="M15 8h5v5"></path></svg><span>Interpolation</span></span><select id="ge-canvas-prompt-interpolation"><option value="high">Smooth</option><option value="medium">Balanced</option><option value="low">Crisp</option></select></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="modal-footer ge-canvas-prompt-footer">
|
||||
<p class="ge-canvas-prompt-hint">Pixels, or type a ratio like 3x5 / 16:9 in either field.</p>
|
||||
<button class="confirm-btn confirm-btn-secondary" id="ge-canvas-prompt-cancel">Cancel</button>
|
||||
<button class="confirm-btn confirm-btn-primary" id="ge-canvas-prompt-ok">Create</button>
|
||||
</div>
|
||||
|
||||
@@ -77,6 +77,10 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
|
||||
// docked inside the right panel above the layers list.
|
||||
if (window.innerWidth <= 700 && state.container) {
|
||||
state.container.appendChild(controls);
|
||||
// Start canvas-first on phones. The active tool button brings this
|
||||
// sheet back and minimizes Layers, so the two bottom sheets never
|
||||
// compete for the same pointer events on initial open.
|
||||
controls.classList.add('dismissed');
|
||||
}
|
||||
|
||||
// Move every slider-row's value chip out of its <label> and place
|
||||
@@ -102,6 +106,9 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
|
||||
{
|
||||
const header = layerPanel.querySelector('.ge-layers-header');
|
||||
if (header) {
|
||||
const claimLayerSheet = () => {
|
||||
if (window.innerWidth <= 700) controls.classList.add('dismissed');
|
||||
};
|
||||
let sy = 0, sx = 0, dragging = false, didSwipe = false;
|
||||
header.addEventListener('touchstart', (e) => {
|
||||
if (window.innerWidth > 700) return;
|
||||
@@ -121,6 +128,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
|
||||
// expanded → peek → minimized (swipe down)
|
||||
if (Math.abs(dy) > 20 && Math.abs(dy) > dx) {
|
||||
didSwipe = true;
|
||||
claimLayerSheet();
|
||||
const isExpanded = rightPanel.classList.contains('expanded');
|
||||
const isMinimized = rightPanel.classList.contains('minimized');
|
||||
if (dy < 0) {
|
||||
@@ -143,6 +151,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
|
||||
if (window.innerWidth > 700) return;
|
||||
if (e.target.closest('button')) return;
|
||||
if (didSwipe) { didSwipe = false; return; }
|
||||
claimLayerSheet();
|
||||
// Click cycles between peek and expanded; minimized comes
|
||||
// back to peek (so a tap on the handle always reveals at
|
||||
// least the active layer row).
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* @param {{
|
||||
* currentTool: string,
|
||||
* onSelectTool: (toolId: string, btn: HTMLButtonElement, toolbar: HTMLDivElement) => void,
|
||||
* onClearSelection: (which: 'lasso'|'wand') => void,
|
||||
* onClearSelection: (which: 'marquee'|'lasso'|'wand') => void,
|
||||
* }} ctx
|
||||
* @returns {{ toolbar: HTMLDivElement, toolKeyMap: Record<string,string> }}
|
||||
*/
|
||||
@@ -18,15 +18,26 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
toolbar.className = 'ge-toolbar';
|
||||
const tools = [
|
||||
{ id: 'move', label: 'Move', icon: '✥', key: 'V' },
|
||||
{ id: 'hand', label: 'Hand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-4 0v4"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 10V5a2 2 0 0 0-4 0v9"/><path d="M6 13.5 4.5 12A2.1 2.1 0 0 0 2 15l4.5 5A6 6 0 0 0 11 22h2a7 7 0 0 0 7-7v-4a2 2 0 0 0-4 0v1"/></svg>', key: 'H' },
|
||||
{ id: 'crop', label: 'Crop', icon: '✂', key: 'C' },
|
||||
{ id: 'transform', label: 'Transform', icon: '⤢', key: 'T' },
|
||||
{ id: 'transform', label: 'Transform', icon: '⤢' },
|
||||
{ id: 'text', label: 'Text', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>', key: 'T' },
|
||||
{ id: 'shape', label: 'Shape', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="12" height="12" rx="1"/><circle cx="17" cy="15" r="4"/></svg>', key: 'U' },
|
||||
{ sep: true },
|
||||
{ id: 'brush', label: 'Brush', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>', key: 'B' },
|
||||
{ id: 'gradient', label: 'Gradient', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20 20 4"/><path d="M6 18 18 6" opacity=".45"/><path d="M8 16 16 8" opacity=".2"/></svg>', key: 'G' },
|
||||
{ id: 'eraser', label: 'Eraser', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19.4 14.6 14.6 19.4a2 2 0 0 1-2.83 0L4.6 12.23a2 2 0 0 1 0-2.83l7.17-7.17a2 2 0 0 1 2.83 0l4.8 4.8a2 2 0 0 1 0 2.83Z"/><line x1="22" y1="21" x2="7" y2="21"/><line x1="14" y1="3" x2="9" y2="8"/></svg>', key: 'E' },
|
||||
{ id: 'eyedropper', label: 'Eyedropper', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 3 2 2-9.5 9.5-3-3Z"/><path d="m8.5 11.5-5 5V21h4.5l5-5"/></svg>', key: 'I' },
|
||||
{ id: 'smudge', label: 'Smudge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19c2-3 4-5 6-6 2-1 4-3 4-6 0-2-1-4-3-4s-3 2-3 4v5"/><path d="M9 13c-2 0-4 1-5 3-.7 1.2-.1 3 1.4 3H18c1.7 0 3-1.3 3-3 0-1.1-.9-2-2-2h-4"/></svg>', key: 'Y' },
|
||||
{ sep: true },
|
||||
{ id: 'clone', label: 'Clone', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="3"/><path d="M9 12l-3 4h12l-3-4"/><path d="M4 20h16"/></svg>', key: 'K' },
|
||||
{ id: 'heal', label: 'Healing', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m5 19 14-14"/><path d="M7 5h4M9 3v4M13 17h4M15 15v4"/></svg>', key: 'J' },
|
||||
{ id: 'dodge', label: 'Dodge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="10" cy="10" r="6"/><path d="m14.5 14.5 6 6"/></svg>', key: 'O' },
|
||||
{ id: 'burn', label: 'Burn', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 22c4 0 7-3 7-7 0-5-4-8-7-13-3 5-7 8-7 13 0 4 3 7 7 7Z"/><path d="M9 16c1.5 1 4.5 1 6 0"/></svg>', key: 'D' },
|
||||
{ id: 'marquee', label: 'Marquee', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg>', key: 'R' },
|
||||
{ id: 'lasso', label: 'Lasso', icon: '⟡', key: 'L' },
|
||||
{ id: 'wand', label: 'Wand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8L19 13"/><path d="M15 9h0"/><path d="M17.8 6.2L19 5"/><path d="M3 21l9-9"/><path d="M12.2 6.2L11 5"/></svg>', key: 'W' },
|
||||
{ id: 'sam', label: 'SAM', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>' },
|
||||
{ sep: true },
|
||||
{ id: 'inpaint', label: 'Inpaint', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>', key: 'M' },
|
||||
{ id: 'rembg', ai: true, label: 'Bg Remove', icon: '✄' },
|
||||
@@ -54,8 +65,9 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
// Selection-clear badge — rendered only for tools that can hold a
|
||||
// selection (lasso, wand). Inpaint masks are first-class sub-layers
|
||||
// now so they get their own delete-X in the layer panel.
|
||||
const clearBadge = (t.id === 'lasso' || t.id === 'wand')
|
||||
? '<span class="ge-tool-clear" title="Clear selection" data-clear-tool="' + t.id + '">' +
|
||||
const clearTitle = t.id === 'sam' ? 'Open SAM prompt' : 'Clear selection';
|
||||
const clearBadge = (t.id === 'marquee' || t.id === 'lasso' || t.id === 'wand' || t.id === 'sam')
|
||||
? '<span class="ge-tool-clear" title="' + clearTitle + '" data-clear-tool="' + t.id + '">' +
|
||||
'<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>' +
|
||||
'</span>'
|
||||
: '';
|
||||
|
||||
@@ -26,6 +26,10 @@ export function buildTopbar() {
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/><polyline points="12 7 12 12 16 14"/></svg></span>
|
||||
<span class="ge-stacked-label">HISTORY</span>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-compare-btn" title="Show the document before editing" aria-label="Show the document before editing" aria-pressed="false">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"></path><path d="M5 7h14M5 17h14"></path></svg></span>
|
||||
<span class="ge-stacked-label">BEFORE</span>
|
||||
</button>
|
||||
<span class="ge-topbar-sep"></span>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-zoom-out" title="Zoom out">−</button>
|
||||
<span class="ge-zoom-stack">
|
||||
@@ -47,13 +51,36 @@ export function buildTopbar() {
|
||||
<span class="ge-topbar-sep"></span>
|
||||
</div>
|
||||
<div class="ge-topbar-right">
|
||||
<span class="ge-draft-status" id="ge-draft-status" role="status" aria-live="polite" title="Draft status">Not saved</span>
|
||||
<span class="ge-canvas-size" id="ge-canvas-size" title="Canvas size" hidden></span>
|
||||
<div class="ge-view-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-view-menu-btn" title="Canvas view" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">VIEW</span>
|
||||
</button>
|
||||
<div class="ge-view-menu dropdown" id="ge-view-menu" hidden>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="rulers"><span class="dropdown-icon ge-view-check">✓</span><span>Rulers</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="grid"><span class="dropdown-icon ge-view-check">✓</span><span>Grid</span></button>
|
||||
<label class="ge-view-grid-size"><span>Grid size</span><input id="ge-grid-size" type="number" min="2" max="1000" step="1" value="16"><span>px</span></label>
|
||||
<div class="dropdown-section-divider"></div>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap"><span class="dropdown-icon ge-view-check">✓</span><span>Snap</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap-grid"><span class="dropdown-icon ge-view-check">✓</span><span>Snap to grid</span></button>
|
||||
<button class="dropdown-item-compact" data-view-action="clear-guides"><span class="dropdown-icon">×</span><span>Clear guides</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-image-wrap">
|
||||
<button class="ge-btn ge-btn-sm" id="ge-image-menu-btn" title="Image actions" aria-haspopup="true">Image ▾</button>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-image-menu-btn" title="Image actions" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">IMAGE</span>
|
||||
</button>
|
||||
<div class="ge-image-menu dropdown" id="ge-image-menu" hidden>
|
||||
<button class="dropdown-item-compact" data-image-action="resize">
|
||||
<button class="dropdown-item-compact" data-image-action="canvas-size">
|
||||
<span class="dropdown-icon">⤢</span>
|
||||
<span>Canvas…</span>
|
||||
<span>Canvas Size...</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-image-action="image-size">
|
||||
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg></span>
|
||||
<span>Image Size...</span>
|
||||
</button>
|
||||
<div class="ge-filter-submenu-label">Transform</div>
|
||||
<button class="dropdown-item-compact" data-image-action="rotate-90">
|
||||
@@ -74,9 +101,61 @@ export function buildTopbar() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-selection-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-selection-menu-btn" title="Selection actions" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">SELECT</span>
|
||||
</button>
|
||||
<div class="ge-selection-menu dropdown" id="ge-selection-menu" hidden>
|
||||
<button class="dropdown-item-compact" data-selection-action="all"><span>Select All</span><span class="dropdown-shortcut">Ctrl+Alt+A</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="deselect"><span>Deselect</span><span class="dropdown-shortcut">Ctrl+Shift+D</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="reselect"><span>Reselect</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="invert"><span>Invert</span><span class="dropdown-shortcut">Ctrl+Alt+I</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="transform"><span>Transform Selection</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="refine"><span>Refine Selection…</span></button>
|
||||
<div class="dropdown-section-divider"></div>
|
||||
<div class="ge-selection-save-row">
|
||||
<input id="ge-selection-name" type="text" maxlength="100" placeholder="Selection name" aria-label="Saved selection name" />
|
||||
<button type="button" class="ge-icon-btn" data-selection-action="save" title="Save current selection" aria-label="Save current selection">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ge-filter-submenu-label">Saved selections</div>
|
||||
<div id="ge-saved-selection-list" class="ge-saved-selection-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-filter-wrap">
|
||||
<button class="ge-btn ge-btn-sm" id="ge-filter-menu-btn" title="Filters" aria-haspopup="true">Filter ▾</button>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-filter-menu-btn" title="Filters" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16M7 12h10M10 19h4"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">FILTER</span>
|
||||
</button>
|
||||
<div class="ge-filter-menu dropdown" id="ge-filter-menu" hidden>
|
||||
<div class="ge-filter-submenu-label">Retained effects</div>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-blur-gaussian">
|
||||
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
|
||||
<span>Gaussian Blur (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-sharpen">
|
||||
<span class="dropdown-icon" aria-hidden="true">◈</span>
|
||||
<span>Sharpen (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-color-overlay">
|
||||
<span class="dropdown-icon" aria-hidden="true">◐</span>
|
||||
<span>Color Overlay (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-drop-shadow">
|
||||
<span class="dropdown-icon" aria-hidden="true">◒</span>
|
||||
<span>Drop Shadow (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-stroke">
|
||||
<span class="dropdown-icon" aria-hidden="true">□</span>
|
||||
<span>Stroke (retained)…</span>
|
||||
</button>
|
||||
<div class="ge-filter-submenu-label">Presets</div>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-preset-soft-blur"><span>Soft Blur</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-preset-crisp-detail"><span>Crisp Detail</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-preset-soft-shadow"><span>Soft Shadow</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-preset-white-outline"><span>White Outline</span></button>
|
||||
<div class="ge-filter-submenu-label">Blur</div>
|
||||
<button class="dropdown-item-compact" data-filter-action="blur-gaussian">
|
||||
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
|
||||
@@ -92,10 +171,14 @@ export function buildTopbar() {
|
||||
<button class="ge-btn ge-btn-sm" id="ge-shortcuts-btn" title="Keyboard shortcuts (?)" aria-label="Shortcuts">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:relative;top:2px;"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-import-topbar" title="Import image as layer">+ Import</button>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-import-topbar" title="Import image as layer">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><polyline points="7 8 12 3 17 8"/><path d="M5 14v5h14v-5"/></svg></span>
|
||||
<span class="ge-stacked-label">IMPORT</span>
|
||||
</button>
|
||||
<div class="ge-save-wrap">
|
||||
<button class="ge-btn ge-btn-primary" id="ge-save-menu-btn" title="Save options" style="display:inline-flex;align-items:center;gap:4px;">Save
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-primary ge-stacked-btn" id="ge-save-menu-btn" title="Save options">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">SAVE</span>
|
||||
</button>
|
||||
<div class="ge-save-menu dropdown" id="ge-save-menu" hidden>
|
||||
<div class="dropdown-section-label">Image</div>
|
||||
@@ -109,9 +192,9 @@ export function buildTopbar() {
|
||||
<span>Save as copy</span>
|
||||
<span class="dropdown-shortcut">Ctrl+Shift+S</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" id="ge-download" title="Download PNG to your computer">
|
||||
<button class="dropdown-item-compact" id="ge-download" title="Export an image to your computer">
|
||||
<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
|
||||
<span>Download PNG</span>
|
||||
<span>Export image...</span>
|
||||
</button>
|
||||
<div class="dropdown-section-divider"></div>
|
||||
<div class="dropdown-section-label">Project</div>
|
||||
|
||||
@@ -23,6 +23,23 @@ export function transformPopupHTML() {
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-transform-popup-body">
|
||||
<div class="ge-transform-field ge-transform-position-field">
|
||||
<label for="ge-transform-x" title="Transform center X">X</label>
|
||||
<input type="number" class="ge-transform-popup-input" id="ge-transform-x" step="1" aria-label="Transform center X" />
|
||||
<span class="ge-transform-spin" data-spin-for="ge-transform-x">
|
||||
<button type="button" data-spin="down" tabindex="-1" aria-label="Move left">−</button>
|
||||
<button type="button" data-spin="up" tabindex="-1" aria-label="Move right">+</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-transform-field ge-transform-position-field">
|
||||
<label for="ge-transform-y" title="Transform center Y">Y</label>
|
||||
<input type="number" class="ge-transform-popup-input" id="ge-transform-y" step="1" aria-label="Transform center Y" />
|
||||
<span class="ge-transform-spin" data-spin-for="ge-transform-y">
|
||||
<button type="button" data-spin="down" tabindex="-1" aria-label="Move up">−</button>
|
||||
<button type="button" data-spin="up" tabindex="-1" aria-label="Move down">+</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-row-break ge-transform-position-break"></div>
|
||||
<div class="ge-transform-field">
|
||||
<label>W</label>
|
||||
<input type="number" class="ge-transform-popup-input" id="ge-transform-w" step="1" />
|
||||
@@ -48,9 +65,24 @@ export function transformPopupHTML() {
|
||||
<button type="button" data-spin="up" tabindex="-1" aria-label="Rotate +1°">+</button>
|
||||
</span>
|
||||
</div>
|
||||
<span class="ge-transform-quick" aria-label="Transform actions">
|
||||
<button type="button" class="ge-transform-quick-btn" id="ge-transform-flip-h" title="Flip horizontally" aria-label="Flip horizontally">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="m8 7-4 5 4 5"/><path d="m16 7 4 5-4 5"/></svg>
|
||||
</button>
|
||||
<button type="button" class="ge-transform-quick-btn" id="ge-transform-flip-v" title="Flip vertically" aria-label="Flip vertically">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h18"/><path d="m7 8 5-4 5 4"/><path d="m7 16 5 4 5-4"/></svg>
|
||||
</button>
|
||||
<button type="button" class="ge-transform-quick-btn" id="ge-transform-rot-90" title="Rotate 90 degrees (Shift: counter-clockwise)" aria-label="Rotate 90 degrees">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 4v7h-7"/></svg>
|
||||
</button>
|
||||
</span>
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-transform-cancel-btn">Cancel</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-btn-primary" id="ge-transform-apply">Apply</button>
|
||||
</div>
|
||||
<label class="ge-transform-preserve-field">
|
||||
<input type="checkbox" id="ge-transform-preserve-source" />
|
||||
<span>Preserve source pixels</span>
|
||||
</label>
|
||||
<p class="ge-transform-popup-hint">Type <strong>-</strong> before W / H to flip.</p>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ export function canvasCoords(e, canvas) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
const clientX = e.touches && e.touches.length ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.touches && e.touches.length ? e.touches[0].clientY : e.clientY;
|
||||
return {
|
||||
x: (clientX - rect.left) * scaleX,
|
||||
y: (clientY - rect.top) * scaleY,
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
* Touch:
|
||||
* touchstart 1 finger → beginDraw
|
||||
* touchmove 1 finger → continueDraw
|
||||
* touchend / touchcancel → endDraw
|
||||
* touchend → endDraw; touchcancel → cancelDraw
|
||||
* touchstart 2 fingers → pinch-zoom + 2-finger pan
|
||||
*
|
||||
* Pan (any free space around the canvas):
|
||||
* pointerdown / pointermove / pointerup on canvas-area, skipping
|
||||
* the canvas + transform overlay + UI elements above them. Sets
|
||||
* canvasArea.dataset.panX/Y + CSS transform on both canvases.
|
||||
* Pan:
|
||||
* Hand tool / held Space / middle mouse can drag directly over the
|
||||
* image. Empty workspace remains draggable with any tool. The same
|
||||
* path drives one-finger Hand-tool panning on touch screens.
|
||||
*
|
||||
* Exposes `canvasArea._resetPan()` so the zoom/fit reset can clear
|
||||
* the pan offset.
|
||||
@@ -30,19 +30,65 @@
|
||||
* beginDraw: (e: Event) => void,
|
||||
* continueDraw: (e: Event) => void,
|
||||
* endDraw: (e?: Event) => void,
|
||||
* cancelDraw?: () => void,
|
||||
* updateBrushCursor: (e: Event) => void,
|
||||
* syncZoomControls?: () => void,
|
||||
* }} ctx
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import {
|
||||
applyCanvasPan,
|
||||
isDirectPanIntent,
|
||||
nextPanOffset,
|
||||
syncPanCursor,
|
||||
} from './canvas-navigation.js';
|
||||
|
||||
export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, updateBrushCursor, syncZoomControls }) {
|
||||
export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, cancelDraw, updateBrushCursor, updateEyedropperPreview, syncZoomControls, onViewportChange }) {
|
||||
let suppressMouseUntil = 0;
|
||||
// Mouse — mousedown stays on the canvas; mousemove/up are bound to
|
||||
// the WINDOW so a drag can continue (and end) past the canvas edge.
|
||||
// Critical for the Resize tool where users overshoot.
|
||||
state.mainCanvas.addEventListener('mousedown', beginDraw);
|
||||
window.addEventListener('mousemove', continueDraw);
|
||||
window.addEventListener('mouseup', endDraw);
|
||||
state.mainCanvas.addEventListener('mousedown', (e) => {
|
||||
if (Date.now() < suppressMouseUntil) return;
|
||||
if (isDirectPanIntent(state.tool, state.spacePanActive, e.button)) return;
|
||||
beginDraw(e);
|
||||
});
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (Date.now() < suppressMouseUntil) return;
|
||||
continueDraw(e);
|
||||
});
|
||||
window.addEventListener('mouseup', (e) => {
|
||||
if (Date.now() < suppressMouseUntil) return;
|
||||
endDraw(e);
|
||||
});
|
||||
// Preserve pressure and browser-coalesced samples for pen input.
|
||||
// Compatibility mouse events are briefly suppressed to avoid a
|
||||
// duplicate stroke after pointerup.
|
||||
let activePenId = null;
|
||||
state.mainCanvas.addEventListener('pointerdown', (e) => {
|
||||
if (e.pointerType !== 'pen') return;
|
||||
suppressMouseUntil = Date.now() + 500;
|
||||
activePenId = e.pointerId;
|
||||
try { state.mainCanvas.setPointerCapture(activePenId); } catch {}
|
||||
beginDraw(e);
|
||||
e.preventDefault();
|
||||
});
|
||||
state.mainCanvas.addEventListener('pointermove', (e) => {
|
||||
if (e.pointerType !== 'pen' || e.pointerId !== activePenId) return;
|
||||
continueDraw(e);
|
||||
e.preventDefault();
|
||||
});
|
||||
const endPen = (e) => {
|
||||
if (e.pointerType !== 'pen' || e.pointerId !== activePenId) return;
|
||||
if (e.type === 'pointercancel') cancelDraw?.();
|
||||
else endDraw(e);
|
||||
try { state.mainCanvas.releasePointerCapture(activePenId); } catch {}
|
||||
activePenId = null;
|
||||
suppressMouseUntil = Date.now() + 500;
|
||||
e.preventDefault();
|
||||
};
|
||||
state.mainCanvas.addEventListener('pointerup', endPen);
|
||||
state.mainCanvas.addEventListener('pointercancel', endPen);
|
||||
// Lasso can start OUTSIDE the canvas — fallback mousedown on the
|
||||
// surrounding canvas-area so the user can begin a lasso path in
|
||||
// the empty space around the image. Other tools stay canvas-only.
|
||||
@@ -52,12 +98,21 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
beginDraw(e);
|
||||
});
|
||||
state.mainCanvas.addEventListener('mouseenter', (e) => {
|
||||
if (['brush', 'eraser', 'inpaint', 'lasso', 'clone'].includes(state.tool)) updateBrushCursor(e);
|
||||
if (state.tool === 'eyedropper') updateEyedropperPreview?.(e);
|
||||
if (['brush', 'eraser', 'inpaint', 'lasso', 'clone', 'heal', 'smudge', 'dodge', 'burn'].includes(state.tool)) updateBrushCursor(e);
|
||||
});
|
||||
state.mainCanvas.addEventListener('mouseleave', () => {
|
||||
// Only hide the brush-cursor overlay on leave — DO NOT end the
|
||||
// drag, so the user can drag a resize handle past the canvas edge.
|
||||
if (state.cursorEl) state.cursorEl.style.display = 'none';
|
||||
if (state.tool === 'eyedropper') updateEyedropperPreview?.(null, true);
|
||||
if (state.tool === 'transform' && state.hoveredHandle) {
|
||||
state.hoveredHandle = null;
|
||||
state.mainCanvas.style.cursor = 'default';
|
||||
// The frame is drawn on a separate overlay, so clear its hover state
|
||||
// explicitly when the pointer leaves the source canvas.
|
||||
continueDraw?.({ clientX: -1, clientY: -1, target: null });
|
||||
}
|
||||
});
|
||||
|
||||
// Touch — single finger draws; two fingers pan + pinch-zoom.
|
||||
@@ -74,13 +129,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
const dy = t2.clientY - t1.clientY;
|
||||
return { cx, cy, dist: Math.hypot(dx, dy) };
|
||||
};
|
||||
const applyCanvasOffset = (x, y) => {
|
||||
canvasArea.dataset.panX = String(x);
|
||||
canvasArea.dataset.panY = String(y);
|
||||
const t = `translate3d(${x}px, ${y}px, 0)`;
|
||||
state.mainCanvas.style.transform = t;
|
||||
if (state.transformOverlay) state.transformOverlay.style.transform = t;
|
||||
};
|
||||
const applyCanvasOffset = (x, y) => applyCanvasPan(state, canvasArea, x, y);
|
||||
state.mainCanvas.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
if (e.touches.length >= 2) {
|
||||
@@ -91,13 +140,11 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
multiStartDist = info.dist;
|
||||
multiStartZoom = state.zoom;
|
||||
multiStartCenter = { x: info.cx, y: info.cy };
|
||||
multiStartPan = {
|
||||
x: parseFloat(canvasArea.dataset.panX || '0') || 0,
|
||||
y: parseFloat(canvasArea.dataset.panY || '0') || 0,
|
||||
};
|
||||
multiStartPan = { x: state.panX || 0, y: state.panY || 0 };
|
||||
return;
|
||||
}
|
||||
if (multiActive) return;
|
||||
if (state.tool === 'hand') return;
|
||||
beginDraw(e);
|
||||
}, { passive: false });
|
||||
state.mainCanvas.addEventListener('touchmove', (e) => {
|
||||
@@ -113,10 +160,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
const label = state.container.querySelector('.ge-zoom-label');
|
||||
if (label) label.textContent = Math.round(state.zoom * 100) + '%';
|
||||
syncZoomControls?.();
|
||||
onViewportChange?.();
|
||||
}
|
||||
const dx = info.cx - multiStartCenter.x;
|
||||
const dy = info.cy - multiStartCenter.y;
|
||||
applyCanvasOffset(multiStartPan.x + dx, multiStartPan.y + dy);
|
||||
onViewportChange?.();
|
||||
return;
|
||||
}
|
||||
if (multiActive) return;
|
||||
@@ -131,67 +180,104 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
});
|
||||
state.mainCanvas.addEventListener('touchcancel', () => {
|
||||
multiActive = false;
|
||||
endDraw();
|
||||
cancelDraw?.();
|
||||
});
|
||||
|
||||
// Press-and-drag in the empty space AROUND the canvas pans the
|
||||
// canvas + overlay via CSS transform. Works even when the image
|
||||
// fits the viewport (no scroll needed). Skips presses on the canvas
|
||||
// itself (the canvas owns its own drawing input) or on UI elements
|
||||
// above it.
|
||||
// Direct pan gestures own the image as well as the surrounding area.
|
||||
// With other tools only empty workspace pans, preserving normal edit input.
|
||||
let panning = false;
|
||||
let pid = null;
|
||||
let transformPointerId = null;
|
||||
let startX = 0, startY = 0;
|
||||
const getOffset = () => {
|
||||
const v = canvasArea.dataset.panX || '0';
|
||||
const u = canvasArea.dataset.panY || '0';
|
||||
return { x: parseFloat(v) || 0, y: parseFloat(u) || 0 };
|
||||
};
|
||||
let startPanX = 0, startPanY = 0;
|
||||
const getOffset = () => ({ x: state.panX || 0, y: state.panY || 0 });
|
||||
const applyOffset = (x, y) => {
|
||||
canvasArea.dataset.panX = String(x);
|
||||
canvasArea.dataset.panY = String(y);
|
||||
const t = `translate3d(${x}px, ${y}px, 0)`;
|
||||
state.mainCanvas.style.transform = t;
|
||||
if (state.transformOverlay) state.transformOverlay.style.transform = t;
|
||||
const result = applyCanvasPan(state, canvasArea, x, y);
|
||||
onViewportChange?.();
|
||||
return result;
|
||||
};
|
||||
canvasArea.addEventListener('pointerdown', (e) => {
|
||||
if (state.tool === 'lasso') return;
|
||||
if (e.target === state.mainCanvas || e.target === state.transformOverlay) return;
|
||||
const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button);
|
||||
if (state.tool === 'lasso' && !directPan) return;
|
||||
if (e.target.closest('button, input, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return;
|
||||
const onImage = e.target === state.mainCanvas || e.target === state.transformOverlay;
|
||||
if (onImage && !directPan) return;
|
||||
// During an active transform the corner/rotation handles render
|
||||
// OUTSIDE the canvas (over the surrounding area), and the overlay is
|
||||
// pointer-events:none — so a grab on an outside handle lands here.
|
||||
// Route it to the transform tool (getHandleAt works in image space,
|
||||
// even for points beyond the canvas) instead of panning the canvas.
|
||||
if (state.transformActive) {
|
||||
if (state.transformActive && !directPan) {
|
||||
beginDraw(e);
|
||||
// Only swallow the event (skip pan) if a handle was grabbed OR the
|
||||
// layer-move fallback engaged; otherwise let the pan logic below
|
||||
// run so empty space still pans while the transform tool is open.
|
||||
if (state.transformHandle || state.moving) return;
|
||||
if (state.transformHandle || state.moving) {
|
||||
// Mouse drags already continue on window mousemove. Pen/touch drags
|
||||
// that start on an outside-canvas handle need pointer capture so the
|
||||
// session survives leaving the editor surface.
|
||||
if (e.pointerType && e.pointerType !== 'mouse') {
|
||||
transformPointerId = e.pointerId;
|
||||
try { canvasArea.setPointerCapture(transformPointerId); } catch {}
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
const off = getOffset();
|
||||
panning = true;
|
||||
pid = e.pointerId;
|
||||
startX = e.clientX - off.x;
|
||||
startY = e.clientY - off.y;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
startPanX = off.x;
|
||||
startPanY = off.y;
|
||||
try { canvasArea.setPointerCapture(pid); } catch {}
|
||||
canvasArea.style.cursor = 'grabbing';
|
||||
state.navigationPanning = true;
|
||||
syncPanCursor(state, canvasArea, true);
|
||||
e.preventDefault();
|
||||
});
|
||||
canvasArea.addEventListener('pointermove', (e) => {
|
||||
if (transformPointerId !== null && e.pointerId === transformPointerId) {
|
||||
continueDraw(e);
|
||||
return;
|
||||
}
|
||||
if (!panning || e.pointerId !== pid) return;
|
||||
applyOffset(e.clientX - startX, e.clientY - startY);
|
||||
const next = nextPanOffset(
|
||||
{ x: startX, y: startY },
|
||||
{ x: startPanX, y: startPanY },
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
);
|
||||
applyOffset(next.x, next.y);
|
||||
});
|
||||
const endPan = () => {
|
||||
const endPan = (e) => {
|
||||
if (transformPointerId !== null && (!e || e.pointerId === transformPointerId)) {
|
||||
const capturedId = transformPointerId;
|
||||
transformPointerId = null;
|
||||
if (e?.type === 'pointercancel') cancelDraw?.();
|
||||
else endDraw(e);
|
||||
try { canvasArea.releasePointerCapture(capturedId); } catch {}
|
||||
}
|
||||
if (!panning) return;
|
||||
panning = false;
|
||||
try { canvasArea.releasePointerCapture(pid); } catch {}
|
||||
pid = null;
|
||||
canvasArea.style.cursor = '';
|
||||
state.navigationPanning = false;
|
||||
syncPanCursor(state, canvasArea, false);
|
||||
};
|
||||
canvasArea.addEventListener('pointerup', endPan);
|
||||
canvasArea.addEventListener('pointercancel', endPan);
|
||||
// Reset offset whenever zoom/fit changes the canvas size.
|
||||
canvasArea._resetPan = () => applyOffset(0, 0);
|
||||
const navigation = {
|
||||
resetPan: canvasArea._resetPan,
|
||||
setTemporaryPan(active) {
|
||||
state.spacePanActive = !!active;
|
||||
syncPanCursor(state, canvasArea, panning);
|
||||
},
|
||||
updateCursor() {
|
||||
syncPanCursor(state, canvasArea, panning);
|
||||
},
|
||||
};
|
||||
navigation.updateCursor();
|
||||
return navigation;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Canvas viewport navigation shared by mouse, pen, and touch input. */
|
||||
export function isDirectPanIntent(tool, temporaryPan, button = 0) {
|
||||
return tool === 'hand' || temporaryPan === true || button === 1;
|
||||
}
|
||||
|
||||
export function nextPanOffset(startPointer, startPan, currentPointer) {
|
||||
return {
|
||||
x: startPan.x + currentPointer.x - startPointer.x,
|
||||
y: startPan.y + currentPointer.y - startPointer.y,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyCanvasPan(state, canvasArea, x, y) {
|
||||
const panX = Number.isFinite(Number(x)) ? Number(x) : 0;
|
||||
const panY = Number.isFinite(Number(y)) ? Number(y) : 0;
|
||||
state.panX = panX;
|
||||
state.panY = panY;
|
||||
canvasArea.dataset.panX = String(panX);
|
||||
canvasArea.dataset.panY = String(panY);
|
||||
const transform = `translate3d(${panX}px, ${panY}px, 0)`;
|
||||
if (state.mainCanvas) state.mainCanvas.style.transform = transform;
|
||||
if (state.selectionOverlay) state.selectionOverlay.style.transform = transform;
|
||||
if (state.transformOverlay) state.transformOverlay.style.transform = transform;
|
||||
return { x: panX, y: panY };
|
||||
}
|
||||
|
||||
export function syncPanCursor(state, canvasArea, panning = false) {
|
||||
const ready = state.tool === 'hand' || state.spacePanActive === true;
|
||||
canvasArea.classList.toggle('ge-pan-ready', ready && !panning);
|
||||
canvasArea.classList.toggle('ge-panning', panning);
|
||||
}
|
||||
@@ -14,11 +14,15 @@
|
||||
* fitZoom: () => void,
|
||||
* showCanvasLoading: (label: string) => void,
|
||||
* hideCanvasLoading: () => void,
|
||||
* renderLayerPanel: () => void,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { flipDocument, rotateDocument } from './document-geometry.js';
|
||||
|
||||
export function createCanvasTransforms({ saveState, composite, fitZoom, showCanvasLoading, hideCanvasLoading }) {
|
||||
export function createCanvasTransforms({
|
||||
saveState, composite, fitZoom, showCanvasLoading, hideCanvasLoading, renderLayerPanel,
|
||||
}) {
|
||||
return {
|
||||
/**
|
||||
* Rotate the entire document by `deg` (90 / 180 / 270). 90 and 270
|
||||
@@ -34,61 +38,14 @@ export function createCanvasTransforms({ saveState, composite, fitZoom, showCanv
|
||||
if (!state.layers.length) return;
|
||||
saveState(`Rotate ${deg}°`);
|
||||
showCanvasLoading('Rotating…');
|
||||
const oldW = state.imgWidth, oldH = state.imgHeight;
|
||||
const swap = (deg === 90 || deg === 270);
|
||||
const newW = swap ? oldH : oldW;
|
||||
const newH = swap ? oldW : oldH;
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const cos = Math.cos(rad), sin = Math.sin(rad);
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
for (const layer of state.layers) {
|
||||
const lw = layer.canvas.width, lh = layer.canvas.height;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
// Layer centre in old image coords.
|
||||
const cx = off.x + lw / 2;
|
||||
const cy = off.y + lh / 2;
|
||||
// Rotate the centre around the old image centre and
|
||||
// translate so the new image centre lands at (newW/2, newH/2).
|
||||
const dx = cx - oldW / 2;
|
||||
const dy = cy - oldH / 2;
|
||||
const nx = dx * cos - dy * sin + newW / 2;
|
||||
const ny = dx * sin + dy * cos + newH / 2;
|
||||
// New per-layer dims: swap when 90/270.
|
||||
const newLw = swap ? lh : lw;
|
||||
const newLh = swap ? lw : lh;
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = newLw; tmp.height = newLh;
|
||||
const tctx = tmp.getContext('2d');
|
||||
tctx.translate(newLw / 2, newLh / 2);
|
||||
tctx.rotate(rad);
|
||||
tctx.drawImage(layer.canvas, -lw / 2, -lh / 2);
|
||||
layer.canvas.width = newLw;
|
||||
layer.canvas.height = newLh;
|
||||
layer.ctx.drawImage(tmp, 0, 0);
|
||||
// The adjustment-render caches are keyed only by the adjustment
|
||||
// signature, which rotation doesn't change — so composite would draw
|
||||
// the STALE pre-rotation cache (the "had to click twice" bug). Drop
|
||||
// them so the next composite re-renders from the rotated canvas.
|
||||
layer._adjCacheKey = null;
|
||||
layer._adjFinalKey = null;
|
||||
state.layerOffsets.set(layer.id, {
|
||||
x: Math.round(nx - newLw / 2),
|
||||
y: Math.round(ny - newLh / 2),
|
||||
});
|
||||
}
|
||||
state.imgWidth = newW;
|
||||
state.imgHeight = newH;
|
||||
state.mainCanvas.width = newW;
|
||||
state.mainCanvas.height = newH;
|
||||
if (state.maskCanvas) {
|
||||
state.maskCanvas.width = newW;
|
||||
state.maskCanvas.height = newH;
|
||||
}
|
||||
const result = rotateDocument(state, deg);
|
||||
const sizeLabel = document.getElementById('ge-canvas-size');
|
||||
if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`;
|
||||
if (sizeLabel && result) sizeLabel.textContent = `${result.width}×${result.height}`;
|
||||
fitZoom();
|
||||
composite();
|
||||
renderLayerPanel?.();
|
||||
} finally {
|
||||
hideCanvasLoading();
|
||||
}
|
||||
@@ -103,30 +60,9 @@ export function createCanvasTransforms({ saveState, composite, fitZoom, showCanv
|
||||
flipAll(axis) {
|
||||
if (!state.layers.length) return;
|
||||
saveState(axis === 'h' ? 'Flip horizontal' : 'Flip vertical');
|
||||
for (const layer of state.layers) {
|
||||
const lw = layer.canvas.width, lh = layer.canvas.height;
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = lw; tmp.height = lh;
|
||||
const tctx = tmp.getContext('2d');
|
||||
tctx.save();
|
||||
if (axis === 'h') { tctx.translate(lw, 0); tctx.scale(-1, 1); }
|
||||
else { tctx.translate(0, lh); tctx.scale(1, -1); }
|
||||
tctx.drawImage(layer.canvas, 0, 0);
|
||||
tctx.restore();
|
||||
layer.ctx.clearRect(0, 0, lw, lh);
|
||||
layer.ctx.drawImage(tmp, 0, 0);
|
||||
// Invalidate the adjustment-render caches (keyed by adjustment sig only)
|
||||
// so composite redraws from the flipped canvas, not a stale cache.
|
||||
layer._adjCacheKey = null;
|
||||
layer._adjFinalKey = null;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
if (axis === 'h') {
|
||||
state.layerOffsets.set(layer.id, { x: state.imgWidth - off.x - lw, y: off.y });
|
||||
} else {
|
||||
state.layerOffsets.set(layer.id, { x: off.x, y: state.imgHeight - off.y - lh });
|
||||
}
|
||||
}
|
||||
flipDocument(state, axis);
|
||||
composite();
|
||||
renderLayerPanel?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,11 +22,12 @@
|
||||
* createLayer: (name: string, w: number, h: number) => object,
|
||||
* renderLayerPanel: () => void,
|
||||
* composite: () => void,
|
||||
* handleImportedImage: (img: HTMLImageElement) => void,
|
||||
* handleImportedImage: (img: HTMLImageElement, sourceName?: string) => void,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { createPlacedData, renderPlacedLayer } from './placed-layer.js';
|
||||
|
||||
export function wireClipboardAndDrop({
|
||||
container, saveState, createLayer, renderLayerPanel, composite,
|
||||
@@ -40,7 +41,13 @@ export function wireClipboardAndDrop({
|
||||
if (!state.editorOpen) return; // user closed mid-paste
|
||||
saveState();
|
||||
const layer = createLayer(label || 'Pasted', imgSource.width, imgSource.height);
|
||||
layer.ctx.drawImage(imgSource, 0, 0);
|
||||
// Selection clipboard data is already a complete layer-sized surface.
|
||||
// Keep it source-backed with an identity matrix so future transforms do
|
||||
// not repeatedly resample the pasted pixels.
|
||||
layer.kind = 'placed';
|
||||
layer.placed = createPlacedData(imgSource, [1, 0, 0, 1, 0, 0], label || 'Pasted');
|
||||
const rendered = renderPlacedLayer(layer);
|
||||
state.layerOffsets.set(layer.id, rendered.offset);
|
||||
state.layers.push(layer);
|
||||
state.activeLayerId = layer.id;
|
||||
state.tool = 'move';
|
||||
@@ -69,7 +76,10 @@ export function wireClipboardAndDrop({
|
||||
const blob = item.getAsFile();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = () => { pasteAsLayer(img, 'Pasted'); URL.revokeObjectURL(url); };
|
||||
// External clipboard images follow the same source-backed import path
|
||||
// as files, gallery images, and drops. Internal selection clipboard
|
||||
// content is handled above as an identity placed layer.
|
||||
img.onload = () => { handleImportedImage(img, 'Pasted image'); URL.revokeObjectURL(url); };
|
||||
img.src = url;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ export function buildMergedMaskCanvas(layers, imgW, imgH) {
|
||||
for (const ly of layers) {
|
||||
if (!ly.masks || !ly.masks.length) continue;
|
||||
for (const mk of ly.masks) {
|
||||
if (mk.mode === 'layer') continue;
|
||||
if (!mk.visible) continue;
|
||||
if (!mk.canvas || !mk.canvas.width || !mk.canvas.height) continue;
|
||||
ctx.drawImage(mk.canvas, 0, 0);
|
||||
@@ -81,3 +82,35 @@ export function buildMergedMaskCanvas(layers, imgW, imgH) {
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
return anyMask ? out : null;
|
||||
}
|
||||
|
||||
|
||||
/** Apply visible true layer masks to an already-adjusted layer canvas. */
|
||||
export function renderWithLayerMasks(source, layer, layerOffset = { x: 0, y: 0 }) {
|
||||
const masks = (layer?.masks || []).filter(mask => mask.mode === 'layer' && mask.visible !== false);
|
||||
if (!source || masks.length === 0) return source;
|
||||
const out = document.createElement('canvas');
|
||||
out.width = source.width;
|
||||
out.height = source.height;
|
||||
const ctx = out.getContext('2d');
|
||||
ctx.drawImage(source, 0, 0);
|
||||
ctx.globalCompositeOperation = 'destination-in';
|
||||
for (const mask of masks) {
|
||||
ctx.globalAlpha = Number.isFinite(Number(mask.density))
|
||||
? Math.max(0, Math.min(1, Number(mask.density)))
|
||||
: 1;
|
||||
const feather = Number.isFinite(Number(mask.feather))
|
||||
? Math.max(0, Math.min(200, Number(mask.feather)))
|
||||
: 0;
|
||||
ctx.filter = feather > 0 ? `blur(${feather}px)` : 'none';
|
||||
if (mask.space === 'document') {
|
||||
ctx.drawImage(mask.canvas, -(layerOffset.x || 0), -(layerOffset.y || 0));
|
||||
} else {
|
||||
const offset = mask.offset && typeof mask.offset === 'object' ? mask.offset : { x: 0, y: 0 };
|
||||
ctx.drawImage(mask.canvas, Number(offset.x) || 0, Number(offset.y) || 0);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.filter = 'none';
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/** Shared lifecycle guard for frame-style pointer interactions. */
|
||||
|
||||
export function createDirectManipulationSession({
|
||||
name = 'interaction',
|
||||
getContext = () => null,
|
||||
isContextCurrent = () => true,
|
||||
onCancel = () => {},
|
||||
} = {}) {
|
||||
let active = null;
|
||||
let nextId = 1;
|
||||
|
||||
const pointerMatches = event => {
|
||||
if (!active || active.pointerId == null || event?.pointerId == null) return true;
|
||||
return active.pointerId === event.pointerId;
|
||||
};
|
||||
|
||||
const releaseCapture = session => {
|
||||
if (!session?.captureTarget || session.pointerId == null) return;
|
||||
try { session.captureTarget.releasePointerCapture(session.pointerId); } catch {}
|
||||
};
|
||||
|
||||
const contextIsCurrent = () => active && isContextCurrent(active.context, active.data);
|
||||
|
||||
function begin(event, data = {}, options = {}) {
|
||||
if (active) cancel('restarted');
|
||||
const pointerId = Number.isFinite(event?.pointerId) ? event.pointerId : null;
|
||||
const captureTarget = options.captureTarget || null;
|
||||
active = {
|
||||
id: nextId++,
|
||||
name,
|
||||
pointerId,
|
||||
captureTarget,
|
||||
context: getContext(),
|
||||
data,
|
||||
};
|
||||
if (captureTarget && pointerId != null) {
|
||||
try { captureTarget.setPointerCapture(pointerId); } catch {}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
function update(event, callback) {
|
||||
if (!active || !pointerMatches(event)) return false;
|
||||
if (!contextIsCurrent()) {
|
||||
cancel('stale-context');
|
||||
return false;
|
||||
}
|
||||
callback?.(active.data, active);
|
||||
return true;
|
||||
}
|
||||
|
||||
function commit(event, callback) {
|
||||
if (!active || !pointerMatches(event)) return false;
|
||||
if (!contextIsCurrent()) {
|
||||
cancel('stale-context');
|
||||
return false;
|
||||
}
|
||||
const completed = active;
|
||||
active = null;
|
||||
releaseCapture(completed);
|
||||
callback?.(completed.data, completed);
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancel(reason = 'cancelled') {
|
||||
if (!active) return false;
|
||||
const cancelled = active;
|
||||
active = null;
|
||||
releaseCapture(cancelled);
|
||||
onCancel(cancelled.data, reason, cancelled);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
begin,
|
||||
update,
|
||||
commit,
|
||||
cancel,
|
||||
get active() { return active; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
/**
|
||||
* Lossless, versioned editor document serialization.
|
||||
*
|
||||
* Draft autosave and downloaded project files must use this same boundary.
|
||||
* Keeping a second hand-written layer shape is how masks and adjustments were
|
||||
* previously dropped when a document was reopened.
|
||||
*/
|
||||
import { normalizeAdjustmentData } from './adjustment-layer.js';
|
||||
import { normalizeEffect } from './effects.js';
|
||||
import { normalizeMaskDensity, normalizeMaskFeather } from './mask-utils.js';
|
||||
|
||||
export const EDITOR_DOCUMENT_VERSION = 15;
|
||||
export const EDITOR_PROJECT_MAX_BYTES = 256 * 1024 * 1024;
|
||||
export const EDITOR_MAX_DIMENSION = 32768;
|
||||
export const EDITOR_MAX_DOCUMENT_PIXELS = 100_000_000;
|
||||
export const EDITOR_MAX_SURFACE_PIXELS = 300_000_000;
|
||||
export const EDITOR_MAX_LAYERS = 256;
|
||||
export const EDITOR_MAX_MASKS_PER_LAYER = 64;
|
||||
export const EDITOR_MAX_GROUPS = 128;
|
||||
export const EDITOR_MAX_SAVED_SELECTIONS = 64;
|
||||
|
||||
const MAX_SERIALIZED_GUIDES = 1000;
|
||||
const SUPPORTED_BLEND_MODES = new Set([
|
||||
'source-over', 'multiply', 'screen', 'overlay', 'soft-light', 'hard-light',
|
||||
'darken', 'lighten', 'color-dodge', 'color-burn', 'difference', 'exclusion',
|
||||
'hue', 'saturation', 'color', 'luminosity',
|
||||
]);
|
||||
|
||||
function normalizeGuideValues(values, fallback = []) {
|
||||
const source = Array.isArray(values) ? values : fallback;
|
||||
const normalized = [];
|
||||
for (const raw of Array.isArray(source) ? source : []) {
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value < 0) continue;
|
||||
if (!normalized.some(existing => Math.abs(existing - value) < 0.0001)) normalized.push(value);
|
||||
if (normalized.length >= MAX_SERIALIZED_GUIDES) break;
|
||||
}
|
||||
return normalized.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function normalizeEditorView(view, fallback = {}) {
|
||||
const source = view && typeof view === 'object' ? view : {};
|
||||
const fallbackGuides = fallback.guides && typeof fallback.guides === 'object'
|
||||
? fallback.guides
|
||||
: { vertical: [], horizontal: [] };
|
||||
const guides = source.guides && typeof source.guides === 'object' ? source.guides : {};
|
||||
const rawGridSize = Number(source.gridSize ?? fallback.gridSize ?? 16);
|
||||
return {
|
||||
rulersVisible: Boolean(source.rulersVisible ?? fallback.rulersVisible ?? true),
|
||||
gridVisible: Boolean(source.gridVisible ?? fallback.gridVisible ?? false),
|
||||
gridSize: Math.max(2, Math.min(1000, Math.round(Number.isFinite(rawGridSize) ? rawGridSize : 16))),
|
||||
snapEnabled: Boolean(source.snapEnabled ?? fallback.snapEnabled ?? false),
|
||||
snapToGrid: Boolean(source.snapToGrid ?? fallback.snapToGrid ?? false),
|
||||
guides: {
|
||||
vertical: normalizeGuideValues(guides.vertical, fallbackGuides.vertical),
|
||||
horizontal: normalizeGuideValues(guides.horizontal, fallbackGuides.horizontal),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneDocumentValue(value, fallback) {
|
||||
try {
|
||||
return value == null ? fallback : JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export class EditorDocumentError extends Error {
|
||||
constructor(message, code = 'invalid-document') {
|
||||
super(message);
|
||||
this.name = 'EditorDocumentError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const MIGRATIONS = new Map([
|
||||
[1, document => ({
|
||||
...document,
|
||||
v: 2,
|
||||
layers: (document.layers || []).map(layer => ({
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
offset: { x: 0, y: 0 },
|
||||
...layer,
|
||||
dataUrl: layer?.dataUrl || layer?.dataURL || null,
|
||||
})),
|
||||
})],
|
||||
[2, document => ({
|
||||
...document,
|
||||
v: 3,
|
||||
layers: (document.layers || []).map(layer => ({
|
||||
adjustments: {},
|
||||
adjLayers: [],
|
||||
activeMaskId: null,
|
||||
masks: [],
|
||||
...layer,
|
||||
})),
|
||||
})],
|
||||
[3, document => ({
|
||||
...document,
|
||||
v: 4,
|
||||
layers: (document.layers || []).map(layer => ({
|
||||
kind: 'raster',
|
||||
text: null,
|
||||
...layer,
|
||||
masks: (layer?.masks || []).map(mask => ({
|
||||
mode: 'selection',
|
||||
...mask,
|
||||
space: mask?.space || (mask?.mode === 'layer' ? 'layer' : 'document'),
|
||||
})),
|
||||
})),
|
||||
})],
|
||||
[4, document => ({
|
||||
...document,
|
||||
v: 5,
|
||||
view: normalizeEditorView(document.view),
|
||||
})],
|
||||
[5, document => ({
|
||||
...document,
|
||||
v: 6,
|
||||
groups: [],
|
||||
})],
|
||||
[6, document => ({
|
||||
...document,
|
||||
v: 7,
|
||||
layers: (document.layers || []).map(layer => ({ clipped: false, ...layer })),
|
||||
})],
|
||||
[7, document => ({
|
||||
...document,
|
||||
v: 8,
|
||||
groups: (document.groups || []).map(group => ({ parentId: null, ...group })),
|
||||
})],
|
||||
[8, document => ({
|
||||
...document,
|
||||
v: 9,
|
||||
groups: (document.groups || []).map(group => ({ masks: [], activeMaskId: null, ...group })),
|
||||
})],
|
||||
[9, document => ({
|
||||
...document,
|
||||
v: 10,
|
||||
layers: (document.layers || []).map(layer => ({
|
||||
locks: { pixels: false, transparency: false, position: false },
|
||||
...layer,
|
||||
})),
|
||||
})],
|
||||
[10, document => ({
|
||||
...document,
|
||||
v: 11,
|
||||
savedSelections: [],
|
||||
})],
|
||||
[11, document => ({
|
||||
...document,
|
||||
v: 12,
|
||||
layers: (document.layers || []).map(layer => ({
|
||||
...layer,
|
||||
masks: (layer.masks || []).map(mask => mask?.mode === 'layer' ? {
|
||||
linked: true,
|
||||
offset: { x: 0, y: 0 },
|
||||
...mask,
|
||||
} : mask),
|
||||
})),
|
||||
})],
|
||||
[12, document => ({
|
||||
...document,
|
||||
v: 13,
|
||||
layers: (document.layers || []).map(layer => ({ placed: null, ...layer })),
|
||||
})],
|
||||
[13, document => ({
|
||||
...document,
|
||||
v: 14,
|
||||
layers: (document.layers || []).map(layer => ({ adjustment: null, ...layer })),
|
||||
})],
|
||||
[14, document => ({
|
||||
...document,
|
||||
v: 15,
|
||||
layers: (document.layers || []).map(layer => ({ effects: [], ...layer })),
|
||||
groups: (document.groups || []).map(group => ({ effects: [], ...group })),
|
||||
})],
|
||||
]);
|
||||
|
||||
function migrateEditorDocument(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
throw new EditorDocumentError('Project root must be a JSON object.');
|
||||
}
|
||||
const storedVersion = raw.v == null ? 1 : Number(raw.v);
|
||||
if (!Number.isInteger(storedVersion) || storedVersion < 1) {
|
||||
throw new EditorDocumentError('Project has an invalid document version.', 'invalid-version');
|
||||
}
|
||||
if (storedVersion > EDITOR_DOCUMENT_VERSION) {
|
||||
throw new EditorDocumentError(
|
||||
`This project uses version ${storedVersion}; this editor supports up to version ${EDITOR_DOCUMENT_VERSION}.`,
|
||||
'future-version',
|
||||
);
|
||||
}
|
||||
let document = { ...raw };
|
||||
while (document.v == null || document.v < EDITOR_DOCUMENT_VERSION) {
|
||||
const version = document.v == null ? 1 : document.v;
|
||||
const migrate = MIGRATIONS.get(version);
|
||||
if (!migrate) throw new EditorDocumentError(`No migration is available for project version ${version}.`, 'missing-migration');
|
||||
document = migrate(document);
|
||||
}
|
||||
return { document, storedVersion };
|
||||
}
|
||||
|
||||
function positiveDimension(value, fallback = 0) {
|
||||
const numeric = Math.round(Number(value ?? fallback));
|
||||
return Number.isFinite(numeric) && numeric > 0 && numeric <= EDITOR_MAX_DIMENSION ? numeric : null;
|
||||
}
|
||||
|
||||
function isEmbeddedPng(value) {
|
||||
return typeof value === 'string' && value.startsWith('data:image/png;base64,');
|
||||
}
|
||||
|
||||
function finiteCoordinate(value, fallback = 0) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) && Math.abs(numeric) <= 1_000_000 ? numeric : fallback;
|
||||
}
|
||||
|
||||
function normalizePlacedRecord(source) {
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) return null;
|
||||
const sourceWidth = positiveDimension(source.sourceWidth);
|
||||
const sourceHeight = positiveDimension(source.sourceHeight);
|
||||
const matrix = Array.isArray(source.matrix) && source.matrix.length === 6
|
||||
? source.matrix.map(Number)
|
||||
: null;
|
||||
if (!sourceWidth || !sourceHeight || !matrix?.every(Number.isFinite) || !isEmbeddedPng(source.sourceDataUrl)) {
|
||||
return null;
|
||||
}
|
||||
if (matrix.some(value => Math.abs(value) > 1_000_000)) return null;
|
||||
return {
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
sourceName: typeof source.sourceName === 'string'
|
||||
? source.sourceName.slice(0, 200)
|
||||
: 'Placed image',
|
||||
sourceDataUrl: source.sourceDataUrl,
|
||||
matrix,
|
||||
};
|
||||
}
|
||||
|
||||
/** Migrate and validate untrusted draft/project JSON before allocating canvases. */
|
||||
export function prepareEditorDocument(raw) {
|
||||
const { document: migrated, storedVersion } = migrateEditorDocument(raw);
|
||||
const warnings = [];
|
||||
const warn = message => {
|
||||
if (warnings.length < 50) warnings.push(message);
|
||||
};
|
||||
const width = positiveDimension(migrated.imgWidth);
|
||||
const height = positiveDimension(migrated.imgHeight);
|
||||
if (!width || !height) {
|
||||
throw new EditorDocumentError(`Project dimensions must be between 1 and ${EDITOR_MAX_DIMENSION} pixels.`, 'invalid-dimensions');
|
||||
}
|
||||
if (width * height > EDITOR_MAX_DOCUMENT_PIXELS) {
|
||||
throw new EditorDocumentError('Project canvas exceeds the 100 megapixel safety limit.', 'pixel-budget');
|
||||
}
|
||||
if (!Array.isArray(migrated.layers)) {
|
||||
throw new EditorDocumentError('Project is missing its layer list.', 'invalid-layers');
|
||||
}
|
||||
if (migrated.layers.length > EDITOR_MAX_LAYERS) {
|
||||
throw new EditorDocumentError(`Project exceeds the ${EDITOR_MAX_LAYERS}-layer limit.`, 'layer-budget');
|
||||
}
|
||||
|
||||
let surfacePixels = width * height;
|
||||
let embeddedCharacters = 0;
|
||||
let recoveredId = 1;
|
||||
const usedIds = new Set();
|
||||
const layers = [];
|
||||
for (let index = 0; index < migrated.layers.length; index += 1) {
|
||||
const source = migrated.layers[index];
|
||||
const label = typeof source?.name === 'string' && source.name.trim()
|
||||
? source.name.trim().slice(0, 200)
|
||||
: `Layer ${index + 1}`;
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
||||
warn(`${label} was skipped because its record is invalid.`);
|
||||
continue;
|
||||
}
|
||||
const canvasW = positiveDimension(source.canvasW, width);
|
||||
const canvasH = positiveDimension(source.canvasH, height);
|
||||
if (!canvasW || !canvasH) {
|
||||
warn(`${label} was skipped because its canvas dimensions are invalid.`);
|
||||
continue;
|
||||
}
|
||||
const textIsValid = source.kind === 'text' && source.text && typeof source.text === 'object' && !Array.isArray(source.text);
|
||||
const shapeIsValid = source.kind === 'shape' && source.shape && typeof source.shape === 'object' && !Array.isArray(source.shape);
|
||||
const adjustmentIsValid = source.kind === 'adjustment' && source.adjustment && typeof source.adjustment === 'object' && !Array.isArray(source.adjustment);
|
||||
const placed = source.kind === 'placed' ? normalizePlacedRecord(source.placed) : null;
|
||||
const placedIsValid = source.kind === 'placed' && !!placed;
|
||||
if (source.kind && source.kind !== 'raster' && source.kind !== 'text' && source.kind !== 'shape' && source.kind !== 'placed' && source.kind !== 'adjustment') {
|
||||
warn(`${label} used unsupported layer type "${String(source.kind).slice(0, 40)}" and was recovered as raster pixels.`);
|
||||
} else if (source.kind === 'text' && !textIsValid) {
|
||||
warn(`${label} had invalid editable text metadata and was recovered as raster pixels.`);
|
||||
} else if (source.kind === 'shape' && !shapeIsValid) {
|
||||
warn(`${label} had invalid editable shape metadata and was recovered as raster pixels.`);
|
||||
} else if (source.kind === 'adjustment' && !adjustmentIsValid) {
|
||||
warn(`${label} had invalid adjustment metadata and was recovered as raster pixels.`);
|
||||
} else if (source.kind === 'placed' && !placedIsValid) {
|
||||
warn(`${label} had a missing or corrupt placed source and was recovered from its raster preview.`);
|
||||
}
|
||||
if (!isEmbeddedPng(source.dataUrl) && !textIsValid && !shapeIsValid && !placedIsValid && !adjustmentIsValid) {
|
||||
warn(`${label} was skipped because its pixel data is missing or corrupt.`);
|
||||
continue;
|
||||
}
|
||||
if (!isEmbeddedPng(source.dataUrl) && textIsValid) {
|
||||
warn(`${label} had no valid preview pixels and will be rebuilt from its editable text data.`);
|
||||
}
|
||||
if (!isEmbeddedPng(source.dataUrl) && shapeIsValid) {
|
||||
warn(`${label} had no valid preview pixels and will be rebuilt from its editable shape data.`);
|
||||
}
|
||||
if (!isEmbeddedPng(source.dataUrl) && placedIsValid) {
|
||||
warn(`${label} had no valid preview pixels and will be rebuilt from its placed source.`);
|
||||
}
|
||||
embeddedCharacters += typeof source.dataUrl === 'string' ? source.dataUrl.length : 0;
|
||||
surfacePixels += canvasW * canvasH;
|
||||
if (placedIsValid) {
|
||||
embeddedCharacters += placed.sourceDataUrl.length;
|
||||
surfacePixels += placed.sourceWidth * placed.sourceHeight;
|
||||
}
|
||||
let id = typeof source.id === 'string' && source.id.trim() ? source.id.trim().slice(0, 100) : '';
|
||||
if (!id || usedIds.has(id)) {
|
||||
do { id = `layer-recovered-${recoveredId++}`; } while (usedIds.has(id));
|
||||
warn(`${label} received a replacement layer id.`);
|
||||
}
|
||||
usedIds.add(id);
|
||||
const masks = [];
|
||||
const maskRecords = Array.isArray(source.masks) ? source.masks : [];
|
||||
if (maskRecords.length > EDITOR_MAX_MASKS_PER_LAYER) {
|
||||
warn(`${label} has more than ${EDITOR_MAX_MASKS_PER_LAYER} masks; extras were skipped.`);
|
||||
}
|
||||
for (let maskIndex = 0; maskIndex < Math.min(maskRecords.length, EDITOR_MAX_MASKS_PER_LAYER); maskIndex += 1) {
|
||||
const mask = maskRecords[maskIndex];
|
||||
const maskLabel = typeof mask?.name === 'string' && mask.name.trim() ? mask.name.trim().slice(0, 200) : `Mask ${maskIndex + 1}`;
|
||||
const maskW = positiveDimension(mask?.canvasW, width);
|
||||
const maskH = positiveDimension(mask?.canvasH, height);
|
||||
if (!mask || typeof mask !== 'object' || !maskW || !maskH || !isEmbeddedPng(mask.dataUrl)) {
|
||||
warn(`${label} / ${maskLabel} was skipped because its mask data is invalid.`);
|
||||
continue;
|
||||
}
|
||||
embeddedCharacters += mask.dataUrl.length;
|
||||
surfacePixels += maskW * maskH;
|
||||
let maskId = typeof mask.id === 'string' && mask.id.trim() ? mask.id.trim().slice(0, 100) : '';
|
||||
if (!maskId || usedIds.has(maskId)) {
|
||||
do { maskId = `mask-recovered-${recoveredId++}`; } while (usedIds.has(maskId));
|
||||
warn(`${label} / ${maskLabel} received a replacement mask id.`);
|
||||
}
|
||||
usedIds.add(maskId);
|
||||
const mode = mask.mode === 'layer' ? 'layer' : 'selection';
|
||||
masks.push({
|
||||
...mask,
|
||||
id: maskId,
|
||||
name: maskLabel,
|
||||
visible: mask.visible !== false,
|
||||
density: normalizeMaskDensity(mask.density),
|
||||
feather: normalizeMaskFeather(mask.feather),
|
||||
mode,
|
||||
space: mask.space === 'layer' || mask.space === 'document'
|
||||
? mask.space
|
||||
: (mode === 'layer' ? 'layer' : 'document'),
|
||||
linked: mode === 'layer' ? mask.linked !== false : true,
|
||||
offset: mode === 'layer' ? {
|
||||
x: finiteCoordinate(mask.offset?.x),
|
||||
y: finiteCoordinate(mask.offset?.y),
|
||||
} : { x: 0, y: 0 },
|
||||
canvasW: maskW,
|
||||
canvasH: maskH,
|
||||
});
|
||||
}
|
||||
|
||||
if (surfacePixels > EDITOR_MAX_SURFACE_PIXELS) {
|
||||
throw new EditorDocumentError('Project layers and masks exceed the safe in-memory pixel budget.', 'surface-budget');
|
||||
}
|
||||
if (embeddedCharacters > EDITOR_PROJECT_MAX_BYTES) {
|
||||
throw new EditorDocumentError('Project embedded image data exceeds the 256 MB safety limit.', 'data-budget');
|
||||
}
|
||||
const offset = source.offset && typeof source.offset === 'object' ? source.offset : {};
|
||||
const opacity = Number(source.opacity);
|
||||
const blendMode = SUPPORTED_BLEND_MODES.has(source.blendMode) ? source.blendMode : 'source-over';
|
||||
if (source.blendMode && blendMode !== source.blendMode) {
|
||||
warn(`${label} used unsupported blend mode "${String(source.blendMode).slice(0, 40)}" and was reset to Normal.`);
|
||||
}
|
||||
layers.push({
|
||||
...source,
|
||||
id,
|
||||
name: label,
|
||||
visible: source.visible !== false,
|
||||
opacity: Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1,
|
||||
locked: !!source.locked,
|
||||
locks: {
|
||||
pixels: !!source.locks?.pixels,
|
||||
transparency: !!source.locks?.transparency,
|
||||
position: !!source.locks?.position,
|
||||
},
|
||||
clipped: !!source.clipped,
|
||||
blendMode,
|
||||
kind: textIsValid ? 'text' : (shapeIsValid ? 'shape' : (placedIsValid ? 'placed' : (adjustmentIsValid ? 'adjustment' : 'raster'))),
|
||||
text: textIsValid ? source.text : null,
|
||||
shape: shapeIsValid ? source.shape : null,
|
||||
adjustment: adjustmentIsValid ? normalizeAdjustmentData(source.adjustment) : null,
|
||||
effects: Array.isArray(source.effects) ? source.effects.map(normalizeEffect) : [],
|
||||
placed: placedIsValid ? placed : null,
|
||||
canvasW,
|
||||
canvasH,
|
||||
offset: { x: finiteCoordinate(offset.x), y: finiteCoordinate(offset.y) },
|
||||
masks,
|
||||
activeMaskId: masks.some(mask => mask.id === source.activeMaskId) ? source.activeMaskId : null,
|
||||
adjustments: source.adjustments && typeof source.adjustments === 'object' ? source.adjustments : {},
|
||||
adjLayers: Array.isArray(source.adjLayers) ? source.adjLayers : [],
|
||||
});
|
||||
}
|
||||
if (!layers.length) {
|
||||
throw new EditorDocumentError('No recoverable layers were found in this project.', 'no-layers');
|
||||
}
|
||||
|
||||
const activeLayerId = layers.some(layer => layer.id === migrated.activeLayerId)
|
||||
? migrated.activeLayerId
|
||||
: layers[layers.length - 1].id;
|
||||
const groups = [];
|
||||
const claimedLayerIds = new Set();
|
||||
const validLayerIds = new Set(layers.map(layer => layer.id));
|
||||
const groupRecords = Array.isArray(migrated.groups) ? migrated.groups : [];
|
||||
if (groupRecords.length > EDITOR_MAX_GROUPS) {
|
||||
warn(`Project has more than ${EDITOR_MAX_GROUPS} groups; extras were skipped.`);
|
||||
}
|
||||
for (let index = 0; index < Math.min(groupRecords.length, EDITOR_MAX_GROUPS); index += 1) {
|
||||
const source = groupRecords[index];
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
||||
warn(`Group ${index + 1} was skipped because its record is invalid.`);
|
||||
continue;
|
||||
}
|
||||
const layerIds = [];
|
||||
for (const id of Array.isArray(source.layerIds) ? source.layerIds : []) {
|
||||
if (validLayerIds.has(id) && !claimedLayerIds.has(id)) {
|
||||
claimedLayerIds.add(id);
|
||||
layerIds.push(id);
|
||||
}
|
||||
}
|
||||
let id = typeof source.id === 'string' && source.id.trim() ? source.id.trim().slice(0, 100) : '';
|
||||
if (!id || usedIds.has(id)) {
|
||||
do { id = `group-recovered-${recoveredId++}`; } while (usedIds.has(id));
|
||||
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} received a replacement group id.`);
|
||||
}
|
||||
usedIds.add(id);
|
||||
const groupMasks = [];
|
||||
const groupMaskRecords = Array.isArray(source.masks) ? source.masks : [];
|
||||
if (groupMaskRecords.length > EDITOR_MAX_MASKS_PER_LAYER) {
|
||||
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} has too many masks; extras were skipped.`);
|
||||
}
|
||||
for (let maskIndex = 0; maskIndex < Math.min(groupMaskRecords.length, EDITOR_MAX_MASKS_PER_LAYER); maskIndex += 1) {
|
||||
const mask = groupMaskRecords[maskIndex];
|
||||
const maskLabel = typeof mask?.name === 'string' && mask.name.trim()
|
||||
? mask.name.trim().slice(0, 200)
|
||||
: `Group Mask ${maskIndex + 1}`;
|
||||
const maskW = positiveDimension(mask?.canvasW, width);
|
||||
const maskH = positiveDimension(mask?.canvasH, height);
|
||||
if (!mask || typeof mask !== 'object' || !maskW || !maskH || !isEmbeddedPng(mask.dataUrl)) {
|
||||
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} / ${maskLabel} was skipped because its mask data is invalid.`);
|
||||
continue;
|
||||
}
|
||||
embeddedCharacters += mask.dataUrl.length;
|
||||
surfacePixels += maskW * maskH;
|
||||
let maskId = typeof mask.id === 'string' && mask.id.trim() ? mask.id.trim().slice(0, 100) : '';
|
||||
if (!maskId || usedIds.has(maskId)) {
|
||||
do { maskId = `mask-recovered-${recoveredId++}`; } while (usedIds.has(maskId));
|
||||
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} / ${maskLabel} received a replacement mask id.`);
|
||||
}
|
||||
usedIds.add(maskId);
|
||||
groupMasks.push({
|
||||
id: maskId,
|
||||
name: maskLabel,
|
||||
visible: mask.visible !== false,
|
||||
density: normalizeMaskDensity(mask.density),
|
||||
feather: normalizeMaskFeather(mask.feather),
|
||||
mode: 'group',
|
||||
space: 'document',
|
||||
canvasW: maskW,
|
||||
canvasH: maskH,
|
||||
dataUrl: mask.dataUrl,
|
||||
});
|
||||
}
|
||||
const opacity = Number(source.opacity);
|
||||
const blendMode = SUPPORTED_BLEND_MODES.has(source.blendMode) ? source.blendMode : 'source-over';
|
||||
if (source.blendMode && blendMode !== source.blendMode) {
|
||||
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} used an unsupported blend mode and was reset to Normal.`);
|
||||
}
|
||||
groups.push({
|
||||
id,
|
||||
name: String(source.name || `Group ${index + 1}`).trim().slice(0, 200) || `Group ${index + 1}`,
|
||||
layerIds,
|
||||
parentId: typeof source.parentId === 'string' && source.parentId.trim()
|
||||
? source.parentId.trim().slice(0, 100)
|
||||
: null,
|
||||
visible: source.visible !== false,
|
||||
opacity: Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1,
|
||||
blendMode,
|
||||
locked: !!source.locked,
|
||||
collapsed: !!source.collapsed,
|
||||
...(Array.isArray(source.effects) && source.effects.length
|
||||
? { effects: source.effects.map(serializeEffect) }
|
||||
: {}),
|
||||
masks: groupMasks,
|
||||
activeMaskId: groupMasks.some(mask => mask.id === source.activeMaskId) ? source.activeMaskId : null,
|
||||
});
|
||||
}
|
||||
if (surfacePixels > EDITOR_MAX_SURFACE_PIXELS) {
|
||||
throw new EditorDocumentError('Project layers and masks exceed the safe in-memory pixel budget.', 'surface-budget');
|
||||
}
|
||||
if (embeddedCharacters > EDITOR_PROJECT_MAX_BYTES) {
|
||||
throw new EditorDocumentError('Project embedded image data exceeds the 256 MB safety limit.', 'data-budget');
|
||||
}
|
||||
const savedSelections = [];
|
||||
const selectionRecords = Array.isArray(migrated.savedSelections) ? migrated.savedSelections : [];
|
||||
if (selectionRecords.length > EDITOR_MAX_SAVED_SELECTIONS) {
|
||||
warn(`Project has more than ${EDITOR_MAX_SAVED_SELECTIONS} saved selections; extras were skipped.`);
|
||||
}
|
||||
for (let index = 0; index < Math.min(selectionRecords.length, EDITOR_MAX_SAVED_SELECTIONS); index += 1) {
|
||||
const source = selectionRecords[index];
|
||||
const name = typeof source?.name === 'string' && source.name.trim()
|
||||
? source.name.trim().slice(0, 100)
|
||||
: `Selection ${index + 1}`;
|
||||
if (!source || typeof source !== 'object' || source.canvasW !== width || source.canvasH !== height || !isEmbeddedPng(source.dataUrl)) {
|
||||
warn(`${name} was skipped because its saved selection data is invalid or has the wrong dimensions.`);
|
||||
continue;
|
||||
}
|
||||
embeddedCharacters += source.dataUrl.length;
|
||||
surfacePixels += width * height;
|
||||
let id = typeof source.id === 'string' && source.id.trim() ? source.id.trim().slice(0, 100) : '';
|
||||
if (!id || usedIds.has(id)) {
|
||||
do { id = `selection-recovered-${recoveredId++}`; } while (usedIds.has(id));
|
||||
warn(`${name} received a replacement selection id.`);
|
||||
}
|
||||
usedIds.add(id);
|
||||
savedSelections.push({ id, name, canvasW: width, canvasH: height, dataUrl: source.dataUrl });
|
||||
}
|
||||
if (surfacePixels > EDITOR_MAX_SURFACE_PIXELS) {
|
||||
throw new EditorDocumentError('Project layers, masks, and saved selections exceed the safe in-memory pixel budget.', 'surface-budget');
|
||||
}
|
||||
if (embeddedCharacters > EDITOR_PROJECT_MAX_BYTES) {
|
||||
throw new EditorDocumentError('Project embedded image data exceeds the 256 MB safety limit.', 'data-budget');
|
||||
}
|
||||
const validGroupIds = new Set(groups.map(group => group.id));
|
||||
const groupsById = new Map(groups.map(group => [group.id, group]));
|
||||
for (const group of groups) {
|
||||
if (group.parentId === group.id || (group.parentId && !validGroupIds.has(group.parentId))) {
|
||||
warn(`${group.name} referenced a missing or invalid parent group and was moved to the top level.`);
|
||||
group.parentId = null;
|
||||
}
|
||||
}
|
||||
for (const group of groups) {
|
||||
const visited = new Set([group.id]);
|
||||
let cursor = group;
|
||||
while (cursor.parentId) {
|
||||
if (visited.has(cursor.parentId)) {
|
||||
warn(`${group.name} contained a cyclic group relationship and was moved to the top level.`);
|
||||
group.parentId = null;
|
||||
break;
|
||||
}
|
||||
visited.add(cursor.parentId);
|
||||
cursor = groupsById.get(cursor.parentId);
|
||||
if (!cursor) break;
|
||||
}
|
||||
}
|
||||
// A group may contain only child groups. Remove only empty leaves after the
|
||||
// complete parent graph is available, then repeat for newly empty parents.
|
||||
while (true) {
|
||||
const parentIds = new Set(groups.map(group => group.parentId).filter(Boolean));
|
||||
const emptyIndex = groups.findIndex(group => !group.layerIds.length && !parentIds.has(group.id));
|
||||
if (emptyIndex < 0) break;
|
||||
const [empty] = groups.splice(emptyIndex, 1);
|
||||
warn(`${empty.name} was skipped because it has no recoverable layers or child groups.`);
|
||||
}
|
||||
const scopeByLayer = new Map();
|
||||
for (const group of groups) {
|
||||
for (const id of group.layerIds) scopeByLayer.set(id, group.id);
|
||||
}
|
||||
for (let index = 0; index < layers.length; index += 1) {
|
||||
const layer = layers[index];
|
||||
if (!layer.clipped) continue;
|
||||
const previous = layers[index - 1];
|
||||
const scope = scopeByLayer.get(layer.id) || null;
|
||||
if (!previous || (scopeByLayer.get(previous.id) || null) !== scope) {
|
||||
layer.clipped = false;
|
||||
warn(`${layer.name} had no clipping base in its layer scope and was released.`);
|
||||
}
|
||||
}
|
||||
if (storedVersion < EDITOR_DOCUMENT_VERSION) {
|
||||
warnings.unshift(`Project upgraded from version ${storedVersion} to ${EDITOR_DOCUMENT_VERSION}.`);
|
||||
}
|
||||
return {
|
||||
document: {
|
||||
...migrated,
|
||||
v: EDITOR_DOCUMENT_VERSION,
|
||||
imgWidth: width,
|
||||
imgHeight: height,
|
||||
activeLayerId,
|
||||
view: normalizeEditorView(migrated.view),
|
||||
groups,
|
||||
savedSelections,
|
||||
layers,
|
||||
},
|
||||
warnings,
|
||||
migratedFrom: storedVersion < EDITOR_DOCUMENT_VERSION ? storedVersion : null,
|
||||
};
|
||||
}
|
||||
|
||||
function canvasDataUrl(canvas) {
|
||||
if (!canvas || typeof canvas.toDataURL !== 'function') return null;
|
||||
try {
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function serializeEffect(effect) {
|
||||
const normalized = normalizeEffect(effect);
|
||||
if (!normalized.mask) return normalized;
|
||||
return {
|
||||
...normalized,
|
||||
mask: {
|
||||
id: normalized.mask.id,
|
||||
name: normalized.mask.name,
|
||||
visible: normalized.mask.visible !== false,
|
||||
canvasW: normalized.mask.canvas?.width || normalized.mask.canvasW || 0,
|
||||
canvasH: normalized.mask.canvas?.height || normalized.mask.canvasH || 0,
|
||||
dataUrl: canvasDataUrl(normalized.mask.canvas),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeEditorDocument(state, extras = {}) {
|
||||
return {
|
||||
v: EDITOR_DOCUMENT_VERSION,
|
||||
imageId: state.imageId || null,
|
||||
imgWidth: state.imgWidth,
|
||||
imgHeight: state.imgHeight,
|
||||
activeLayerId: state.activeLayerId || null,
|
||||
nextLayerId: state.nextLayerId,
|
||||
view: normalizeEditorView(state),
|
||||
...extras,
|
||||
savedSelections: (state.savedSelections || []).slice(0, EDITOR_MAX_SAVED_SELECTIONS).map(selection => ({
|
||||
id: selection.id,
|
||||
name: String(selection.name || 'Selection').slice(0, 100),
|
||||
canvasW: selection.canvas?.width || state.imgWidth || 0,
|
||||
canvasH: selection.canvas?.height || state.imgHeight || 0,
|
||||
dataUrl: canvasDataUrl(selection.canvas),
|
||||
})),
|
||||
groups: (state.layerGroups || []).map(group => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
layerIds: [...(group.layerIds || [])],
|
||||
parentId: group.parentId || null,
|
||||
visible: group.visible !== false,
|
||||
opacity: typeof group.opacity === 'number' ? group.opacity : 1,
|
||||
blendMode: group.blendMode || 'source-over',
|
||||
locked: !!group.locked,
|
||||
collapsed: !!group.collapsed,
|
||||
activeMaskId: group.activeMaskId || null,
|
||||
...(Array.isArray(group.effects) && group.effects.length
|
||||
? { effects: group.effects.map(serializeEffect) }
|
||||
: {}),
|
||||
masks: (group.masks || []).map(mask => ({
|
||||
id: mask.id,
|
||||
name: mask.name,
|
||||
visible: mask.visible !== false,
|
||||
density: normalizeMaskDensity(mask.density),
|
||||
feather: normalizeMaskFeather(mask.feather),
|
||||
mode: 'group',
|
||||
space: 'document',
|
||||
canvasW: mask.canvas?.width || state.imgWidth || 0,
|
||||
canvasH: mask.canvas?.height || state.imgHeight || 0,
|
||||
dataUrl: canvasDataUrl(mask.canvas),
|
||||
})),
|
||||
})),
|
||||
layers: (state.layers || []).map(layer => ({
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
visible: layer.visible !== false,
|
||||
opacity: typeof layer.opacity === 'number' ? layer.opacity : 1,
|
||||
locked: !!layer.locked,
|
||||
locks: {
|
||||
pixels: !!layer.locks?.pixels,
|
||||
transparency: !!layer.locks?.transparency,
|
||||
position: !!layer.locks?.position,
|
||||
},
|
||||
clipped: !!layer.clipped,
|
||||
isBase: !!layer.isBase,
|
||||
blendMode: layer.blendMode || 'source-over',
|
||||
kind: layer.kind || 'raster',
|
||||
text: cloneDocumentValue(layer.text, null),
|
||||
shape: cloneDocumentValue(layer.shape, null),
|
||||
adjustment: cloneDocumentValue(layer.adjustment, null),
|
||||
effects: Array.isArray(layer.effects) ? layer.effects.map(serializeEffect) : [],
|
||||
placed: layer.kind === 'placed' && layer.placed?.sourceCanvas ? {
|
||||
sourceWidth: layer.placed.sourceWidth || layer.placed.sourceCanvas.width,
|
||||
sourceHeight: layer.placed.sourceHeight || layer.placed.sourceCanvas.height,
|
||||
sourceName: String(layer.placed.sourceName || 'Placed image').slice(0, 200),
|
||||
sourceDataUrl: canvasDataUrl(layer.placed.sourceCanvas),
|
||||
matrix: Array.isArray(layer.placed.matrix) ? layer.placed.matrix.map(Number) : [1, 0, 0, 1, 0, 0],
|
||||
} : null,
|
||||
canvasW: layer.canvas?.width || 0,
|
||||
canvasH: layer.canvas?.height || 0,
|
||||
offset: { ...(state.layerOffsets?.get(layer.id) || { x: 0, y: 0 }) },
|
||||
dataUrl: canvasDataUrl(layer.canvas),
|
||||
adjustments: cloneDocumentValue(layer.adjustments, {}),
|
||||
adjLayers: cloneDocumentValue(layer.adjLayers, []),
|
||||
activeMaskId: layer.activeMaskId || null,
|
||||
masks: (layer.masks || []).map(mask => ({
|
||||
id: mask.id,
|
||||
name: mask.name,
|
||||
visible: mask.visible !== false,
|
||||
density: normalizeMaskDensity(mask.density),
|
||||
feather: normalizeMaskFeather(mask.feather),
|
||||
// Existing masks are AI/selection regions. A future true layer mask
|
||||
// uses mode="layer" without changing old saved documents.
|
||||
mode: mask.mode || 'selection',
|
||||
space: mask.space || (mask.mode === 'layer' ? 'layer' : 'document'),
|
||||
linked: mask.mode === 'layer' ? mask.linked !== false : true,
|
||||
offset: mask.mode === 'layer'
|
||||
? { x: finiteCoordinate(mask.offset?.x), y: finiteCoordinate(mask.offset?.y) }
|
||||
: { x: 0, y: 0 },
|
||||
canvasW: mask.canvas?.width || state.imgWidth || 0,
|
||||
canvasH: mask.canvas?.height || state.imgHeight || 0,
|
||||
dataUrl: canvasDataUrl(mask.canvas),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function nextLayerIdFromDocument(data) {
|
||||
const stored = Number(data?.nextLayerId);
|
||||
if (Number.isInteger(stored) && stored > 0) return stored;
|
||||
let max = 0;
|
||||
for (const layer of data?.layers || []) {
|
||||
const match = String(layer?.id || '').match(/(\d+)$/);
|
||||
if (match) max = Math.max(max, Number(match[1]));
|
||||
for (const mask of layer?.masks || []) {
|
||||
const maskMatch = String(mask?.id || '').match(/(\d+)$/);
|
||||
if (maskMatch) max = Math.max(max, Number(maskMatch[1]));
|
||||
}
|
||||
}
|
||||
for (const group of data?.groups || []) {
|
||||
const match = String(group?.id || '').match(/(\d+)$/);
|
||||
if (match) max = Math.max(max, Number(match[1]));
|
||||
for (const mask of group?.masks || []) {
|
||||
const maskMatch = String(mask?.id || '').match(/(\d+)$/);
|
||||
if (maskMatch) max = Math.max(max, Number(maskMatch[1]));
|
||||
}
|
||||
}
|
||||
return max + 1;
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
/**
|
||||
* Document-wide geometry operations.
|
||||
*
|
||||
* Layer pixels live in layer coordinates, while AI/selection masks live in
|
||||
* document coordinates. Keeping those spaces explicit here prevents crop and
|
||||
* resize commands from updating the visible pixels while leaving masks and
|
||||
* selections behind.
|
||||
*/
|
||||
|
||||
import { flipTextLayer, rotateTextLayer, scaleTextLayer } from './text-layer.js';
|
||||
import { flipShapeLayer, rotateShapeLayer, scaleShapeLayer } from './shape-layer.js';
|
||||
import { renderPlacedLayer, transformPlacedData } from './placed-layer.js';
|
||||
|
||||
function makeCanvas(width, height) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(width));
|
||||
canvas.height = Math.max(1, Math.round(height));
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function renderWindow(source, x, y, width, height) {
|
||||
const out = makeCanvas(width, height);
|
||||
out.getContext('2d').drawImage(source, -x, -y);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderTranslated(source, width, height, x, y) {
|
||||
const out = makeCanvas(width, height);
|
||||
out.getContext('2d').drawImage(source, x, y);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderScaled(source, width, height, smoothingQuality = 'high') {
|
||||
const out = makeCanvas(width, height);
|
||||
const ctx = out.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = smoothingQuality;
|
||||
ctx.drawImage(source, 0, 0, out.width, out.height);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderRotated(source, degrees) {
|
||||
const normalized = ((degrees % 360) + 360) % 360;
|
||||
const swap = normalized === 90 || normalized === 270;
|
||||
const out = makeCanvas(swap ? source.height : source.width, swap ? source.width : source.height);
|
||||
const ctx = out.getContext('2d');
|
||||
ctx.translate(out.width / 2, out.height / 2);
|
||||
ctx.rotate((normalized * Math.PI) / 180);
|
||||
ctx.drawImage(source, -source.width / 2, -source.height / 2);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderFlipped(source, axis) {
|
||||
const out = makeCanvas(source.width, source.height);
|
||||
const ctx = out.getContext('2d');
|
||||
ctx.save();
|
||||
if (axis === 'h') {
|
||||
ctx.translate(out.width, 0);
|
||||
ctx.scale(-1, 1);
|
||||
} else {
|
||||
ctx.translate(0, out.height);
|
||||
ctx.scale(1, -1);
|
||||
}
|
||||
ctx.drawImage(source, 0, 0);
|
||||
ctx.restore();
|
||||
return out;
|
||||
}
|
||||
|
||||
function replaceCanvas(holder, rendered) {
|
||||
holder.canvas.width = rendered.width;
|
||||
holder.canvas.height = rendered.height;
|
||||
holder.ctx = holder.canvas.getContext('2d');
|
||||
holder.ctx.clearRect(0, 0, rendered.width, rendered.height);
|
||||
holder.ctx.drawImage(rendered, 0, 0);
|
||||
}
|
||||
|
||||
function replaceStandaloneCanvas(canvas, rendered) {
|
||||
canvas.width = rendered.width;
|
||||
canvas.height = rendered.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, rendered.width, rendered.height);
|
||||
ctx.drawImage(rendered, 0, 0);
|
||||
}
|
||||
|
||||
function invalidateLayerCaches(layer) {
|
||||
layer._adjCache = null;
|
||||
layer._adjCacheKey = null;
|
||||
layer._adjFinal = null;
|
||||
layer._adjFinalKey = null;
|
||||
layer._stagedAdj = null;
|
||||
layer._editingAdjId = null;
|
||||
}
|
||||
|
||||
function syncActiveMask(editorState) {
|
||||
const activeGroup = (editorState.layerGroups || []).find(group => group.id === editorState.activeGroupId);
|
||||
const groupMask = activeGroup?.masks?.find(item => item.id === activeGroup.activeMaskId) || null;
|
||||
if (groupMask) {
|
||||
editorState.maskCanvas = groupMask.canvas;
|
||||
editorState.maskCtx = groupMask.ctx;
|
||||
return;
|
||||
}
|
||||
const active = editorState.layers.find(layer => layer.id === editorState.activeLayerId);
|
||||
const mask = active?.masks?.find(item => item.id === active.activeMaskId) || null;
|
||||
editorState.maskCanvas = mask?.canvas || null;
|
||||
editorState.maskCtx = mask?.ctx || null;
|
||||
}
|
||||
|
||||
function syncDocumentCanvas(editorState, width, height) {
|
||||
editorState.imgWidth = width;
|
||||
editorState.imgHeight = height;
|
||||
if (editorState.mainCanvas) {
|
||||
editorState.mainCanvas.width = width;
|
||||
editorState.mainCanvas.height = height;
|
||||
editorState.mainCtx = editorState.mainCanvas.getContext('2d');
|
||||
}
|
||||
editorState.documentCompositeCanvas = null;
|
||||
editorState.compositeMaskUnion = null;
|
||||
editorState.wandSrcCache = null;
|
||||
syncActiveMask(editorState);
|
||||
}
|
||||
|
||||
function normalizedGuideValues(values, limit, mapper = value => value) {
|
||||
const output = [];
|
||||
for (const raw of Array.isArray(values) ? values : []) {
|
||||
const value = mapper(Number(raw));
|
||||
if (!Number.isFinite(value) || value < 0 || value > limit) continue;
|
||||
if (!output.some(existing => Math.abs(existing - value) < 0.0001)) output.push(value);
|
||||
}
|
||||
return output.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function transformGuides(editorState, vertical, horizontal) {
|
||||
editorState.guides = {
|
||||
vertical: normalizedGuideValues(vertical.values, vertical.limit, vertical.map),
|
||||
horizontal: normalizedGuideValues(horizontal.values, horizontal.limit, horizontal.map),
|
||||
};
|
||||
}
|
||||
|
||||
function clipPolygonAxis(points, inside, intersect) {
|
||||
if (!points.length) return [];
|
||||
const output = [];
|
||||
let previous = points[points.length - 1];
|
||||
let previousInside = inside(previous);
|
||||
for (const current of points) {
|
||||
const currentInside = inside(current);
|
||||
if (currentInside !== previousInside) output.push(intersect(previous, current));
|
||||
if (currentInside) output.push(current);
|
||||
previous = current;
|
||||
previousInside = currentInside;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Clip a polygon to a document rectangle using Sutherland-Hodgman. */
|
||||
export function clipPolygonToRect(points, width, height) {
|
||||
let out = (points || []).map(point => ({ x: point.x, y: point.y }));
|
||||
const xAt = (value) => (a, b) => {
|
||||
const dx = b.x - a.x;
|
||||
const t = dx === 0 ? 0 : (value - a.x) / dx;
|
||||
return { x: value, y: a.y + (b.y - a.y) * t };
|
||||
};
|
||||
const yAt = (value) => (a, b) => {
|
||||
const dy = b.y - a.y;
|
||||
const t = dy === 0 ? 0 : (value - a.y) / dy;
|
||||
return { x: a.x + (b.x - a.x) * t, y: value };
|
||||
};
|
||||
out = clipPolygonAxis(out, p => p.x >= 0, xAt(0));
|
||||
out = clipPolygonAxis(out, p => p.x <= width, xAt(width));
|
||||
out = clipPolygonAxis(out, p => p.y >= 0, yAt(0));
|
||||
out = clipPolygonAxis(out, p => p.y <= height, yAt(height));
|
||||
return out.length >= 3 ? out : [];
|
||||
}
|
||||
|
||||
function normalizeCropRect(editorState, rect) {
|
||||
const x = Math.max(0, Math.min(editorState.imgWidth - 1, Math.round(rect.x)));
|
||||
const y = Math.max(0, Math.min(editorState.imgHeight - 1, Math.round(rect.y)));
|
||||
const width = Math.max(1, Math.min(editorState.imgWidth - x, Math.round(rect.w)));
|
||||
const height = Math.max(1, Math.min(editorState.imgHeight - y, Math.round(rect.h)));
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
function forEachStoredSelection(editorState, callback) {
|
||||
for (const selection of editorState.savedSelections || []) {
|
||||
if (selection?.canvas) callback(selection.canvas);
|
||||
}
|
||||
if (editorState.lastSelection?.canvas) callback(editorState.lastSelection.canvas);
|
||||
}
|
||||
|
||||
/** Crop layer pixels, masks, and transient selections as one document. */
|
||||
export function cropDocument(editorState, rect) {
|
||||
if (!editorState.imgWidth || !editorState.imgHeight) return null;
|
||||
const oldWidth = editorState.imgWidth;
|
||||
const oldHeight = editorState.imgHeight;
|
||||
const { x, y, width, height } = normalizeCropRect(editorState, rect);
|
||||
const oldOffsets = new Map(editorState.layerOffsets);
|
||||
|
||||
for (const layer of editorState.layers) {
|
||||
const offset = oldOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const retainedText = layer.kind === 'text' && layer.text;
|
||||
const retainedShape = layer.kind === 'shape' && layer.shape;
|
||||
let placedOffset = null;
|
||||
if (layer.kind === 'placed' && layer.placed) {
|
||||
layer.placed = transformPlacedData(layer.placed, [1, 0, 0, 1, -x, -y]);
|
||||
placedOffset = renderPlacedLayer(layer)?.offset || null;
|
||||
} else if (!retainedText && !retainedShape) {
|
||||
replaceCanvas(layer, renderWindow(layer.canvas, x - offset.x, y - offset.y, width, height));
|
||||
}
|
||||
for (const mask of layer.masks || []) {
|
||||
const documentSpace = (mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'document';
|
||||
if ((retainedText || retainedShape || layer.kind === 'placed') && !documentSpace) continue;
|
||||
const maskOffset = mask.offset || { x: 0, y: 0 };
|
||||
const sourceX = documentSpace ? x : x - offset.x - (Number(maskOffset.x) || 0);
|
||||
const sourceY = documentSpace ? y : y - offset.y - (Number(maskOffset.y) || 0);
|
||||
replaceCanvas(mask, renderWindow(mask.canvas, sourceX, sourceY, width, height));
|
||||
mask.space = documentSpace ? 'document' : 'layer';
|
||||
if (!documentSpace) mask.offset = { x: 0, y: 0 };
|
||||
}
|
||||
editorState.layerOffsets.set(layer.id, placedOffset || ((retainedText || retainedShape)
|
||||
? { x: offset.x - x, y: offset.y - y }
|
||||
: { x: 0, y: 0 }));
|
||||
invalidateLayerCaches(layer);
|
||||
}
|
||||
for (const group of editorState.layerGroups || []) {
|
||||
for (const mask of group.masks || []) replaceCanvas(mask, renderWindow(mask.canvas, x, y, width, height));
|
||||
}
|
||||
|
||||
if (editorState.wandMask) {
|
||||
const sourceLayerOffset = oldOffsets.get(editorState.wandLayerId) || { x: 0, y: 0 };
|
||||
const documentSpace = editorState.wandMaskSpace === 'document' || (
|
||||
!editorState.wandMaskSpace && editorState.wandMask.width === oldWidth && editorState.wandMask.height === oldHeight
|
||||
);
|
||||
const sourceX = documentSpace ? x : x - sourceLayerOffset.x;
|
||||
const sourceY = documentSpace ? y : y - sourceLayerOffset.y;
|
||||
replaceStandaloneCanvas(
|
||||
editorState.wandMask,
|
||||
renderWindow(editorState.wandMask, sourceX, sourceY, width, height),
|
||||
);
|
||||
}
|
||||
forEachStoredSelection(editorState, canvas => {
|
||||
replaceStandaloneCanvas(canvas, renderWindow(canvas, x, y, width, height));
|
||||
});
|
||||
if (editorState.wandLastSeed) {
|
||||
const seed = { ...editorState.wandLastSeed, x: editorState.wandLastSeed.x - x, y: editorState.wandLastSeed.y - y };
|
||||
editorState.wandLastSeed = seed.x >= 0 && seed.y >= 0 && seed.x < width && seed.y < height ? seed : null;
|
||||
}
|
||||
editorState.lassoPoints = clipPolygonToRect(
|
||||
(editorState.lassoPoints || []).map(point => ({ x: point.x - x, y: point.y - y })),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
const guides = editorState.guides || {};
|
||||
transformGuides(
|
||||
editorState,
|
||||
{ values: guides.vertical, limit: width, map: value => value - x },
|
||||
{ values: guides.horizontal, limit: height, map: value => value - y },
|
||||
);
|
||||
editorState.cropRect = null;
|
||||
editorState.cropStart = null;
|
||||
editorState.cropEnd = null;
|
||||
syncDocumentCanvas(editorState, width, height);
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
/** Resize the actual image content, including every layer-space/document-space mask. */
|
||||
export function resizeImageDocument(editorState, width, height, options = {}) {
|
||||
const newWidth = Math.max(1, Math.round(width));
|
||||
const newHeight = Math.max(1, Math.round(height));
|
||||
const oldWidth = editorState.imgWidth;
|
||||
const oldHeight = editorState.imgHeight;
|
||||
if (!oldWidth || !oldHeight) return null;
|
||||
const scaleX = newWidth / oldWidth;
|
||||
const scaleY = newHeight / oldHeight;
|
||||
const quality = options.smoothingQuality || 'high';
|
||||
|
||||
for (const layer of editorState.layers) {
|
||||
const targetLayerWidth = Math.max(1, Math.round(layer.canvas.width * scaleX));
|
||||
const targetLayerHeight = Math.max(1, Math.round(layer.canvas.height * scaleY));
|
||||
let placedOffset = null;
|
||||
if (layer.kind === 'text' && layer.text) scaleTextLayer(layer, scaleX, scaleY);
|
||||
else if (layer.kind === 'shape' && layer.shape) scaleShapeLayer(layer, scaleX, scaleY);
|
||||
else if (layer.kind === 'placed' && layer.placed) {
|
||||
layer.placed = transformPlacedData(layer.placed, [scaleX, 0, 0, scaleY, 0, 0]);
|
||||
placedOffset = renderPlacedLayer(layer)?.offset || null;
|
||||
} else replaceCanvas(layer, renderScaled(layer.canvas, targetLayerWidth, targetLayerHeight, quality));
|
||||
const layerWidth = layer.canvas.width;
|
||||
const layerHeight = layer.canvas.height;
|
||||
for (const mask of layer.masks || []) {
|
||||
const documentSpace = (mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'document';
|
||||
const targetMaskWidth = documentSpace
|
||||
? newWidth
|
||||
: Math.max(1, Math.round(mask.canvas.width * scaleX));
|
||||
const targetMaskHeight = documentSpace
|
||||
? newHeight
|
||||
: Math.max(1, Math.round(mask.canvas.height * scaleY));
|
||||
replaceCanvas(
|
||||
mask,
|
||||
renderScaled(mask.canvas, targetMaskWidth, targetMaskHeight, quality),
|
||||
);
|
||||
mask.space = documentSpace ? 'document' : 'layer';
|
||||
if (!documentSpace) {
|
||||
mask.offset = {
|
||||
x: Math.round((Number(mask.offset?.x) || 0) * scaleX),
|
||||
y: Math.round((Number(mask.offset?.y) || 0) * scaleY),
|
||||
};
|
||||
}
|
||||
}
|
||||
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
editorState.layerOffsets.set(layer.id, placedOffset || {
|
||||
x: Math.round(offset.x * scaleX),
|
||||
y: Math.round(offset.y * scaleY),
|
||||
});
|
||||
invalidateLayerCaches(layer);
|
||||
}
|
||||
for (const group of editorState.layerGroups || []) {
|
||||
for (const mask of group.masks || []) replaceCanvas(mask, renderScaled(mask.canvas, newWidth, newHeight, quality));
|
||||
}
|
||||
|
||||
if (editorState.wandMask) {
|
||||
replaceStandaloneCanvas(
|
||||
editorState.wandMask,
|
||||
renderScaled(
|
||||
editorState.wandMask,
|
||||
editorState.wandMaskSpace === 'document' ? newWidth : Math.max(1, Math.round(editorState.wandMask.width * scaleX)),
|
||||
editorState.wandMaskSpace === 'document' ? newHeight : Math.max(1, Math.round(editorState.wandMask.height * scaleY)),
|
||||
quality,
|
||||
),
|
||||
);
|
||||
}
|
||||
forEachStoredSelection(editorState, canvas => {
|
||||
replaceStandaloneCanvas(canvas, renderScaled(canvas, newWidth, newHeight, quality));
|
||||
});
|
||||
if (editorState.wandLastSeed) {
|
||||
editorState.wandLastSeed = {
|
||||
...editorState.wandLastSeed,
|
||||
x: editorState.wandLastSeed.x * scaleX,
|
||||
y: editorState.wandLastSeed.y * scaleY,
|
||||
};
|
||||
}
|
||||
editorState.lassoPoints = (editorState.lassoPoints || []).map(point => ({
|
||||
x: point.x * scaleX,
|
||||
y: point.y * scaleY,
|
||||
}));
|
||||
const guides = editorState.guides || {};
|
||||
transformGuides(
|
||||
editorState,
|
||||
{ values: guides.vertical, limit: newWidth, map: value => value * scaleX },
|
||||
{ values: guides.horizontal, limit: newHeight, map: value => value * scaleY },
|
||||
);
|
||||
syncDocumentCanvas(editorState, newWidth, newHeight);
|
||||
return { width: newWidth, height: newHeight, scaleX, scaleY };
|
||||
}
|
||||
|
||||
/** Change document bounds without resampling layer pixels. Origin stays top-left. */
|
||||
export function resizeCanvasDocument(editorState, width, height, options = {}) {
|
||||
const newWidth = Math.max(1, Math.round(width));
|
||||
const newHeight = Math.max(1, Math.round(height));
|
||||
const anchorX = Math.max(0, Math.min(1, Number(options.anchorX) || 0));
|
||||
const anchorY = Math.max(0, Math.min(1, Number(options.anchorY) || 0));
|
||||
const shiftX = Math.round((newWidth - editorState.imgWidth) * anchorX);
|
||||
const shiftY = Math.round((newHeight - editorState.imgHeight) * anchorY);
|
||||
for (const layer of editorState.layers) {
|
||||
for (const mask of layer.masks || []) {
|
||||
const documentSpace = (mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'document';
|
||||
if (!documentSpace) continue;
|
||||
replaceCanvas(mask, renderTranslated(mask.canvas, newWidth, newHeight, shiftX, shiftY));
|
||||
mask.space = 'document';
|
||||
}
|
||||
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
editorState.layerOffsets.set(layer.id, {
|
||||
x: (Number(offset.x) || 0) + shiftX,
|
||||
y: (Number(offset.y) || 0) + shiftY,
|
||||
});
|
||||
}
|
||||
for (const group of editorState.layerGroups || []) {
|
||||
for (const mask of group.masks || []) replaceCanvas(mask, renderTranslated(mask.canvas, newWidth, newHeight, shiftX, shiftY));
|
||||
}
|
||||
if (editorState.wandMask && editorState.wandMaskSpace === 'document') {
|
||||
replaceStandaloneCanvas(editorState.wandMask, renderTranslated(editorState.wandMask, newWidth, newHeight, shiftX, shiftY));
|
||||
}
|
||||
forEachStoredSelection(editorState, canvas => {
|
||||
replaceStandaloneCanvas(canvas, renderTranslated(canvas, newWidth, newHeight, shiftX, shiftY));
|
||||
});
|
||||
editorState.lassoPoints = clipPolygonToRect(
|
||||
(editorState.lassoPoints || []).map(point => ({ x: point.x + shiftX, y: point.y + shiftY })),
|
||||
newWidth,
|
||||
newHeight,
|
||||
);
|
||||
if (editorState.wandLastSeed) {
|
||||
const seed = { ...editorState.wandLastSeed, x: editorState.wandLastSeed.x + shiftX, y: editorState.wandLastSeed.y + shiftY };
|
||||
editorState.wandLastSeed = seed.x >= 0 && seed.y >= 0 && seed.x < newWidth && seed.y < newHeight ? seed : null;
|
||||
}
|
||||
const guides = editorState.guides || {};
|
||||
transformGuides(
|
||||
editorState,
|
||||
{ values: guides.vertical, limit: newWidth, map: value => value + shiftX },
|
||||
{ values: guides.horizontal, limit: newHeight, map: value => value + shiftY },
|
||||
);
|
||||
syncDocumentCanvas(editorState, newWidth, newHeight);
|
||||
return { width: newWidth, height: newHeight };
|
||||
}
|
||||
|
||||
function rotatedPoint(point, degrees, oldWidth, oldHeight) {
|
||||
const normalized = ((degrees % 360) + 360) % 360;
|
||||
if (normalized === 90) return { x: oldHeight - point.y, y: point.x };
|
||||
if (normalized === 180) return { x: oldWidth - point.x, y: oldHeight - point.y };
|
||||
if (normalized === 270) return { x: point.y, y: oldWidth - point.x };
|
||||
return { x: point.x, y: point.y };
|
||||
}
|
||||
|
||||
function rotatedOffset(offset, width, height, degrees, oldWidth, oldHeight) {
|
||||
const normalized = ((degrees % 360) + 360) % 360;
|
||||
if (normalized === 90) return { x: oldHeight - offset.y - height, y: offset.x };
|
||||
if (normalized === 180) return { x: oldWidth - offset.x - width, y: oldHeight - offset.y - height };
|
||||
if (normalized === 270) return { x: offset.y, y: oldWidth - offset.x - width };
|
||||
return { x: offset.x, y: offset.y };
|
||||
}
|
||||
|
||||
/** Rotate every layer, mask, offset, and active selection by a right angle. */
|
||||
export function rotateDocument(editorState, degrees) {
|
||||
const normalized = ((degrees % 360) + 360) % 360;
|
||||
if (![90, 180, 270].includes(normalized)) return null;
|
||||
const oldWidth = editorState.imgWidth;
|
||||
const oldHeight = editorState.imgHeight;
|
||||
const newWidth = normalized === 180 ? oldWidth : oldHeight;
|
||||
const newHeight = normalized === 180 ? oldHeight : oldWidth;
|
||||
|
||||
for (const layer of editorState.layers) {
|
||||
const oldLayerWidth = layer.canvas.width;
|
||||
const oldLayerHeight = layer.canvas.height;
|
||||
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
let placedOffset = null;
|
||||
if (layer.kind === 'text' && layer.text) rotateTextLayer(layer, normalized);
|
||||
else if (layer.kind === 'shape' && layer.shape) rotateShapeLayer(layer, normalized);
|
||||
else if (layer.kind === 'placed' && layer.placed) {
|
||||
const matrix = normalized === 90
|
||||
? [0, 1, -1, 0, oldHeight, 0]
|
||||
: normalized === 180
|
||||
? [-1, 0, 0, -1, oldWidth, oldHeight]
|
||||
: [0, -1, 1, 0, 0, oldWidth];
|
||||
layer.placed = transformPlacedData(layer.placed, matrix);
|
||||
placedOffset = renderPlacedLayer(layer)?.offset || null;
|
||||
} else replaceCanvas(layer, renderRotated(layer.canvas, normalized));
|
||||
for (const mask of layer.masks || []) {
|
||||
const oldMaskWidth = mask.canvas.width;
|
||||
const oldMaskHeight = mask.canvas.height;
|
||||
const maskOffset = mask.offset || { x: 0, y: 0 };
|
||||
replaceCanvas(mask, renderRotated(mask.canvas, normalized));
|
||||
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
|
||||
const ox = Number(maskOffset.x) || 0;
|
||||
const oy = Number(maskOffset.y) || 0;
|
||||
if (normalized === 90) {
|
||||
mask.offset = { x: oldLayerHeight - oy - oldMaskHeight, y: ox };
|
||||
} else if (normalized === 180) {
|
||||
mask.offset = { x: oldLayerWidth - ox - oldMaskWidth, y: oldLayerHeight - oy - oldMaskHeight };
|
||||
} else {
|
||||
mask.offset = { x: oy, y: oldLayerWidth - ox - oldMaskWidth };
|
||||
}
|
||||
}
|
||||
}
|
||||
editorState.layerOffsets.set(layer.id, placedOffset ||
|
||||
rotatedOffset(offset, oldLayerWidth, oldLayerHeight, normalized, oldWidth, oldHeight));
|
||||
invalidateLayerCaches(layer);
|
||||
}
|
||||
for (const group of editorState.layerGroups || []) {
|
||||
for (const mask of group.masks || []) replaceCanvas(mask, renderRotated(mask.canvas, normalized));
|
||||
}
|
||||
|
||||
if (editorState.wandMask) replaceStandaloneCanvas(editorState.wandMask, renderRotated(editorState.wandMask, normalized));
|
||||
forEachStoredSelection(editorState, canvas => {
|
||||
replaceStandaloneCanvas(canvas, renderRotated(canvas, normalized));
|
||||
});
|
||||
if (editorState.wandLastSeed) {
|
||||
editorState.wandLastSeed = {
|
||||
...editorState.wandLastSeed,
|
||||
...rotatedPoint(editorState.wandLastSeed, normalized, oldWidth, oldHeight),
|
||||
};
|
||||
}
|
||||
editorState.lassoPoints = (editorState.lassoPoints || []).map(point =>
|
||||
rotatedPoint(point, normalized, oldWidth, oldHeight));
|
||||
const guides = editorState.guides || {};
|
||||
if (normalized === 90) {
|
||||
transformGuides(
|
||||
editorState,
|
||||
{ values: guides.horizontal, limit: newWidth, map: value => oldHeight - value },
|
||||
{ values: guides.vertical, limit: newHeight },
|
||||
);
|
||||
} else if (normalized === 180) {
|
||||
transformGuides(
|
||||
editorState,
|
||||
{ values: guides.vertical, limit: newWidth, map: value => oldWidth - value },
|
||||
{ values: guides.horizontal, limit: newHeight, map: value => oldHeight - value },
|
||||
);
|
||||
} else {
|
||||
transformGuides(
|
||||
editorState,
|
||||
{ values: guides.horizontal, limit: newWidth },
|
||||
{ values: guides.vertical, limit: newHeight, map: value => oldWidth - value },
|
||||
);
|
||||
}
|
||||
syncDocumentCanvas(editorState, newWidth, newHeight);
|
||||
return { width: newWidth, height: newHeight, degrees: normalized };
|
||||
}
|
||||
|
||||
/** Flip every layer, mask, offset, and active selection across the document. */
|
||||
export function flipDocument(editorState, axis) {
|
||||
if (axis !== 'h' && axis !== 'v') return null;
|
||||
const width = editorState.imgWidth;
|
||||
const height = editorState.imgHeight;
|
||||
for (const layer of editorState.layers) {
|
||||
const layerWidth = layer.canvas.width;
|
||||
const layerHeight = layer.canvas.height;
|
||||
let placedOffset = null;
|
||||
if (layer.kind === 'text' && layer.text) flipTextLayer(layer, axis);
|
||||
else if (layer.kind === 'shape' && layer.shape) flipShapeLayer(layer, axis);
|
||||
else if (layer.kind === 'placed' && layer.placed) {
|
||||
const matrix = axis === 'h'
|
||||
? [-1, 0, 0, 1, width, 0]
|
||||
: [1, 0, 0, -1, 0, height];
|
||||
layer.placed = transformPlacedData(layer.placed, matrix);
|
||||
placedOffset = renderPlacedLayer(layer)?.offset || null;
|
||||
} else replaceCanvas(layer, renderFlipped(layer.canvas, axis));
|
||||
for (const mask of layer.masks || []) {
|
||||
const maskWidth = mask.canvas.width;
|
||||
const maskHeight = mask.canvas.height;
|
||||
const maskOffset = mask.offset || { x: 0, y: 0 };
|
||||
replaceCanvas(mask, renderFlipped(mask.canvas, axis));
|
||||
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
|
||||
const ox = Number(maskOffset.x) || 0;
|
||||
const oy = Number(maskOffset.y) || 0;
|
||||
mask.offset = axis === 'h'
|
||||
? { x: layerWidth - ox - maskWidth, y: oy }
|
||||
: { x: ox, y: layerHeight - oy - maskHeight };
|
||||
}
|
||||
}
|
||||
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
editorState.layerOffsets.set(layer.id, placedOffset || (axis === 'h'
|
||||
? { x: width - offset.x - layerWidth, y: offset.y }
|
||||
: { x: offset.x, y: height - offset.y - layerHeight }));
|
||||
invalidateLayerCaches(layer);
|
||||
}
|
||||
for (const group of editorState.layerGroups || []) {
|
||||
for (const mask of group.masks || []) replaceCanvas(mask, renderFlipped(mask.canvas, axis));
|
||||
}
|
||||
if (editorState.wandMask) replaceStandaloneCanvas(editorState.wandMask, renderFlipped(editorState.wandMask, axis));
|
||||
forEachStoredSelection(editorState, canvas => {
|
||||
replaceStandaloneCanvas(canvas, renderFlipped(canvas, axis));
|
||||
});
|
||||
if (editorState.wandLastSeed) {
|
||||
editorState.wandLastSeed = {
|
||||
...editorState.wandLastSeed,
|
||||
x: axis === 'h' ? width - editorState.wandLastSeed.x : editorState.wandLastSeed.x,
|
||||
y: axis === 'v' ? height - editorState.wandLastSeed.y : editorState.wandLastSeed.y,
|
||||
};
|
||||
}
|
||||
editorState.lassoPoints = (editorState.lassoPoints || []).map(point => ({
|
||||
x: axis === 'h' ? width - point.x : point.x,
|
||||
y: axis === 'v' ? height - point.y : point.y,
|
||||
}));
|
||||
const guides = editorState.guides || {};
|
||||
transformGuides(
|
||||
editorState,
|
||||
{
|
||||
values: guides.vertical,
|
||||
limit: width,
|
||||
map: axis === 'h' ? value => width - value : value => value,
|
||||
},
|
||||
{
|
||||
values: guides.horizontal,
|
||||
limit: height,
|
||||
map: axis === 'v' ? value => height - value : value => value,
|
||||
},
|
||||
);
|
||||
syncDocumentCanvas(editorState, width, height);
|
||||
return { width, height, axis };
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/* Worker implementation for retained-effect rasterization. */
|
||||
|
||||
function copyCanvas(source) {
|
||||
const out = new OffscreenCanvas(source.width, source.height);
|
||||
out.getContext('2d').drawImage(source, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
function sharpenCanvas(source, amount) {
|
||||
if (!amount) return copyCanvas(source);
|
||||
const out = new OffscreenCanvas(source.width, source.height);
|
||||
const src = source.getContext('2d').getImageData(0, 0, source.width, source.height);
|
||||
const dst = out.getContext('2d').createImageData(source.width, source.height);
|
||||
const a = Math.max(0, Math.min(1, Number(amount) || 0));
|
||||
const stride = source.width * 4;
|
||||
for (let y = 0; y < source.height; y += 1) {
|
||||
for (let x = 0; x < source.width; x += 1) {
|
||||
const at = (y * source.width + x) * 4;
|
||||
for (let channel = 0; channel < 3; channel += 1) {
|
||||
const center = src.data[at + channel] * (1 + 4 * a);
|
||||
const left = src.data[y * stride + Math.max(0, x - 1) * 4 + channel];
|
||||
const right = src.data[y * stride + Math.min(source.width - 1, x + 1) * 4 + channel];
|
||||
const above = src.data[Math.max(0, y - 1) * stride + x * 4 + channel];
|
||||
const below = src.data[Math.min(source.height - 1, y + 1) * stride + x * 4 + channel];
|
||||
dst.data[at + channel] = Math.max(0, Math.min(255, center - a * (left + right + above + below)));
|
||||
}
|
||||
dst.data[at + 3] = src.data[at + 3];
|
||||
}
|
||||
}
|
||||
out.getContext('2d').putImageData(dst, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
function maskedContribution(effectCanvas, mask) {
|
||||
if (!mask) return effectCanvas;
|
||||
const out = new OffscreenCanvas(effectCanvas.width, effectCanvas.height);
|
||||
const ctx = out.getContext('2d');
|
||||
ctx.drawImage(effectCanvas, 0, 0);
|
||||
ctx.globalCompositeOperation = 'destination-in';
|
||||
ctx.drawImage(mask, 0, 0, out.width, out.height);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
return out;
|
||||
}
|
||||
|
||||
function gradientColor(value, alpha = 1) {
|
||||
const raw = String(value || '').replace(/^#/, '');
|
||||
if (!/^[0-9a-f]{6}$/i.test(raw)) return `rgba(0,0,0,${alpha})`;
|
||||
const channels = [0, 2, 4].map(offset => parseInt(raw.slice(offset, offset + 2), 16));
|
||||
return `rgba(${channels.join(',')},${alpha})`;
|
||||
}
|
||||
|
||||
function renderEffects(source, effects, masks) {
|
||||
let current = copyCanvas(source);
|
||||
for (let index = 0; index < effects.length; index += 1) {
|
||||
const effect = effects[index];
|
||||
if (effect.visible === false || effect.opacity <= 0) continue;
|
||||
const next = new OffscreenCanvas(current.width, current.height);
|
||||
const ctx = next.getContext('2d');
|
||||
if (effect.type === 'gaussian-blur') {
|
||||
ctx.filter = `blur(${effect.params.radius}px)`;
|
||||
ctx.drawImage(current, 0, 0);
|
||||
ctx.filter = 'none';
|
||||
} else if (effect.type === 'sharpen') {
|
||||
ctx.drawImage(sharpenCanvas(current, effect.params.amount), 0, 0);
|
||||
} else if (effect.type === 'color-overlay') {
|
||||
ctx.drawImage(current, 0, 0);
|
||||
ctx.globalAlpha = effect.params.opacity;
|
||||
ctx.globalCompositeOperation = effect.params.blendMode;
|
||||
ctx.fillStyle = effect.params.color;
|
||||
ctx.fillRect(0, 0, next.width, next.height);
|
||||
} else if (effect.type === 'drop-shadow') {
|
||||
ctx.globalAlpha = effect.params.opacity;
|
||||
ctx.shadowColor = effect.params.color;
|
||||
ctx.shadowBlur = effect.params.blur;
|
||||
ctx.shadowOffsetX = effect.params.x;
|
||||
ctx.shadowOffsetY = effect.params.y;
|
||||
ctx.drawImage(current, 0, 0);
|
||||
} else if (effect.type === 'stroke') {
|
||||
ctx.globalAlpha = effect.params.opacity;
|
||||
ctx.shadowColor = effect.params.color;
|
||||
ctx.shadowBlur = effect.params.width;
|
||||
ctx.drawImage(current, 0, 0);
|
||||
} else if (effect.type === 'linear-gradient' || effect.type === 'radial-gradient') {
|
||||
const p = effect.params;
|
||||
const gradient = effect.type === 'radial-gradient'
|
||||
? ctx.createRadialGradient(p.x1, p.y1, 0, p.x1, p.y1, Math.max(0.5, Math.hypot(p.x2 - p.x1, p.y2 - p.y1)))
|
||||
: ctx.createLinearGradient(p.x1, p.y1, p.x2, p.y2);
|
||||
for (const stop of p.stops || []) gradient.addColorStop(stop.position / 100, gradientColor(stop.color, stop.alpha));
|
||||
ctx.globalAlpha = p.opacity;
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, next.width, next.height);
|
||||
ctx.shadowColor = 'transparent';
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.drawImage(current, 0, 0);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
const contribution = maskedContribution(next, masks[index]);
|
||||
const blended = copyCanvas(current);
|
||||
const blendCtx = blended.getContext('2d');
|
||||
blendCtx.globalAlpha = effect.opacity;
|
||||
blendCtx.drawImage(contribution, 0, 0);
|
||||
blendCtx.globalAlpha = 1;
|
||||
current = blended;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
self.onmessage = event => {
|
||||
try {
|
||||
const { source, effects, masks } = event.data;
|
||||
const output = renderEffects(source, effects || [], masks || []);
|
||||
const bitmap = output.transferToImageBitmap();
|
||||
self.postMessage({ bitmap }, [bitmap]);
|
||||
} catch (error) {
|
||||
self.postMessage({ error: String(error?.message || error) });
|
||||
}
|
||||
};
|
||||
Vendored
+323
@@ -0,0 +1,323 @@
|
||||
/** Retained, non-destructive layer effects. */
|
||||
|
||||
const EFFECT_TYPES = new Set(['gaussian-blur', 'sharpen', 'color-overlay', 'drop-shadow', 'stroke', 'linear-gradient', 'radial-gradient']);
|
||||
|
||||
export const EFFECT_PRESETS = {
|
||||
'soft-blur': { type: 'gaussian-blur', params: { radius: 4 } },
|
||||
'crisp-detail': { type: 'sharpen', params: { amount: 0.35 } },
|
||||
'soft-shadow': { type: 'drop-shadow', params: { color: '#000000', opacity: 0.3, blur: 8, x: 2, y: 3 } },
|
||||
'white-outline': { type: 'stroke', params: { color: '#ffffff', opacity: 0.85, width: 2 } },
|
||||
};
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function normalizeEffectMask(mask) {
|
||||
if (!mask || typeof mask !== 'object' || Array.isArray(mask)) return null;
|
||||
return {
|
||||
id: String(mask.id || `effect-mask-${Math.random().toString(36).slice(2, 9)}`),
|
||||
name: String(mask.name || 'Effect Mask'),
|
||||
visible: mask.visible !== false,
|
||||
canvas: mask.canvas || null,
|
||||
ctx: mask.ctx || null,
|
||||
canvasW: Number(mask.canvasW) || mask.canvas?.width || 0,
|
||||
canvasH: Number(mask.canvasH) || mask.canvas?.height || 0,
|
||||
dataUrl: typeof mask.dataUrl === 'string' ? mask.dataUrl : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultEffectParams(type) {
|
||||
if (type === 'gaussian-blur') return { radius: 6 };
|
||||
if (type === 'sharpen') return { amount: 0.5 };
|
||||
if (type === 'color-overlay') return { color: '#ffffff', opacity: 0.2, blendMode: 'source-atop' };
|
||||
if (type === 'drop-shadow') return { color: '#000000', opacity: 0.45, blur: 12, x: 4, y: 6 };
|
||||
if (type === 'stroke') return { color: '#ffffff', opacity: 1, width: 3 };
|
||||
if (type === 'linear-gradient' || type === 'radial-gradient') return {
|
||||
x1: 0, y1: 0, x2: 1, y2: 0,
|
||||
stops: [{ position: 0, color: '#e06c75', alpha: 1 }, { position: 100, color: '#ffffff', alpha: 1 }],
|
||||
opacity: 1,
|
||||
};
|
||||
return {};
|
||||
}
|
||||
|
||||
export function effectPreset(name) {
|
||||
const preset = EFFECT_PRESETS[name];
|
||||
return preset ? { type: preset.type, params: clone(preset.params) } : null;
|
||||
}
|
||||
|
||||
export function normalizeEffect(effect) {
|
||||
const type = EFFECT_TYPES.has(effect?.type) ? effect.type : 'gaussian-blur';
|
||||
const defaults = defaultEffectParams(type);
|
||||
const params = effect?.params && typeof effect.params === 'object'
|
||||
? { ...defaults, ...clone(effect.params) }
|
||||
: defaults;
|
||||
if (type === 'gaussian-blur') params.radius = Math.max(0, Math.min(200, Number(params.radius) || 0));
|
||||
if (type === 'sharpen') params.amount = Math.max(0, Math.min(1, Number(params.amount) || 0));
|
||||
if (type === 'color-overlay') {
|
||||
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
|
||||
params.opacity = Math.max(0, Math.min(1, Number(params.opacity) || 0));
|
||||
params.blendMode = typeof params.blendMode === 'string' ? params.blendMode : defaults.blendMode;
|
||||
}
|
||||
if (type === 'drop-shadow') {
|
||||
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
|
||||
for (const key of ['opacity', 'blur', 'x', 'y']) params[key] = Number(params[key]) || 0;
|
||||
params.opacity = Math.max(0, Math.min(1, params.opacity));
|
||||
params.blur = Math.max(0, Math.min(200, params.blur));
|
||||
params.x = Math.max(-200, Math.min(200, params.x));
|
||||
params.y = Math.max(-200, Math.min(200, params.y));
|
||||
}
|
||||
if (type === 'stroke') {
|
||||
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
|
||||
params.opacity = Math.max(0, Math.min(1, Number(params.opacity) || 0));
|
||||
params.width = Math.max(0, Math.min(100, Number(params.width) || 0));
|
||||
}
|
||||
if (type === 'linear-gradient' || type === 'radial-gradient') {
|
||||
for (const key of ['x1', 'y1', 'x2', 'y2']) {
|
||||
const value = Number(params[key]);
|
||||
params[key] = Number.isFinite(value) ? Math.max(-2_000_000, Math.min(2_000_000, value)) : defaults[key];
|
||||
}
|
||||
params.opacity = Math.max(0, Math.min(1, Number(params.opacity) || 0));
|
||||
const stops = Array.isArray(params.stops) ? params.stops : defaults.stops;
|
||||
params.stops = stops.map(stop => ({
|
||||
position: Math.max(0, Math.min(100, Number(stop?.position) || 0)),
|
||||
color: /^#[0-9a-f]{6}$/i.test(stop?.color) ? stop.color : '#000000',
|
||||
alpha: Math.max(0, Math.min(1, Number(stop?.alpha ?? 1) || 0)),
|
||||
})).sort((a, b) => a.position - b.position).slice(0, 12);
|
||||
if (params.stops.length < 2) params.stops = clone(defaults.stops);
|
||||
}
|
||||
return {
|
||||
id: String(effect?.id || `effect-${Math.random().toString(36).slice(2, 9)}`),
|
||||
type,
|
||||
name: String(effect?.name || effectLabel(type)),
|
||||
visible: effect?.visible !== false,
|
||||
opacity: Math.max(0, Math.min(1, Number(effect?.opacity ?? 1) || 0)),
|
||||
mask: normalizeEffectMask(effect?.mask),
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
export function effectLabel(type) {
|
||||
return {
|
||||
'gaussian-blur': 'Gaussian Blur',
|
||||
sharpen: 'Sharpen',
|
||||
'color-overlay': 'Color Overlay',
|
||||
'drop-shadow': 'Drop Shadow',
|
||||
'stroke': 'Stroke',
|
||||
'linear-gradient': 'Gradient',
|
||||
'radial-gradient': 'Radial Gradient',
|
||||
}[type] || type;
|
||||
}
|
||||
|
||||
function copyCanvas(source) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = source.width;
|
||||
canvas.height = source.height;
|
||||
canvas.getContext('2d').drawImage(source, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function gradientColor(value, alpha = 1) {
|
||||
const raw = String(value || '').replace(/^#/, '');
|
||||
if (!/^[0-9a-f]{6}$/i.test(raw)) return `rgba(0,0,0,${alpha})`;
|
||||
const channels = [0, 2, 4].map(offset => parseInt(raw.slice(offset, offset + 2), 16));
|
||||
return `rgba(${channels.join(',')},${alpha})`;
|
||||
}
|
||||
|
||||
function renderGradient(ctx, effect, width, height) {
|
||||
const p = effect.params;
|
||||
const gradient = effect.type === 'radial-gradient'
|
||||
? ctx.createRadialGradient(p.x1, p.y1, 0, p.x1, p.y1, Math.max(0.5, Math.hypot(p.x2 - p.x1, p.y2 - p.y1)))
|
||||
: ctx.createLinearGradient(p.x1, p.y1, p.x2, p.y2);
|
||||
for (const stop of p.stops) gradient.addColorStop(stop.position / 100, gradientColor(stop.color, stop.alpha));
|
||||
ctx.globalAlpha = p.opacity;
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function applyEffectMask(source, effected, mask) {
|
||||
if (!mask?.canvas || !source || !effected) return effected;
|
||||
const masked = document.createElement('canvas');
|
||||
masked.width = effected.width;
|
||||
masked.height = effected.height;
|
||||
const mctx = masked.getContext('2d');
|
||||
mctx.drawImage(effected, 0, 0);
|
||||
mctx.globalCompositeOperation = 'destination-in';
|
||||
mctx.drawImage(mask.canvas, 0, 0, masked.width, masked.height);
|
||||
mctx.globalCompositeOperation = 'source-over';
|
||||
return masked;
|
||||
}
|
||||
|
||||
function sharpenCanvas(source, amount, shouldContinue = () => true) {
|
||||
if (!amount) return copyCanvas(source);
|
||||
const out = document.createElement('canvas');
|
||||
out.width = source.width;
|
||||
out.height = source.height;
|
||||
const src = source.getContext('2d').getImageData(0, 0, source.width, source.height);
|
||||
const dst = out.getContext('2d').createImageData(source.width, source.height);
|
||||
const a = Math.max(0, Math.min(1, Number(amount) || 0));
|
||||
const stride = source.width * 4;
|
||||
for (let y = 0; y < source.height; y += 1) {
|
||||
if ((y & 7) === 0 && !shouldContinue()) return null;
|
||||
for (let x = 0; x < source.width; x += 1) {
|
||||
const at = (y * source.width + x) * 4;
|
||||
for (let channel = 0; channel < 3; channel += 1) {
|
||||
const center = src.data[at + channel] * (1 + 4 * a);
|
||||
const left = src.data[y * stride + Math.max(0, x - 1) * 4 + channel];
|
||||
const right = src.data[y * stride + Math.min(source.width - 1, x + 1) * 4 + channel];
|
||||
const above = src.data[Math.max(0, y - 1) * stride + x * 4 + channel];
|
||||
const below = src.data[Math.min(source.height - 1, y + 1) * stride + x * 4 + channel];
|
||||
dst.data[at + channel] = Math.max(0, Math.min(255, center - a * (left + right + above + below)));
|
||||
}
|
||||
dst.data[at + 3] = src.data[at + 3];
|
||||
}
|
||||
}
|
||||
out.getContext('2d').putImageData(dst, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Render effects in list order without mutating the source layer. */
|
||||
export function renderEffects(source, effects = [], shouldContinue = () => true) {
|
||||
if (!source || !Array.isArray(effects) || effects.length === 0) return source;
|
||||
let current = copyCanvas(source);
|
||||
for (const raw of effects) {
|
||||
if (!shouldContinue()) return current;
|
||||
const effect = normalizeEffect(raw);
|
||||
if (!effect.visible || effect.opacity <= 0) continue;
|
||||
const next = document.createElement('canvas');
|
||||
next.width = current.width;
|
||||
next.height = current.height;
|
||||
const ctx = next.getContext('2d');
|
||||
if (effect.type === 'gaussian-blur') {
|
||||
ctx.filter = `blur(${effect.params.radius}px)`;
|
||||
ctx.drawImage(current, 0, 0);
|
||||
ctx.filter = 'none';
|
||||
} else if (effect.type === 'sharpen') {
|
||||
const sharpened = sharpenCanvas(current, effect.params.amount, shouldContinue);
|
||||
if (!sharpened) return current;
|
||||
ctx.drawImage(sharpened, 0, 0);
|
||||
} else if (effect.type === 'color-overlay') {
|
||||
ctx.drawImage(current, 0, 0);
|
||||
ctx.globalAlpha = effect.params.opacity;
|
||||
ctx.globalCompositeOperation = effect.params.blendMode;
|
||||
ctx.fillStyle = effect.params.color;
|
||||
ctx.fillRect(0, 0, next.width, next.height);
|
||||
ctx.globalAlpha = 1;
|
||||
} else if (effect.type === 'drop-shadow') {
|
||||
ctx.globalAlpha = effect.params.opacity;
|
||||
ctx.shadowColor = effect.params.color;
|
||||
ctx.shadowBlur = effect.params.blur;
|
||||
ctx.shadowOffsetX = effect.params.x;
|
||||
ctx.shadowOffsetY = effect.params.y;
|
||||
ctx.drawImage(current, 0, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
} else if (effect.type === 'stroke') {
|
||||
ctx.globalAlpha = effect.params.opacity;
|
||||
ctx.shadowColor = effect.params.color;
|
||||
ctx.shadowBlur = effect.params.width;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
ctx.drawImage(current, 0, 0);
|
||||
ctx.shadowColor = 'transparent';
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.drawImage(current, 0, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
} else if (effect.type === 'linear-gradient' || effect.type === 'radial-gradient') {
|
||||
renderGradient(ctx, effect, next.width, next.height);
|
||||
}
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
const contribution = effect.mask?.canvas && effect.mask.visible !== false
|
||||
? applyEffectMask(current, next, effect.mask)
|
||||
: next;
|
||||
const blended = copyCanvas(current);
|
||||
const blendCtx = blended.getContext('2d');
|
||||
blendCtx.globalAlpha = effect.opacity;
|
||||
blendCtx.drawImage(contribution, 0, 0);
|
||||
blendCtx.globalAlpha = 1;
|
||||
current = blended;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rasterize retained effects off the main thread when browser canvas workers
|
||||
* are available. The synchronous renderer remains the compatibility fallback.
|
||||
*/
|
||||
export async function renderEffectsAsync(source, effects = [], shouldContinue = () => true) {
|
||||
if (!source || !Array.isArray(effects) || effects.length === 0) return source;
|
||||
if (typeof Worker === 'undefined' || typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap !== 'function') {
|
||||
return renderEffects(source, effects, shouldContinue);
|
||||
}
|
||||
if (!shouldContinue()) return source;
|
||||
let sourceBitmap;
|
||||
const maskBitmaps = [];
|
||||
try {
|
||||
sourceBitmap = await createImageBitmap(source);
|
||||
for (const effect of effects) {
|
||||
if (!shouldContinue()) {
|
||||
sourceBitmap.close?.();
|
||||
for (const bitmap of maskBitmaps) bitmap?.close?.();
|
||||
return null;
|
||||
}
|
||||
maskBitmaps.push(effect?.mask?.canvas && effect.mask.visible !== false
|
||||
? await createImageBitmap(effect.mask.canvas)
|
||||
: null);
|
||||
}
|
||||
} catch {
|
||||
sourceBitmap?.close?.();
|
||||
for (const bitmap of maskBitmaps) bitmap?.close?.();
|
||||
return shouldContinue() ? renderEffects(source, effects, shouldContinue) : null;
|
||||
}
|
||||
if (!shouldContinue()) {
|
||||
sourceBitmap.close?.();
|
||||
for (const bitmap of maskBitmaps) bitmap?.close?.();
|
||||
return source;
|
||||
}
|
||||
let worker;
|
||||
try {
|
||||
worker = new Worker(new URL('./effects-worker.js', import.meta.url), { type: 'module' });
|
||||
} catch {
|
||||
sourceBitmap.close?.();
|
||||
for (const bitmap of maskBitmaps) bitmap?.close?.();
|
||||
return shouldContinue() ? renderEffects(source, effects, shouldContinue) : null;
|
||||
}
|
||||
const payloadEffects = effects.map(raw => {
|
||||
const effect = normalizeEffect(raw);
|
||||
return { ...effect, mask: null };
|
||||
});
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = result => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
worker.terminate();
|
||||
sourceBitmap.close?.();
|
||||
for (const bitmap of maskBitmaps) bitmap?.close?.();
|
||||
resolve(result);
|
||||
};
|
||||
worker.onmessage = event => {
|
||||
const { bitmap, error } = event.data || {};
|
||||
if (error || !bitmap || !shouldContinue()) {
|
||||
bitmap?.close?.();
|
||||
finish(shouldContinue() ? renderEffects(source, effects, shouldContinue) : source);
|
||||
return;
|
||||
}
|
||||
const output = document.createElement('canvas');
|
||||
output.width = source.width;
|
||||
output.height = source.height;
|
||||
output.getContext('2d').drawImage(bitmap, 0, 0);
|
||||
bitmap.close?.();
|
||||
finish(output);
|
||||
};
|
||||
// A stale worker must not trigger a full-resolution fallback while a newer
|
||||
// preview is already queued.
|
||||
worker.onerror = () => finish(shouldContinue() ? renderEffects(source, effects, shouldContinue) : null);
|
||||
worker.postMessage({ source: sourceBitmap, effects: payloadEffects, masks: maskBitmaps }, [
|
||||
sourceBitmap,
|
||||
...maskBitmaps.filter(Boolean),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
export const EFFECT_TYPES_LIST = [...EFFECT_TYPES];
|
||||
@@ -0,0 +1,221 @@
|
||||
/** Controlled image export dialog and canvas encoding helpers. */
|
||||
|
||||
const FORMAT_META = {
|
||||
png: { mime: 'image/png', extension: 'png', quality: undefined },
|
||||
jpeg: { mime: 'image/jpeg', extension: 'jpg', quality: 0.9 },
|
||||
webp: { mime: 'image/webp', extension: 'webp', quality: 0.9 },
|
||||
};
|
||||
|
||||
function clampNumber(value, min, max, fallback) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
|
||||
}
|
||||
|
||||
export function normalizeExportSettings(settings, sourceWidth, sourceHeight) {
|
||||
const format = FORMAT_META[settings?.format] ? settings.format : 'png';
|
||||
return {
|
||||
format,
|
||||
width: Math.round(clampNumber(settings?.width, 1, 16384, sourceWidth)),
|
||||
height: Math.round(clampNumber(settings?.height, 1, 16384, sourceHeight)),
|
||||
quality: clampNumber(settings?.quality, 0.01, 1, FORMAT_META[format].quality ?? 1),
|
||||
transparency: format !== 'jpeg' && settings?.transparency !== false,
|
||||
matte: /^#[0-9a-f]{6}$/i.test(settings?.matte || '') ? settings.matte : '#ffffff',
|
||||
filename: String(settings?.filename || 'edited-image').trim() || 'edited-image',
|
||||
};
|
||||
}
|
||||
|
||||
export function prepareExportCanvas(sourceCanvas, settings) {
|
||||
const normalized = normalizeExportSettings(settings, sourceCanvas.width, sourceCanvas.height);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = normalized.width;
|
||||
canvas.height = normalized.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
if (!normalized.transparency) {
|
||||
ctx.fillStyle = normalized.matte;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
ctx.drawImage(sourceCanvas, 0, 0, canvas.width, canvas.height);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function encodeExportCanvas(sourceCanvas, settings) {
|
||||
const normalized = normalizeExportSettings(settings, sourceCanvas.width, sourceCanvas.height);
|
||||
const output = prepareExportCanvas(sourceCanvas, normalized);
|
||||
const meta = FORMAT_META[normalized.format];
|
||||
return new Promise((resolve, reject) => {
|
||||
output.toBlob(
|
||||
blob => blob ? resolve({ blob, settings: normalized, extension: meta.extension }) : reject(new Error('Image encoding failed')),
|
||||
meta.mime,
|
||||
meta.quality == null ? undefined : normalized.quality,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return 'Estimating...';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function openExportDialog({
|
||||
sourceCanvas,
|
||||
defaultName = 'edited-image',
|
||||
title = 'Export Image',
|
||||
submitLabel = 'Export',
|
||||
attachColorPicker = null,
|
||||
returnFocus = null,
|
||||
}) {
|
||||
return new Promise(resolve => {
|
||||
const previouslyFocused = returnFocus || document.activeElement;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'ge-export-overlay';
|
||||
overlay.innerHTML = `
|
||||
<form class="ge-export-dialog" role="dialog" aria-modal="true" aria-labelledby="ge-export-title">
|
||||
<header class="ge-export-head">
|
||||
<h2 id="ge-export-title">${String(title).replace(/[<>&]/g, '')}</h2>
|
||||
<button type="button" class="ge-export-close" aria-label="Close">×</button>
|
||||
</header>
|
||||
<div class="ge-export-body">
|
||||
<div class="ge-export-preview-wrap"><canvas class="ge-export-preview"></canvas></div>
|
||||
<div class="ge-export-fields">
|
||||
<label class="ge-export-field"><span>Filename</span><input id="ge-export-filename" type="text" value="${String(defaultName).replace(/[<>&"']/g, '')}" /></label>
|
||||
<div class="ge-export-field"><span>Format</span><div class="ge-export-format" role="group" aria-label="Export format">
|
||||
<button type="button" class="active" data-format="png">PNG</button>
|
||||
<button type="button" data-format="jpeg">JPEG</button>
|
||||
<button type="button" data-format="webp">WebP</button>
|
||||
</div></div>
|
||||
<div class="ge-export-dimensions">
|
||||
<label class="ge-export-field"><span>Width</span><input id="ge-export-width" type="number" min="1" max="16384" value="${sourceCanvas.width}" /></label>
|
||||
<button type="button" class="ge-export-link active" aria-label="Lock aspect ratio" title="Lock aspect ratio" aria-pressed="true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
</button>
|
||||
<label class="ge-export-field"><span>Height</span><input id="ge-export-height" type="number" min="1" max="16384" value="${sourceCanvas.height}" /></label>
|
||||
</div>
|
||||
<label class="ge-export-field ge-export-quality"><span>Quality <output>90%</output></span><input id="ge-export-quality" type="range" min="1" max="100" value="90" /></label>
|
||||
<div class="ge-export-surface-row">
|
||||
<label class="ge-export-toggle"><input id="ge-export-transparency" type="checkbox" checked /><span>Transparency</span></label>
|
||||
<label class="ge-export-matte"><span>Matte</span><input id="ge-export-matte" class="ge-color-picker" type="color" value="#ffffff" /></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="ge-export-footer"><span class="ge-export-estimate">Estimating...</span><div><button type="button" class="ge-btn ge-btn-sm ge-export-cancel">Cancel</button><button type="submit" class="ge-btn ge-btn-primary">${String(submitLabel).replace(/[<>&]/g, '')}</button></div></footer>
|
||||
</form>`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const dialog = overlay.querySelector('.ge-export-dialog');
|
||||
const preview = overlay.querySelector('.ge-export-preview');
|
||||
const filename = overlay.querySelector('#ge-export-filename');
|
||||
const widthInput = overlay.querySelector('#ge-export-width');
|
||||
const heightInput = overlay.querySelector('#ge-export-height');
|
||||
const qualityInput = overlay.querySelector('#ge-export-quality');
|
||||
const qualityField = overlay.querySelector('.ge-export-quality');
|
||||
const qualityOutput = qualityField.querySelector('output');
|
||||
const transparency = overlay.querySelector('#ge-export-transparency');
|
||||
const matte = overlay.querySelector('#ge-export-matte');
|
||||
const matteField = overlay.querySelector('.ge-export-matte');
|
||||
const estimate = overlay.querySelector('.ge-export-estimate');
|
||||
const link = overlay.querySelector('.ge-export-link');
|
||||
const ratio = sourceCanvas.width / sourceCanvas.height;
|
||||
let format = 'png';
|
||||
let ratioLocked = true;
|
||||
let estimateTimer = null;
|
||||
let estimateVersion = 0;
|
||||
|
||||
try { attachColorPicker?.(matte); } catch {}
|
||||
|
||||
const settings = () => normalizeExportSettings({
|
||||
format,
|
||||
width: widthInput.value,
|
||||
height: heightInput.value,
|
||||
quality: Number(qualityInput.value) / 100,
|
||||
transparency: transparency.checked,
|
||||
matte: matte.value,
|
||||
filename: filename.value,
|
||||
}, sourceCanvas.width, sourceCanvas.height);
|
||||
|
||||
const renderPreview = () => {
|
||||
const current = settings();
|
||||
const max = 420;
|
||||
const scale = Math.min(max / current.width, max / current.height, 1);
|
||||
preview.width = Math.max(1, Math.round(current.width * scale));
|
||||
preview.height = Math.max(1, Math.round(current.height * scale));
|
||||
const ctx = preview.getContext('2d');
|
||||
// Settings can change without changing dimensions, so do not let the
|
||||
// previous matte or alpha state bleed into the next preview.
|
||||
ctx.clearRect(0, 0, preview.width, preview.height);
|
||||
if (!current.transparency) {
|
||||
ctx.fillStyle = current.matte;
|
||||
ctx.fillRect(0, 0, preview.width, preview.height);
|
||||
}
|
||||
ctx.drawImage(sourceCanvas, 0, 0, preview.width, preview.height);
|
||||
};
|
||||
|
||||
const update = () => {
|
||||
const lossy = format !== 'png';
|
||||
qualityField.hidden = !lossy;
|
||||
transparency.disabled = format === 'jpeg';
|
||||
if (format === 'jpeg') transparency.checked = false;
|
||||
matteField.classList.toggle('disabled', transparency.checked && format !== 'jpeg');
|
||||
qualityOutput.textContent = `${qualityInput.value}%`;
|
||||
renderPreview();
|
||||
estimate.textContent = 'Estimating...';
|
||||
clearTimeout(estimateTimer);
|
||||
const version = ++estimateVersion;
|
||||
estimateTimer = setTimeout(async () => {
|
||||
try {
|
||||
const encoded = await encodeExportCanvas(sourceCanvas, settings());
|
||||
if (version === estimateVersion) estimate.textContent = formatBytes(encoded.blob.size);
|
||||
} catch {
|
||||
if (version === estimateVersion) estimate.textContent = 'Estimate unavailable';
|
||||
}
|
||||
}, 180);
|
||||
};
|
||||
|
||||
overlay.querySelectorAll('[data-format]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
format = button.dataset.format;
|
||||
overlay.querySelectorAll('[data-format]').forEach(candidate => {
|
||||
candidate.classList.toggle('active', candidate.dataset.format === format);
|
||||
});
|
||||
update();
|
||||
});
|
||||
});
|
||||
link.addEventListener('click', () => {
|
||||
ratioLocked = !ratioLocked;
|
||||
link.classList.toggle('active', ratioLocked);
|
||||
link.setAttribute('aria-pressed', ratioLocked ? 'true' : 'false');
|
||||
});
|
||||
widthInput.addEventListener('input', () => {
|
||||
if (ratioLocked) heightInput.value = String(Math.max(1, Math.round(Number(widthInput.value || 1) / ratio)));
|
||||
update();
|
||||
});
|
||||
heightInput.addEventListener('input', () => {
|
||||
if (ratioLocked) widthInput.value = String(Math.max(1, Math.round(Number(heightInput.value || 1) * ratio)));
|
||||
update();
|
||||
});
|
||||
[qualityInput, transparency, matte].forEach(input => input.addEventListener('input', update));
|
||||
|
||||
const close = result => {
|
||||
clearTimeout(estimateTimer);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
overlay.remove();
|
||||
if (previouslyFocused && previouslyFocused.isConnected && typeof previouslyFocused.focus === 'function') {
|
||||
previouslyFocused.focus({ preventScroll: true });
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
const onKey = event => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); close(null); }
|
||||
};
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
overlay.querySelector('.ge-export-close').addEventListener('click', () => close(null));
|
||||
overlay.querySelector('.ge-export-cancel').addEventListener('click', () => close(null));
|
||||
overlay.addEventListener('click', event => { if (event.target === overlay) close(null); });
|
||||
dialog.addEventListener('submit', event => { event.preventDefault(); close(settings()); });
|
||||
update();
|
||||
filename.select();
|
||||
});
|
||||
}
|
||||
@@ -43,11 +43,13 @@ import modalManager from '../../modalManager.js';
|
||||
import {
|
||||
ADJ_ICONS,
|
||||
adjLayerLabel,
|
||||
adjustmentPresetOptions,
|
||||
adjustmentPresetParams,
|
||||
defaultAdjParams,
|
||||
} from '../layer-helpers.js';
|
||||
import { drawHistogram } from './histogram.js';
|
||||
|
||||
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel }) {
|
||||
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null }) {
|
||||
function suppressLayerGhostTap() {
|
||||
window.__geSuppressLayerTapUntil = Date.now() + 650;
|
||||
}
|
||||
@@ -104,6 +106,10 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
}
|
||||
|
||||
function openFxPopup(layer, anchorEl) {
|
||||
if (layer?.kind === 'adjustment') {
|
||||
openAdjPopup(layer, layer.adjustment?.type || 'levels', anchorEl, layer.adjustment);
|
||||
return;
|
||||
}
|
||||
// Toggle off ONLY if a menu for this layer is genuinely on-screen.
|
||||
// `state` is a shared singleton that survives editor close/reopen,
|
||||
// so a stale `fxMenuEl` from a previous session (whose detached
|
||||
@@ -135,9 +141,17 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
menu.style.pointerEvents = 'auto';
|
||||
const items = [
|
||||
{ type: 'brightness-contrast', label: 'Brightness / Contrast' },
|
||||
{ type: 'exposure', label: 'Exposure' },
|
||||
{ type: 'white-balance', label: 'White Balance' },
|
||||
{ type: 'hue-saturation', label: 'Hue / Saturation' },
|
||||
{ type: 'vibrance', label: 'Vibrance' },
|
||||
{ type: 'black-white', label: 'Black & White' },
|
||||
{ type: 'shadows-highlights', label: 'Shadows / Highlights' },
|
||||
{ type: 'levels', label: 'Levels' },
|
||||
{ type: 'curves', label: 'Curves' },
|
||||
{ type: 'color-balance', label: 'Color Balance' },
|
||||
{ type: 'selective-color', label: 'Selective Color' },
|
||||
{ type: 'gradient-map', label: 'Gradient Map' },
|
||||
];
|
||||
menu.innerHTML = items.map(i =>
|
||||
`<button class="ge-fx-menu-item" data-fx-type="${i.type}"><span class="ge-fx-menu-icon">${ADJ_ICONS[i.type] || ''}</span><span>${i.label}</span></button>`
|
||||
@@ -257,7 +271,21 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
suppressLayerGhostTap();
|
||||
const layer = state.adjPopupEl._layer;
|
||||
if (layer) {
|
||||
if (state.adjPreviewLayer === layer) state.adjPreviewLayer = null;
|
||||
layer._adjPreview = false;
|
||||
layer._adjCompare = false;
|
||||
if (layer._stagedAdj) layer._stagedAdj = null;
|
||||
if (layer._newAdjustmentLayer) {
|
||||
const index = state.layers.findIndex(item => item.id === layer.id);
|
||||
if (index >= 0) state.layers.splice(index, 1);
|
||||
state.layerOffsets.delete(layer.id);
|
||||
if (Number.isInteger(layer._newAdjustmentUndoLength) && state.undoStack.length > layer._newAdjustmentUndoLength) {
|
||||
state.undoStack.length = layer._newAdjustmentUndoLength;
|
||||
}
|
||||
state.activeLayerId = state.layers[state.layers.length - 1]?.id || null;
|
||||
layer._newAdjustmentLayer = false;
|
||||
renderLayerPanel();
|
||||
}
|
||||
if (layer._editingAdjId) layer._editingAdjId = null;
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
@@ -277,11 +305,15 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
closeAdjPopup();
|
||||
// Editing an existing sub-layer? Pre-load its params as the staged
|
||||
// preview and mark the popup so Apply updates instead of appending.
|
||||
const firstClass = layer?.kind === 'adjustment';
|
||||
const editing = !!existingAdj;
|
||||
const presetOptions = adjustmentPresetOptions(type);
|
||||
const startParams = editing
|
||||
? JSON.parse(JSON.stringify(existingAdj.params))
|
||||
: defaultAdjParams(type);
|
||||
layer._stagedAdj = { type, params: startParams };
|
||||
layer._adjPreview = true;
|
||||
state.adjPreviewLayer = layer;
|
||||
if (editing) {
|
||||
// Hide the existing sub-layer from the render stack so the
|
||||
// staged preview shows correctly without doubling the effect.
|
||||
@@ -303,8 +335,17 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
<button class="ge-adj-min" type="button" title="Minimise">−</button>
|
||||
</span>
|
||||
</div>
|
||||
<label class="ge-adj-preset-field">Preset
|
||||
<select class="ge-adj-preset-select">
|
||||
<option value="custom"${editing ? ' selected' : ''}>Custom</option>
|
||||
${presetOptions.map(name => `<option value="${name}"${!editing && name === 'Default' ? ' selected' : ''}>${name}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
<div class="ge-adj-body" data-adj-body></div>
|
||||
<div class="ge-adj-foot">
|
||||
<button class="ge-btn ge-btn-sm ge-adj-reset-btn" data-adj-action="reset">Reset</button>
|
||||
<span class="ge-adj-foot-spacer"></span>
|
||||
<button class="ge-btn ge-btn-sm ge-adj-compare-btn" data-adj-action="compare" aria-pressed="false">Compare</button>
|
||||
<button class="ge-btn ge-btn-sm ge-adj-cancel-btn" data-adj-action="cancel">Cancel</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-primary ge-adj-apply-btn" data-adj-action="ok">Apply</button>
|
||||
</div>
|
||||
@@ -330,6 +371,15 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
|
||||
const body = pop.querySelector('[data-adj-body]');
|
||||
buildAdjBody(layer, type, body, pop);
|
||||
body.addEventListener('input', () => {
|
||||
const presetSelect = pop.querySelector('.ge-adj-preset-select');
|
||||
if (presetSelect) presetSelect.value = 'custom';
|
||||
});
|
||||
const popupRect = pop.getBoundingClientRect();
|
||||
if (!window.matchMedia('(max-width: 820px)').matches) {
|
||||
const clampedTop = Math.max(8, Math.min(Number.parseFloat(pop.style.top) || 8, window.innerHeight - popupRect.height - 8));
|
||||
pop.style.top = `${clampedTop}px`;
|
||||
}
|
||||
|
||||
pop.querySelector('.ge-adj-close')?.addEventListener('click', closeAdjPopup);
|
||||
pop.querySelector('.ge-adj-min')?.addEventListener('click', () => minimiseAdjPopup(pop));
|
||||
@@ -391,14 +441,52 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
e.stopPropagation();
|
||||
closeAdjPopup();
|
||||
});
|
||||
pop.querySelector('[data-adj-action="reset"]')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
layer._stagedAdj.params = defaultAdjParams(type);
|
||||
const presetSelect = pop.querySelector('.ge-adj-preset-select');
|
||||
if (presetSelect) presetSelect.value = 'Default';
|
||||
body.innerHTML = '';
|
||||
buildAdjBody(layer, type, body, pop);
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
pop.querySelector('.ge-adj-preset-select')?.addEventListener('change', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const preset = e.currentTarget.value;
|
||||
layer._stagedAdj.params = adjustmentPresetParams(type, preset);
|
||||
layer._adjFinalKey = null;
|
||||
body.innerHTML = '';
|
||||
buildAdjBody(layer, type, body, pop);
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
pop.querySelector('[data-adj-action="compare"]')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
layer._adjCompare = !layer._adjCompare;
|
||||
e.currentTarget.setAttribute('aria-pressed', layer._adjCompare ? 'true' : 'false');
|
||||
e.currentTarget.classList.toggle('is-active', layer._adjCompare);
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
});
|
||||
pop.querySelector('[data-adj-action="ok"]')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
suppressLayerGhostTap();
|
||||
saveState(editing ? `Edit ${adjLayerLabel(type)}` : `Add ${adjLayerLabel(type)}`);
|
||||
if (!layer._newAdjustmentLayer) {
|
||||
saveState(editing ? `Edit ${adjLayerLabel(type)}` : `Add ${adjLayerLabel(type)}`);
|
||||
}
|
||||
const params = layer._stagedAdj.params;
|
||||
layer._stagedAdj = null;
|
||||
if (editing) {
|
||||
layer._adjPreview = false;
|
||||
layer._adjCompare = false;
|
||||
if (firstClass) {
|
||||
layer.adjustment = { type, params };
|
||||
layer.name = layer.name || adjLayerLabel(type);
|
||||
layer._newAdjustmentLayer = false;
|
||||
layer._newAdjustmentUndoLength = null;
|
||||
} else if (editing) {
|
||||
const existing = (layer.adjLayers || []).find(a => a.id === existingAdj.id);
|
||||
if (existing) existing.params = params;
|
||||
layer._editingAdjId = null;
|
||||
@@ -426,6 +514,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
state.adjRafPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
state.adjRafPending = false;
|
||||
if (state.adjPreviewLayer !== layer || !layer._stagedAdj) return;
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
});
|
||||
@@ -433,6 +522,13 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
|
||||
function buildAdjBody(layer, type, body, popEl) {
|
||||
const p = layer._stagedAdj.params;
|
||||
const levelValues = () => {
|
||||
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
|
||||
if (channel === 'rgb') return p;
|
||||
if (!p.channels) p.channels = {};
|
||||
if (!p.channels[channel]) p.channels[channel] = defaultAdjParams('levels').channels[channel];
|
||||
return p.channels[channel];
|
||||
};
|
||||
const revertIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
|
||||
const sliderRow = (key, label, min, max, value, suffix) => `
|
||||
<div class="ge-adj-row" data-adj-key="${key}">
|
||||
@@ -449,14 +545,46 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
${sliderRow('brightness', 'Brightness', -100, 100, bSlider, '')}
|
||||
${sliderRow('contrast', 'Contrast', -100, 100, cSlider, '')}
|
||||
`;
|
||||
} else if (type === 'exposure') {
|
||||
body.innerHTML = `
|
||||
${sliderRow('exposure', 'Exposure', -500, 500, Math.round(p.exposure * 100), ' EV')}
|
||||
${sliderRow('offset', 'Offset', -50, 50, Math.round(p.offset * 100), '')}
|
||||
${sliderRow('gamma', 'Gamma', 10, 300, Math.round(p.gamma * 100), 'γ')}
|
||||
`;
|
||||
} else if (type === 'white-balance') {
|
||||
body.innerHTML = `
|
||||
${sliderRow('temperature', 'Temperature', -100, 100, Math.round(p.temperature), '')}
|
||||
${sliderRow('tint', 'Tint', -100, 100, Math.round(p.tint), '')}
|
||||
`;
|
||||
} else if (type === 'hue-saturation') {
|
||||
const hSlider = Math.round(p.hue);
|
||||
const sSlider = Math.round((p.saturation - 1) * 100);
|
||||
body.innerHTML = `
|
||||
${sliderRow('hue', 'Hue', -180, 180, hSlider, ' °')}
|
||||
${sliderRow('saturation', 'Saturation', -100, 100, sSlider, '')}
|
||||
${sliderRow('lightness', 'Lightness', -100, 100, Math.round(p.lightness || 0), '')}
|
||||
`;
|
||||
} else if (type === 'vibrance') {
|
||||
body.innerHTML = `
|
||||
${sliderRow('vibrance', 'Vibrance', -100, 100, Math.round(p.vibrance || 0), '')}
|
||||
<p class="ge-adj-help">Boosts muted colors more than already-saturated colors.</p>
|
||||
`;
|
||||
} else if (type === 'black-white') {
|
||||
body.innerHTML = `
|
||||
${sliderRow('red', 'Red', 0, 100, Math.round(p.red ?? 30), '%')}
|
||||
${sliderRow('green', 'Green', 0, 100, Math.round(p.green ?? 59), '%')}
|
||||
${sliderRow('blue', 'Blue', 0, 100, Math.round(p.blue ?? 11), '%')}
|
||||
${sliderRow('constant', 'Constant', -100, 100, Math.round(p.constant || 0), '')}
|
||||
<p class="ge-adj-help">Mixes the source channels into a neutral grayscale result.</p>
|
||||
`;
|
||||
} else if (type === 'shadows-highlights') {
|
||||
body.innerHTML = `
|
||||
${sliderRow('shadows', 'Shadows', -100, 100, Math.round(p.shadows || 0), '')}
|
||||
${sliderRow('highlights', 'Highlights', -100, 100, Math.round(p.highlights || 0), '')}
|
||||
<p class="ge-adj-help">Recover detail in dark and bright tones without shifting midtones.</p>
|
||||
`;
|
||||
} else if (type === 'levels') {
|
||||
const values = levelValues();
|
||||
// Histogram canvas + sliders. Histogram is computed from the
|
||||
// layer's pixel data (after any adjLayers below this one) so
|
||||
// the user is matching levels against what they're really seeing.
|
||||
@@ -464,6 +592,14 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
// vertical space; open by default on desktop.
|
||||
const isMobile = window.matchMedia('(max-width: 820px)').matches;
|
||||
body.innerHTML = `
|
||||
<label class="ge-adj-channel-field">Channel
|
||||
<select class="ge-adj-channel-select">
|
||||
<option value="rgb"${(p.channel || 'rgb') === 'rgb' ? ' selected' : ''}>RGB</option>
|
||||
<option value="red"${p.channel === 'red' ? ' selected' : ''}>Red</option>
|
||||
<option value="green"${p.channel === 'green' ? ' selected' : ''}>Green</option>
|
||||
<option value="blue"${p.channel === 'blue' ? ' selected' : ''}>Blue</option>
|
||||
</select>
|
||||
</label>
|
||||
<details class="ge-adj-hist-details"${isMobile ? '' : ' open'}>
|
||||
<summary>Histogram</summary>
|
||||
<div class="ge-adj-hist-wrap">
|
||||
@@ -474,20 +610,60 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
<div class="ge-adj-hist-handle hist-h-white" data-handle="inWhite" title="Input white — drag"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-adj-clip-status" aria-live="polite"></div>
|
||||
</details>
|
||||
${sliderRow('inBlack', 'Input black', 0, 254, p.inBlack, '')}
|
||||
${sliderRow('inWhite', 'Input white', 1, 255, p.inWhite, '')}
|
||||
${sliderRow('gamma', 'Gamma', 10, 990, Math.round((p.gamma || 1) * 100), 'γ')}
|
||||
${sliderRow('outBlack', 'Output black', 0, 255, p.outBlack, '')}
|
||||
${sliderRow('outWhite', 'Output white', 0, 255, p.outWhite, '')}
|
||||
${sliderRow('inBlack', 'Input black', 0, 254, values.inBlack, '')}
|
||||
${sliderRow('inWhite', 'Input white', 1, 255, values.inWhite, '')}
|
||||
${sliderRow('gamma', 'Gamma', 10, 990, Math.round((values.gamma || 1) * 100), 'γ')}
|
||||
${sliderRow('outBlack', 'Output black', 0, 255, values.outBlack, '')}
|
||||
${sliderRow('outWhite', 'Output white', 0, 255, values.outWhite, '')}
|
||||
`;
|
||||
const hist = body.querySelector('.ge-adj-histogram');
|
||||
drawHistogram(hist, layer);
|
||||
const histogramLayer = layer.kind === 'adjustment' && getAdjustmentSource
|
||||
? { canvas: getAdjustmentSource(layer), _stagedAdj: { params: values } }
|
||||
: layer;
|
||||
const clipStatus = body.querySelector('.ge-adj-clip-status');
|
||||
const renderHistogram = () => {
|
||||
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
|
||||
const stats = drawHistogram(hist, histogramLayer, channel);
|
||||
if (clipStatus) {
|
||||
const channelLabel = channel === 'rgb' ? 'RGB' : channel[0].toUpperCase() + channel.slice(1);
|
||||
clipStatus.textContent = `${channelLabel} clipping: black ${stats.black.toFixed(1)}% · white ${stats.white.toFixed(1)}%`;
|
||||
}
|
||||
};
|
||||
renderHistogram();
|
||||
wireHistogramHandles(body, layer, type);
|
||||
// Redraw histogram when the user opens the disclosure (canvas
|
||||
// dimensions are layout-dependent).
|
||||
body.querySelector('.ge-adj-hist-details')?.addEventListener('toggle', (e) => {
|
||||
if (e.target.open) drawHistogram(hist, layer);
|
||||
if (e.target.open) renderHistogram();
|
||||
});
|
||||
body.querySelector('.ge-adj-channel-select')?.addEventListener('change', event => {
|
||||
p.channel = event.target.value;
|
||||
buildAdjBody(layer, type, body, popEl);
|
||||
});
|
||||
} else if (type === 'curves') {
|
||||
if (!p.points) p.points = defaultAdjParams('curves').points;
|
||||
if (!p.channel) p.channel = 'rgb';
|
||||
body.innerHTML = `
|
||||
<label class="ge-adj-channel-field">Channel
|
||||
<select class="ge-adj-channel-select">
|
||||
<option value="rgb"${p.channel === 'rgb' ? ' selected' : ''}>RGB</option>
|
||||
<option value="red"${p.channel === 'red' ? ' selected' : ''}>Red</option>
|
||||
<option value="green"${p.channel === 'green' ? ' selected' : ''}>Green</option>
|
||||
<option value="blue"${p.channel === 'blue' ? ' selected' : ''}>Blue</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="ge-curves-wrap">
|
||||
<canvas class="ge-curves-canvas" width="280" height="180" aria-label="Editable tone curve"></canvas>
|
||||
</div>
|
||||
<p class="ge-adj-help">Click the curve to add a point. Drag to adjust. Double-click a point to remove it.</p>
|
||||
`;
|
||||
const canvas = body.querySelector('.ge-curves-canvas');
|
||||
wireCurveEditor(canvas, layer);
|
||||
body.querySelector('.ge-adj-channel-select')?.addEventListener('change', event => {
|
||||
p.channel = event.target.value;
|
||||
buildAdjBody(layer, type, body, popEl);
|
||||
});
|
||||
} else if (type === 'color-balance') {
|
||||
// Color-tinted slider ends so the user sees what direction does what.
|
||||
@@ -527,6 +703,49 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
body.innerHTML = '';
|
||||
buildAdjBody(layer, type, body, popEl);
|
||||
});
|
||||
} else if (type === 'selective-color') {
|
||||
if (!p.range) p.range = 'reds';
|
||||
if (!p.ranges?.[p.range]) p.ranges = defaultAdjParams(type).ranges;
|
||||
const values = p.ranges[p.range];
|
||||
const ranges = ['reds', 'yellows', 'greens', 'cyans', 'blues', 'magentas', 'neutrals', 'blacks'];
|
||||
body.innerHTML = `
|
||||
<label class="ge-adj-channel-field">Colors
|
||||
<select class="ge-adj-selective-range">
|
||||
${ranges.map(name => `<option value="${name}"${p.range === name ? ' selected' : ''}>${name[0].toUpperCase() + name.slice(1)}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
${sliderRow('cyan', 'Cyan', -100, 100, values.cyan, '')}
|
||||
${sliderRow('magenta', 'Magenta', -100, 100, values.magenta, '')}
|
||||
${sliderRow('yellow', 'Yellow', -100, 100, values.yellow, '')}
|
||||
${sliderRow('black', 'Black', -100, 100, values.black, '')}
|
||||
`;
|
||||
body.querySelector('.ge-adj-selective-range')?.addEventListener('change', event => {
|
||||
p.range = event.target.value;
|
||||
buildAdjBody(layer, type, body, popEl);
|
||||
});
|
||||
} else if (type === 'gradient-map') {
|
||||
body.innerHTML = `
|
||||
<div class="ge-gradient-map-preview" style="--ge-gradient-start:${p.shadows};--ge-gradient-end:${p.highlights}"></div>
|
||||
<div class="ge-gradient-color-row">
|
||||
<label>Shadows <input type="color" data-gradient-key="shadows" value="${p.shadows}"></label>
|
||||
<label>Highlights <input type="color" data-gradient-key="highlights" value="${p.highlights}"></label>
|
||||
</div>
|
||||
${sliderRow('midpoint', 'Midpoint', 1, 99, Math.round(p.midpoint), '%')}
|
||||
<label class="ge-adj-toggle-row"><span>Reverse</span><input type="checkbox" data-gradient-reverse${p.reverse ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
|
||||
`;
|
||||
body.querySelectorAll('[data-gradient-key]').forEach(input => {
|
||||
input.addEventListener('input', () => {
|
||||
p[input.dataset.gradientKey] = input.value;
|
||||
const preview = body.querySelector('.ge-gradient-map-preview');
|
||||
preview?.style.setProperty('--ge-gradient-start', p.shadows);
|
||||
preview?.style.setProperty('--ge-gradient-end', p.highlights);
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
});
|
||||
body.querySelector('[data-gradient-reverse]')?.addEventListener('change', event => {
|
||||
p.reverse = event.target.checked;
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
}
|
||||
// Wire all sliders.
|
||||
body.querySelectorAll('input[type="range"]').forEach(sl => {
|
||||
@@ -550,13 +769,18 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
function revertAdjKey(layer, type, key) {
|
||||
const defaults = defaultAdjParams(type);
|
||||
const p = layer._stagedAdj.params;
|
||||
if (type === 'brightness-contrast' || type === 'hue-saturation') {
|
||||
if (['brightness-contrast', 'exposure', 'white-balance', 'hue-saturation', 'vibrance', 'black-white', 'shadows-highlights', 'gradient-map'].includes(type)) {
|
||||
p[key] = defaults[key];
|
||||
} else if (type === 'levels') {
|
||||
p[key] = defaults[key];
|
||||
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
|
||||
const values = channel === 'rgb' ? p : p.channels[channel];
|
||||
const defaultValues = channel === 'rgb' ? defaults : defaults.channels[channel];
|
||||
values[key] = defaultValues[key];
|
||||
} else if (type === 'color-balance') {
|
||||
const [tone, ch] = key.split('-');
|
||||
p[tone][ch] = defaults[tone][ch];
|
||||
} else if (type === 'selective-color') {
|
||||
p.ranges[p.range][key] = defaults.ranges[p.range][key];
|
||||
}
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
@@ -573,21 +797,142 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
p[key] = 1 + raw / 100;
|
||||
} else if (key === 'hue') {
|
||||
p.hue = raw; display = raw + ' °';
|
||||
} else if (key === 'lightness') {
|
||||
p.lightness = raw;
|
||||
}
|
||||
} else if (type === 'exposure') {
|
||||
if (key === 'exposure') { p.exposure = raw / 100; display = (raw / 100).toFixed(2) + ' EV'; }
|
||||
else if (key === 'offset') { p.offset = raw / 100; display = (raw / 100).toFixed(2); }
|
||||
else if (key === 'gamma') { p.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ'; }
|
||||
} else if (type === 'white-balance') {
|
||||
p[key] = raw;
|
||||
} else if (type === 'vibrance') {
|
||||
p[key] = raw;
|
||||
} else if (type === 'black-white') {
|
||||
p[key] = raw;
|
||||
} else if (type === 'shadows-highlights') {
|
||||
p[key] = raw;
|
||||
} else if (type === 'levels') {
|
||||
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
|
||||
const values = channel === 'rgb' ? p : p.channels[channel];
|
||||
if (key === 'gamma') {
|
||||
p.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ';
|
||||
values.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ';
|
||||
} else {
|
||||
p[key] = raw;
|
||||
values[key] = raw;
|
||||
}
|
||||
} else if (type === 'color-balance') {
|
||||
const [tone, ch] = key.split('-');
|
||||
p[tone][ch] = raw;
|
||||
} else if (type === 'selective-color') {
|
||||
p.ranges[p.range][key] = raw;
|
||||
} else if (type === 'gradient-map') {
|
||||
p.midpoint = raw;
|
||||
display = raw + '%';
|
||||
}
|
||||
if (valEl) valEl.textContent = display;
|
||||
scheduleAdjRefresh(layer);
|
||||
}
|
||||
|
||||
function wireCurveEditor(canvas, layer) {
|
||||
if (!canvas) return;
|
||||
const params = layer._stagedAdj.params;
|
||||
const channelColor = { rgb: '#f1f5f9', red: '#ff6b78', green: '#57d68d', blue: '#69a7ff' };
|
||||
const points = () => params.points[params.channel] || params.points.rgb;
|
||||
const canvasPoint = event => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.max(0, Math.min(255, ((event.clientX - rect.left) / rect.width) * 255)),
|
||||
y: Math.max(0, Math.min(255, (1 - (event.clientY - rect.top) / rect.height) * 255)),
|
||||
};
|
||||
};
|
||||
const draw = () => {
|
||||
const source = layer.kind === 'adjustment' && getAdjustmentSource
|
||||
? getAdjustmentSource(layer)
|
||||
: layer.canvas;
|
||||
drawHistogram(canvas, { canvas: source });
|
||||
const ctx = canvas.getContext('2d');
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
ctx.strokeStyle = 'rgba(255,255,255,.12)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let index = 1; index < 4; index += 1) {
|
||||
const x = Math.round(width * index / 4) + .5;
|
||||
const y = Math.round(height * index / 4) + .5;
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke();
|
||||
}
|
||||
const list = points();
|
||||
ctx.strokeStyle = channelColor[params.channel] || channelColor.rgb;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
list.forEach((point, index) => {
|
||||
const x = point[0] / 255 * width;
|
||||
const y = height - point[1] / 255 * height;
|
||||
if (index) ctx.lineTo(x, y); else ctx.moveTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
for (const point of list) {
|
||||
const x = point[0] / 255 * width;
|
||||
const y = height - point[1] / 255 * height;
|
||||
ctx.fillStyle = '#0b0d10';
|
||||
ctx.strokeStyle = channelColor[params.channel] || channelColor.rgb;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
}
|
||||
};
|
||||
const nearestIndex = event => {
|
||||
const point = canvasPoint(event);
|
||||
let nearest = -1;
|
||||
let distance = 14;
|
||||
points().forEach((candidate, index) => {
|
||||
const next = Math.hypot(candidate[0] - point.x, candidate[1] - point.y);
|
||||
if (next < distance) { distance = next; nearest = index; }
|
||||
});
|
||||
return nearest;
|
||||
};
|
||||
canvas.addEventListener('pointerdown', event => {
|
||||
event.preventDefault();
|
||||
const list = points();
|
||||
let index = nearestIndex(event);
|
||||
if (index < 0) {
|
||||
const point = canvasPoint(event);
|
||||
list.push([Math.round(point.x), Math.round(point.y)]);
|
||||
list.sort((a, b) => a[0] - b[0]);
|
||||
index = list.findIndex(item => item[0] === Math.round(point.x) && item[1] === Math.round(point.y));
|
||||
}
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
const move = moveEvent => {
|
||||
const next = canvasPoint(moveEvent);
|
||||
const current = list[index];
|
||||
const endpoint = index === 0 || index === list.length - 1;
|
||||
current[0] = endpoint
|
||||
? (index === 0 ? 0 : 255)
|
||||
: Math.round(Math.max(list[index - 1][0] + 1, Math.min(list[index + 1][0] - 1, next.x)));
|
||||
current[1] = Math.round(next.y);
|
||||
draw();
|
||||
scheduleAdjRefresh(layer);
|
||||
};
|
||||
const up = () => {
|
||||
canvas.releasePointerCapture(event.pointerId);
|
||||
canvas.removeEventListener('pointermove', move);
|
||||
canvas.removeEventListener('pointerup', up);
|
||||
};
|
||||
canvas.addEventListener('pointermove', move);
|
||||
canvas.addEventListener('pointerup', up);
|
||||
draw();
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
canvas.addEventListener('dblclick', event => {
|
||||
const index = nearestIndex(event);
|
||||
const list = points();
|
||||
if (index <= 0 || index >= list.length - 1) return;
|
||||
list.splice(index, 1);
|
||||
draw();
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
draw();
|
||||
}
|
||||
|
||||
// Position the three histogram triangle handles by current staged
|
||||
// values + wire pointer drags.
|
||||
function wireHistogramHandles(bodyEl, layer, type) {
|
||||
@@ -595,9 +940,14 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
const canvas = bodyEl.querySelector('.ge-adj-histogram');
|
||||
if (!wrap || !canvas) return;
|
||||
const handles = bodyEl.querySelectorAll('.ge-adj-hist-handle');
|
||||
const levelParams = () => {
|
||||
const root = layer._stagedAdj.params;
|
||||
const channel = ['red', 'green', 'blue'].includes(root.channel) ? root.channel : 'rgb';
|
||||
return channel === 'rgb' ? root : root.channels[channel];
|
||||
};
|
||||
const placeHandles = () => {
|
||||
const w = canvas.getBoundingClientRect().width;
|
||||
const p = layer._stagedAdj.params;
|
||||
const p = levelParams();
|
||||
const xB = (p.inBlack / 255) * w;
|
||||
const xW = (p.inWhite / 255) * w;
|
||||
// Gamma handle sits at a fraction of the (xB..xW) span, mapped
|
||||
@@ -623,7 +973,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
const onMove = (ev) => {
|
||||
const x = Math.max(0, Math.min(rect.width, ev.clientX - rect.left));
|
||||
const v = Math.round((x / rect.width) * 255);
|
||||
const p = layer._stagedAdj.params;
|
||||
const p = levelParams();
|
||||
if (which === 'inBlack') {
|
||||
p.inBlack = Math.min(p.inWhite - 1, v);
|
||||
} else if (which === 'inWhite') {
|
||||
@@ -642,14 +992,26 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
// Update visible slider rows + value labels.
|
||||
const updateRow = (key, displayVal) => {
|
||||
const sl = bodyEl.querySelector(`input[type="range"][data-key="${key}"]`);
|
||||
if (sl) sl.value = String(key === 'gamma' ? Math.round(layer._stagedAdj.params.gamma * 100) : layer._stagedAdj.params[key]);
|
||||
const values = levelParams();
|
||||
if (sl) sl.value = String(key === 'gamma' ? Math.round(values.gamma * 100) : values[key]);
|
||||
const val = sl?.parentElement.querySelector('.ge-adj-value');
|
||||
if (val) val.textContent = displayVal;
|
||||
};
|
||||
if (which === 'inBlack') updateRow('inBlack', String(layer._stagedAdj.params.inBlack));
|
||||
if (which === 'inWhite') updateRow('inWhite', String(layer._stagedAdj.params.inWhite));
|
||||
if (which === 'gamma') updateRow('gamma', layer._stagedAdj.params.gamma.toFixed(2) + 'γ');
|
||||
drawHistogram(canvas, layer);
|
||||
const values = levelParams();
|
||||
if (which === 'inBlack') updateRow('inBlack', String(values.inBlack));
|
||||
if (which === 'inWhite') updateRow('inWhite', String(values.inWhite));
|
||||
if (which === 'gamma') updateRow('gamma', values.gamma.toFixed(2) + 'γ');
|
||||
const histogramLayer = layer.kind === 'adjustment' && getAdjustmentSource
|
||||
? { canvas: getAdjustmentSource(layer), _stagedAdj: { params: values } }
|
||||
: layer;
|
||||
const channel = ['red', 'green', 'blue'].includes(layer._stagedAdj.params.channel)
|
||||
? layer._stagedAdj.params.channel : 'rgb';
|
||||
const stats = drawHistogram(canvas, histogramLayer, channel);
|
||||
const clipStatus = bodyEl.querySelector('.ge-adj-clip-status');
|
||||
if (clipStatus) {
|
||||
const channelLabel = channel === 'rgb' ? 'RGB' : channel[0].toUpperCase() + channel.slice(1);
|
||||
clipStatus.textContent = `${channelLabel} clipping: black ${stats.black.toFixed(1)}% · white ${stats.white.toFixed(1)}%`;
|
||||
}
|
||||
scheduleAdjRefresh(layer);
|
||||
};
|
||||
const onUp = () => {
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
* canvas: HTMLCanvasElement,
|
||||
* _stagedAdj?: {params?: {inBlack?: number, inWhite?: number}}
|
||||
* }} layer Source layer.
|
||||
* @param {'rgb'|'red'|'green'|'blue'} channel Channel to inspect. `rgb` uses luminance.
|
||||
* @returns {{black: number, white: number, samples: number}}
|
||||
*/
|
||||
export function drawHistogram(canvas, layer) {
|
||||
if (!canvas) return;
|
||||
export function drawHistogram(canvas, layer, channel = 'rgb') {
|
||||
if (!canvas) return { black: 0, white: 0, samples: 0 };
|
||||
const w = canvas.width, h = canvas.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
@@ -33,11 +35,21 @@ export function drawHistogram(canvas, layer) {
|
||||
const img = tctx.getImageData(0, 0, sampleW, sampleH).data;
|
||||
|
||||
const hist = new Uint32Array(256);
|
||||
let samples = 0;
|
||||
let black = 0;
|
||||
let white = 0;
|
||||
for (let i = 0; i < img.length; i += 4) {
|
||||
if (img[i + 3] < 8) continue; // skip near-transparent
|
||||
// Rec. 709 luminance — common choice for histograms in photo editors.
|
||||
const Y = (0.2126 * img[i] + 0.7152 * img[i + 1] + 0.0722 * img[i + 2]) | 0;
|
||||
hist[Math.min(255, Y)]++;
|
||||
const value = channel === 'red' ? img[i]
|
||||
: channel === 'green' ? img[i + 1]
|
||||
: channel === 'blue' ? img[i + 2]
|
||||
// Rec. 709 luminance — common choice for composite histograms.
|
||||
: 0.2126 * img[i] + 0.7152 * img[i + 1] + 0.0722 * img[i + 2];
|
||||
const bucket = Math.max(0, Math.min(255, value | 0));
|
||||
hist[bucket]++;
|
||||
samples++;
|
||||
if (bucket === 0) black++;
|
||||
if (bucket === 255) white++;
|
||||
}
|
||||
let peak = 1;
|
||||
for (let i = 0; i < 256; i++) if (hist[i] > peak) peak = hist[i];
|
||||
@@ -64,4 +76,9 @@ export function drawHistogram(canvas, layer) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.fillRect((p.inWhite / 256) * w, 0, 1, h);
|
||||
}
|
||||
return {
|
||||
black: samples ? black / samples * 100 : 0,
|
||||
white: samples ? white / samples * 100 : 0,
|
||||
samples,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Apply a Brightness/Contrast, Hue/Saturation, Levels, or Color Balance
|
||||
* Apply a Brightness/Contrast, Black & White, Hue/Saturation, Levels, Curves, or Color Balance
|
||||
* adjustment to a source canvas and return a fresh canvas with the
|
||||
* result. Pure pixel math — no DOM, no module state.
|
||||
*
|
||||
@@ -11,8 +11,111 @@
|
||||
* { type: 'brightness-contrast', params: { brightness, contrast } }
|
||||
* { type: 'hue-saturation', params: { hue, saturation } }
|
||||
* { type: 'levels', params: { inBlack, inWhite, gamma, outBlack, outWhite } }
|
||||
* { type: 'curves', params: { points: {rgb, red, green, blue} } }
|
||||
* { type: 'color-balance', params: { shadows, midtones, highlights } }
|
||||
*/
|
||||
function clampByte(value) {
|
||||
return value < 0 ? 0 : value > 255 ? 255 : Math.round(value);
|
||||
}
|
||||
|
||||
function finiteOr(value, fallback) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
}
|
||||
|
||||
function levelLut(values = {}) {
|
||||
const inLow = Math.max(0, Math.min(254, finiteOr(values.inBlack, 0)));
|
||||
const inHigh = Math.max(inLow + 1, Math.min(255, finiteOr(values.inWhite, 255)));
|
||||
const gamma = Math.max(0.1, finiteOr(values.gamma, 1));
|
||||
const outLow = Math.max(0, Math.min(255, finiteOr(values.outBlack, 0)));
|
||||
const outHigh = Math.max(outLow, Math.min(255, finiteOr(values.outWhite, 255)));
|
||||
const lut = new Uint8ClampedArray(256);
|
||||
for (let value = 0; value < 256; value += 1) {
|
||||
const normalized = Math.max(0, Math.min(1, (value - inLow) / (inHigh - inLow)));
|
||||
lut[value] = clampByte(Math.pow(normalized, 1 / gamma) * (outHigh - outLow) + outLow);
|
||||
}
|
||||
return lut;
|
||||
}
|
||||
|
||||
function normalizedCurvePoints(points) {
|
||||
const clean = (Array.isArray(points) ? points : [])
|
||||
.filter(point => Array.isArray(point) && point.length >= 2)
|
||||
.map(point => [clampByte(Number(point[0]) || 0), clampByte(Number(point[1]) || 0)])
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
if (!clean.length || clean[0][0] !== 0) clean.unshift([0, clean[0]?.[1] ?? 0]);
|
||||
if (clean[clean.length - 1][0] !== 255) clean.push([255, clean[clean.length - 1]?.[1] ?? 255]);
|
||||
const unique = [];
|
||||
for (const point of clean) {
|
||||
if (unique.length && unique[unique.length - 1][0] === point[0]) unique[unique.length - 1] = point;
|
||||
else unique.push(point);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
export function curveLut(points) {
|
||||
const clean = normalizedCurvePoints(points);
|
||||
const lut = new Uint8ClampedArray(256);
|
||||
let segment = 0;
|
||||
for (let value = 0; value < 256; value += 1) {
|
||||
while (segment < clean.length - 2 && value > clean[segment + 1][0]) segment += 1;
|
||||
const left = clean[segment];
|
||||
const right = clean[Math.min(segment + 1, clean.length - 1)];
|
||||
const span = Math.max(1, right[0] - left[0]);
|
||||
const amount = Math.max(0, Math.min(1, (value - left[0]) / span));
|
||||
lut[value] = clampByte(left[1] + (right[1] - left[1]) * amount);
|
||||
}
|
||||
return lut;
|
||||
}
|
||||
|
||||
function rgbToHsl(r, g, b) {
|
||||
r /= 255; g /= 255; b /= 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
const lightness = (max + min) / 2;
|
||||
if (max === min) return [0, 0, lightness];
|
||||
const delta = max - min;
|
||||
const saturation = lightness > .5 ? delta / (2 - max - min) : delta / (max + min);
|
||||
let hue;
|
||||
if (max === r) hue = ((g - b) / delta + (g < b ? 6 : 0)) / 6;
|
||||
else if (max === g) hue = ((b - r) / delta + 2) / 6;
|
||||
else hue = ((r - g) / delta + 4) / 6;
|
||||
return [hue, saturation, lightness];
|
||||
}
|
||||
|
||||
function hueChannel(p, q, t) {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
if (!s) return [l * 255, l * 255, l * 255];
|
||||
const q = l < .5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
return [hueChannel(p, q, h + 1 / 3) * 255, hueChannel(p, q, h) * 255, hueChannel(p, q, h - 1 / 3) * 255];
|
||||
}
|
||||
|
||||
function parseHexColor(value, fallback) {
|
||||
const match = /^#?([0-9a-f]{6})$/i.exec(String(value || ''));
|
||||
const hex = match ? match[1] : fallback;
|
||||
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)];
|
||||
}
|
||||
|
||||
function hueDistance(a, b) {
|
||||
const distance = Math.abs(a - b);
|
||||
return Math.min(distance, 1 - distance);
|
||||
}
|
||||
|
||||
function selectiveRangeWeight(name, hue, saturation, lightness) {
|
||||
if (name === 'blacks') return Math.max(0, Math.min(1, (0.55 - lightness) / 0.45));
|
||||
if (name === 'neutrals') return Math.max(0, Math.min(1, (1 - saturation) * (1 - Math.abs(lightness - .5) * 1.5)));
|
||||
const centers = { reds: 0, yellows: 1 / 6, greens: 1 / 3, cyans: .5, blues: 2 / 3, magentas: 5 / 6 };
|
||||
const distance = hueDistance(hue, centers[name] ?? 0);
|
||||
return Math.max(0, 1 - distance * 6) * saturation;
|
||||
}
|
||||
|
||||
export function applyAdjustment(srcCanvas, adj) {
|
||||
const w = srcCanvas.width, h = srcCanvas.height;
|
||||
const out = document.createElement('canvas');
|
||||
@@ -27,37 +130,129 @@ export function applyAdjustment(srcCanvas, adj) {
|
||||
octx.filter = 'none';
|
||||
return out;
|
||||
}
|
||||
if (adj.type === 'hue-saturation') {
|
||||
const p = adj.params;
|
||||
octx.filter = `saturate(${p.saturation}) hue-rotate(${p.hue}deg)`;
|
||||
octx.drawImage(srcCanvas, 0, 0);
|
||||
octx.filter = 'none';
|
||||
return out;
|
||||
}
|
||||
|
||||
// Levels + Color Balance need per-pixel math.
|
||||
// The remaining adjustments need deterministic per-pixel math so preview,
|
||||
// flattening, and reopened documents produce the same result.
|
||||
octx.drawImage(srcCanvas, 0, 0);
|
||||
const img = octx.getImageData(0, 0, w, h);
|
||||
const d = img.data;
|
||||
|
||||
if (adj.type === 'exposure') {
|
||||
const p = adj.params || {};
|
||||
const multiplier = Math.pow(2, finiteOr(p.exposure, 0));
|
||||
const offset = finiteOr(p.offset, 0);
|
||||
const gamma = Math.max(.1, finiteOr(p.gamma, 1));
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
d[i] = clampByte(Math.pow(Math.max(0, Math.min(1, d[i] / 255 * multiplier + offset)), 1 / gamma) * 255);
|
||||
d[i + 1] = clampByte(Math.pow(Math.max(0, Math.min(1, d[i + 1] / 255 * multiplier + offset)), 1 / gamma) * 255);
|
||||
d[i + 2] = clampByte(Math.pow(Math.max(0, Math.min(1, d[i + 2] / 255 * multiplier + offset)), 1 / gamma) * 255);
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'white-balance') {
|
||||
const p = adj.params || {};
|
||||
const temperature = finiteOr(p.temperature, 0) / 100;
|
||||
const tint = finiteOr(p.tint, 0) / 100;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
d[i] = clampByte(d[i] * (1 + temperature * .28 + tint * .09));
|
||||
d[i + 1] = clampByte(d[i + 1] * (1 - tint * .18));
|
||||
d[i + 2] = clampByte(d[i + 2] * (1 - temperature * .28 + tint * .09));
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'hue-saturation') {
|
||||
const p = adj.params || {};
|
||||
const hueShift = finiteOr(p.hue, 0) / 360;
|
||||
const saturationScale = Math.max(0, finiteOr(p.saturation, 1));
|
||||
const lightnessShift = finiteOr(p.lightness, 0) / 100;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
let [hue, saturation, lightness] = rgbToHsl(d[i], d[i + 1], d[i + 2]);
|
||||
hue = (hue + hueShift + 1) % 1;
|
||||
saturation = Math.max(0, Math.min(1, saturation * saturationScale));
|
||||
lightness = Math.max(0, Math.min(1, lightness + lightnessShift));
|
||||
const rgb = hslToRgb(hue, saturation, lightness);
|
||||
d[i] = clampByte(rgb[0]); d[i + 1] = clampByte(rgb[1]); d[i + 2] = clampByte(rgb[2]);
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'vibrance') {
|
||||
const amount = Math.max(-1, Math.min(1, finiteOr(adj.params?.vibrance, 0) / 100));
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const [hue, saturation, lightness] = rgbToHsl(d[i], d[i + 1], d[i + 2]);
|
||||
const adjustedSaturation = amount >= 0
|
||||
? saturation + (1 - saturation) * amount
|
||||
: saturation * (1 + amount);
|
||||
const rgb = hslToRgb(hue, Math.max(0, Math.min(1, adjustedSaturation)), lightness);
|
||||
d[i] = clampByte(rgb[0]); d[i + 1] = clampByte(rgb[1]); d[i + 2] = clampByte(rgb[2]);
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'black-white') {
|
||||
const p = adj.params || {};
|
||||
const red = finiteOr(p.red, 30) / 100;
|
||||
const green = finiteOr(p.green, 59) / 100;
|
||||
const blue = finiteOr(p.blue, 11) / 100;
|
||||
const constant = finiteOr(p.constant, 0) * 2.55;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const gray = d[i] * red + d[i + 1] * green + d[i + 2] * blue + constant;
|
||||
d[i] = clampByte(gray);
|
||||
d[i + 1] = clampByte(gray);
|
||||
d[i + 2] = clampByte(gray);
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'shadows-highlights') {
|
||||
const shadows = Math.max(-1, Math.min(1, finiteOr(adj.params?.shadows, 0) / 100));
|
||||
const highlights = Math.max(-1, Math.min(1, finiteOr(adj.params?.highlights, 0) / 100));
|
||||
const toneAdjust = (value, amount, weight) => amount >= 0
|
||||
? value + (255 - value) * amount * weight
|
||||
: value + value * amount * weight;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const luminance = (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]) / 255;
|
||||
const shadowWeight = (1 - luminance) ** 2;
|
||||
const highlightWeight = luminance ** 2;
|
||||
d[i] = clampByte(toneAdjust(toneAdjust(d[i], shadows, shadowWeight), highlights, highlightWeight));
|
||||
d[i + 1] = clampByte(toneAdjust(toneAdjust(d[i + 1], shadows, shadowWeight), highlights, highlightWeight));
|
||||
d[i + 2] = clampByte(toneAdjust(toneAdjust(d[i + 2], shadows, shadowWeight), highlights, highlightWeight));
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'levels') {
|
||||
const l = adj.params;
|
||||
const inLow = Math.max(0, Math.min(254, l.inBlack));
|
||||
const inHigh = Math.max(inLow + 1, Math.min(255, l.inWhite));
|
||||
const gamma = Math.max(0.1, l.gamma || 1);
|
||||
const outLow = Math.max(0, Math.min(255, l.outBlack));
|
||||
const outHigh = Math.max(outLow, Math.min(255, l.outWhite));
|
||||
const inv = 1.0 / gamma;
|
||||
const span = (outHigh - outLow);
|
||||
const lut = new Uint8ClampedArray(256);
|
||||
for (let v = 0; v < 256; v++) {
|
||||
let t = (v - inLow) / (inHigh - inLow);
|
||||
if (t < 0) t = 0; else if (t > 1) t = 1;
|
||||
t = Math.pow(t, inv);
|
||||
lut[v] = Math.round(t * span + outLow);
|
||||
}
|
||||
const master = levelLut(l);
|
||||
const red = levelLut(l.channels?.red);
|
||||
const green = levelLut(l.channels?.green);
|
||||
const blue = levelLut(l.channels?.blue);
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
d[i] = lut[d[i]]; d[i+1] = lut[d[i+1]]; d[i+2] = lut[d[i+2]];
|
||||
d[i] = red[master[d[i]]];
|
||||
d[i + 1] = green[master[d[i + 1]]];
|
||||
d[i + 2] = blue[master[d[i + 2]]];
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'curves') {
|
||||
const points = adj.params?.points || {};
|
||||
const master = curveLut(points.rgb);
|
||||
const red = curveLut(points.red);
|
||||
const green = curveLut(points.green);
|
||||
const blue = curveLut(points.blue);
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
d[i] = red[master[d[i]]];
|
||||
d[i + 1] = green[master[d[i + 1]]];
|
||||
d[i + 2] = blue[master[d[i + 2]]];
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
@@ -95,6 +290,43 @@ export function applyAdjustment(srcCanvas, adj) {
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'selective-color') {
|
||||
const ranges = adj.params?.ranges || {};
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
let r = d[i], g = d[i + 1], b = d[i + 2];
|
||||
const [hue, saturation, lightness] = rgbToHsl(r, g, b);
|
||||
for (const [name, values] of Object.entries(ranges)) {
|
||||
const weight = selectiveRangeWeight(name, hue, saturation, lightness) * .65;
|
||||
if (weight <= 0) continue;
|
||||
const black = finiteOr(values.black, 0) / 100;
|
||||
r += (-finiteOr(values.cyan, 0) / 100 * 255 - black * r) * weight;
|
||||
g += (-finiteOr(values.magenta, 0) / 100 * 255 - black * g) * weight;
|
||||
b += (-finiteOr(values.yellow, 0) / 100 * 255 - black * b) * weight;
|
||||
}
|
||||
d[i] = clampByte(r); d[i + 1] = clampByte(g); d[i + 2] = clampByte(b);
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'gradient-map') {
|
||||
const p = adj.params || {};
|
||||
let shadows = parseHexColor(p.shadows, '000000');
|
||||
let highlights = parseHexColor(p.highlights, 'ffffff');
|
||||
if (p.reverse) [shadows, highlights] = [highlights, shadows];
|
||||
const midpoint = Math.max(.01, Math.min(.99, finiteOr(p.midpoint, 50) / 100));
|
||||
const exponent = Math.log(.5) / Math.log(midpoint);
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const luminance = (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]) / 255;
|
||||
const amount = Math.pow(luminance, exponent);
|
||||
d[i] = clampByte(shadows[0] + (highlights[0] - shadows[0]) * amount);
|
||||
d[i + 1] = clampByte(shadows[1] + (highlights[1] - shadows[1]) * amount);
|
||||
d[i + 2] = clampByte(shadows[2] + (highlights[2] - shadows[2]) * amount);
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -215,14 +447,16 @@ export function renderLayerWithAdjLayers(layer) {
|
||||
const editingId = layer._editingAdjId || null;
|
||||
const stack = (layer.adjLayers || []).filter(a => a.visible && a.id !== editingId);
|
||||
const staged = layer._stagedAdj;
|
||||
if (stack.length === 0 && !staged) {
|
||||
if (stack.length === 0 && (!staged || layer._adjCompare)) {
|
||||
layer._adjFinalKey = '';
|
||||
return layer.canvas;
|
||||
}
|
||||
const sig = stack.map(a => `${a.id}:${a.visible?1:0}:${a.opacity}:${a.type}:${JSON.stringify(a.params)}`).join('|') +
|
||||
(staged ? `|S:${staged.type}:${JSON.stringify(staged.params)}` : '') +
|
||||
(editingId ? `|E:${editingId}` : '');
|
||||
if (layer._adjFinal && layer._adjFinalKey === sig) return layer._adjFinal;
|
||||
const compare = !!layer._adjCompare;
|
||||
const fullSig = `${sig}|C:${compare ? 1 : 0}`;
|
||||
if (layer._adjFinal && layer._adjFinalKey === fullSig) return layer._adjFinal;
|
||||
let cur = layer.canvas;
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
for (const adj of stack) {
|
||||
@@ -240,10 +474,10 @@ export function renderLayerWithAdjLayers(layer) {
|
||||
cur = blend;
|
||||
}
|
||||
}
|
||||
if (staged) {
|
||||
if (staged && !compare) {
|
||||
cur = applyAdjustment(cur, staged);
|
||||
}
|
||||
layer._adjFinal = cur;
|
||||
layer._adjFinalKey = sig;
|
||||
layer._adjFinalKey = fullSig;
|
||||
return cur;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Shared normalization for editable linear-gradient stops. */
|
||||
|
||||
export const MAX_GRADIENT_STOPS = 12;
|
||||
|
||||
function finite(value, fallback, min, max) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
|
||||
}
|
||||
|
||||
function color(value, fallback) {
|
||||
const text = String(value || '').trim();
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
export function legacyGradientStops({
|
||||
start = '#ffffff',
|
||||
mid = '#808080',
|
||||
midEnabled = false,
|
||||
midPosition = 50,
|
||||
end = '#000000',
|
||||
} = {}) {
|
||||
const stops = [{ position: 0, color: color(start, '#ffffff') }];
|
||||
if (midEnabled) {
|
||||
stops.push({
|
||||
position: finite(midPosition, 50, 1, 99),
|
||||
color: color(mid, '#808080'),
|
||||
});
|
||||
}
|
||||
stops.push({ position: 100, color: color(end, '#000000') });
|
||||
return stops;
|
||||
}
|
||||
|
||||
export function normalizeGradientStops(value, legacy = {}) {
|
||||
const source = Array.isArray(value) && value.length ? value : legacyGradientStops(legacy);
|
||||
const stops = source.map((stop, index) => ({
|
||||
position: finite(stop?.position, index === 0 ? 0 : 100, 0, 100),
|
||||
color: color(stop?.color, index === 0 ? '#ffffff' : '#000000'),
|
||||
}));
|
||||
stops.sort((a, b) => a.position - b.position);
|
||||
|
||||
const unique = [];
|
||||
for (const stop of stops) {
|
||||
const previous = unique.at(-1);
|
||||
if (previous && Math.abs(previous.position - stop.position) < 0.0001) {
|
||||
unique[unique.length - 1] = stop;
|
||||
} else {
|
||||
unique.push(stop);
|
||||
}
|
||||
}
|
||||
if (!unique.length || unique[0].position > 0) {
|
||||
unique.unshift({ position: 0, color: unique[0]?.color || '#ffffff' });
|
||||
} else {
|
||||
unique[0].position = 0;
|
||||
}
|
||||
if (unique.at(-1).position < 100) {
|
||||
unique.push({ position: 100, color: unique.at(-1)?.color || '#000000' });
|
||||
} else {
|
||||
unique.at(-1).position = 100;
|
||||
}
|
||||
const first = unique[0];
|
||||
const last = unique.at(-1);
|
||||
return [first, ...unique.slice(1, -1).slice(0, MAX_GRADIENT_STOPS - 2), last];
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
* @returns {HTMLCanvasElement|null}
|
||||
*/
|
||||
export function layerUnionAlpha(w, h, layers) {
|
||||
if (!Array.isArray(layers)) return null;
|
||||
const visible = layers.filter(l => l.visible);
|
||||
if (visible.length < 2) return null;
|
||||
const bgId = visible[0].id;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Adaptive bounds for raw ImageData history snapshots. */
|
||||
|
||||
export const MAX_HISTORY_ENTRIES = 30;
|
||||
export const MAX_HISTORY_BYTES = 192 * 1024 * 1024;
|
||||
|
||||
function imageDataBytes(imageData) {
|
||||
return Number(imageData?.data?.byteLength || imageData?.data?.length || 0);
|
||||
}
|
||||
|
||||
export function snapshotByteSize(snapshot) {
|
||||
if (!snapshot) return 0;
|
||||
if (Number.isFinite(snapshot._bytes)) return snapshot._bytes;
|
||||
let bytes = imageDataBytes(snapshot.wand?.imageData);
|
||||
bytes += imageDataBytes(snapshot.lastSelection?.imageData);
|
||||
for (const selection of snapshot.savedSelections || []) bytes += imageDataBytes(selection.imageData);
|
||||
for (const group of snapshot.layerGroups || []) {
|
||||
for (const mask of group.masks || []) bytes += imageDataBytes(mask.imageData);
|
||||
}
|
||||
for (const layer of snapshot.layers || []) {
|
||||
bytes += imageDataBytes(layer.imageData);
|
||||
bytes += imageDataBytes(layer.placed?.sourceImageData);
|
||||
for (const mask of layer.masks || []) bytes += imageDataBytes(mask.imageData);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function trimHistoryStack(
|
||||
stack,
|
||||
maxEntries = MAX_HISTORY_ENTRIES,
|
||||
maxBytes = MAX_HISTORY_BYTES,
|
||||
) {
|
||||
while (stack.length > maxEntries) stack.shift();
|
||||
let bytes = stack.reduce((total, snapshot) => total + snapshotByteSize(snapshot), 0);
|
||||
// Keep the newest state even when one snapshot alone exceeds the budget.
|
||||
while (stack.length > 1 && bytes > maxBytes) {
|
||||
bytes -= snapshotByteSize(stack.shift());
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
@@ -14,11 +14,14 @@
|
||||
* Ctrl+Alt+T start free transform
|
||||
* Ctrl+Alt+I invert wand / lasso selection
|
||||
* Ctrl+Alt+J new empty layer
|
||||
* Ctrl/Cmd+J duplicate the active layer
|
||||
* Ctrl+Alt+G create/release clipping mask
|
||||
* Ctrl+Alt+A select all canvas (lasso polygon = full bounds)
|
||||
* Ctrl+C/X copy / cut wand or lasso selection (image clipboard
|
||||
* + internal clipboard)
|
||||
* Ctrl+V (handled by the paste event listener)
|
||||
* Tool keys (V, B, E, L, …) → toolbar click
|
||||
* Hold Space temporarily pan without changing the active tool
|
||||
* [ / ] shrink / grow brush size proportionally
|
||||
* D, C, M (when lasso has 3+ points) → delete / copy / convert mask
|
||||
* Delete / Backspace (wand or lasso) → delete pixels
|
||||
@@ -33,6 +36,7 @@
|
||||
* toggleShortcuts: (show?: boolean) => void,
|
||||
* confirmTransform: () => void,
|
||||
* cancelTransform: () => void,
|
||||
* nudgeTransform: (dx: number, dy: number) => boolean,
|
||||
* startTransform: () => void,
|
||||
* resizeCustomPrompt: () => void,
|
||||
* addEmptyLayer: () => void,
|
||||
@@ -46,26 +50,83 @@
|
||||
* buildLassoMask: (w: number, h: number, offX: number, offY: number, feather: number, grow: number) => HTMLCanvasElement,
|
||||
* drawLassoOverlay: () => void,
|
||||
* activeLayer: () => object | null,
|
||||
* deleteSelectedLayers: () => boolean | Promise<boolean>,
|
||||
* duplicateActiveLayer: () => boolean,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { isAltGrEvent } from '../platform.js';
|
||||
import { createMarqueeMask, selectionMaskForLayer } from './selection-mask.js';
|
||||
|
||||
export function wireKeyboardShortcuts(deps) {
|
||||
const {
|
||||
toolbar, toolKeyMap,
|
||||
composite, saveState, undo, redo,
|
||||
toggleShortcuts, confirmTransform, cancelTransform, startTransform,
|
||||
toggleShortcuts, confirmTransform, cancelTransform, startTransform, nudgeTransform,
|
||||
resizeCustomPrompt, addEmptyLayer, brushSizeSync,
|
||||
invertSelection,
|
||||
wandDeleteSelection, wandCopyToNewLayer,
|
||||
lassoDeleteSelection, lassoCopyToLayer, lassoToMask,
|
||||
buildLassoMask, drawLassoOverlay,
|
||||
activeLayer, uiModule,
|
||||
activeLayer, deleteSelectedLayers, duplicateActiveLayer, uiModule,
|
||||
setTemporaryPan,
|
||||
nudgeActiveLayer, endLayerNudge,
|
||||
toggleQuickMask, nudgeSelection,
|
||||
deselectSelection,
|
||||
} = deps;
|
||||
|
||||
const isTypingTarget = (target) => target && (
|
||||
target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
|
||||
);
|
||||
|
||||
const releaseTemporaryPan = () => setTemporaryPan?.(false);
|
||||
document.addEventListener('keyup', (e) => {
|
||||
if (e.code === 'Space') releaseTemporaryPan();
|
||||
if (e.key.startsWith('Arrow')) endLayerNudge?.();
|
||||
});
|
||||
window.addEventListener('blur', releaseTemporaryPan);
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!state.editorOpen) return;
|
||||
if (e.code === 'Space' && !isTypingTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
setTemporaryPan?.(true);
|
||||
return;
|
||||
}
|
||||
if (!isTypingTarget(e.target) && state.tool === 'marquee' && e.key.startsWith('Arrow')) {
|
||||
const amount = e.shiftKey ? 10 : 1;
|
||||
const delta = {
|
||||
ArrowLeft: [-amount, 0], ArrowRight: [amount, 0],
|
||||
ArrowUp: [0, -amount], ArrowDown: [0, amount],
|
||||
}[e.key];
|
||||
if (delta && nudgeSelection?.(...delta)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!isTypingTarget(e.target) && state.transformActive && e.key.startsWith('Arrow')) {
|
||||
const amount = e.shiftKey ? 10 : 1;
|
||||
const delta = {
|
||||
ArrowLeft: [-amount, 0], ArrowRight: [amount, 0],
|
||||
ArrowUp: [0, -amount], ArrowDown: [0, amount],
|
||||
}[e.key];
|
||||
if (delta && nudgeTransform?.(...delta)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!isTypingTarget(e.target) && ['move', 'transform'].includes(state.tool) && e.key.startsWith('Arrow')) {
|
||||
const amount = e.shiftKey ? 10 : 1;
|
||||
const delta = {
|
||||
ArrowLeft: [-amount, 0], ArrowRight: [amount, 0],
|
||||
ArrowUp: [0, -amount], ArrowDown: [0, amount],
|
||||
}[e.key];
|
||||
if (delta && nudgeActiveLayer?.(...delta)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// `?` toggles the cheatsheet. Don't fire while typing in a text
|
||||
// field — the user might be typing a prompt with a `?`.
|
||||
if (e.key === '?' && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') {
|
||||
@@ -79,24 +140,18 @@ export function wireKeyboardShortcuts(deps) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') return;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Skip the Ctrl+Alt editor chords for an AltGr keystroke (see platform.js);
|
||||
// only the chord block is skipped, so the layout-character handlers below
|
||||
// still act — AltGr+5 / AltGr+8 stay as the [ ] brush-size shortcut on
|
||||
// AZERTY / QWERTZ.
|
||||
if ((e.ctrlKey || e.metaKey) && !isAltGrEvent(e)) {
|
||||
if (e.key === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); }
|
||||
// Ctrl+Shift+D = Deselect: clears the wand selection (and
|
||||
// lasso if active) without affecting layers.
|
||||
if (e.shiftKey && (e.key === 'D' || e.key === 'd')) {
|
||||
if (state.wandMask || state.lassoPoints.length) {
|
||||
e.preventDefault();
|
||||
if (state.wandMask) {
|
||||
saveState();
|
||||
state.wandMask = null;
|
||||
state.wandLayerId = null;
|
||||
state.wandLastSeed = null;
|
||||
}
|
||||
if (state.lassoPoints.length) {
|
||||
state.lassoPoints = [];
|
||||
state.lassoActive = false;
|
||||
}
|
||||
composite();
|
||||
deselectSelection?.();
|
||||
}
|
||||
}
|
||||
// Save shortcuts — match the hints shown in the Save dropdown.
|
||||
@@ -121,6 +176,25 @@ export function wireKeyboardShortcuts(deps) {
|
||||
e.stopPropagation();
|
||||
addEmptyLayer();
|
||||
}
|
||||
// Ctrl/Cmd+J duplicates the active layer through the layer panel's
|
||||
// existing implementation, which preserves masks and effects.
|
||||
if (!e.altKey && e.code === 'KeyJ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
duplicateActiveLayer?.();
|
||||
return;
|
||||
}
|
||||
// Ctrl+Alt+G — Photoshop-compatible clipping mask shortcut.
|
||||
if (e.altKey && e.code === 'KeyG') {
|
||||
const row = [...document.querySelectorAll('.ge-layer-item[data-layer-id]')]
|
||||
.find(item => item.dataset.layerId === state.activeLayerId);
|
||||
const button = row?.querySelector('.ge-layer-clip-btn');
|
||||
if (button && !button.disabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
button.click();
|
||||
}
|
||||
}
|
||||
// Wand selection: Delete = erase pixels. Ctrl+X = cut to
|
||||
// clipboard + new layer + erase. Ctrl+C = copy.
|
||||
// (Legacy `&& !_wandActive` clause referenced an undeclared
|
||||
@@ -135,7 +209,7 @@ export function wireKeyboardShortcuts(deps) {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'x' || e.key === 'c')) {
|
||||
e.preventDefault();
|
||||
const isCut = e.key === 'x';
|
||||
const src = state.layers.find(l => l.id === state.wandLayerId);
|
||||
const src = activeLayer();
|
||||
if (!src) return;
|
||||
// Clip source by wand mask into a temp canvas.
|
||||
const w = src.canvas.width, h = src.canvas.height;
|
||||
@@ -144,7 +218,14 @@ export function wireKeyboardShortcuts(deps) {
|
||||
const tCtx = tmp.getContext('2d');
|
||||
tCtx.drawImage(src.canvas, 0, 0);
|
||||
tCtx.globalCompositeOperation = 'destination-in';
|
||||
tCtx.drawImage(state.wandMask, 0, 0);
|
||||
const off = state.layerOffsets.get(src.id) || { x: 0, y: 0 };
|
||||
tCtx.drawImage(selectionMaskForLayer(
|
||||
state.wandMask,
|
||||
state.wandMaskSpace || 'layer',
|
||||
off,
|
||||
w,
|
||||
h,
|
||||
), 0, 0);
|
||||
state.internalClipboard = tmp;
|
||||
tmp.toBlob(blob => {
|
||||
if (blob && navigator.clipboard?.write) {
|
||||
@@ -154,9 +235,17 @@ export function wireKeyboardShortcuts(deps) {
|
||||
}
|
||||
}, 'image/png');
|
||||
if (isCut) {
|
||||
// Cut also moves the selection to a new layer + erases source.
|
||||
wandCopyToNewLayer();
|
||||
wandDeleteSelection();
|
||||
// Cut is one user action: make one history checkpoint, then move
|
||||
// the selected pixels and erase the source without nested saves.
|
||||
saveState('Cut selection');
|
||||
const cutLayer = wandCopyToNewLayer({ saveHistory: false, activate: false, announce: false });
|
||||
wandDeleteSelection({ saveHistory: false, message: 'Selection cut' });
|
||||
if (cutLayer) {
|
||||
state.activeLayerId = cutLayer.id;
|
||||
document.querySelectorAll('.ge-layer-item[data-layer-id]').forEach(row => {
|
||||
row.classList.toggle('active', row.dataset.layerId === cutLayer.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -227,13 +316,19 @@ export function wireKeyboardShortcuts(deps) {
|
||||
// Ctrl+Alt+A = select all canvas.
|
||||
if (e.altKey && e.key === 'a' && state.imgWidth > 0 && state.imgHeight > 0) {
|
||||
e.preventDefault();
|
||||
state.lassoPoints = [
|
||||
{ x: 0, y: 0 }, { x: state.imgWidth, y: 0 },
|
||||
{ x: state.imgWidth, y: state.imgHeight }, { x: 0, y: state.imgHeight },
|
||||
];
|
||||
state.lassoActive = false;
|
||||
saveState('Select all');
|
||||
state.wandMask = createMarqueeMask(
|
||||
state.imgWidth,
|
||||
state.imgHeight,
|
||||
{ x: 0, y: 0, w: state.imgWidth, h: state.imgHeight },
|
||||
'rectangle',
|
||||
);
|
||||
state.wandLayerId = state.activeLayerId;
|
||||
state.wandMaskSpace = 'document';
|
||||
state.selectionSource = 'marquee';
|
||||
state.wandLastSeed = null;
|
||||
state.lassoPoints = [];
|
||||
composite();
|
||||
drawLassoOverlay();
|
||||
uiModule.showToast('All selected — Ctrl+C to copy, Del to delete');
|
||||
}
|
||||
// Ctrl+V handled by the paste event listener.
|
||||
@@ -241,7 +336,43 @@ export function wireKeyboardShortcuts(deps) {
|
||||
return;
|
||||
}
|
||||
// Tool shortcuts (only when not typing in an input).
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
if (isTypingTarget(e.target)) return;
|
||||
|
||||
// Delete pixels for a selection, otherwise delete the selected layer(s).
|
||||
// Clipboard shortcuts above retain ownership of Ctrl/Cmd+X and C.
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (state.wandMask) {
|
||||
e.preventDefault();
|
||||
wandDeleteSelection();
|
||||
return;
|
||||
}
|
||||
if (state.lassoPoints.length >= 3) {
|
||||
e.preventDefault();
|
||||
lassoDeleteSelection();
|
||||
return;
|
||||
}
|
||||
const layer = activeLayer?.();
|
||||
const activeMask = layer?.activeMaskId &&
|
||||
layer.masks?.some(mask => mask.id === layer.activeMaskId);
|
||||
const group = state.activeGroupId &&
|
||||
state.layerGroups?.find(item => item.id === state.activeGroupId);
|
||||
const activeGroupMask = group?.activeMaskId &&
|
||||
group.masks?.some(mask => mask.id === group.activeMaskId);
|
||||
if (state.transformActive || state.cropping || state.cropMoving ||
|
||||
state.marqueeActive || state.selectionMoving || state.lassoActive ||
|
||||
activeMask || activeGroupMask || state.maskInspectMode) return;
|
||||
if (deleteSelectedLayers) {
|
||||
e.preventDefault();
|
||||
deleteSelectedLayers();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey && e.key.toLowerCase() === 'q') {
|
||||
e.preventDefault();
|
||||
toggleQuickMask?.();
|
||||
return;
|
||||
}
|
||||
const toolId = toolKeyMap[e.key.toLowerCase()];
|
||||
if (toolId) {
|
||||
const toolBtn = toolbar.querySelector(`[data-tool="${toolId}"]`);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/** Alpha clipping for flat layer stacks and isolated groups. */
|
||||
|
||||
function scopeByLayer(editorState) {
|
||||
const scopes = new Map();
|
||||
for (const group of editorState.layerGroups || []) {
|
||||
for (const id of group.layerIds || []) scopes.set(id, group.id);
|
||||
}
|
||||
return scopes;
|
||||
}
|
||||
|
||||
export function clippingBaseForLayer(editorState, layerId) {
|
||||
const layers = editorState.layers || [];
|
||||
const index = layers.findIndex(layer => layer.id === layerId);
|
||||
if (index < 0 || !layers[index]?.clipped || index === 0) return null;
|
||||
const scopes = scopeByLayer(editorState);
|
||||
const scope = scopes.get(layerId) || null;
|
||||
if ((scopes.get(layers[index - 1].id) || null) !== scope) return null;
|
||||
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
|
||||
const candidate = layers[cursor];
|
||||
if ((scopes.get(candidate.id) || null) !== scope) return null;
|
||||
if (candidate.kind === 'adjustment') return null;
|
||||
if (!candidate.clipped) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canToggleLayerClipping(editorState, layerId) {
|
||||
const layer = (editorState.layers || []).find(item => item.id === layerId);
|
||||
if (!layer) return false;
|
||||
if (layer.clipped) return true;
|
||||
layer.clipped = true;
|
||||
const canClip = !!clippingBaseForLayer(editorState, layerId);
|
||||
layer.clipped = false;
|
||||
return canClip;
|
||||
}
|
||||
|
||||
/** Clear clipping flags made invalid by delete, grouping, or reorder. */
|
||||
export function normalizeLayerClipping(editorState) {
|
||||
const cleared = [];
|
||||
for (const layer of editorState.layers || []) {
|
||||
layer.clipped = !!layer.clipped;
|
||||
if (layer.clipped && !clippingBaseForLayer(editorState, layer.id)) {
|
||||
layer.clipped = false;
|
||||
cleared.push(layer.id);
|
||||
}
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
|
||||
function ensureScratchCanvas(editorState) {
|
||||
const canvas = editorState.clippingCompositeCanvas || document.createElement('canvas');
|
||||
editorState.clippingCompositeCanvas = canvas;
|
||||
if (canvas.width !== editorState.imgWidth) canvas.width = editorState.imgWidth;
|
||||
if (canvas.height !== editorState.imgHeight) canvas.height = editorState.imgHeight;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/** Draw one contiguous stack scope, bottom to top. */
|
||||
export function drawLayerStack(target, editorState, layers, renderLayer, renderAdjustment = null) {
|
||||
let base = null;
|
||||
const drawNormal = layer => {
|
||||
if (!layer.visible) return;
|
||||
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
target.globalAlpha = layer.opacity;
|
||||
target.globalCompositeOperation = layer.blendMode || 'source-over';
|
||||
target.drawImage(renderLayer(layer), offset.x, offset.y);
|
||||
};
|
||||
for (const layer of layers) {
|
||||
if (layer.kind === 'adjustment' && renderAdjustment) {
|
||||
renderAdjustment(target, layer, layer.clipped ? base : null);
|
||||
if (!layer.clipped) base = null;
|
||||
continue;
|
||||
}
|
||||
if (!layer.clipped || !base) {
|
||||
base = layer;
|
||||
drawNormal(layer);
|
||||
continue;
|
||||
}
|
||||
if (!layer.visible || !base.visible) continue;
|
||||
const scratch = ensureScratchCanvas(editorState);
|
||||
const scratchCtx = scratch.getContext('2d');
|
||||
scratchCtx.globalAlpha = 1;
|
||||
scratchCtx.globalCompositeOperation = 'source-over';
|
||||
scratchCtx.clearRect(0, 0, scratch.width, scratch.height);
|
||||
const layerOffset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
scratchCtx.drawImage(renderLayer(layer), layerOffset.x, layerOffset.y);
|
||||
scratchCtx.globalCompositeOperation = 'destination-in';
|
||||
const baseOffset = editorState.layerOffsets.get(base.id) || { x: 0, y: 0 };
|
||||
scratchCtx.drawImage(renderLayer(base), baseOffset.x, baseOffset.y);
|
||||
scratchCtx.globalCompositeOperation = 'source-over';
|
||||
target.globalAlpha = layer.opacity;
|
||||
target.globalCompositeOperation = layer.blendMode || 'source-over';
|
||||
target.drawImage(scratch, 0, 0);
|
||||
}
|
||||
target.globalAlpha = 1;
|
||||
target.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
|
||||
/** Async counterpart used by the worker-backed live effect compositor. */
|
||||
export async function drawLayerStackAsync(target, editorState, layers, renderLayer, renderAdjustment = null, shouldContinue = () => true) {
|
||||
let base = null;
|
||||
const drawNormal = async layer => {
|
||||
if (!layer.visible || !shouldContinue()) return;
|
||||
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
target.globalAlpha = layer.opacity;
|
||||
target.globalCompositeOperation = layer.blendMode || 'source-over';
|
||||
const rendered = await renderLayer(layer);
|
||||
if (!shouldContinue() || !rendered) return;
|
||||
target.drawImage(rendered, offset.x, offset.y);
|
||||
};
|
||||
for (const layer of layers) {
|
||||
if (!shouldContinue()) return;
|
||||
if (layer.kind === 'adjustment' && renderAdjustment) {
|
||||
await renderAdjustment(target, layer, layer.clipped ? base : null);
|
||||
if (!layer.clipped) base = null;
|
||||
continue;
|
||||
}
|
||||
if (!layer.clipped || !base) {
|
||||
base = layer;
|
||||
await drawNormal(layer);
|
||||
continue;
|
||||
}
|
||||
if (!layer.visible || !base.visible) continue;
|
||||
const scratch = ensureScratchCanvas(editorState);
|
||||
const scratchCtx = scratch.getContext('2d');
|
||||
scratchCtx.globalAlpha = 1;
|
||||
scratchCtx.globalCompositeOperation = 'source-over';
|
||||
scratchCtx.clearRect(0, 0, scratch.width, scratch.height);
|
||||
const layerOffset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const renderedLayer = await renderLayer(layer);
|
||||
const renderedBase = await renderLayer(base);
|
||||
if (!shouldContinue() || !renderedLayer || !renderedBase) return;
|
||||
scratchCtx.drawImage(renderedLayer, layerOffset.x, layerOffset.y);
|
||||
scratchCtx.globalCompositeOperation = 'destination-in';
|
||||
const baseOffset = editorState.layerOffsets.get(base.id) || { x: 0, y: 0 };
|
||||
scratchCtx.drawImage(renderedBase, baseOffset.x, baseOffset.y);
|
||||
scratchCtx.globalCompositeOperation = 'source-over';
|
||||
target.globalAlpha = layer.opacity;
|
||||
target.globalCompositeOperation = layer.blendMode || 'source-over';
|
||||
target.drawImage(scratch, 0, 0);
|
||||
}
|
||||
target.globalAlpha = 1;
|
||||
target.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/** Exact active-layer geometry used by controls, dragging, and key nudging. */
|
||||
import { state } from './state.js';
|
||||
import { selectedLayers, selectionLabel } from './layer-selection.js';
|
||||
import { isLayerPositionLocked } from './layer-groups.js';
|
||||
import { translatePlacedData } from './placed-layer.js';
|
||||
|
||||
const MAX_COORDINATE = 1000000;
|
||||
|
||||
export function normalizeLayerCoordinate(value, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return Math.round(Number(fallback) || 0);
|
||||
return Math.max(-MAX_COORDINATE, Math.min(MAX_COORDINATE, Math.round(parsed)));
|
||||
}
|
||||
|
||||
export function readLayerGeometry(editorState, layer) {
|
||||
if (!layer?.canvas) return null;
|
||||
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
return {
|
||||
x: normalizeLayerCoordinate(offset.x),
|
||||
y: normalizeLayerCoordinate(offset.y),
|
||||
width: Math.max(1, Math.round(layer.canvas.width || 1)),
|
||||
height: Math.max(1, Math.round(layer.canvas.height || 1)),
|
||||
};
|
||||
}
|
||||
|
||||
export function setLayerPosition(editorState, layer, x, y) {
|
||||
if (!layer) return null;
|
||||
const current = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const next = {
|
||||
x: normalizeLayerCoordinate(x, current.x),
|
||||
y: normalizeLayerCoordinate(y, current.y),
|
||||
};
|
||||
editorState.layerOffsets.set(layer.id, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function nudgeLayerPosition(editorState, layer, dx, dy) {
|
||||
const current = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
return setLayerPosition(
|
||||
editorState,
|
||||
layer,
|
||||
current.x + normalizeLayerCoordinate(dx),
|
||||
current.y + normalizeLayerCoordinate(dy),
|
||||
);
|
||||
}
|
||||
|
||||
function moveLayerBy(editorState, layer, dx, dy) {
|
||||
if (!layer || isLayerPositionLocked(editorState, layer)) return false;
|
||||
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const next = setLayerPosition(editorState, layer, offset.x + dx, offset.y + dy);
|
||||
if (layer.kind === 'placed' && layer.placed) {
|
||||
layer.placed = translatePlacedData(layer.placed, next.x - offset.x, next.y - offset.y);
|
||||
}
|
||||
for (const mask of layer.masks || []) {
|
||||
if (mask.mode !== 'layer' || mask.linked !== false) continue;
|
||||
mask.offset = {
|
||||
x: (Number(mask.offset?.x) || 0) - (next.x - offset.x),
|
||||
y: (Number(mask.offset?.y) || 0) - (next.y - offset.y),
|
||||
};
|
||||
}
|
||||
return next.x !== offset.x || next.y !== offset.y;
|
||||
}
|
||||
|
||||
/** Move selected layers to a document edge/center without rasterizing them. */
|
||||
export function alignSelectedLayers(editorState, layers, alignment) {
|
||||
const targets = (layers || []).filter(layer => readLayerGeometry(editorState, layer));
|
||||
if (targets.length < 2) return false;
|
||||
let changed = false;
|
||||
for (const layer of targets) {
|
||||
const geometry = readLayerGeometry(editorState, layer);
|
||||
let x = geometry.x;
|
||||
let y = geometry.y;
|
||||
if (alignment === 'left') x = 0;
|
||||
else if (alignment === 'center') x = (editorState.imgWidth - geometry.width) / 2;
|
||||
else if (alignment === 'right') x = editorState.imgWidth - geometry.width;
|
||||
else if (alignment === 'top') y = 0;
|
||||
else if (alignment === 'middle') y = (editorState.imgHeight - geometry.height) / 2;
|
||||
else if (alignment === 'bottom') y = editorState.imgHeight - geometry.height;
|
||||
changed = moveLayerBy(editorState, layer, x - geometry.x, y - geometry.y) || changed;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Evenly distribute selected layer centers along one document axis. */
|
||||
export function distributeSelectedLayers(editorState, layers, axis) {
|
||||
const entries = (layers || []).map(layer => ({ layer, geometry: readLayerGeometry(editorState, layer) }))
|
||||
.filter(entry => entry.geometry)
|
||||
.sort((a, b) => a.geometry[axis === 'vertical' ? 'y' : 'x'] - b.geometry[axis === 'vertical' ? 'y' : 'x']);
|
||||
if (entries.length < 3) return false;
|
||||
const coordinate = axis === 'vertical' ? 'y' : 'x';
|
||||
const size = axis === 'vertical' ? 'height' : 'width';
|
||||
const first = entries[0].geometry[coordinate] + entries[0].geometry[size] / 2;
|
||||
const lastEntry = entries[entries.length - 1];
|
||||
const last = lastEntry.geometry[coordinate] + lastEntry.geometry[size] / 2;
|
||||
const step = (last - first) / (entries.length - 1);
|
||||
let changed = false;
|
||||
entries.forEach((entry, index) => {
|
||||
const target = first + step * index - entry.geometry[size] / 2;
|
||||
const delta = target - entry.geometry[coordinate];
|
||||
changed = moveLayerBy(editorState, entry.layer, axis === 'vertical' ? 0 : delta, axis === 'vertical' ? delta : 0) || changed;
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function createLayerGeometryController({ activeLayer, saveState, composite }) {
|
||||
let root = null;
|
||||
let fieldHistorySaved = false;
|
||||
let nudgeHistorySaved = false;
|
||||
|
||||
function shiftTransformFrame(dx, dy) {
|
||||
if (!state.transformActive) return;
|
||||
if (state.transformCenter) {
|
||||
state.transformCenter = { x: state.transformCenter.x + dx, y: state.transformCenter.y + dy };
|
||||
}
|
||||
if (state.transformBounds) {
|
||||
state.transformBounds = { ...state.transformBounds, x: state.transformBounds.x + dx, y: state.transformBounds.y + dy };
|
||||
}
|
||||
if (state.transformOrigOffset) {
|
||||
state.transformOrigOffset.x += dx;
|
||||
state.transformOrigOffset.y += dy;
|
||||
}
|
||||
}
|
||||
|
||||
const inputs = () => ({
|
||||
x: root?.querySelector('#ge-layer-x') || null,
|
||||
y: root?.querySelector('#ge-layer-y') || null,
|
||||
width: root?.querySelector('#ge-layer-width') || null,
|
||||
height: root?.querySelector('#ge-layer-height') || null,
|
||||
name: root?.querySelector('#ge-layer-geometry-name') || null,
|
||||
});
|
||||
|
||||
function sync() {
|
||||
const layer = activeLayer();
|
||||
const fields = inputs();
|
||||
const geometry = readLayerGeometry(state, layer);
|
||||
if (fields.name) fields.name.textContent = layer?.name || 'No layer selected';
|
||||
for (const field of [fields.x, fields.y]) field && (field.disabled = !layer || isLayerPositionLocked(state, layer));
|
||||
for (const field of [fields.width, fields.height]) field && (field.disabled = !layer);
|
||||
if (!geometry) {
|
||||
for (const field of [fields.x, fields.y, fields.width, fields.height]) {
|
||||
if (field) field.value = '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (fields.x && document.activeElement !== fields.x) fields.x.value = String(geometry.x);
|
||||
if (fields.y && document.activeElement !== fields.y) fields.y.value = String(geometry.y);
|
||||
if (fields.width) fields.width.value = String(geometry.width);
|
||||
if (fields.height) fields.height.value = String(geometry.height);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function moveTo(x, y, label = null) {
|
||||
const layer = activeLayer();
|
||||
if (!layer || isLayerPositionLocked(state, layer)) return false;
|
||||
const before = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const nextX = normalizeLayerCoordinate(x, before.x);
|
||||
const nextY = normalizeLayerCoordinate(y, before.y);
|
||||
if (before.x === nextX && before.y === nextY) {
|
||||
sync();
|
||||
return false;
|
||||
}
|
||||
if (label) saveState(label);
|
||||
const dx = nextX - before.x;
|
||||
const dy = nextY - before.y;
|
||||
for (const selected of selectedLayers(state).filter(item => !isLayerPositionLocked(state, item))) {
|
||||
const selectedOffset = state.layerOffsets.get(selected.id) || { x: 0, y: 0 };
|
||||
setLayerPosition(state, selected, selectedOffset.x + dx, selectedOffset.y + dy);
|
||||
if (selected.kind === 'placed' && selected.placed) {
|
||||
selected.placed = translatePlacedData(selected.placed, dx, dy);
|
||||
}
|
||||
for (const mask of selected.masks || []) {
|
||||
if (mask.mode !== 'layer' || mask.linked !== false) continue;
|
||||
mask.offset = {
|
||||
x: (Number(mask.offset?.x) || 0) - dx,
|
||||
y: (Number(mask.offset?.y) || 0) - dy,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (state.transformActive && state.transformLayer?.id === layer.id) shiftTransformFrame(dx, dy);
|
||||
composite();
|
||||
sync();
|
||||
return true;
|
||||
}
|
||||
|
||||
function nudge(dx, dy) {
|
||||
const layer = activeLayer();
|
||||
if (!layer || isLayerPositionLocked(state, layer)) return false;
|
||||
const activeMask = (layer.masks || []).find(mask =>
|
||||
mask.id === layer.activeMaskId && mask.mode === 'layer' && mask.linked === false);
|
||||
if (activeMask) {
|
||||
if (!nudgeHistorySaved) saveState(`Nudge mask "${activeMask.name || 'Layer Mask'}"`);
|
||||
activeMask.offset = {
|
||||
x: (Number(activeMask.offset?.x) || 0) + dx,
|
||||
y: (Number(activeMask.offset?.y) || 0) + dy,
|
||||
};
|
||||
nudgeHistorySaved = true;
|
||||
composite();
|
||||
return true;
|
||||
}
|
||||
const current = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const label = !state.transformActive && !nudgeHistorySaved
|
||||
? `Nudge ${selectionLabel(state, `"${layer.name || 'layer'}"`)}`
|
||||
: null;
|
||||
const moved = moveTo(current.x + dx, current.y + dy, label);
|
||||
if (moved) nudgeHistorySaved = true;
|
||||
return moved;
|
||||
}
|
||||
|
||||
function endNudge() {
|
||||
nudgeHistorySaved = false;
|
||||
}
|
||||
|
||||
function trackExternalMove(layer, before, next) {
|
||||
if (state.transformActive && state.transformLayer?.id === layer?.id) {
|
||||
shiftTransformFrame(next.x - before.x, next.y - before.y);
|
||||
}
|
||||
sync();
|
||||
}
|
||||
|
||||
function wire(nextRoot) {
|
||||
root = nextRoot;
|
||||
const fields = inputs();
|
||||
const applyFields = () => {
|
||||
const layer = activeLayer();
|
||||
const geometry = readLayerGeometry(state, layer);
|
||||
if (!geometry || isLayerPositionLocked(state, layer)) return sync();
|
||||
const nextX = normalizeLayerCoordinate(fields.x?.value, geometry.x);
|
||||
const nextY = normalizeLayerCoordinate(fields.y?.value, geometry.y);
|
||||
if (nextX === geometry.x && nextY === geometry.y) return;
|
||||
const label = !state.transformActive && !fieldHistorySaved
|
||||
? `Position "${layer.name || 'layer'}"`
|
||||
: null;
|
||||
if (moveTo(nextX, nextY, label)) fieldHistorySaved = true;
|
||||
};
|
||||
for (const field of [fields.x, fields.y]) {
|
||||
if (!field) continue;
|
||||
field.addEventListener('input', applyFields);
|
||||
field.addEventListener('change', () => { applyFields(); fieldHistorySaved = false; });
|
||||
field.addEventListener('blur', () => { fieldHistorySaved = false; sync(); });
|
||||
field.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') field.blur();
|
||||
if (event.key === 'Escape') { event.preventDefault(); field.blur(); sync(); }
|
||||
});
|
||||
}
|
||||
sync();
|
||||
}
|
||||
|
||||
return { wire, sync, moveTo, nudge, endNudge, trackExternalMove };
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
/** Parent-linked layer groups with recursively isolated compositing. */
|
||||
|
||||
import { drawLayerStack, drawLayerStackAsync } from './layer-clipping.js';
|
||||
import { renderEffects, renderEffectsAsync } from './effects.js';
|
||||
|
||||
function groupMap(editorState) {
|
||||
return new Map((editorState.layerGroups || []).map(group => [group.id, group]));
|
||||
}
|
||||
|
||||
function breakParentCycles(groups) {
|
||||
const byId = new Map(groups.map(group => [group.id, group]));
|
||||
for (const group of groups) {
|
||||
const visited = new Set([group.id]);
|
||||
let cursor = group;
|
||||
while (cursor.parentId) {
|
||||
if (visited.has(cursor.parentId)) {
|
||||
group.parentId = null;
|
||||
break;
|
||||
}
|
||||
visited.add(cursor.parentId);
|
||||
cursor = byId.get(cursor.parentId);
|
||||
if (!cursor) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeLayerGroups(editorState) {
|
||||
const validLayerIds = new Set((editorState.layers || []).map(layer => layer.id));
|
||||
const claimedLayers = new Set();
|
||||
const usedGroupIds = new Set();
|
||||
const normalized = [];
|
||||
for (const source of editorState.layerGroups || []) {
|
||||
if (!source || typeof source !== 'object') continue;
|
||||
let id = String(source.id || `group-${normalized.length + 1}`);
|
||||
while (usedGroupIds.has(id)) id = `${id}-${normalized.length + 1}`;
|
||||
usedGroupIds.add(id);
|
||||
const layerIds = [];
|
||||
for (const layerId of source.layerIds || []) {
|
||||
if (validLayerIds.has(layerId) && !claimedLayers.has(layerId)) {
|
||||
claimedLayers.add(layerId);
|
||||
layerIds.push(layerId);
|
||||
}
|
||||
}
|
||||
source.id = id;
|
||||
source.name = String(source.name || `Group ${normalized.length + 1}`);
|
||||
source.layerIds = layerIds;
|
||||
source.parentId = source.parentId == null ? null : String(source.parentId);
|
||||
source.visible = source.visible !== false;
|
||||
source.opacity = Number.isFinite(Number(source.opacity))
|
||||
? Math.max(0, Math.min(1, Number(source.opacity)))
|
||||
: 1;
|
||||
source.blendMode = source.blendMode || 'source-over';
|
||||
source.locked = !!source.locked;
|
||||
source.collapsed = !!source.collapsed;
|
||||
source.masks = Array.isArray(source.masks) ? source.masks.filter(mask => mask && typeof mask === 'object') : [];
|
||||
source.effects = Array.isArray(source.effects) ? source.effects : [];
|
||||
source.activeMaskId = source.masks.some(mask => mask.id === source.activeMaskId) ? source.activeMaskId : null;
|
||||
normalized.push(source);
|
||||
}
|
||||
|
||||
const validGroupIds = new Set(normalized.map(group => group.id));
|
||||
for (const group of normalized) {
|
||||
if (group.parentId === group.id || !validGroupIds.has(group.parentId)) group.parentId = null;
|
||||
}
|
||||
breakParentCycles(normalized);
|
||||
|
||||
// Parent-only groups are valid. Remove only empty leaves, then repeat because
|
||||
// their removal can make an ancestor empty as well.
|
||||
let retained = normalized;
|
||||
while (true) {
|
||||
const parentIds = new Set(retained.map(group => group.parentId).filter(Boolean));
|
||||
const next = retained.filter(group => group.layerIds.length || parentIds.has(group.id));
|
||||
if (next.length === retained.length) break;
|
||||
const nextIds = new Set(next.map(group => group.id));
|
||||
for (const group of next) {
|
||||
if (group.parentId && !nextIds.has(group.parentId)) group.parentId = null;
|
||||
}
|
||||
retained = next;
|
||||
}
|
||||
|
||||
editorState.layerGroups = retained;
|
||||
if (!retained.some(group => group.id === editorState.activeGroupId)) editorState.activeGroupId = null;
|
||||
return retained;
|
||||
}
|
||||
|
||||
export function groupForLayer(editorState, layerId) {
|
||||
return normalizeLayerGroups(editorState).find(group => group.layerIds.includes(layerId)) || null;
|
||||
}
|
||||
|
||||
export function groupAncestors(editorState, groupOrId, { includeSelf = false } = {}) {
|
||||
const groups = normalizeLayerGroups(editorState);
|
||||
const byId = new Map(groups.map(group => [group.id, group]));
|
||||
const group = typeof groupOrId === 'string' ? byId.get(groupOrId) : groupOrId;
|
||||
const result = [];
|
||||
let cursor = includeSelf ? group : byId.get(group?.parentId);
|
||||
while (cursor && !result.some(item => item.id === cursor.id)) {
|
||||
result.push(cursor);
|
||||
cursor = byId.get(cursor.parentId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function groupDepth(editorState, groupOrId) {
|
||||
return groupAncestors(editorState, groupOrId).length;
|
||||
}
|
||||
|
||||
export function groupsForLayer(editorState, layerId) {
|
||||
const direct = groupForLayer(editorState, layerId);
|
||||
return direct ? [direct, ...groupAncestors(editorState, direct)] : [];
|
||||
}
|
||||
|
||||
export function allLayerIdsInGroup(editorState, groupOrId) {
|
||||
const groups = normalizeLayerGroups(editorState);
|
||||
const byId = new Map(groups.map(group => [group.id, group]));
|
||||
const root = typeof groupOrId === 'string' ? byId.get(groupOrId) : groupOrId;
|
||||
if (!root) return [];
|
||||
const children = new Map();
|
||||
for (const group of groups) {
|
||||
if (!group.parentId) continue;
|
||||
if (!children.has(group.parentId)) children.set(group.parentId, []);
|
||||
children.get(group.parentId).push(group);
|
||||
}
|
||||
const ids = new Set();
|
||||
const visit = group => {
|
||||
for (const id of group.layerIds) ids.add(id);
|
||||
for (const child of children.get(group.id) || []) visit(child);
|
||||
};
|
||||
visit(root);
|
||||
return (editorState.layers || []).filter(layer => ids.has(layer.id)).map(layer => layer.id);
|
||||
}
|
||||
|
||||
export function isLayerEffectivelyLocked(editorState, layer) {
|
||||
if (!layer) return true;
|
||||
return !!layer.locked || groupsForLayer(editorState, layer.id).some(group => group.locked);
|
||||
}
|
||||
|
||||
export function normalizeLayerLocks(value) {
|
||||
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
return {
|
||||
pixels: !!source.pixels,
|
||||
transparency: !!source.transparency,
|
||||
position: !!source.position,
|
||||
};
|
||||
}
|
||||
|
||||
export function layerHasAnyLock(layer) {
|
||||
const locks = normalizeLayerLocks(layer?.locks);
|
||||
return !!layer?.locked || locks.pixels || locks.transparency || locks.position;
|
||||
}
|
||||
|
||||
export function isLayerPixelLocked(editorState, layer) {
|
||||
return isLayerEffectivelyLocked(editorState, layer) || normalizeLayerLocks(layer?.locks).pixels;
|
||||
}
|
||||
|
||||
export function isLayerTransparencyLocked(editorState, layer) {
|
||||
return isLayerEffectivelyLocked(editorState, layer) || normalizeLayerLocks(layer?.locks).transparency;
|
||||
}
|
||||
|
||||
export function isLayerPositionLocked(editorState, layer) {
|
||||
return isLayerEffectivelyLocked(editorState, layer) || normalizeLayerLocks(layer?.locks).position;
|
||||
}
|
||||
|
||||
export function createGroupFromSelection(editorState, name = null) {
|
||||
const selected = new Set(editorState.selectedLayerIds || []);
|
||||
const indexed = (editorState.layers || [])
|
||||
.map((layer, index) => ({ layer, index }))
|
||||
.filter(item => selected.has(item.layer.id));
|
||||
if (indexed.length < 2) return null;
|
||||
|
||||
const groups = normalizeLayerGroups(editorState);
|
||||
const descendantIds = new Map(groups.map(group => [group.id, allLayerIdsInGroup(editorState, group)]));
|
||||
const fullySelected = new Set(groups
|
||||
.filter(group => descendantIds.get(group.id).length && descendantIds.get(group.id).every(id => selected.has(id)))
|
||||
.map(group => group.id));
|
||||
const selectedGroups = groups.filter(group =>
|
||||
fullySelected.has(group.id) && !groupAncestors(editorState, group).some(parent => fullySelected.has(parent.id))
|
||||
);
|
||||
const covered = new Set(selectedGroups.flatMap(group => descendantIds.get(group.id)));
|
||||
const directLayerIds = indexed.map(item => item.layer.id).filter(id => !covered.has(id));
|
||||
|
||||
const componentParents = [
|
||||
...selectedGroups.map(group => group.parentId || null),
|
||||
...directLayerIds.map(id => groupForLayer(editorState, id)?.id || null),
|
||||
];
|
||||
const commonParentId = componentParents.length && componentParents.every(id => id === componentParents[0])
|
||||
? componentParents[0]
|
||||
: null;
|
||||
|
||||
const directSet = new Set(directLayerIds);
|
||||
for (const group of groups) group.layerIds = group.layerIds.filter(id => !directSet.has(id));
|
||||
|
||||
const selectedIds = new Set(indexed.map(item => item.layer.id));
|
||||
const topIndex = indexed[indexed.length - 1].index;
|
||||
const remaining = editorState.layers.filter(layer => !selectedIds.has(layer.id));
|
||||
const insertion = editorState.layers
|
||||
.slice(0, topIndex + 1)
|
||||
.filter(layer => !selectedIds.has(layer.id)).length;
|
||||
const members = indexed.map(item => item.layer);
|
||||
remaining.splice(insertion, 0, ...members);
|
||||
editorState.layers = remaining;
|
||||
|
||||
const id = `group-${editorState.nextLayerId++}`;
|
||||
const group = {
|
||||
id,
|
||||
name: name || `Group ${groups.length + 1}`,
|
||||
layerIds: directLayerIds,
|
||||
parentId: commonParentId,
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
blendMode: 'source-over',
|
||||
locked: false,
|
||||
collapsed: false,
|
||||
masks: [],
|
||||
activeMaskId: null,
|
||||
effects: [],
|
||||
};
|
||||
for (const child of selectedGroups) child.parentId = id;
|
||||
editorState.layerGroups.push(group);
|
||||
normalizeLayerGroups(editorState);
|
||||
editorState.activeGroupId = id;
|
||||
editorState.selectedLayerIds = allLayerIdsInGroup(editorState, id);
|
||||
editorState.activeLayerId = editorState.selectedLayerIds[editorState.selectedLayerIds.length - 1];
|
||||
editorState.selectionAnchorId = editorState.activeLayerId;
|
||||
return groupMap(editorState).get(id) || group;
|
||||
}
|
||||
|
||||
export function ungroupLayers(editorState, groupId) {
|
||||
const group = normalizeLayerGroups(editorState).find(item => item.id === groupId);
|
||||
if (!group || group.masks?.length) return null;
|
||||
const selectedIds = allLayerIdsInGroup(editorState, group);
|
||||
const parent = group.parentId ? groupMap(editorState).get(group.parentId) : null;
|
||||
if (parent) {
|
||||
const direct = new Set([...parent.layerIds, ...group.layerIds]);
|
||||
parent.layerIds = (editorState.layers || []).filter(layer => direct.has(layer.id)).map(layer => layer.id);
|
||||
}
|
||||
for (const child of editorState.layerGroups) {
|
||||
if (child.parentId === group.id) child.parentId = group.parentId || null;
|
||||
}
|
||||
editorState.layerGroups = editorState.layerGroups.filter(item => item.id !== groupId);
|
||||
if (editorState.activeGroupId === groupId) editorState.activeGroupId = group.parentId || null;
|
||||
editorState.selectedLayerIds = selectedIds;
|
||||
normalizeLayerGroups(editorState);
|
||||
return group;
|
||||
}
|
||||
|
||||
export function groupSiblingUnits(editorState, parentId = null) {
|
||||
const groups = normalizeLayerGroups(editorState);
|
||||
const parent = parentId ? groups.find(group => group.id === parentId) : null;
|
||||
if (parentId && !parent) return [];
|
||||
const layerOrder = new Map((editorState.layers || []).map((layer, index) => [layer.id, index]));
|
||||
const units = [];
|
||||
const directIds = parent
|
||||
? parent.layerIds
|
||||
: (editorState.layers || [])
|
||||
.filter(layer => !groups.some(group => group.layerIds.includes(layer.id)))
|
||||
.map(layer => layer.id);
|
||||
for (const layerId of directIds) {
|
||||
if (layerOrder.has(layerId)) units.push({ type: 'layer', id: layerId, layerIds: [layerId] });
|
||||
}
|
||||
for (const group of groups.filter(item => (item.parentId || null) === (parentId || null))) {
|
||||
const layerIds = allLayerIdsInGroup(editorState, group);
|
||||
if (layerIds.length) units.push({ type: 'group', id: group.id, layerIds });
|
||||
}
|
||||
return units.sort((a, b) => {
|
||||
const aIndex = Math.min(...a.layerIds.map(id => layerOrder.get(id)).filter(Number.isInteger));
|
||||
const bIndex = Math.min(...b.layerIds.map(id => layerOrder.get(id)).filter(Number.isInteger));
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
}
|
||||
|
||||
/** Move a complete group subtree among siblings without changing its parent. */
|
||||
export function reorderGroupAmongSiblings(editorState, groupId, targetIndex) {
|
||||
const group = normalizeLayerGroups(editorState).find(item => item.id === groupId);
|
||||
if (!group) return null;
|
||||
const parentId = group.parentId || null;
|
||||
const units = groupSiblingUnits(editorState, parentId);
|
||||
const currentIndex = units.findIndex(unit => unit.type === 'group' && unit.id === groupId);
|
||||
if (currentIndex < 0) return null;
|
||||
const nextIndex = Math.max(0, Math.min(units.length - 1, Math.round(Number(targetIndex))));
|
||||
if (nextIndex === currentIndex) return { currentIndex, targetIndex: nextIndex, changed: false };
|
||||
const [moving] = units.splice(currentIndex, 1);
|
||||
units.splice(nextIndex, 0, moving);
|
||||
|
||||
const scopeIds = new Set(units.flatMap(unit => unit.layerIds));
|
||||
const byId = new Map((editorState.layers || []).map(layer => [layer.id, layer]));
|
||||
const reordered = units.flatMap(unit => unit.layerIds).map(id => byId.get(id)).filter(Boolean);
|
||||
let cursor = 0;
|
||||
editorState.layers = (editorState.layers || []).map(layer =>
|
||||
scopeIds.has(layer.id) ? reordered[cursor++] : layer
|
||||
);
|
||||
return { currentIndex, targetIndex: nextIndex, changed: true, parentId };
|
||||
}
|
||||
|
||||
function groupCanvas(editorState, groupId) {
|
||||
if (!(editorState.groupCompositeCanvases instanceof Map)) editorState.groupCompositeCanvases = new Map();
|
||||
let canvas = editorState.groupCompositeCanvases.get(groupId);
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas');
|
||||
editorState.groupCompositeCanvases.set(groupId, canvas);
|
||||
}
|
||||
if (canvas.width !== editorState.imgWidth) canvas.width = editorState.imgWidth;
|
||||
if (canvas.height !== editorState.imgHeight) canvas.height = editorState.imgHeight;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function drawGroupedLayers(ctx, editorState, renderLayer, renderAdjustment = null, renderGroup = null, shouldContinue = () => true) {
|
||||
ctx.clearRect(0, 0, editorState.imgWidth, editorState.imgHeight);
|
||||
if (!(editorState.groupCompositeCanvases instanceof Map)) editorState.groupCompositeCanvases = new Map();
|
||||
// A hidden or removed group must not keep advertising its previous pixels to
|
||||
// the layer panel while the next document composite is being built.
|
||||
editorState.groupCompositeCanvases.clear();
|
||||
const groups = normalizeLayerGroups(editorState);
|
||||
const byId = new Map(groups.map(group => [group.id, group]));
|
||||
const directGroupByLayer = new Map();
|
||||
const children = new Map();
|
||||
for (const group of groups) {
|
||||
for (const id of group.layerIds) directGroupByLayer.set(id, group.id);
|
||||
const parentId = group.parentId || null;
|
||||
if (!children.has(parentId)) children.set(parentId, []);
|
||||
children.get(parentId).push(group);
|
||||
}
|
||||
const indexes = new Map((editorState.layers || []).map((layer, index) => [layer.id, index]));
|
||||
const boundsMemo = new Map();
|
||||
const groupBounds = group => {
|
||||
if (boundsMemo.has(group.id)) return boundsMemo.get(group.id);
|
||||
const values = [
|
||||
...group.layerIds.map(id => indexes.get(id)).filter(Number.isInteger),
|
||||
...(children.get(group.id) || []).flatMap(child => groupBounds(child)),
|
||||
];
|
||||
const result = values.length ? [Math.min(...values), Math.max(...values)] : [];
|
||||
boundsMemo.set(group.id, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const renderScope = (target, parentId = null) => {
|
||||
const nodes = [];
|
||||
for (const layer of editorState.layers || []) {
|
||||
if ((directGroupByLayer.get(layer.id) || null) === parentId) {
|
||||
nodes.push({ type: 'layer', index: indexes.get(layer.id), layer });
|
||||
}
|
||||
}
|
||||
for (const group of children.get(parentId) || []) {
|
||||
const bounds = groupBounds(group);
|
||||
if (bounds.length) nodes.push({ type: 'group', index: bounds[0], group });
|
||||
}
|
||||
nodes.sort((a, b) => a.index - b.index);
|
||||
let layerRun = [];
|
||||
const flushLayers = () => {
|
||||
if (!layerRun.length) return;
|
||||
drawLayerStack(target, editorState, layerRun, renderLayer, renderAdjustment);
|
||||
layerRun = [];
|
||||
};
|
||||
for (const node of nodes) {
|
||||
if (!shouldContinue()) return;
|
||||
if (node.type === 'layer') {
|
||||
layerRun.push(node.layer);
|
||||
continue;
|
||||
}
|
||||
flushLayers();
|
||||
const group = byId.get(node.group.id);
|
||||
if (!group?.visible) continue;
|
||||
const canvas = groupCanvas(editorState, group.id);
|
||||
const groupCtx = canvas.getContext('2d');
|
||||
groupCtx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
renderScope(groupCtx, group.id);
|
||||
const masks = (group.masks || []).filter(mask => mask.visible !== false && mask.canvas);
|
||||
if (masks.length) {
|
||||
groupCtx.globalAlpha = 1;
|
||||
groupCtx.globalCompositeOperation = 'destination-in';
|
||||
for (const mask of masks) {
|
||||
groupCtx.globalAlpha = Number.isFinite(Number(mask.density))
|
||||
? Math.max(0, Math.min(1, Number(mask.density)))
|
||||
: 1;
|
||||
const feather = Number.isFinite(Number(mask.feather))
|
||||
? Math.max(0, Math.min(200, Number(mask.feather)))
|
||||
: 0;
|
||||
groupCtx.filter = feather > 0 ? `blur(${feather}px)` : 'none';
|
||||
groupCtx.drawImage(mask.canvas, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
groupCtx.globalAlpha = 1;
|
||||
groupCtx.filter = 'none';
|
||||
groupCtx.globalCompositeOperation = 'source-over';
|
||||
const groupOutput = renderGroup
|
||||
? renderGroup(canvas, group, shouldContinue)
|
||||
: (group.effects?.length ? renderEffects(canvas, group.effects, shouldContinue) : canvas);
|
||||
if (!shouldContinue()) return;
|
||||
if (groupOutput?.width && groupOutput?.height) {
|
||||
// Keep the exact final group output for the layer-panel thumbnail. A
|
||||
// raw member fallback loses masks, blending, and group effects.
|
||||
editorState.groupCompositeCanvases.set(group.id, groupOutput);
|
||||
}
|
||||
target.globalAlpha = group.opacity;
|
||||
target.globalCompositeOperation = group.blendMode || 'source-over';
|
||||
target.drawImage(groupOutput, 0, 0);
|
||||
}
|
||||
flushLayers();
|
||||
target.globalAlpha = 1;
|
||||
target.globalCompositeOperation = 'source-over';
|
||||
};
|
||||
|
||||
renderScope(ctx);
|
||||
const validIds = new Set(groups.map(group => group.id));
|
||||
for (const id of editorState.groupCompositeCanvases?.keys?.() || []) {
|
||||
if (!validIds.has(id)) editorState.groupCompositeCanvases.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Async group compositor. It deliberately uses fresh group surfaces so an
|
||||
* older generation cannot overwrite the cached surface used by a newer one.
|
||||
*/
|
||||
export async function drawGroupedLayersAsync(ctx, editorState, renderLayer, renderAdjustment = null, renderGroup = null, shouldContinue = () => true) {
|
||||
ctx.clearRect(0, 0, editorState.imgWidth, editorState.imgHeight);
|
||||
if (!(editorState.groupCompositeCanvases instanceof Map)) editorState.groupCompositeCanvases = new Map();
|
||||
editorState.groupCompositeCanvases.clear();
|
||||
const groups = normalizeLayerGroups(editorState);
|
||||
const byId = new Map(groups.map(group => [group.id, group]));
|
||||
const directGroupByLayer = new Map();
|
||||
const children = new Map();
|
||||
for (const group of groups) {
|
||||
for (const id of group.layerIds) directGroupByLayer.set(id, group.id);
|
||||
const parentId = group.parentId || null;
|
||||
if (!children.has(parentId)) children.set(parentId, []);
|
||||
children.get(parentId).push(group);
|
||||
}
|
||||
const indexes = new Map((editorState.layers || []).map((layer, index) => [layer.id, index]));
|
||||
const boundsMemo = new Map();
|
||||
const groupBounds = group => {
|
||||
if (boundsMemo.has(group.id)) return boundsMemo.get(group.id);
|
||||
const values = [
|
||||
...group.layerIds.map(id => indexes.get(id)).filter(Number.isInteger),
|
||||
...(children.get(group.id) || []).flatMap(child => groupBounds(child)),
|
||||
];
|
||||
const result = values.length ? [Math.min(...values), Math.max(...values)] : [];
|
||||
boundsMemo.set(group.id, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const renderScope = async (target, parentId = null) => {
|
||||
const nodes = [];
|
||||
for (const layer of editorState.layers || []) {
|
||||
if ((directGroupByLayer.get(layer.id) || null) === parentId) {
|
||||
nodes.push({ type: 'layer', index: indexes.get(layer.id), layer });
|
||||
}
|
||||
}
|
||||
for (const group of children.get(parentId) || []) {
|
||||
const bounds = groupBounds(group);
|
||||
if (bounds.length) nodes.push({ type: 'group', index: bounds[0], group });
|
||||
}
|
||||
nodes.sort((a, b) => a.index - b.index);
|
||||
let layerRun = [];
|
||||
const flushLayers = async () => {
|
||||
if (!layerRun.length || !shouldContinue()) return;
|
||||
const run = layerRun;
|
||||
layerRun = [];
|
||||
await drawLayerStackAsync(target, editorState, run, renderLayer, renderAdjustment, shouldContinue);
|
||||
};
|
||||
for (const node of nodes) {
|
||||
if (!shouldContinue()) return;
|
||||
if (node.type === 'layer') {
|
||||
layerRun.push(node.layer);
|
||||
continue;
|
||||
}
|
||||
await flushLayers();
|
||||
if (!shouldContinue()) return;
|
||||
const group = byId.get(node.group.id);
|
||||
if (!group?.visible) continue;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = editorState.imgWidth;
|
||||
canvas.height = editorState.imgHeight;
|
||||
const groupCtx = canvas.getContext('2d');
|
||||
await renderScope(groupCtx, group.id);
|
||||
if (!shouldContinue()) return;
|
||||
const masks = (group.masks || []).filter(mask => mask.visible !== false && mask.canvas);
|
||||
if (masks.length) {
|
||||
groupCtx.globalAlpha = 1;
|
||||
groupCtx.globalCompositeOperation = 'destination-in';
|
||||
for (const mask of masks) {
|
||||
groupCtx.globalAlpha = Number.isFinite(Number(mask.density))
|
||||
? Math.max(0, Math.min(1, Number(mask.density)))
|
||||
: 1;
|
||||
const feather = Number.isFinite(Number(mask.feather))
|
||||
? Math.max(0, Math.min(200, Number(mask.feather)))
|
||||
: 0;
|
||||
groupCtx.filter = feather > 0 ? `blur(${feather}px)` : 'none';
|
||||
groupCtx.drawImage(mask.canvas, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
groupCtx.globalAlpha = 1;
|
||||
groupCtx.filter = 'none';
|
||||
groupCtx.globalCompositeOperation = 'source-over';
|
||||
const groupOutput = renderGroup
|
||||
? await renderGroup(canvas, group, shouldContinue)
|
||||
: (group.effects?.length ? await renderEffectsAsync(canvas, group.effects, shouldContinue) : canvas);
|
||||
if (!shouldContinue() || !groupOutput) return;
|
||||
if (groupOutput.width && groupOutput.height) {
|
||||
editorState.groupCompositeCanvases.set(group.id, groupOutput);
|
||||
}
|
||||
target.globalAlpha = group.opacity;
|
||||
target.globalCompositeOperation = group.blendMode || 'source-over';
|
||||
target.drawImage(groupOutput, 0, 0);
|
||||
}
|
||||
await flushLayers();
|
||||
target.globalAlpha = 1;
|
||||
target.globalCompositeOperation = 'source-over';
|
||||
};
|
||||
|
||||
await renderScope(ctx);
|
||||
}
|
||||
@@ -53,25 +53,134 @@ export function adjustmentsKey(adj) {
|
||||
export function defaultAdjParams(type) {
|
||||
switch (type) {
|
||||
case 'brightness-contrast': return { brightness: 1, contrast: 1 };
|
||||
case 'hue-saturation': return { hue: 0, saturation: 1 };
|
||||
case 'levels': return { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 };
|
||||
case 'exposure': return { exposure: 0, offset: 0, gamma: 1 };
|
||||
case 'white-balance': return { temperature: 0, tint: 0 };
|
||||
case 'hue-saturation': return { hue: 0, saturation: 1, lightness: 0 };
|
||||
case 'vibrance': return { vibrance: 0 };
|
||||
case 'black-white': return { red: 30, green: 59, blue: 11, constant: 0 };
|
||||
case 'shadows-highlights': return { shadows: 0, highlights: 0 };
|
||||
case 'levels': return {
|
||||
channel: 'rgb',
|
||||
inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255,
|
||||
channels: {
|
||||
red: { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 },
|
||||
green: { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 },
|
||||
blue: { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 },
|
||||
},
|
||||
};
|
||||
case 'curves': return {
|
||||
channel: 'rgb',
|
||||
points: {
|
||||
rgb: [[0, 0], [255, 255]],
|
||||
red: [[0, 0], [255, 255]],
|
||||
green: [[0, 0], [255, 255]],
|
||||
blue: [[0, 0], [255, 255]],
|
||||
},
|
||||
};
|
||||
case 'color-balance': return {
|
||||
shadows: { r: 0, g: 0, b: 0 },
|
||||
midtones: { r: 0, g: 0, b: 0 },
|
||||
highlights: { r: 0, g: 0, b: 0 },
|
||||
};
|
||||
case 'selective-color': return {
|
||||
range: 'reds',
|
||||
ranges: Object.fromEntries(
|
||||
['reds', 'yellows', 'greens', 'cyans', 'blues', 'magentas', 'neutrals', 'blacks']
|
||||
.map(name => [name, { cyan: 0, magenta: 0, yellow: 0, black: 0 }]),
|
||||
),
|
||||
};
|
||||
case 'gradient-map': return {
|
||||
shadows: '#000000',
|
||||
highlights: '#ffffff',
|
||||
midpoint: 50,
|
||||
reverse: false,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Preset names for the retained adjustment popup. */
|
||||
export function adjustmentPresetOptions(type) {
|
||||
const options = {
|
||||
'brightness-contrast': ['Default', 'High Contrast'],
|
||||
'exposure': ['Default', 'Lift Exposure', 'Lower Exposure'],
|
||||
'white-balance': ['Default', 'Warm', 'Cool'],
|
||||
'hue-saturation': ['Default', 'Desaturate', 'Boost Color'],
|
||||
'vibrance': ['Default', 'Muted', 'Color Pop'],
|
||||
'black-white': ['Default', 'High Contrast'],
|
||||
'shadows-highlights': ['Default', 'Lift Shadows', 'Recover Highlights'],
|
||||
'levels': ['Default', 'Auto Contrast'],
|
||||
'curves': ['Default', 'S-Curve'],
|
||||
'color-balance': ['Default', 'Warm Highlights', 'Cool Shadows'],
|
||||
'selective-color': ['Default', 'Deep Reds'],
|
||||
'gradient-map': ['Default', 'Warm Tone', 'Cool Tone'],
|
||||
};
|
||||
return options[type] || ['Default'];
|
||||
}
|
||||
|
||||
/** Return a fresh parameter object for a named adjustment preset. */
|
||||
export function adjustmentPresetParams(type, preset) {
|
||||
const params = defaultAdjParams(type);
|
||||
switch (`${type}:${preset}`) {
|
||||
case 'brightness-contrast:High Contrast':
|
||||
return { ...params, contrast: 1.28 };
|
||||
case 'exposure:Lift Exposure':
|
||||
return { ...params, exposure: 0.45 };
|
||||
case 'exposure:Lower Exposure':
|
||||
return { ...params, exposure: -0.45 };
|
||||
case 'white-balance:Warm':
|
||||
return { ...params, temperature: 35 };
|
||||
case 'white-balance:Cool':
|
||||
return { ...params, temperature: -35 };
|
||||
case 'hue-saturation:Desaturate':
|
||||
return { ...params, saturation: 0.35 };
|
||||
case 'hue-saturation:Boost Color':
|
||||
return { ...params, saturation: 1.35 };
|
||||
case 'vibrance:Muted':
|
||||
return { ...params, vibrance: -30 };
|
||||
case 'vibrance:Color Pop':
|
||||
return { ...params, vibrance: 45 };
|
||||
case 'black-white:High Contrast':
|
||||
return { ...params, red: 38, green: 54, blue: 8, constant: 0 };
|
||||
case 'shadows-highlights:Lift Shadows':
|
||||
return { ...params, shadows: 38 };
|
||||
case 'shadows-highlights:Recover Highlights':
|
||||
return { ...params, highlights: -38 };
|
||||
case 'levels:Auto Contrast':
|
||||
return { ...params, inBlack: 12, inWhite: 243 };
|
||||
case 'curves:S-Curve':
|
||||
return { ...params, points: { ...params.points, rgb: [[0, 12], [76, 64], [178, 194], [255, 243]] } };
|
||||
case 'color-balance:Warm Highlights':
|
||||
return { ...params, highlights: { r: 16, g: 4, b: -14 } };
|
||||
case 'color-balance:Cool Shadows':
|
||||
return { ...params, shadows: { r: -12, g: 3, b: 16 } };
|
||||
case 'selective-color:Deep Reds':
|
||||
return { ...params, ranges: { ...params.ranges, reds: { ...params.ranges.reds, black: -18 } } };
|
||||
case 'gradient-map:Warm Tone':
|
||||
return { ...params, shadows: '#21151a', highlights: '#ffd59a' };
|
||||
case 'gradient-map:Cool Tone':
|
||||
return { ...params, shadows: '#101c36', highlights: '#d8f2ff' };
|
||||
default:
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Human-readable name for an adjustment type. */
|
||||
export function adjLayerLabel(type) {
|
||||
return {
|
||||
'brightness-contrast': 'Brightness/Contrast',
|
||||
'exposure': 'Exposure',
|
||||
'white-balance': 'White Balance',
|
||||
'hue-saturation': 'Hue/Saturation',
|
||||
'vibrance': 'Vibrance',
|
||||
'black-white': 'Black & White',
|
||||
'shadows-highlights': 'Shadows/Highlights',
|
||||
'levels': 'Levels',
|
||||
'curves': 'Curves',
|
||||
'color-balance': 'Color Balance',
|
||||
'selective-color': 'Selective Color',
|
||||
'gradient-map': 'Gradient Map',
|
||||
}[type] || type;
|
||||
}
|
||||
|
||||
@@ -83,9 +192,17 @@ export function adjLayerLabel(type) {
|
||||
*/
|
||||
export const ADJ_ICONS = {
|
||||
'brightness-contrast': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18Z" fill="currentColor" stroke="none"/></svg>',
|
||||
'exposure': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 4.5 13H11l-1 9 8.5-11H12z"/></svg>',
|
||||
'white-balance': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v10"/><circle cx="12" cy="17" r="4"/><path d="M9 6h3"/></svg>',
|
||||
'hue-saturation': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="12" r="4"/><circle cx="15" cy="9.5" r="4"/><circle cx="15" cy="14.5" r="4"/></svg>',
|
||||
'vibrance': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 18 10 6l4 12 6-12"/><path d="M7 14h10"/></svg>',
|
||||
'black-white': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18Z" fill="currentColor" stroke="none"/></svg>',
|
||||
'shadows-highlights': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="4"/><path d="M14 14h7M17.5 10.5v7"/><path d="M5 17h6"/></svg>',
|
||||
'levels': '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="14" width="3" height="6" rx="0.5"/><rect x="8" y="9" width="3" height="11" rx="0.5"/><rect x="13" y="11" width="3" height="9" rx="0.5"/><rect x="18" y="6" width="3" height="14" rx="0.5"/></svg>',
|
||||
'curves': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19C7 19 8 5 13 5s4 8 7 8"/><path d="M4 4v16h16"/></svg>',
|
||||
'color-balance': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12a9 9 0 0 1 9-9v18a9 9 0 0 1-9-9z" fill="currentColor" stroke="none"/></svg>',
|
||||
'selective-color': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v6M21 12h-6M12 21v-6M3 12h6"/></svg>',
|
||||
'gradient-map': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="18" height="12" rx="2"/><path d="M12 6v12"/></svg>',
|
||||
};
|
||||
|
||||
|
||||
|
||||
+1541
-67
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
/** Shared multi-layer selection semantics for panel and canvas tools. */
|
||||
import { isLayerEffectivelyLocked } from './layer-groups.js';
|
||||
|
||||
function existingIds(editorState) {
|
||||
return new Set((editorState.layers || []).map(layer => layer.id));
|
||||
}
|
||||
|
||||
export function normalizeLayerSelection(editorState) {
|
||||
const valid = existingIds(editorState);
|
||||
const selected = [];
|
||||
for (const id of editorState.selectedLayerIds || []) {
|
||||
if (valid.has(id) && !selected.includes(id)) selected.push(id);
|
||||
}
|
||||
if (valid.has(editorState.activeLayerId) && !selected.includes(editorState.activeLayerId)) {
|
||||
selected.length = 0;
|
||||
selected.push(editorState.activeLayerId);
|
||||
editorState.activeGroupId = null;
|
||||
}
|
||||
if (!selected.length && editorState.layers?.length) {
|
||||
const fallback = editorState.layers[editorState.layers.length - 1];
|
||||
editorState.activeLayerId = fallback.id;
|
||||
selected.push(fallback.id);
|
||||
editorState.activeGroupId = null;
|
||||
}
|
||||
editorState.selectedLayerIds = selected;
|
||||
if (!valid.has(editorState.selectionAnchorId)) {
|
||||
editorState.selectionAnchorId = editorState.activeLayerId || null;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function selectedLayers(editorState, { unlockedOnly = false } = {}) {
|
||||
const ids = new Set(normalizeLayerSelection(editorState));
|
||||
return (editorState.layers || []).filter(layer =>
|
||||
ids.has(layer.id) && (!unlockedOnly || !isLayerEffectivelyLocked(editorState, layer))
|
||||
);
|
||||
}
|
||||
|
||||
export function selectOnlyLayer(editorState, layerId) {
|
||||
if (!(editorState.layers || []).some(layer => layer.id === layerId)) return [];
|
||||
editorState.activeLayerId = layerId;
|
||||
editorState.selectionAnchorId = layerId;
|
||||
editorState.selectedLayerIds = [layerId];
|
||||
editorState.activeGroupId = null;
|
||||
return editorState.selectedLayerIds;
|
||||
}
|
||||
|
||||
export function toggleLayerSelection(editorState, layerId) {
|
||||
const valid = (editorState.layers || []).some(layer => layer.id === layerId);
|
||||
if (!valid) return normalizeLayerSelection(editorState);
|
||||
const selected = normalizeLayerSelection(editorState);
|
||||
const index = selected.indexOf(layerId);
|
||||
if (index >= 0 && selected.length > 1) {
|
||||
selected.splice(index, 1);
|
||||
if (editorState.activeLayerId === layerId) {
|
||||
editorState.activeLayerId = selected[selected.length - 1];
|
||||
}
|
||||
} else if (index < 0) {
|
||||
selected.push(layerId);
|
||||
editorState.activeLayerId = layerId;
|
||||
}
|
||||
editorState.selectionAnchorId = layerId;
|
||||
editorState.selectedLayerIds = selected;
|
||||
editorState.activeGroupId = null;
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function selectLayerRange(editorState, layerId) {
|
||||
const layers = editorState.layers || [];
|
||||
const target = layers.findIndex(layer => layer.id === layerId);
|
||||
if (target < 0) return normalizeLayerSelection(editorState);
|
||||
const anchorId = editorState.selectionAnchorId || editorState.activeLayerId || layerId;
|
||||
const anchor = layers.findIndex(layer => layer.id === anchorId);
|
||||
if (anchor < 0) return selectOnlyLayer(editorState, layerId);
|
||||
const start = Math.min(anchor, target);
|
||||
const end = Math.max(anchor, target);
|
||||
editorState.selectedLayerIds = layers.slice(start, end + 1).map(layer => layer.id);
|
||||
editorState.activeLayerId = layerId;
|
||||
editorState.activeGroupId = null;
|
||||
return editorState.selectedLayerIds;
|
||||
}
|
||||
|
||||
export function selectAllLayers(editorState) {
|
||||
editorState.selectedLayerIds = (editorState.layers || []).map(layer => layer.id);
|
||||
if (editorState.selectedLayerIds.length && !editorState.selectedLayerIds.includes(editorState.activeLayerId)) {
|
||||
editorState.activeLayerId = editorState.selectedLayerIds[editorState.selectedLayerIds.length - 1];
|
||||
}
|
||||
editorState.selectionAnchorId = editorState.activeLayerId || null;
|
||||
editorState.activeGroupId = null;
|
||||
return editorState.selectedLayerIds;
|
||||
}
|
||||
|
||||
export function selectionLabel(editorState, singular = 'layer') {
|
||||
const count = normalizeLayerSelection(editorState).length;
|
||||
return count === 1 ? singular : `${count} layers`;
|
||||
}
|
||||
@@ -46,6 +46,33 @@ export function dilateMask(src, px) {
|
||||
return tmp;
|
||||
}
|
||||
|
||||
/** Invert a mask's reveal amount while preserving partial opacity. */
|
||||
export function invertMaskInPlace(canvas) {
|
||||
if (!canvas?.width || !canvas?.height) return false;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return false;
|
||||
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
for (let index = 0; index < image.data.length; index += 4) {
|
||||
const alpha = 255 - image.data[index + 3];
|
||||
image.data[index] = 255;
|
||||
image.data[index + 1] = 255;
|
||||
image.data[index + 2] = 255;
|
||||
image.data[index + 3] = alpha;
|
||||
}
|
||||
ctx.putImageData(image, 0, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function normalizeMaskDensity(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.max(0, Math.min(1, parsed)) : 1;
|
||||
}
|
||||
|
||||
export function normalizeMaskFeather(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.max(0, Math.min(200, parsed)) : 0;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Re-derive an inpaint-result layer's alpha from its cached AI image +
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/** Shared-bounds geometry for transforming one or many layers. */
|
||||
|
||||
export function selectionBounds(editorState, layers) {
|
||||
if (!layers?.length) return null;
|
||||
let left = Infinity;
|
||||
let top = Infinity;
|
||||
let right = -Infinity;
|
||||
let bottom = -Infinity;
|
||||
for (const layer of layers) {
|
||||
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
left = Math.min(left, offset.x);
|
||||
top = Math.min(top, offset.y);
|
||||
right = Math.max(right, offset.x + Math.max(1, layer.canvas?.width || 1));
|
||||
bottom = Math.max(bottom, offset.y + Math.max(1, layer.canvas?.height || 1));
|
||||
}
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
width: Math.max(1, right - left),
|
||||
height: Math.max(1, bottom - top),
|
||||
centerX: (left + right) / 2,
|
||||
centerY: (top + bottom) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformedLayerGeometry(snapshot, sourceBounds, target) {
|
||||
const scaleX = Math.max(1, target.width) / Math.max(1, sourceBounds.width);
|
||||
const scaleY = Math.max(1, target.height) / Math.max(1, sourceBounds.height);
|
||||
const signedScaleX = target.flipH ? -scaleX : scaleX;
|
||||
const signedScaleY = target.flipV ? -scaleY : scaleY;
|
||||
const radians = (target.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(radians);
|
||||
const sin = Math.sin(radians);
|
||||
const absCos = Math.abs(cos);
|
||||
const absSin = Math.abs(sin);
|
||||
const sourceCenterX = snapshot.offset.x + snapshot.width / 2;
|
||||
const sourceCenterY = snapshot.offset.y + snapshot.height / 2;
|
||||
const relativeX = (sourceCenterX - sourceBounds.centerX) * signedScaleX;
|
||||
const relativeY = (sourceCenterY - sourceBounds.centerY) * signedScaleY;
|
||||
const centerX = target.centerX + relativeX * cos - relativeY * sin;
|
||||
const centerY = target.centerY + relativeX * sin + relativeY * cos;
|
||||
const scaledWidth = snapshot.width * scaleX;
|
||||
const scaledHeight = snapshot.height * scaleY;
|
||||
const width = Math.max(1, Math.round(scaledWidth * absCos + scaledHeight * absSin));
|
||||
const height = Math.max(1, Math.round(scaledWidth * absSin + scaledHeight * absCos));
|
||||
return {
|
||||
scaleX,
|
||||
scaleY,
|
||||
signedScaleX,
|
||||
signedScaleY,
|
||||
rotation: target.rotation,
|
||||
radians,
|
||||
width,
|
||||
height,
|
||||
centerX,
|
||||
centerY,
|
||||
offset: {
|
||||
x: Math.round(centerX - width / 2),
|
||||
y: Math.round(centerY - height / 2),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function transformedSelectionBounds(sourceBounds, target) {
|
||||
const radians = (target.rotation * Math.PI) / 180;
|
||||
const cos = Math.abs(Math.cos(radians));
|
||||
const sin = Math.abs(Math.sin(radians));
|
||||
const width = Math.max(1, target.width * cos + target.height * sin);
|
||||
const height = Math.max(1, target.width * sin + target.height * cos);
|
||||
return {
|
||||
x: target.centerX - width / 2,
|
||||
y: target.centerY - height / 2,
|
||||
width,
|
||||
height,
|
||||
centerX: target.centerX,
|
||||
centerY: target.centerY,
|
||||
};
|
||||
}
|
||||
|
||||
function canvasPixels(canvas) {
|
||||
const width = Number(canvas?.width) || 0;
|
||||
const height = Number(canvas?.height) || 0;
|
||||
return Math.max(0, width) * Math.max(0, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a transform plan without allocating output canvases. The project
|
||||
* decoder and the live editor share the same limits so a transform cannot
|
||||
* create a document that the editor would later refuse to reopen.
|
||||
*/
|
||||
export function planLayerTransform(editorState, snapshots, sourceBounds, target, limits) {
|
||||
const maxDimension = Number(limits?.maxDimension) || 32768;
|
||||
const maxSurfacePixels = Number(limits?.maxSurfacePixels) || 300_000_000;
|
||||
const numericTarget = [
|
||||
target?.width, target?.height, target?.rotation,
|
||||
target?.centerX, target?.centerY,
|
||||
].every(Number.isFinite);
|
||||
if (!numericTarget || target.width < 1 || target.height < 1) {
|
||||
return { ok: false, reason: 'Transform values must be finite positive numbers.' };
|
||||
}
|
||||
|
||||
const planned = [];
|
||||
const byLayerId = new Map();
|
||||
for (const snapshot of snapshots || []) {
|
||||
const geometry = transformedLayerGeometry(snapshot, sourceBounds, target);
|
||||
if (![geometry.width, geometry.height, geometry.offset.x, geometry.offset.y].every(Number.isFinite)) {
|
||||
return { ok: false, reason: 'Transform geometry is outside the supported numeric range.' };
|
||||
}
|
||||
if (geometry.width > maxDimension || geometry.height > maxDimension) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `A transformed layer would exceed the ${maxDimension.toLocaleString()} px dimension limit.`,
|
||||
};
|
||||
}
|
||||
const item = { snapshot, geometry };
|
||||
planned.push(item);
|
||||
byLayerId.set(snapshot.layer.id, item);
|
||||
}
|
||||
|
||||
let surfacePixels = 0;
|
||||
const addPixels = pixels => {
|
||||
surfacePixels += pixels;
|
||||
return surfacePixels <= maxSurfacePixels;
|
||||
};
|
||||
for (const layer of editorState.layers || []) {
|
||||
const item = byLayerId.get(layer.id);
|
||||
if (!addPixels(item ? item.geometry.width * item.geometry.height : canvasPixels(layer.canvas))) {
|
||||
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
|
||||
}
|
||||
if (layer.kind === 'placed' && layer.placed?.sourceCanvas && !addPixels(canvasPixels(layer.placed.sourceCanvas))) {
|
||||
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
|
||||
}
|
||||
for (const mask of layer.masks || []) {
|
||||
const maskSnapshot = item?.snapshot.masks?.find(candidate => candidate.mask === mask);
|
||||
const pixels = maskSnapshot?.linked
|
||||
? item.geometry.width * item.geometry.height
|
||||
: canvasPixels(mask.canvas);
|
||||
if (!addPixels(pixels)) {
|
||||
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const group of editorState.layerGroups || []) {
|
||||
for (const mask of group.masks || []) {
|
||||
if (!addPixels(canvasPixels(mask.canvas))) {
|
||||
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const selection of editorState.savedSelections || []) {
|
||||
if (!addPixels(canvasPixels(selection.canvas))) {
|
||||
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
|
||||
}
|
||||
}
|
||||
if (editorState.wandMask && !addPixels(canvasPixels(editorState.wandMask))) {
|
||||
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
|
||||
}
|
||||
return { ok: true, surfacePixels, items: planned };
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/** Non-destructive image layers backed by immutable source pixels. */
|
||||
|
||||
const IDENTITY = Object.freeze([1, 0, 0, 1, 0, 0]);
|
||||
|
||||
export function normalizeAffine(value, fallback = IDENTITY) {
|
||||
const source = Array.isArray(value) && value.length === 6 ? value : fallback;
|
||||
const matrix = source.map(Number);
|
||||
return matrix.every(Number.isFinite) ? matrix : [...fallback];
|
||||
}
|
||||
|
||||
export function multiplyAffine(left, right) {
|
||||
const [a, b, c, d, e, f] = normalizeAffine(left);
|
||||
const [g, h, i, j, k, l] = normalizeAffine(right);
|
||||
return [
|
||||
a * g + c * h,
|
||||
b * g + d * h,
|
||||
a * i + c * j,
|
||||
b * i + d * j,
|
||||
a * k + c * l + e,
|
||||
b * k + d * l + f,
|
||||
];
|
||||
}
|
||||
|
||||
export function transformPoint(matrix, x, y) {
|
||||
const [a, b, c, d, e, f] = normalizeAffine(matrix);
|
||||
return { x: a * x + c * y + e, y: b * x + d * y + f };
|
||||
}
|
||||
|
||||
export function affineBounds(matrix, width, height) {
|
||||
const points = [
|
||||
transformPoint(matrix, 0, 0),
|
||||
transformPoint(matrix, width, 0),
|
||||
transformPoint(matrix, width, height),
|
||||
transformPoint(matrix, 0, height),
|
||||
];
|
||||
const minX = Math.floor(Math.min(...points.map(point => point.x)));
|
||||
const minY = Math.floor(Math.min(...points.map(point => point.y)));
|
||||
const maxX = Math.ceil(Math.max(...points.map(point => point.x)));
|
||||
const maxY = Math.ceil(Math.max(...points.map(point => point.y)));
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: Math.max(1, maxX - minX),
|
||||
height: Math.max(1, maxY - minY),
|
||||
centerX: (minX + maxX) / 2,
|
||||
centerY: (minY + maxY) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function initialPlacedMatrix(sourceWidth, sourceHeight, frame) {
|
||||
const width = Math.max(1, Number(sourceWidth) || 1);
|
||||
const height = Math.max(1, Number(sourceHeight) || 1);
|
||||
return [
|
||||
Math.max(1, Number(frame?.width) || width) / width,
|
||||
0,
|
||||
0,
|
||||
Math.max(1, Number(frame?.height) || height) / height,
|
||||
Number(frame?.x) || 0,
|
||||
Number(frame?.y) || 0,
|
||||
];
|
||||
}
|
||||
|
||||
/** Matrix that maps the session's document-space frame to its target frame. */
|
||||
export function frameTransformMatrix(sourceBounds, target) {
|
||||
const scaleX = Math.max(1, target.width) / Math.max(1, sourceBounds.width);
|
||||
const scaleY = Math.max(1, target.height) / Math.max(1, sourceBounds.height);
|
||||
const sx = target.flipH ? -scaleX : scaleX;
|
||||
const sy = target.flipV ? -scaleY : scaleY;
|
||||
const radians = (Number(target.rotation) || 0) * Math.PI / 180;
|
||||
const cos = Math.cos(radians);
|
||||
const sin = Math.sin(radians);
|
||||
return multiplyAffine(
|
||||
[1, 0, 0, 1, target.centerX, target.centerY],
|
||||
multiplyAffine(
|
||||
[cos, sin, -sin, cos, 0, 0],
|
||||
multiplyAffine([sx, 0, 0, sy, 0, 0], [1, 0, 0, 1, -sourceBounds.centerX, -sourceBounds.centerY]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function copyCanvas(source) {
|
||||
if (!source) return null;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Number(source.width) || 1);
|
||||
canvas.height = Math.max(1, Number(source.height) || 1);
|
||||
canvas.getContext('2d').drawImage(source, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function createPlacedData(sourceCanvas, matrix, sourceName = 'Placed image') {
|
||||
if (!sourceCanvas?.width || !sourceCanvas?.height) throw new Error('Placed source is empty.');
|
||||
return {
|
||||
sourceCanvas: copyCanvas(sourceCanvas),
|
||||
sourceWidth: sourceCanvas.width,
|
||||
sourceHeight: sourceCanvas.height,
|
||||
sourceName: String(sourceName || 'Placed image').slice(0, 200),
|
||||
matrix: normalizeAffine(matrix),
|
||||
};
|
||||
}
|
||||
|
||||
export function clonePlacedData(placed, { copySource = false } = {}) {
|
||||
if (!placed?.sourceCanvas) return null;
|
||||
return {
|
||||
sourceCanvas: copySource ? copyCanvas(placed.sourceCanvas) : placed.sourceCanvas,
|
||||
sourceWidth: placed.sourceWidth || placed.sourceCanvas.width,
|
||||
sourceHeight: placed.sourceHeight || placed.sourceCanvas.height,
|
||||
sourceName: placed.sourceName || 'Placed image',
|
||||
matrix: normalizeAffine(placed.matrix),
|
||||
};
|
||||
}
|
||||
|
||||
/** Render from the immutable source and return the layer's document offset. */
|
||||
export function renderPlacedLayer(layer, placed = layer?.placed) {
|
||||
if (!layer || !placed?.sourceCanvas) return null;
|
||||
const sourceWidth = placed.sourceWidth || placed.sourceCanvas.width;
|
||||
const sourceHeight = placed.sourceHeight || placed.sourceCanvas.height;
|
||||
const bounds = affineBounds(placed.matrix, sourceWidth, sourceHeight);
|
||||
const canvas = layer.canvas || document.createElement('canvas');
|
||||
canvas.width = bounds.width;
|
||||
canvas.height = bounds.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const [a, b, c, d, e, f] = normalizeAffine(placed.matrix);
|
||||
ctx.setTransform(a, b, c, d, e - bounds.x, f - bounds.y);
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(placed.sourceCanvas, 0, 0, sourceWidth, sourceHeight);
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
layer.canvas = canvas;
|
||||
layer.ctx = ctx;
|
||||
layer.kind = 'placed';
|
||||
layer.placed = placed;
|
||||
layer._adjCache = null;
|
||||
layer._adjCacheKey = null;
|
||||
layer._adjFinal = null;
|
||||
layer._adjFinalKey = null;
|
||||
return { canvas, offset: { x: bounds.x, y: bounds.y }, bounds };
|
||||
}
|
||||
|
||||
export function transformPlacedData(placed, documentTransform) {
|
||||
return {
|
||||
...clonePlacedData(placed),
|
||||
matrix: multiplyAffine(documentTransform, placed.matrix),
|
||||
};
|
||||
}
|
||||
|
||||
export function translatePlacedData(placed, dx, dy) {
|
||||
return transformPlacedData(placed, [1, 0, 0, 1, Number(dx) || 0, Number(dy) || 0]);
|
||||
}
|
||||
|
||||
/** Replace pixels while preserving the exact four document-space corners. */
|
||||
export function replacePlacedSource(layer, sourceCanvas, sourceName = 'Placed image') {
|
||||
if (layer?.kind !== 'placed' || !layer.placed?.sourceCanvas) return null;
|
||||
if (!sourceCanvas?.width || !sourceCanvas?.height) throw new Error('Replacement source is empty.');
|
||||
const oldWidth = layer.placed.sourceWidth || layer.placed.sourceCanvas.width;
|
||||
const oldHeight = layer.placed.sourceHeight || layer.placed.sourceCanvas.height;
|
||||
const [a, b, c, d, e, f] = normalizeAffine(layer.placed.matrix);
|
||||
layer.placed = createPlacedData(sourceCanvas, [
|
||||
a * oldWidth / sourceCanvas.width,
|
||||
b * oldWidth / sourceCanvas.width,
|
||||
c * oldHeight / sourceCanvas.height,
|
||||
d * oldHeight / sourceCanvas.height,
|
||||
e,
|
||||
f,
|
||||
], sourceName);
|
||||
return renderPlacedLayer(layer);
|
||||
}
|
||||
|
||||
export function rasterizePlacedLayer(layer) {
|
||||
if (layer?.kind !== 'placed') return false;
|
||||
layer.kind = 'raster';
|
||||
layer.placed = null;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/** Rulers, grid, and document-space guides. These are viewport overlays only. */
|
||||
import { state } from './state.js';
|
||||
|
||||
export const RULER_SIZE = 18;
|
||||
|
||||
export function chooseRulerStep(zoom, minimumPixels = 48) {
|
||||
const safeZoom = Math.max(0.0001, Number(zoom) || 1);
|
||||
const target = minimumPixels / safeZoom;
|
||||
const magnitude = 10 ** Math.floor(Math.log10(Math.max(1, target)));
|
||||
for (const factor of [1, 2, 5, 10]) {
|
||||
const step = magnitude * factor;
|
||||
if (step >= target) return step;
|
||||
}
|
||||
return magnitude * 10;
|
||||
}
|
||||
|
||||
export function pointerToDocument(clientX, clientY, canvasRect, width, height) {
|
||||
const scaleX = canvasRect.width / Math.max(1, width);
|
||||
const scaleY = canvasRect.height / Math.max(1, height);
|
||||
return {
|
||||
x: (clientX - canvasRect.left) / Math.max(scaleX, 0.0001),
|
||||
y: (clientY - canvasRect.top) / Math.max(scaleY, 0.0001),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedGuides() {
|
||||
if (!state.guides || typeof state.guides !== 'object') state.guides = { vertical: [], horizontal: [] };
|
||||
state.guides.vertical = Array.isArray(state.guides.vertical) ? state.guides.vertical : [];
|
||||
state.guides.horizontal = Array.isArray(state.guides.horizontal) ? state.guides.horizontal : [];
|
||||
return state.guides;
|
||||
}
|
||||
|
||||
export function createPrecisionGuides({ canvasArea, composite, saveState, schedulePersist }) {
|
||||
const corner = document.createElement('div');
|
||||
corner.className = 'ge-ruler-corner';
|
||||
const horizontal = document.createElement('canvas');
|
||||
horizontal.className = 'ge-ruler ge-ruler-horizontal';
|
||||
horizontal.title = 'Drag down to add a horizontal guide';
|
||||
const vertical = document.createElement('canvas');
|
||||
vertical.className = 'ge-ruler ge-ruler-vertical';
|
||||
vertical.title = 'Drag right to add a vertical guide';
|
||||
canvasArea.append(corner, horizontal, vertical);
|
||||
|
||||
let drag = null;
|
||||
let frame = null;
|
||||
|
||||
function themeColors() {
|
||||
const styles = getComputedStyle(state.container || canvasArea);
|
||||
return {
|
||||
background: styles.getPropertyValue('--panel').trim() || '#111',
|
||||
foreground: styles.getPropertyValue('--fg').trim() || '#ddd',
|
||||
border: styles.getPropertyValue('--border').trim() || '#333',
|
||||
};
|
||||
}
|
||||
|
||||
function prepareRuler(canvas) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const ratio = Math.max(1, window.devicePixelRatio || 1);
|
||||
const width = Math.max(1, Math.round(rect.width * ratio));
|
||||
const height = Math.max(1, Math.round(rect.height * ratio));
|
||||
if (canvas.width !== width) canvas.width = width;
|
||||
if (canvas.height !== height) canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
return { ctx, rect };
|
||||
}
|
||||
|
||||
function drawRuler(canvas, orientation) {
|
||||
if (!state.rulersVisible || !state.mainCanvas) return;
|
||||
const { ctx, rect } = prepareRuler(canvas);
|
||||
const areaRect = canvasArea.getBoundingClientRect();
|
||||
const imageRect = state.mainCanvas.getBoundingClientRect();
|
||||
const colors = themeColors();
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
ctx.fillStyle = colors.background;
|
||||
ctx.fillRect(0, 0, rect.width, rect.height);
|
||||
ctx.strokeStyle = colors.border;
|
||||
ctx.beginPath();
|
||||
if (orientation === 'horizontal') {
|
||||
ctx.moveTo(0, rect.height - 0.5); ctx.lineTo(rect.width, rect.height - 0.5);
|
||||
} else {
|
||||
ctx.moveTo(rect.width - 0.5, 0); ctx.lineTo(rect.width - 0.5, rect.height);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
const scale = orientation === 'horizontal'
|
||||
? imageRect.width / Math.max(1, state.imgWidth)
|
||||
: imageRect.height / Math.max(1, state.imgHeight);
|
||||
const origin = orientation === 'horizontal'
|
||||
? imageRect.left - areaRect.left - RULER_SIZE
|
||||
: imageRect.top - areaRect.top - RULER_SIZE;
|
||||
const limit = orientation === 'horizontal' ? state.imgWidth : state.imgHeight;
|
||||
const major = chooseRulerStep(scale);
|
||||
const minor = major / 5;
|
||||
ctx.strokeStyle = colors.foreground;
|
||||
ctx.fillStyle = colors.foreground;
|
||||
ctx.globalAlpha = 0.62;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.font = '8px ui-monospace, monospace';
|
||||
ctx.textBaseline = 'top';
|
||||
for (let value = 0; value <= limit + 0.001; value += minor) {
|
||||
const pixel = origin + value * scale;
|
||||
const rulerLimit = orientation === 'horizontal' ? rect.width : rect.height;
|
||||
if (pixel < -2 || pixel > rulerLimit + 2) continue;
|
||||
const isMajor = Math.round(value / minor) % 5 === 0;
|
||||
if (orientation === 'horizontal') {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(Math.round(pixel) + 0.5, rect.height);
|
||||
ctx.lineTo(Math.round(pixel) + 0.5, rect.height - (isMajor ? 9 : 4));
|
||||
ctx.stroke();
|
||||
if (isMajor) ctx.fillText(String(Math.round(value)), pixel + 2, 1);
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(rect.width, Math.round(pixel) + 0.5);
|
||||
ctx.lineTo(rect.width - (isMajor ? 9 : 4), Math.round(pixel) + 0.5);
|
||||
ctx.stroke();
|
||||
if (isMajor) {
|
||||
ctx.save();
|
||||
ctx.translate(1, pixel - 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.fillText(String(Math.round(value)), 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function redrawRulers() {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
canvasArea.classList.toggle('ge-rulers-visible', !!state.rulersVisible);
|
||||
corner.hidden = !state.rulersVisible;
|
||||
horizontal.hidden = !state.rulersVisible;
|
||||
vertical.hidden = !state.rulersVisible;
|
||||
if (state.rulersVisible) {
|
||||
drawRuler(horizontal, 'horizontal');
|
||||
drawRuler(vertical, 'vertical');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function drawDocumentOverlay(ctx) {
|
||||
if (!ctx || !state.imgWidth || !state.imgHeight) return;
|
||||
ctx.save();
|
||||
if (state.gridVisible) {
|
||||
const base = Math.max(2, Number(state.gridSize) || 16);
|
||||
const multiplier = Math.max(1, Math.ceil(5 / Math.max(0.0001, base * state.zoom)));
|
||||
const spacing = base * multiplier;
|
||||
ctx.lineWidth = 1 / state.zoom;
|
||||
for (let x = spacing; x < state.imgWidth; x += spacing) {
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, state.imgHeight);
|
||||
ctx.strokeStyle = Math.round(x / spacing) % 4 === 0
|
||||
? 'rgba(110, 180, 220, 0.42)' : 'rgba(110, 180, 220, 0.22)';
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = spacing; y < state.imgHeight; y += spacing) {
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(state.imgWidth, y);
|
||||
ctx.strokeStyle = Math.round(y / spacing) % 4 === 0
|
||||
? 'rgba(110, 180, 220, 0.42)' : 'rgba(110, 180, 220, 0.22)';
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
const guides = normalizedGuides();
|
||||
ctx.strokeStyle = 'rgba(78, 190, 255, 0.92)';
|
||||
ctx.lineWidth = 1 / state.zoom;
|
||||
ctx.setLineDash([]);
|
||||
for (const x of guides.vertical) {
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, state.imgHeight); ctx.stroke();
|
||||
}
|
||||
for (const y of guides.horizontal) {
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(state.imgWidth, y); ctx.stroke();
|
||||
}
|
||||
if (drag?.moved && Number.isFinite(drag.position)) {
|
||||
ctx.strokeStyle = 'rgba(120, 215, 255, 1)';
|
||||
if (drag.orientation === 'vertical') {
|
||||
ctx.beginPath(); ctx.moveTo(drag.position, 0); ctx.lineTo(drag.position, state.imgHeight); ctx.stroke();
|
||||
} else {
|
||||
ctx.beginPath(); ctx.moveTo(0, drag.position); ctx.lineTo(state.imgWidth, drag.position); ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function updateDrag(event) {
|
||||
if (!drag || !state.mainCanvas) return;
|
||||
const point = pointerToDocument(
|
||||
event.clientX, event.clientY,
|
||||
state.mainCanvas.getBoundingClientRect(),
|
||||
state.imgWidth, state.imgHeight,
|
||||
);
|
||||
const delta = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY);
|
||||
drag.moved = drag.moved || delta > 3;
|
||||
drag.position = drag.orientation === 'vertical'
|
||||
? Math.max(0, Math.min(state.imgWidth, Math.round(point.x)))
|
||||
: Math.max(0, Math.min(state.imgHeight, Math.round(point.y)));
|
||||
if (drag.moved) composite();
|
||||
}
|
||||
|
||||
function beginGuide(event, orientation) {
|
||||
if (event.button != null && event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
drag = { orientation, startX: event.clientX, startY: event.clientY, moved: false, position: 0 };
|
||||
const move = next => updateDrag(next);
|
||||
const cancel = () => finish(false);
|
||||
const finish = (commit, next) => {
|
||||
if (next) updateDrag(next);
|
||||
if (commit && drag?.moved) {
|
||||
const values = normalizedGuides()[drag.orientation];
|
||||
if (!values.some(value => Math.abs(value - drag.position) < 0.5)) {
|
||||
saveState?.('Add guide');
|
||||
values.push(drag.position);
|
||||
values.sort((a, b) => a - b);
|
||||
schedulePersist?.();
|
||||
}
|
||||
}
|
||||
drag = null;
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', end);
|
||||
window.removeEventListener('pointercancel', cancel);
|
||||
composite();
|
||||
redrawRulers();
|
||||
};
|
||||
const end = next => finish(true, next);
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', end);
|
||||
window.addEventListener('pointercancel', cancel);
|
||||
}
|
||||
|
||||
horizontal.addEventListener('pointerdown', event => beginGuide(event, 'horizontal'));
|
||||
vertical.addEventListener('pointerdown', event => beginGuide(event, 'vertical'));
|
||||
canvasArea.addEventListener('scroll', redrawRulers, { passive: true });
|
||||
const observer = window.ResizeObserver ? new ResizeObserver(redrawRulers) : null;
|
||||
observer?.observe(canvasArea);
|
||||
redrawRulers();
|
||||
|
||||
return {
|
||||
drawDocumentOverlay,
|
||||
redrawRulers,
|
||||
syncVisibility() { redrawRulers(); composite(); },
|
||||
clearGuides() {
|
||||
const guides = normalizedGuides();
|
||||
if (!guides.vertical.length && !guides.horizontal.length) return;
|
||||
saveState?.('Clear guides');
|
||||
state.guides = { vertical: [], horizontal: [] };
|
||||
schedulePersist?.();
|
||||
composite();
|
||||
redrawRulers();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Cooperative render generations shared by synchronous and future async renderers. */
|
||||
export function createRenderGeneration() {
|
||||
let generation = 0;
|
||||
return {
|
||||
begin() {
|
||||
const id = ++generation;
|
||||
return {
|
||||
id,
|
||||
isCurrent: () => id === generation,
|
||||
cancel: () => { if (id === generation) generation += 1; },
|
||||
};
|
||||
},
|
||||
invalidate() { generation += 1; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/** Shared selection-mask geometry and compositing helpers. */
|
||||
|
||||
import { dilateMask } from './mask-utils.js';
|
||||
|
||||
function makeCanvas(width, height) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(width));
|
||||
canvas.height = Math.max(1, Math.round(height));
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function normalizeSelectionRect(start, end, width, height, square = false) {
|
||||
let dx = end.x - start.x;
|
||||
let dy = end.y - start.y;
|
||||
if (square) {
|
||||
const size = Math.min(Math.abs(dx), Math.abs(dy));
|
||||
dx = Math.sign(dx || 1) * size;
|
||||
dy = Math.sign(dy || 1) * size;
|
||||
}
|
||||
const x1 = Math.max(0, Math.min(width, start.x));
|
||||
const y1 = Math.max(0, Math.min(height, start.y));
|
||||
const x2 = Math.max(0, Math.min(width, start.x + dx));
|
||||
const y2 = Math.max(0, Math.min(height, start.y + dy));
|
||||
return {
|
||||
x: Math.min(x1, x2),
|
||||
y: Math.min(y1, y2),
|
||||
w: Math.abs(x2 - x1),
|
||||
h: Math.abs(y2 - y1),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeConstrainedSelectionRect(start, end, width, height, options = {}) {
|
||||
const mode = ['ratio', 'size'].includes(options.mode) ? options.mode : 'free';
|
||||
if (mode === 'free') return normalizeSelectionRect(start, end, width, height, !!options.square);
|
||||
|
||||
const sx = Math.max(0, Math.min(width, Number(start.x) || 0));
|
||||
const sy = Math.max(0, Math.min(height, Number(start.y) || 0));
|
||||
if (mode === 'size') {
|
||||
const w = Math.max(1, Math.min(width, Math.round(Number(options.fixedWidth) || 1)));
|
||||
const h = Math.max(1, Math.min(height, Math.round(Number(options.fixedHeight) || 1)));
|
||||
return {
|
||||
x: Math.max(0, Math.min(width - w, sx)),
|
||||
y: Math.max(0, Math.min(height - h, sy)),
|
||||
w,
|
||||
h,
|
||||
};
|
||||
}
|
||||
|
||||
const ratioWidth = Math.max(0.01, Number(options.ratioWidth) || 1);
|
||||
const ratioHeight = Math.max(0.01, Number(options.ratioHeight) || 1);
|
||||
const ratio = ratioWidth / ratioHeight;
|
||||
const dx = (Number(end.x) || 0) - sx;
|
||||
const dy = (Number(end.y) || 0) - sy;
|
||||
const signX = dx < 0 ? -1 : 1;
|
||||
const signY = dy < 0 ? -1 : 1;
|
||||
let w;
|
||||
let h;
|
||||
if (Math.abs(dx) / Math.max(Math.abs(dy), 0.0001) >= ratio) {
|
||||
w = Math.abs(dx);
|
||||
h = w / ratio;
|
||||
} else {
|
||||
h = Math.abs(dy);
|
||||
w = h * ratio;
|
||||
}
|
||||
const availableW = signX < 0 ? sx : width - sx;
|
||||
const availableH = signY < 0 ? sy : height - sy;
|
||||
const scale = Math.min(1, availableW / Math.max(w, 0.0001), availableH / Math.max(h, 0.0001));
|
||||
w *= scale;
|
||||
h *= scale;
|
||||
return {
|
||||
x: signX < 0 ? sx - w : sx,
|
||||
y: signY < 0 ? sy - h : sy,
|
||||
w,
|
||||
h,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMarqueeMask(width, height, rect, shape = 'rectangle') {
|
||||
const canvas = makeCanvas(width, height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#fff';
|
||||
if (shape === 'ellipse') {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(
|
||||
rect.x + rect.w / 2,
|
||||
rect.y + rect.h / 2,
|
||||
rect.w / 2,
|
||||
rect.h / 2,
|
||||
0,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function mergeSelectionMasks(current, incoming, mode = 'replace') {
|
||||
if (!current || mode === 'replace') return incoming;
|
||||
const merged = makeCanvas(incoming.width, incoming.height);
|
||||
const ctx = merged.getContext('2d');
|
||||
ctx.drawImage(current, 0, 0);
|
||||
if (mode === 'subtract') ctx.globalCompositeOperation = 'destination-out';
|
||||
else if (mode === 'intersect') ctx.globalCompositeOperation = 'destination-in';
|
||||
else ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.drawImage(incoming, 0, 0);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
return merged;
|
||||
}
|
||||
|
||||
function copyMask(mask) {
|
||||
if (!mask) return null;
|
||||
const canvas = makeCanvas(mask.width, mask.height);
|
||||
canvas.getContext('2d').drawImage(mask, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function blurMask(mask, radius) {
|
||||
if (!mask || radius <= 0) return copyMask(mask);
|
||||
const canvas = makeCanvas(mask.width, mask.height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.filter = `blur(${Math.max(0, radius)}px)`;
|
||||
ctx.drawImage(mask, 0, 0);
|
||||
ctx.filter = 'none';
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function thresholdMask(mask, threshold = 128) {
|
||||
const canvas = copyMask(mask);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
for (let i = 0; i < image.data.length; i += 4) {
|
||||
const alpha = image.data[i + 3] >= threshold ? 255 : 0;
|
||||
image.data[i] = alpha;
|
||||
image.data[i + 1] = alpha;
|
||||
image.data[i + 2] = alpha;
|
||||
image.data[i + 3] = alpha;
|
||||
}
|
||||
ctx.putImageData(image, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/** Build a non-destructive preview for the Select > Refine workflow. */
|
||||
export function refineSelectionMask(mask, options = {}) {
|
||||
if (!mask) return null;
|
||||
const expand = Math.max(-100, Math.min(100, Math.round(Number(options.expand) || 0)));
|
||||
const smooth = Math.max(0, Math.min(100, Math.round(Number(options.smooth) || 0)));
|
||||
const border = Math.max(0, Math.min(200, Math.round(Number(options.border) || 0)));
|
||||
const feather = Math.max(0, Math.min(200, Number(options.feather) || 0));
|
||||
let result = expand ? dilateMask(mask, expand) : copyMask(mask);
|
||||
if (smooth) result = thresholdMask(blurMask(result, smooth), 128);
|
||||
if (border) {
|
||||
const outer = dilateMask(result, Math.ceil(border / 2));
|
||||
const inner = dilateMask(result, -Math.floor(border / 2));
|
||||
const ctx = outer.getContext('2d');
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.drawImage(inner, 0, 0);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
result = outer;
|
||||
}
|
||||
return feather ? blurMask(result, feather) : result;
|
||||
}
|
||||
|
||||
export function selectionMaskToDocument(mask, space, layerOffset, width, height) {
|
||||
if (!mask) return null;
|
||||
if (space === 'document' && mask.width === width && mask.height === height) return mask;
|
||||
const canvas = makeCanvas(width, height);
|
||||
canvas.getContext('2d').drawImage(
|
||||
mask,
|
||||
space === 'document' ? 0 : (layerOffset?.x || 0),
|
||||
space === 'document' ? 0 : (layerOffset?.y || 0),
|
||||
);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function selectionMaskForLayer(mask, space, layerOffset, width, height) {
|
||||
if (!mask) return null;
|
||||
if (space !== 'document' && mask.width === width && mask.height === height) return mask;
|
||||
const canvas = makeCanvas(width, height);
|
||||
canvas.getContext('2d').drawImage(
|
||||
mask,
|
||||
space === 'document' ? -(layerOffset?.x || 0) : 0,
|
||||
space === 'document' ? -(layerOffset?.y || 0) : 0,
|
||||
);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function selectionMaskContains(mask, x, y, threshold = 1) {
|
||||
if (!mask || x < 0 || y < 0 || x >= mask.width || y >= mask.height) return false;
|
||||
try {
|
||||
return mask.getContext('2d').getImageData(Math.floor(x), Math.floor(y), 1, 1).data[3] >= threshold;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function selectionMaskBounds(mask, threshold = 1) {
|
||||
if (!mask?.width || !mask?.height) return null;
|
||||
let data;
|
||||
try { data = mask.getContext('2d').getImageData(0, 0, mask.width, mask.height).data; }
|
||||
catch { return null; }
|
||||
let left = mask.width;
|
||||
let top = mask.height;
|
||||
let right = -1;
|
||||
let bottom = -1;
|
||||
for (let y = 0; y < mask.height; y += 1) {
|
||||
for (let x = 0; x < mask.width; x += 1) {
|
||||
if (data[(y * mask.width + x) * 4 + 3] < threshold) continue;
|
||||
left = Math.min(left, x);
|
||||
top = Math.min(top, y);
|
||||
right = Math.max(right, x);
|
||||
bottom = Math.max(bottom, y);
|
||||
}
|
||||
}
|
||||
return right < left ? null : { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
|
||||
}
|
||||
|
||||
export function translateSelectionMask(mask, dx, dy, width = mask?.width, height = mask?.height) {
|
||||
if (!mask) return null;
|
||||
const canvas = makeCanvas(width, height);
|
||||
canvas.getContext('2d').drawImage(mask, Math.round(dx), Math.round(dy));
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a document-space selection through one affine transform. The source
|
||||
* mask is never mutated, so callers can rebuild every live preview from the
|
||||
* same pixels instead of accumulating interpolation damage.
|
||||
*/
|
||||
export function transformSelectionMask(mask, sourceBounds, target, width = mask?.width, height = mask?.height) {
|
||||
if (!mask || !sourceBounds || !target) return null;
|
||||
const sourceWidth = Math.max(1, Number(sourceBounds.width) || 1);
|
||||
const sourceHeight = Math.max(1, Number(sourceBounds.height) || 1);
|
||||
const targetWidth = Math.max(1, Number(target.width) || 1);
|
||||
const targetHeight = Math.max(1, Number(target.height) || 1);
|
||||
const canvas = makeCanvas(width, height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.save();
|
||||
ctx.translate(Number(target.centerX) || 0, Number(target.centerY) || 0);
|
||||
ctx.rotate(((Number(target.rotation) || 0) * Math.PI) / 180);
|
||||
ctx.scale(target.flipH ? -1 : 1, target.flipV ? -1 : 1);
|
||||
ctx.drawImage(
|
||||
mask,
|
||||
sourceBounds.x,
|
||||
sourceBounds.y,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
-targetWidth / 2,
|
||||
-targetHeight / 2,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
);
|
||||
ctx.restore();
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function selectionBoundaryPixels(mask, threshold = 128) {
|
||||
if (!mask?.width || !mask?.height) return [];
|
||||
let data;
|
||||
try { data = mask.getContext('2d').getImageData(0, 0, mask.width, mask.height).data; }
|
||||
catch { return []; }
|
||||
const selected = (x, y) => x >= 0 && y >= 0 && x < mask.width && y < mask.height
|
||||
&& data[(y * mask.width + x) * 4 + 3] >= threshold;
|
||||
const pixels = [];
|
||||
for (let y = 0; y < mask.height; y += 1) {
|
||||
for (let x = 0; x < mask.width; x += 1) {
|
||||
if (!selected(x, y)) continue;
|
||||
if (!selected(x - 1, y) || !selected(x + 1, y) || !selected(x, y - 1) || !selected(x, y + 1)) {
|
||||
pixels.push(y * mask.width + x);
|
||||
}
|
||||
}
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
export function paintSelectionBoundary(ctx, pixels, width, height, phase = 0) {
|
||||
if (!ctx || !pixels?.length || !width || !height) return;
|
||||
const image = ctx.createImageData(width, height);
|
||||
for (const index of pixels) {
|
||||
const x = index % width;
|
||||
const y = Math.floor(index / width);
|
||||
const light = ((x + y + phase) % 8) < 4;
|
||||
const offset = index * 4;
|
||||
image.data[offset] = light ? 255 : 0;
|
||||
image.data[offset + 1] = light ? 255 : 0;
|
||||
image.data[offset + 2] = light ? 255 : 0;
|
||||
image.data[offset + 3] = 255;
|
||||
}
|
||||
ctx.putImageData(image, 0, 0);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/* Encode a batch of canvas bitmaps without blocking the editor thread. */
|
||||
self.onmessage = async event => {
|
||||
const bitmaps = Array.isArray(event.data?.bitmaps) ? event.data.bitmaps : [];
|
||||
try {
|
||||
const blobs = [];
|
||||
for (const bitmap of bitmaps) {
|
||||
if (!bitmap?.width || !bitmap?.height) {
|
||||
blobs.push(null);
|
||||
continue;
|
||||
}
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
||||
blobs.push(await canvas.convertToBlob({ type: 'image/png' }));
|
||||
bitmap.close?.();
|
||||
}
|
||||
self.postMessage({ blobs });
|
||||
} catch (error) {
|
||||
for (const bitmap of bitmaps) bitmap?.close?.();
|
||||
self.postMessage({ error: String(error?.message || error) });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
/** Retained vector-shape model and raster-cache renderer. */
|
||||
|
||||
import { normalizeGradientStops } from './gradient-stops.js';
|
||||
|
||||
export { normalizeGradientStops } from './gradient-stops.js';
|
||||
|
||||
export const DEFAULT_SHAPE_DATA = Object.freeze({
|
||||
type: 'rectangle',
|
||||
width: 180,
|
||||
height: 120,
|
||||
fillColor: '#ffffff',
|
||||
fillType: 'solid',
|
||||
gradientStart: '#ffffff',
|
||||
gradientMid: '#808080',
|
||||
gradientMidEnabled: false,
|
||||
gradientMidPosition: 50,
|
||||
gradientEnd: '#000000',
|
||||
gradientAngle: 0,
|
||||
strokeColor: '#111111',
|
||||
strokeWidth: 2,
|
||||
cornerRadius: 0,
|
||||
sides: 5,
|
||||
transform: Object.freeze({ scaleX: 1, scaleY: 1, rotation: 0, flipH: false, flipV: false }),
|
||||
});
|
||||
|
||||
function finite(value, fallback, min = -Infinity, max = Infinity) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
|
||||
}
|
||||
|
||||
export function normalizeShapeData(value = {}) {
|
||||
const transform = value.transform || {};
|
||||
const gradientStart = String(value.gradientStart || DEFAULT_SHAPE_DATA.gradientStart);
|
||||
const gradientMid = String(value.gradientMid || DEFAULT_SHAPE_DATA.gradientMid);
|
||||
const gradientMidEnabled = !!value.gradientMidEnabled;
|
||||
const gradientMidPosition = finite(value.gradientMidPosition, DEFAULT_SHAPE_DATA.gradientMidPosition, 1, 99);
|
||||
const gradientEnd = String(value.gradientEnd || DEFAULT_SHAPE_DATA.gradientEnd);
|
||||
return {
|
||||
type: ['rectangle', 'ellipse', 'line', 'polygon'].includes(value.type) ? value.type : 'rectangle',
|
||||
width: finite(value.width, DEFAULT_SHAPE_DATA.width, 1, 10000),
|
||||
height: finite(value.height, DEFAULT_SHAPE_DATA.height, 1, 10000),
|
||||
fillColor: String(value.fillColor || DEFAULT_SHAPE_DATA.fillColor),
|
||||
fillType: value.fillType === 'linear-gradient' ? 'linear-gradient' : DEFAULT_SHAPE_DATA.fillType,
|
||||
gradientStart,
|
||||
gradientMid,
|
||||
gradientMidEnabled,
|
||||
gradientMidPosition,
|
||||
gradientEnd,
|
||||
gradientStops: normalizeGradientStops(value.gradientStops, {
|
||||
start: gradientStart,
|
||||
mid: gradientMid,
|
||||
midEnabled: gradientMidEnabled,
|
||||
midPosition: gradientMidPosition,
|
||||
end: gradientEnd,
|
||||
}),
|
||||
gradientAngle: finite(value.gradientAngle, DEFAULT_SHAPE_DATA.gradientAngle, -36000, 36000),
|
||||
strokeColor: String(value.strokeColor || DEFAULT_SHAPE_DATA.strokeColor),
|
||||
strokeWidth: finite(value.strokeWidth, DEFAULT_SHAPE_DATA.strokeWidth, 0, 500),
|
||||
cornerRadius: finite(value.cornerRadius, DEFAULT_SHAPE_DATA.cornerRadius, 0, 5000),
|
||||
sides: Math.round(finite(value.sides, DEFAULT_SHAPE_DATA.sides, 3, 24)),
|
||||
transform: {
|
||||
scaleX: finite(transform.scaleX, 1, 0.01, 100),
|
||||
scaleY: finite(transform.scaleY, 1, 0.01, 100),
|
||||
rotation: finite(transform.rotation, 0, -36000, 36000),
|
||||
flipH: !!transform.flipH,
|
||||
flipV: !!transform.flipV,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shapePath(ctx, shape, padding) {
|
||||
const x = padding;
|
||||
const y = padding;
|
||||
const width = shape.width;
|
||||
const height = shape.height;
|
||||
ctx.beginPath();
|
||||
if (shape.type === 'ellipse') {
|
||||
ctx.ellipse(x + width / 2, y + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
|
||||
} else if (shape.type === 'line') {
|
||||
ctx.moveTo(x, y + height);
|
||||
ctx.lineTo(x + width, y);
|
||||
} else if (shape.type === 'polygon') {
|
||||
const cx = x + width / 2;
|
||||
const cy = y + height / 2;
|
||||
for (let index = 0; index < shape.sides; index += 1) {
|
||||
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / shape.sides;
|
||||
const px = cx + Math.cos(angle) * width / 2;
|
||||
const py = cy + Math.sin(angle) * height / 2;
|
||||
if (!index) ctx.moveTo(px, py); else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
} else {
|
||||
const radius = Math.min(shape.cornerRadius, width / 2, height / 2);
|
||||
if (ctx.roundRect) ctx.roundRect(x, y, width, height, radius);
|
||||
else ctx.rect(x, y, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
function baseShapeCanvas(shape) {
|
||||
const padding = Math.ceil(Math.max(2, shape.strokeWidth / 2 + 2));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.ceil(shape.width + padding * 2));
|
||||
canvas.height = Math.max(1, Math.ceil(shape.height + padding * 2));
|
||||
const ctx = canvas.getContext('2d');
|
||||
shapePath(ctx, shape, padding);
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
if (shape.type !== 'line' && shape.fillColor !== 'transparent') {
|
||||
if (shape.fillType === 'linear-gradient' && typeof ctx.createLinearGradient === 'function') {
|
||||
const radians = shape.gradientAngle * Math.PI / 180;
|
||||
const dx = Math.cos(radians) * shape.width / 2;
|
||||
const dy = Math.sin(radians) * shape.height / 2;
|
||||
const gradient = ctx.createLinearGradient(
|
||||
padding + shape.width / 2 - dx,
|
||||
padding + shape.height / 2 - dy,
|
||||
padding + shape.width / 2 + dx,
|
||||
padding + shape.height / 2 + dy,
|
||||
);
|
||||
for (const stop of shape.gradientStops) {
|
||||
gradient.addColorStop(stop.position / 100, stop.color);
|
||||
}
|
||||
ctx.fillStyle = gradient;
|
||||
} else {
|
||||
ctx.fillStyle = shape.fillColor;
|
||||
}
|
||||
ctx.fill();
|
||||
}
|
||||
if (shape.strokeWidth > 0) {
|
||||
ctx.strokeStyle = shape.strokeColor;
|
||||
ctx.lineWidth = shape.strokeWidth;
|
||||
ctx.stroke();
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function renderShapeLayer(layer) {
|
||||
if (!layer?.canvas || !layer?.ctx) return null;
|
||||
layer.kind = 'shape';
|
||||
layer.shape = normalizeShapeData(layer.shape);
|
||||
const base = baseShapeCanvas(layer.shape);
|
||||
const transform = layer.shape.transform;
|
||||
const scaledWidth = Math.max(1, base.width * transform.scaleX);
|
||||
const scaledHeight = Math.max(1, base.height * transform.scaleY);
|
||||
const radians = transform.rotation * Math.PI / 180;
|
||||
const cos = Math.abs(Math.cos(radians));
|
||||
const sin = Math.abs(Math.sin(radians));
|
||||
const width = Math.max(1, Math.ceil(scaledWidth * cos + scaledHeight * sin));
|
||||
const height = Math.max(1, Math.ceil(scaledWidth * sin + scaledHeight * cos));
|
||||
const rendered = document.createElement('canvas');
|
||||
rendered.width = width;
|
||||
rendered.height = height;
|
||||
const ctx = rendered.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.translate(width / 2, height / 2);
|
||||
ctx.rotate(radians);
|
||||
ctx.scale(transform.flipH ? -1 : 1, transform.flipV ? -1 : 1);
|
||||
ctx.drawImage(base, -scaledWidth / 2, -scaledHeight / 2, scaledWidth, scaledHeight);
|
||||
layer.canvas.width = width;
|
||||
layer.canvas.height = height;
|
||||
layer.ctx = layer.canvas.getContext('2d');
|
||||
layer.ctx.clearRect(0, 0, width, height);
|
||||
layer.ctx.drawImage(rendered, 0, 0);
|
||||
layer._adjCache = null;
|
||||
layer._adjCacheKey = null;
|
||||
layer._adjFinal = null;
|
||||
layer._adjFinalKey = null;
|
||||
return layer.canvas;
|
||||
}
|
||||
|
||||
export function rasterizeShapeLayer(layer) {
|
||||
if (!layer || layer.kind !== 'shape') return false;
|
||||
layer.kind = 'raster';
|
||||
layer.shape = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function scaleShapeLayer(layer, scaleX, scaleY) {
|
||||
layer.shape = normalizeShapeData(layer.shape);
|
||||
layer.shape.transform.scaleX *= scaleX;
|
||||
layer.shape.transform.scaleY *= scaleY;
|
||||
return renderShapeLayer(layer);
|
||||
}
|
||||
|
||||
export function rotateShapeLayer(layer, degrees) {
|
||||
layer.shape = normalizeShapeData(layer.shape);
|
||||
layer.shape.transform.rotation += degrees;
|
||||
return renderShapeLayer(layer);
|
||||
}
|
||||
|
||||
export function flipShapeLayer(layer, axis) {
|
||||
layer.shape = normalizeShapeData(layer.shape);
|
||||
if (axis === 'h') layer.shape.transform.flipH = !layer.shape.transform.flipH;
|
||||
else layer.shape.transform.flipV = !layer.shape.transform.flipV;
|
||||
return renderShapeLayer(layer);
|
||||
}
|
||||
+108
-11
@@ -1,3 +1,5 @@
|
||||
import { transformFrameGeometry } from './transform-frame-geometry.js';
|
||||
|
||||
/**
|
||||
* Snap-while-dragging: when the move tool drags a layer near another
|
||||
* layer's edge or the canvas centre/edges, gently lock the proposed
|
||||
@@ -19,12 +21,16 @@
|
||||
* canvasW: number,
|
||||
* canvasH: number,
|
||||
* otherLayers: Array<{visible: boolean, id: string, canvas: HTMLCanvasElement, offset: {x:number, y:number}}>,
|
||||
* verticalGuides?: number[], horizontalGuides?: number[],
|
||||
* snapToGrid?: boolean, gridSize?: number,
|
||||
* }} ctx
|
||||
* @returns {{x: number, y: number, guides: Array}}
|
||||
*/
|
||||
export function computeSnap(layer, nx, ny, ctx) {
|
||||
const SNAP_PX = 6 / Math.max(ctx.zoom, 0.0001);
|
||||
const cw = ctx.canvasW, ch = ctx.canvasH;
|
||||
if (!layer || !layer.canvas || !ctx) return { x: nx, y: ny, guides: [] };
|
||||
const zoom = Number.isFinite(Number(ctx.zoom)) ? Number(ctx.zoom) : 1;
|
||||
const SNAP_PX = 6 / Math.max(zoom, 0.0001);
|
||||
const cw = Number(ctx.canvasW) || 0, ch = Number(ctx.canvasH) || 0;
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
|
||||
const vTargets = [
|
||||
@@ -37,7 +43,14 @@ export function computeSnap(layer, nx, ny, ctx) {
|
||||
{ y: ch, label: 'canvas-b' },
|
||||
{ y: ch / 2, label: 'canvas-cy' },
|
||||
];
|
||||
for (const other of ctx.otherLayers) {
|
||||
for (const x of Array.isArray(ctx.verticalGuides) ? ctx.verticalGuides : []) {
|
||||
if (Number.isFinite(Number(x))) vTargets.push({ x: Number(x), label: 'guide-v' });
|
||||
}
|
||||
for (const y of Array.isArray(ctx.horizontalGuides) ? ctx.horizontalGuides : []) {
|
||||
if (Number.isFinite(Number(y))) hTargets.push({ y: Number(y), label: 'guide-h' });
|
||||
}
|
||||
const otherLayers = Array.isArray(ctx.otherLayers) ? ctx.otherLayers : [];
|
||||
for (const other of otherLayers) {
|
||||
if (!other.visible || other.id === layer.id) continue;
|
||||
const o = other.offset || { x: 0, y: 0 };
|
||||
const ow = other.canvas.width, oh = other.canvas.height;
|
||||
@@ -61,6 +74,15 @@ export function computeSnap(layer, nx, ny, ctx) {
|
||||
bestX = { snapTo: t.x, src, target: t };
|
||||
}
|
||||
}
|
||||
if (ctx.snapToGrid && Number(ctx.gridSize) > 0) {
|
||||
const grid = Number(ctx.gridSize);
|
||||
const target = Math.round(val / grid) * grid;
|
||||
const d = Math.abs(target - val);
|
||||
if (d < SNAP_PX && d < bestDx) {
|
||||
bestDx = d;
|
||||
bestX = { snapTo: target, src, target: { x: target, label: 'grid' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [src, val] of Object.entries(myEdgesY)) {
|
||||
for (const t of hTargets) {
|
||||
@@ -70,6 +92,15 @@ export function computeSnap(layer, nx, ny, ctx) {
|
||||
bestY = { snapTo: t.y, src, target: t };
|
||||
}
|
||||
}
|
||||
if (ctx.snapToGrid && Number(ctx.gridSize) > 0) {
|
||||
const grid = Number(ctx.gridSize);
|
||||
const target = Math.round(val / grid) * grid;
|
||||
const d = Math.abs(target - val);
|
||||
if (d < SNAP_PX && d < bestDy) {
|
||||
bestDy = d;
|
||||
bestY = { snapTo: target, src, target: { y: target, label: 'grid' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const guides = [];
|
||||
@@ -89,18 +120,84 @@ export function computeSnap(layer, nx, ny, ctx) {
|
||||
return { x: snappedX, y: snappedY, guides };
|
||||
}
|
||||
|
||||
/** Snap a rotated transform frame by its visible bounds and center. */
|
||||
export function computeTransformSnap(frame, proposedCenter, ctx = {}) {
|
||||
const zoom = Number.isFinite(Number(ctx.zoom)) ? Number(ctx.zoom) : 1;
|
||||
const threshold = 6 / Math.max(zoom, 0.0001);
|
||||
const geometry = transformFrameGeometry({
|
||||
...frame,
|
||||
centerX: proposedCenter.x,
|
||||
centerY: proposedCenter.y,
|
||||
});
|
||||
const points = Object.values(geometry.corners);
|
||||
const minX = Math.min(...points.map(point => point.x));
|
||||
const maxX = Math.max(...points.map(point => point.x));
|
||||
const minY = Math.min(...points.map(point => point.y));
|
||||
const maxY = Math.max(...points.map(point => point.y));
|
||||
const sourcesX = [minX, geometry.centerX, maxX];
|
||||
const sourcesY = [minY, geometry.centerY, maxY];
|
||||
const vertical = [0, Number(ctx.canvasW) || 0, (Number(ctx.canvasW) || 0) / 2];
|
||||
const horizontal = [0, Number(ctx.canvasH) || 0, (Number(ctx.canvasH) || 0) / 2];
|
||||
for (const value of Array.isArray(ctx.verticalGuides) ? ctx.verticalGuides : []) {
|
||||
if (Number.isFinite(Number(value))) vertical.push(Number(value));
|
||||
}
|
||||
for (const value of Array.isArray(ctx.horizontalGuides) ? ctx.horizontalGuides : []) {
|
||||
if (Number.isFinite(Number(value))) horizontal.push(Number(value));
|
||||
}
|
||||
for (const layer of Array.isArray(ctx.otherLayers) ? ctx.otherLayers : []) {
|
||||
if (!layer?.visible || !layer.canvas) continue;
|
||||
const offset = layer.offset || { x: 0, y: 0 };
|
||||
const left = Number(offset.x) || 0;
|
||||
const top = Number(offset.y) || 0;
|
||||
const right = left + (Number(layer.canvas.width) || 0);
|
||||
const bottom = top + (Number(layer.canvas.height) || 0);
|
||||
vertical.push(left, (left + right) / 2, right);
|
||||
horizontal.push(top, (top + bottom) / 2, bottom);
|
||||
}
|
||||
|
||||
let bestX = null;
|
||||
let bestY = null;
|
||||
const consider = (source, target, current) => {
|
||||
const delta = target - source;
|
||||
if (Math.abs(delta) >= threshold) return current;
|
||||
return !current || Math.abs(delta) < Math.abs(current.delta) ? { delta, target } : current;
|
||||
};
|
||||
for (const source of sourcesX) for (const target of vertical) bestX = consider(source, target, bestX);
|
||||
for (const source of sourcesY) for (const target of horizontal) bestY = consider(source, target, bestY);
|
||||
if (ctx.snapToGrid && Number(ctx.gridSize) > 0) {
|
||||
const size = Number(ctx.gridSize);
|
||||
for (const source of sourcesX) bestX = consider(source, Math.round(source / size) * size, bestX);
|
||||
for (const source of sourcesY) bestY = consider(source, Math.round(source / size) * size, bestY);
|
||||
}
|
||||
return {
|
||||
centerX: proposedCenter.x + (bestX?.delta || 0),
|
||||
centerY: proposedCenter.y + (bestY?.delta || 0),
|
||||
guides: [
|
||||
...(bestX ? [{ vertical: true, x: bestX.target }] : []),
|
||||
...(bestY ? [{ vertical: false, y: bestY.target }] : []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* CSS cursor name for each transform-tool handle.
|
||||
*
|
||||
* @param {'tl'|'tr'|'bl'|'br'|'rot'|string} id
|
||||
* @param {'tl'|'t'|'tr'|'r'|'br'|'b'|'bl'|'l'|'rot'|string} id
|
||||
* @param {number} rotation Current transform-frame rotation in degrees.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cursorForHandle(id) {
|
||||
switch (id) {
|
||||
case 'tl': case 'br': return 'nwse-resize';
|
||||
case 'tr': case 'bl': return 'nesw-resize';
|
||||
case 'rot': return 'grab';
|
||||
default: return 'default';
|
||||
}
|
||||
export function cursorForHandle(id, rotation = 0) {
|
||||
if (id === 'rot') return 'grab';
|
||||
const localAngles = {
|
||||
r: 0, br: 45, b: 90, bl: 135,
|
||||
l: 180, tl: 225, t: 270, tr: 315,
|
||||
};
|
||||
if (!(id in localAngles)) return 'default';
|
||||
const angle = ((localAngles[id] + Number(rotation || 0)) % 180 + 180) % 180;
|
||||
const direction = Math.round(angle / 45) % 4;
|
||||
if (direction === 0) return 'ew-resize';
|
||||
if (direction === 1) return 'nwse-resize';
|
||||
if (direction === 2) return 'ns-resize';
|
||||
return 'nesw-resize';
|
||||
}
|
||||
|
||||
+107
-1
@@ -19,11 +19,14 @@ export const state = {
|
||||
// Drag-resize / rotate session state. While `transformActive` is
|
||||
// false every field below should be considered stale.
|
||||
transformActive: false,
|
||||
transformTarget: null,
|
||||
transformLayer: null,
|
||||
transformOrigW: 0,
|
||||
transformOrigH: 0,
|
||||
transformSessionStartW: 0,
|
||||
transformSessionStartH: 0,
|
||||
// Which corner/edge handle the user is currently dragging. One of
|
||||
// 'tl' | 'tr' | 'bl' | 'br' | 'rot' | null.
|
||||
// 'tl' | 't' | 'tr' | 'r' | 'br' | 'b' | 'bl' | 'l' | 'rot' | null.
|
||||
transformHandle: null,
|
||||
// Which handle is currently under the cursor (no drag). Drives the
|
||||
// hover cursor lookup; lives next to `transformHandle` because both
|
||||
@@ -32,7 +35,15 @@ export const state = {
|
||||
// Snapshot of the layer canvas + offset at transform start so Cancel
|
||||
// can restore exactly without re-fetching from the layer.
|
||||
transformOrigCanvas: null,
|
||||
transformOrigMasks: [],
|
||||
transformOrigOffset: null,
|
||||
transformSelectionCanvas: null,
|
||||
transformLayers: [],
|
||||
transformItems: [],
|
||||
transformSelectionBounds: null,
|
||||
transformBounds: null,
|
||||
transformCenter: null,
|
||||
transformStartCenter: null,
|
||||
// In-progress dimensions / rotation / flips committed on Apply.
|
||||
transformPendingW: 0,
|
||||
transformPendingH: 0,
|
||||
@@ -40,10 +51,16 @@ export const state = {
|
||||
transformPendingFlipH: false,
|
||||
transformPendingFlipV: false,
|
||||
transformAspectLock: true,
|
||||
// Keep raster transforms source-backed unless the user explicitly opts out
|
||||
// in the Transform popup.
|
||||
transformPreserveSource: true,
|
||||
// Floating Transform popup element + drag-start offsets.
|
||||
transformPopup: null,
|
||||
transformStartX: 0,
|
||||
transformStartY: 0,
|
||||
transformStartRotation: 0,
|
||||
transformStartFlipH: false,
|
||||
transformStartFlipV: false,
|
||||
transformStartOffX: 0,
|
||||
transformStartOffY: 0,
|
||||
// Transform overlay canvas — separate canvas positioned over the
|
||||
@@ -60,6 +77,8 @@ export const state = {
|
||||
// tolerance retunes can re-run the flood-fill without re-prompting.
|
||||
wandMask: null,
|
||||
wandLayerId: null,
|
||||
wandMaskSpace: 'layer',
|
||||
selectionSource: null,
|
||||
wandTolerance: 24,
|
||||
wandMaskVisible: true,
|
||||
wandMode: 'replace',
|
||||
@@ -81,12 +100,33 @@ export const state = {
|
||||
brushOpacity: 100,
|
||||
brushFlow: 100,
|
||||
brushSoftness: 100,
|
||||
smudgeStrength: 65,
|
||||
eraserOpacity: 100,
|
||||
eraserFlow: 100,
|
||||
eraserSoftness: 100,
|
||||
cloneOpacity: 100,
|
||||
cloneFlow: 100,
|
||||
cloneSoftness: 100,
|
||||
brushSpacing: 15,
|
||||
brushSmoothing: 25,
|
||||
brushBlendMode: 'source-over',
|
||||
gradientStart: '#e06c75',
|
||||
gradientMid: '#808080',
|
||||
gradientMidPosition: 50,
|
||||
gradientMidEnabled: false,
|
||||
gradientEnd: '#ffffff',
|
||||
gradientEndAlpha: 100,
|
||||
gradientOpacity: 100,
|
||||
gradientType: 'linear-gradient',
|
||||
gradientStops: [],
|
||||
gradientActive: false,
|
||||
// Flattened visual baseline for the non-destructive BEFORE compare view.
|
||||
compareBaselineCanvas: null,
|
||||
compareActive: false,
|
||||
pressureSize: true,
|
||||
pressureOpacity: false,
|
||||
pressureFlow: true,
|
||||
eyedropperSample: 'composite',
|
||||
// Clone-stamp source point (set via Alt-click or double-tap). Null
|
||||
// means no source picked yet — clicking with the clone tool no-ops
|
||||
// until a source is set.
|
||||
@@ -101,6 +141,12 @@ export const state = {
|
||||
// original, not the in-progress stamp ring.
|
||||
cloneSourceSnapshot: null,
|
||||
cloneSourceLayerId: null,
|
||||
// Where Clone / Healing reads pixels from. Keep the existing active-layer
|
||||
// behavior as the default; composite sampling is useful for retouching a
|
||||
// visible result while painting on a separate layer.
|
||||
cloneSampleMode: 'active-layer',
|
||||
cloneSourceOffsetX: 0,
|
||||
cloneSourceOffsetY: 0,
|
||||
// Mobile: double-tap detection for "set source" since Alt-click
|
||||
// isn't an option without a keyboard.
|
||||
cloneLastTapTime: 0,
|
||||
@@ -114,6 +160,8 @@ export const state = {
|
||||
maskCanvas: null,
|
||||
maskCtx: null,
|
||||
maskVisible: true,
|
||||
// Show the active mask as a standalone black/white document preview.
|
||||
maskInspectMode: false,
|
||||
// Reused canvas for the union-of-masks tint pass (saves repeated
|
||||
// allocation on every composite).
|
||||
compositeMaskUnion: null,
|
||||
@@ -157,6 +205,15 @@ export const state = {
|
||||
// (mouse - startMouse) + startOffset rather than accumulating delta.
|
||||
moveLayerOffsetX: 0,
|
||||
moveLayerOffsetY: 0,
|
||||
moveLayerOffsets: new Map(),
|
||||
movePlacedSources: new Map(),
|
||||
moveMaskTarget: null,
|
||||
moveMaskOffsetX: 0,
|
||||
moveMaskOffsetY: 0,
|
||||
moveUnlinkedMaskOffsets: new Map(),
|
||||
// Becomes true after the first real position change in a drag. A plain
|
||||
// click must not create an empty undo step or clear the redo stack.
|
||||
moveHistorySaved: false,
|
||||
// Snap guides drawn during a move-tool drag (Ctrl held). Each entry
|
||||
// is a vertical / horizontal line in canvas space.
|
||||
activeSnapGuides: null,
|
||||
@@ -178,6 +235,26 @@ export const state = {
|
||||
lassoPoints: [],
|
||||
lassoActive: false,
|
||||
|
||||
// ── Marquee selection ──
|
||||
marqueeShape: 'rectangle',
|
||||
marqueeConstraint: 'free',
|
||||
marqueeRatioWidth: 1,
|
||||
marqueeRatioHeight: 1,
|
||||
marqueeFixedWidth: 100,
|
||||
marqueeFixedHeight: 100,
|
||||
marqueeActive: false,
|
||||
marqueeStart: null,
|
||||
marqueeRect: null,
|
||||
selectionMoving: false,
|
||||
selectionMoveStart: null,
|
||||
selectionMoveOrigin: null,
|
||||
quickMaskActive: false,
|
||||
selectionOverlay: null,
|
||||
selectionOverlayCtx: null,
|
||||
savedSelections: [],
|
||||
lastSelection: null,
|
||||
nextSavedSelectionId: 1,
|
||||
|
||||
// In-editor copy/paste — separate from the OS clipboard so we can
|
||||
// round-trip layer alpha and metadata losslessly.
|
||||
internalClipboard: null,
|
||||
@@ -189,10 +266,24 @@ export const state = {
|
||||
// so the editor can reopen with fresh dimensions.
|
||||
mainCanvas: null,
|
||||
mainCtx: null,
|
||||
// Transparent document composite. The checkerboard is drawn separately on
|
||||
// mainCanvas so blend modes match flattened export instead of blending with UI.
|
||||
documentCompositeCanvas: null,
|
||||
// True only when documentCompositeCanvas matches the latest render
|
||||
// generation. Autosave thumbnails may reuse it; async renders clear it
|
||||
// before starting and set it again only after committing the frame.
|
||||
documentRenderReady: false,
|
||||
groupCompositeCanvas: null,
|
||||
groupCompositeCanvases: new Map(),
|
||||
clippingCompositeCanvas: null,
|
||||
|
||||
// ── Document + layers ──
|
||||
layers: [],
|
||||
activeLayerId: null,
|
||||
selectedLayerIds: [],
|
||||
selectionAnchorId: null,
|
||||
layerGroups: [],
|
||||
activeGroupId: null,
|
||||
// Active tool ID — one of move/crop/transform/brush/eraser/clone/
|
||||
// lasso/wand/inpaint/rembg/harmonize/sharpen/upscale/style.
|
||||
tool: 'move',
|
||||
@@ -201,6 +292,14 @@ export const state = {
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
spacePanActive: false,
|
||||
navigationPanning: false,
|
||||
rulersVisible: true,
|
||||
gridVisible: false,
|
||||
gridSize: 16,
|
||||
snapEnabled: false,
|
||||
snapToGrid: false,
|
||||
guides: { vertical: [], horizontal: [] },
|
||||
// Document dimensions in canvas pixels.
|
||||
imgWidth: 0,
|
||||
imgHeight: 0,
|
||||
@@ -215,6 +314,8 @@ export const state = {
|
||||
// that fire after the user closes the editor (don't draw onto a
|
||||
// dead canvas, don't re-mount the spinner).
|
||||
editorOpen: false,
|
||||
// Changes whenever a new editor document owns the shared persistence lane.
|
||||
editorSessionToken: 0,
|
||||
// Document-level click-away handlers registered for the current
|
||||
// session. Tracked so closeEditor can detach them all cleanly.
|
||||
// Mutated in place (push / length = 0); the reference never changes.
|
||||
@@ -237,6 +338,9 @@ export const state = {
|
||||
adjPopupEl: null,
|
||||
// rAF-throttled live preview while sliders are dragged in adj popups.
|
||||
adjRafPending: false,
|
||||
// Layer whose staged adjustment owns the queued preview frame. Clearing
|
||||
// this invalidates a frame when the popup closes or changes layers.
|
||||
adjPreviewLayer: null,
|
||||
historyPanelEl: null,
|
||||
// Custom brush-cursor overlay element (circle following the mouse).
|
||||
cursorEl: null,
|
||||
@@ -254,4 +358,6 @@ export const state = {
|
||||
// True when an edit happened during an in-flight save — triggers a
|
||||
// follow-up persist after the current one finishes.
|
||||
persistDirty: false,
|
||||
// Last surfaced autosave failure; avoids repeating the same toast per edit.
|
||||
persistErrorMessage: null,
|
||||
};
|
||||
|
||||
+263
-142
@@ -1,162 +1,283 @@
|
||||
/**
|
||||
* Stroke pipeline — paints one segment (last-position → current
|
||||
* position) onto the active layer (or its active mask sub-layer).
|
||||
*
|
||||
* `strokeTo` dispatches by tool:
|
||||
* - clone → cloneStrokeTo (custom stamp-based paint loop)
|
||||
* - brush → source-over with opacity × flow + softness blur
|
||||
* - eraser → destination-out with opacity × flow + softness blur
|
||||
* - inpaint → source-over (paint) or destination-out (erase) with
|
||||
* full alpha on the mask canvas
|
||||
*
|
||||
* If the active parent has an active mask sub-layer, brush / eraser /
|
||||
* inpaint target the mask canvas instead of the layer's pixel canvas.
|
||||
*
|
||||
* @param {{
|
||||
* activeLayer: () => object | null,
|
||||
* getActiveMaskLayer: () => object | null,
|
||||
* composite: () => void,
|
||||
* }} deps
|
||||
*/
|
||||
/** Shared sampled renderer for brush, eraser, clone, masks, and retouch tools. */
|
||||
import { state } from './state.js';
|
||||
import { createBrushSampler, pressureValue } from './brush-engine.js';
|
||||
import { isLayerTransparencyLocked } from './layer-groups.js';
|
||||
|
||||
const PAINT_TOOLS = new Set(['brush', 'eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'inpaint']);
|
||||
|
||||
function toolSettings(tool) {
|
||||
if (tool === 'eraser') return { opacity: state.eraserOpacity, flow: state.eraserFlow, softness: state.eraserSoftness };
|
||||
if (tool === 'clone' || tool === 'heal') return { opacity: state.cloneOpacity, flow: state.cloneFlow, softness: state.cloneSoftness };
|
||||
if (tool === 'smudge') return { opacity: state.smudgeStrength, flow: state.brushFlow, softness: state.brushSoftness };
|
||||
return { opacity: state.brushOpacity, flow: state.brushFlow, softness: state.brushSoftness };
|
||||
}
|
||||
|
||||
function transparentColor(color) {
|
||||
if (color.startsWith('#')) {
|
||||
const raw = color.slice(1);
|
||||
const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw;
|
||||
const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16));
|
||||
return `rgba(${rgb.join(',')},0)`;
|
||||
}
|
||||
const match = color.match(/rgba?\(([^)]+)\)/);
|
||||
return match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
|
||||
}
|
||||
|
||||
function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
|
||||
const hard = Math.max(0, Math.min(1, 1 - softness));
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = operation;
|
||||
ctx.globalAlpha = alpha;
|
||||
if (softness <= 0.001) {
|
||||
ctx.fillStyle = color;
|
||||
} else {
|
||||
const gradient = ctx.createRadialGradient(x, y, radius * hard, x, y, radius);
|
||||
gradient.addColorStop(0, color);
|
||||
gradient.addColorStop(1, transparentColor(color));
|
||||
ctx.fillStyle = gradient;
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composite }) {
|
||||
function cloneStrokeTo(x, y, layer) {
|
||||
let strokeTool = null;
|
||||
let compositeFrame = null;
|
||||
let smudgeBuffer = null;
|
||||
|
||||
// Pointer events can arrive much faster than the display refresh rate.
|
||||
// Stamps are applied synchronously, so only the visible document composite
|
||||
// needs coalescing while a stroke is in progress.
|
||||
function scheduleComposite() {
|
||||
if (compositeFrame !== null) return;
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
compositeFrame = requestAnimationFrame(() => {
|
||||
compositeFrame = null;
|
||||
composite();
|
||||
});
|
||||
} else {
|
||||
compositeFrame = setTimeout(() => {
|
||||
compositeFrame = null;
|
||||
composite();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function flushComposite() {
|
||||
if (compositeFrame !== null) {
|
||||
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(compositeFrame);
|
||||
else clearTimeout(compositeFrame);
|
||||
compositeFrame = null;
|
||||
}
|
||||
composite();
|
||||
}
|
||||
|
||||
function targetFor(tool, layer) {
|
||||
const activeMask = state.quickMaskActive && state.wandMask
|
||||
? { mode: 'quick-selection', canvas: state.wandMask, ctx: state.wandMask.getContext('2d') }
|
||||
: getActiveMaskLayer();
|
||||
const paintingMask = !!activeMask && ['brush', 'eraser', 'inpaint'].includes(tool);
|
||||
const ctx = paintingMask ? activeMask.ctx : (tool === 'inpaint' ? state.maskCtx : layer.ctx);
|
||||
const layerOff = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const documentMask = paintingMask && activeMask.mode !== 'layer';
|
||||
const maskOff = paintingMask && activeMask.mode === 'layer' ? (activeMask.offset || { x: 0, y: 0 }) : { x: 0, y: 0 };
|
||||
return {
|
||||
ctx,
|
||||
paintingMask,
|
||||
xOffset: (documentMask || tool === 'inpaint') ? 0 : layerOff.x + maskOff.x,
|
||||
yOffset: (documentMask || tool === 'inpaint') ? 0 : layerOff.y + maskOff.y,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneStamp(sample, layer, heal = false) {
|
||||
if (!state.cloneSourceSnapshot) return;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const dx = x - state.cloneStrokeStartX;
|
||||
const dy = y - state.cloneStrokeStartY;
|
||||
const srcX = state.cloneSourceX + dx;
|
||||
const srcY = state.cloneSourceY + dy;
|
||||
const ctx = layer.ctx;
|
||||
const radius = Math.max(1, state.brushSize / 2);
|
||||
// Walk last → current in roughly half-brush steps so stamps
|
||||
// overlap into a continuous brush trail.
|
||||
const lastSrcX = state.cloneSourceX + (state.lastX - state.cloneStrokeStartX);
|
||||
const lastSrcY = state.cloneSourceY + (state.lastY - state.cloneStrokeStartY);
|
||||
const dist = Math.hypot(x - state.lastX, y - state.lastY);
|
||||
const step = Math.max(1, radius * 0.5);
|
||||
const steps = Math.max(1, Math.ceil(dist / step));
|
||||
const stampSize = Math.max(2, Math.ceil(radius * 2));
|
||||
const stampRadius = stampSize / 2;
|
||||
const diameter = pressureValue(state.brushSize, sample.pressure, state.pressureSize);
|
||||
const radius = Math.max(0.5, diameter / 2);
|
||||
const settings = toolSettings(heal ? 'heal' : 'clone');
|
||||
const alpha = pressureValue(settings.opacity / 100, sample.pressure, state.pressureOpacity)
|
||||
* pressureValue(settings.flow / 100, sample.pressure, state.pressureFlow);
|
||||
const sx = state.cloneSourceX - (state.cloneSourceOffsetX || 0) + (sample.x - state.cloneStrokeStartX);
|
||||
const sy = state.cloneSourceY - (state.cloneSourceOffsetY || 0) + (sample.y - state.cloneStrokeStartY);
|
||||
const size = Math.max(2, Math.ceil(radius * 2));
|
||||
const stamp = document.createElement('canvas');
|
||||
stamp.width = stampSize;
|
||||
stamp.height = stampSize;
|
||||
stamp.width = size;
|
||||
stamp.height = size;
|
||||
const stampCtx = stamp.getContext('2d');
|
||||
const softness = Math.max(0, Math.min(1, state.cloneSoftness / 300));
|
||||
const hardStop = stampRadius * (1 - softness);
|
||||
stampCtx.drawImage(state.cloneSourceSnapshot, sx - radius, sy - radius, size, size, 0, 0, size, size);
|
||||
stampCtx.globalCompositeOperation = 'destination-in';
|
||||
const hard = Math.max(0, Math.min(1, 1 - settings.softness / 300));
|
||||
const mask = stampCtx.createRadialGradient(radius, radius, radius * hard, radius, radius, radius);
|
||||
mask.addColorStop(0, 'rgba(0,0,0,1)');
|
||||
mask.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
stampCtx.fillStyle = mask;
|
||||
stampCtx.fillRect(0, 0, size, size);
|
||||
const ctx = layer.ctx;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = (state.cloneOpacity / 100) * (state.cloneFlow / 100);
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
const px = state.lastX + (x - state.lastX) * t - off.x;
|
||||
const py = state.lastY + (y - state.lastY) * t - off.y;
|
||||
const sx = lastSrcX + (srcX - lastSrcX) * t;
|
||||
const sy = lastSrcY + (srcY - lastSrcY) * t;
|
||||
stampCtx.clearRect(0, 0, stampSize, stampSize);
|
||||
stampCtx.globalCompositeOperation = 'source-over';
|
||||
stampCtx.drawImage(
|
||||
state.cloneSourceSnapshot,
|
||||
sx - stampRadius, sy - stampRadius, stampSize, stampSize,
|
||||
0, 0, stampSize, stampSize,
|
||||
);
|
||||
stampCtx.globalCompositeOperation = 'destination-in';
|
||||
const mask = stampCtx.createRadialGradient(stampRadius, stampRadius, hardStop, stampRadius, stampRadius, stampRadius);
|
||||
mask.addColorStop(0, 'rgba(0,0,0,1)');
|
||||
mask.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
stampCtx.fillStyle = mask;
|
||||
stampCtx.fillRect(0, 0, stampSize, stampSize);
|
||||
ctx.drawImage(stamp, px - stampRadius, py - stampRadius);
|
||||
}
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.globalCompositeOperation = isLayerTransparencyLocked(state, layer) ? 'source-atop' : 'source-over';
|
||||
if (heal) ctx.filter = `blur(${Math.max(0.4, radius * 0.08).toFixed(2)}px)`;
|
||||
ctx.drawImage(stamp, sample.x - off.x - radius, sample.y - off.y - radius);
|
||||
ctx.restore();
|
||||
state.lastX = x;
|
||||
state.lastY = y;
|
||||
composite();
|
||||
}
|
||||
|
||||
function strokeTo(x, y) {
|
||||
function spotHealStamp(sample, layer) {
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const diameter = pressureValue(state.brushSize, sample.pressure, state.pressureSize);
|
||||
const radius = Math.max(0.5, diameter / 2);
|
||||
const settings = toolSettings('heal');
|
||||
const alpha = pressureValue(settings.opacity / 100, sample.pressure, state.pressureOpacity)
|
||||
* pressureValue(settings.flow / 100, sample.pressure, state.pressureFlow);
|
||||
const x = Math.round(sample.x - off.x);
|
||||
const y = Math.round(sample.y - off.y);
|
||||
const ring = Math.max(2, Math.ceil(radius * 2.2));
|
||||
const left = Math.max(0, x - ring);
|
||||
const top = Math.max(0, y - ring);
|
||||
const right = Math.min(layer.canvas.width - 1, x + ring);
|
||||
const bottom = Math.min(layer.canvas.height - 1, y + ring);
|
||||
if (right < left || bottom < top) return;
|
||||
const pixels = layer.ctx.getImageData(left, top, right - left + 1, bottom - top + 1).data;
|
||||
let red = 0, green = 0, blue = 0, alphaSum = 0, weightSum = 0;
|
||||
const exclusion = Math.max(1, radius * 0.72);
|
||||
for (let py = top; py <= bottom; py += 1) {
|
||||
for (let px = left; px <= right; px += 1) {
|
||||
const distance = Math.hypot(px - x, py - y);
|
||||
if (distance < exclusion || distance > ring) continue;
|
||||
const index = ((py - top) * (right - left + 1) + (px - left)) * 4;
|
||||
const weight = 1 / Math.max(1, distance);
|
||||
red += pixels[index] * weight;
|
||||
green += pixels[index + 1] * weight;
|
||||
blue += pixels[index + 2] * weight;
|
||||
alphaSum += pixels[index + 3] * weight;
|
||||
weightSum += weight;
|
||||
}
|
||||
}
|
||||
if (!weightSum) return;
|
||||
const color = `rgba(${Math.round(red / weightSum)},${Math.round(green / weightSum)},${Math.round(blue / weightSum)},${Math.max(0, Math.min(1, alphaSum / weightSum / 255))})`;
|
||||
circleStamp(layer.ctx, x, y, radius, Math.max(0, Math.min(1, settings.softness / 300)), color, alpha, 'source-over');
|
||||
}
|
||||
|
||||
function smudgeStamp(sample, layer) {
|
||||
const previousX = Number.isFinite(state.lastX) ? state.lastX : sample.x;
|
||||
const previousY = Number.isFinite(state.lastY) ? state.lastY : sample.y;
|
||||
const distance = Math.hypot(sample.x - previousX, sample.y - previousY);
|
||||
if (distance < 0.25) return;
|
||||
const diameter = pressureValue(state.brushSize, sample.pressure, state.pressureSize);
|
||||
const radius = Math.max(0.5, diameter / 2);
|
||||
const settings = toolSettings('smudge');
|
||||
const alpha = pressureValue(settings.opacity / 100, sample.pressure, state.pressureOpacity)
|
||||
* pressureValue(settings.flow / 100, sample.pressure, state.pressureFlow);
|
||||
const size = Math.max(2, Math.ceil(radius * 2));
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
if (!smudgeBuffer || smudgeBuffer.width !== size || smudgeBuffer.height !== size) {
|
||||
smudgeBuffer = document.createElement('canvas');
|
||||
smudgeBuffer.width = size;
|
||||
smudgeBuffer.height = size;
|
||||
smudgeBuffer.getContext('2d').drawImage(
|
||||
layer.canvas,
|
||||
previousX - off.x - radius, previousY - off.y - radius, size, size,
|
||||
0, 0, size, size,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const stamp = document.createElement('canvas');
|
||||
stamp.width = size;
|
||||
stamp.height = size;
|
||||
const stampCtx = stamp.getContext('2d');
|
||||
stampCtx.drawImage(smudgeBuffer, 0, 0);
|
||||
stampCtx.globalCompositeOperation = 'destination-in';
|
||||
const hard = Math.max(0, Math.min(1, 1 - settings.softness / 300));
|
||||
const mask = stampCtx.createRadialGradient(radius, radius, radius * hard, radius, radius, radius);
|
||||
mask.addColorStop(0, 'rgba(0,0,0,1)');
|
||||
mask.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
stampCtx.fillStyle = mask;
|
||||
stampCtx.fillRect(0, 0, size, size);
|
||||
const ctx = layer.ctx;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.globalCompositeOperation = isLayerTransparencyLocked(state, layer) ? 'source-atop' : 'source-over';
|
||||
ctx.drawImage(stamp, sample.x - off.x - radius, sample.y - off.y - radius);
|
||||
ctx.restore();
|
||||
// Capture the newly mixed patch so the next sample carries the smear
|
||||
// forward instead of repeatedly sampling the untouched canvas.
|
||||
smudgeBuffer.getContext('2d').clearRect(0, 0, size, size);
|
||||
smudgeBuffer.getContext('2d').drawImage(
|
||||
layer.canvas,
|
||||
sample.x - off.x - radius, sample.y - off.y - radius, size, size,
|
||||
0, 0, size, size,
|
||||
);
|
||||
}
|
||||
|
||||
function renderSample(sample) {
|
||||
const tool = strokeTool || state.tool;
|
||||
if (!PAINT_TOOLS.has(tool)) return;
|
||||
const layer = activeLayer();
|
||||
if (!layer) return;
|
||||
// Clone uses a stamp-based paint loop, not the line-stroke
|
||||
// pipeline below.
|
||||
if (state.tool === 'clone') return cloneStrokeTo(x, y, layer);
|
||||
|
||||
// If the active parent has an active mask sub-layer, brush /
|
||||
// eraser / inpaint paint the mask canvas instead of the layer's
|
||||
// pixel canvas. Brush adds to the mask, Eraser carves it away,
|
||||
// Inpaint still works (its mask plumbing was already pointed at
|
||||
// the same canvas).
|
||||
const activeMask = getActiveMaskLayer();
|
||||
const paintingMask = !!activeMask &&
|
||||
(state.tool === 'brush' || state.tool === 'eraser' || state.tool === 'inpaint');
|
||||
const ctx = paintingMask
|
||||
? activeMask.ctx
|
||||
: (state.tool === 'inpaint' ? state.maskCtx : layer.ctx);
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
|
||||
ctx.save();
|
||||
ctx.lineWidth = state.brushSize;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
if (state.tool === 'eraser') {
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
// Effective alpha = opacity × flow. Opacity = max strength a
|
||||
// stroke can reach; flow = how much erases per pass.
|
||||
ctx.globalAlpha = (state.eraserOpacity / 100) * (state.eraserFlow / 100);
|
||||
ctx.strokeStyle = 'rgba(0,0,0,1)';
|
||||
if (state.eraserSoftness > 0) {
|
||||
const blurPx = (state.eraserSoftness / 100) * (state.brushSize / 2);
|
||||
ctx.filter = `blur(${blurPx.toFixed(2)}px)`;
|
||||
}
|
||||
} else if (state.tool === 'brush') {
|
||||
// Brush — state.color onto the layer (or white onto an active
|
||||
// mask sub-layer). Mask painting forces full alpha so masks
|
||||
// stay a clean binary by default (a sub-100% brush would
|
||||
// silently paint partial-strength mask pixels).
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.strokeStyle = paintingMask ? 'rgba(255,255,255,1)' : state.color;
|
||||
if (paintingMask) {
|
||||
ctx.globalAlpha = 1;
|
||||
} else {
|
||||
ctx.globalAlpha = (state.brushOpacity / 100) * (state.brushFlow / 100);
|
||||
if (state.brushSoftness > 0) {
|
||||
const blurPx = (state.brushSoftness / 100) * (state.brushSize / 2);
|
||||
ctx.filter = `blur(${blurPx.toFixed(2)}px)`;
|
||||
}
|
||||
}
|
||||
} else if (state.tool === 'inpaint') {
|
||||
if (state.inpaintEraseStroke) {
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.strokeStyle = 'rgba(0,0,0,1)';
|
||||
} else {
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
// Diffusion server expects white = inpaint area. The red
|
||||
// overlay is rendered separately in composite() for the user.
|
||||
ctx.strokeStyle = 'rgba(255,255,255,1)';
|
||||
}
|
||||
} else {
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.strokeStyle = state.color;
|
||||
if (tool === 'clone') return cloneStamp(sample, layer);
|
||||
if (tool === 'heal') {
|
||||
// A healing stroke without a picked source uses the convenient local
|
||||
// spot correction. With a source snapshot, blend sampled texture into
|
||||
// the destination instead of silently falling back to ring averaging.
|
||||
return state.cloneSourceSnapshot
|
||||
? cloneStamp(sample, layer, true)
|
||||
: spotHealStamp(sample, layer);
|
||||
}
|
||||
if (tool === 'smudge') return smudgeStamp(sample, layer);
|
||||
const target = targetFor(tool, layer);
|
||||
if (!target.ctx) return;
|
||||
const settings = toolSettings(tool);
|
||||
const diameter = pressureValue(state.brushSize, sample.pressure, state.pressureSize);
|
||||
const radius = Math.max(0.5, diameter / 2);
|
||||
const opacity = pressureValue(settings.opacity / 100, sample.pressure, state.pressureOpacity);
|
||||
const flow = pressureValue(settings.flow / 100, sample.pressure, state.pressureFlow);
|
||||
let operation = 'source-over';
|
||||
let color = state.color;
|
||||
let alpha = opacity * flow;
|
||||
let softness = Math.max(0, Math.min(1, settings.softness / 300));
|
||||
if (tool === 'eraser') { operation = 'destination-out'; color = 'rgba(0,0,0,1)'; }
|
||||
else if (tool === 'inpaint') {
|
||||
operation = state.inpaintEraseStroke ? 'destination-out' : 'source-over';
|
||||
color = 'rgba(255,255,255,1)';
|
||||
alpha = 1;
|
||||
softness = 0;
|
||||
} else if (tool === 'dodge') { operation = 'screen'; color = 'rgba(255,255,255,1)'; }
|
||||
else if (tool === 'burn') { operation = 'multiply'; color = 'rgba(0,0,0,1)'; }
|
||||
else if (target.paintingMask) { color = 'rgba(255,255,255,1)'; alpha = 1; softness = 0; }
|
||||
else operation = isLayerTransparencyLocked(state, layer) ? 'source-atop' : state.brushBlendMode;
|
||||
circleStamp(target.ctx, sample.x - target.xOffset, sample.y - target.yOffset, radius, softness, color, alpha, operation);
|
||||
}
|
||||
|
||||
// Mask canvases are always full-image (no per-layer offset), so
|
||||
// painting onto a mask uses canvas-coord origin too — same as
|
||||
// inpaint.
|
||||
const onMaskOrInpaint = paintingMask || state.tool === 'inpaint';
|
||||
const drawX = onMaskOrInpaint ? 0 : off.x;
|
||||
const drawY = onMaskOrInpaint ? 0 : off.y;
|
||||
const sampler = createBrushSampler({
|
||||
getDiameter: () => state.brushSize,
|
||||
getSpacing: () => state.brushSpacing / 100,
|
||||
getSmoothing: () => state.brushSmoothing / 100,
|
||||
emit: renderSample,
|
||||
});
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(state.lastX - drawX, state.lastY - drawY);
|
||||
ctx.lineTo(x - drawX, y - drawY);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
state.lastX = x;
|
||||
state.lastY = y;
|
||||
function beginStroke(sample, tool = state.tool) {
|
||||
strokeTool = tool;
|
||||
smudgeBuffer = null;
|
||||
sampler.begin(sample);
|
||||
state.lastX = sample.x;
|
||||
state.lastY = sample.y;
|
||||
composite();
|
||||
}
|
||||
|
||||
return { strokeTo, cloneStrokeTo };
|
||||
function strokeTo(sample) {
|
||||
sampler.add(sample);
|
||||
state.lastX = sample.x;
|
||||
state.lastY = sample.y;
|
||||
scheduleComposite();
|
||||
}
|
||||
|
||||
function endStroke(sample) {
|
||||
sampler.end(sample);
|
||||
strokeTool = null;
|
||||
smudgeBuffer = null;
|
||||
flushComposite();
|
||||
}
|
||||
|
||||
return { beginStroke, strokeTo, endStroke };
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
* + their labels + preview swatches.
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import {
|
||||
applyBrushPreset,
|
||||
captureBrushPreset,
|
||||
loadBrushPresets,
|
||||
saveCustomBrushPresets,
|
||||
} from './brush-presets.js';
|
||||
|
||||
/** Wire the three sliders for one stroke tool. */
|
||||
function wireToolSliders(prefix, fields) {
|
||||
@@ -57,8 +63,75 @@ function wireToolSliders(prefix, fields) {
|
||||
});
|
||||
}
|
||||
|
||||
export function wireStrokeToolSliders() {
|
||||
export function wireStrokeToolSliders({ onPresetApplied } = {}) {
|
||||
wireToolSliders('eraser', { opacity: 'eraserOpacity', flow: 'eraserFlow', softness: 'eraserSoftness' });
|
||||
wireToolSliders('brush', { opacity: 'brushOpacity', flow: 'brushFlow', softness: 'brushSoftness' });
|
||||
wireToolSliders('clone', { opacity: 'cloneOpacity', flow: 'cloneFlow', softness: 'cloneSoftness' });
|
||||
|
||||
const smudgeStrength = document.getElementById('ge-smudge-strength');
|
||||
smudgeStrength?.addEventListener('input', (e) => {
|
||||
state.smudgeStrength = parseInt(e.target.value, 10);
|
||||
document.getElementById('ge-smudge-strength-label').textContent = `${state.smudgeStrength}%`;
|
||||
const preview = document.getElementById('ge-smudge-preview-strength');
|
||||
if (preview) preview.style.opacity = (state.smudgeStrength / 100).toFixed(2);
|
||||
});
|
||||
|
||||
const spacing = document.getElementById('ge-brush-spacing');
|
||||
const smoothing = document.getElementById('ge-brush-smoothing');
|
||||
const blend = document.getElementById('ge-brush-blend');
|
||||
spacing?.addEventListener('input', (e) => {
|
||||
state.brushSpacing = parseInt(e.target.value, 10);
|
||||
document.getElementById('ge-brush-spacing-label').textContent = `${state.brushSpacing}%`;
|
||||
});
|
||||
smoothing?.addEventListener('input', (e) => {
|
||||
state.brushSmoothing = parseInt(e.target.value, 10);
|
||||
document.getElementById('ge-brush-smoothing-label').textContent = `${state.brushSmoothing}%`;
|
||||
});
|
||||
blend?.addEventListener('change', (e) => { state.brushBlendMode = e.target.value; });
|
||||
document.getElementById('ge-pressure-size')?.addEventListener('change', e => { state.pressureSize = e.target.checked; });
|
||||
document.getElementById('ge-pressure-opacity')?.addEventListener('change', e => { state.pressureOpacity = e.target.checked; });
|
||||
document.getElementById('ge-pressure-flow')?.addEventListener('change', e => { state.pressureFlow = e.target.checked; });
|
||||
document.getElementById('ge-eyedropper-sample')?.addEventListener('change', e => { state.eyedropperSample = e.target.value; });
|
||||
|
||||
let presets = loadBrushPresets();
|
||||
const presetSelect = document.getElementById('ge-brush-preset');
|
||||
const renderPresets = (selectedId = '') => {
|
||||
if (!presetSelect) return;
|
||||
presetSelect.innerHTML = '<option value="">Brush presets</option>' + presets.map(preset =>
|
||||
`<option value="${preset.id}">${preset.name}</option>`).join('');
|
||||
presetSelect.value = selectedId;
|
||||
};
|
||||
const syncControls = () => {
|
||||
if (spacing) spacing.value = String(state.brushSpacing);
|
||||
if (smoothing) smoothing.value = String(state.brushSmoothing);
|
||||
if (blend) blend.value = state.brushBlendMode;
|
||||
if (smudgeStrength) smudgeStrength.value = String(state.smudgeStrength);
|
||||
const smudgeLabel = document.getElementById('ge-smudge-strength-label');
|
||||
if (smudgeLabel) smudgeLabel.textContent = `${state.smudgeStrength}%`;
|
||||
document.getElementById('ge-brush-spacing-label').textContent = `${state.brushSpacing}%`;
|
||||
document.getElementById('ge-brush-smoothing-label').textContent = `${state.brushSmoothing}%`;
|
||||
onPresetApplied?.();
|
||||
};
|
||||
renderPresets();
|
||||
presetSelect?.addEventListener('change', () => {
|
||||
const preset = presets.find(item => item.id === presetSelect.value);
|
||||
if (!preset) return;
|
||||
applyBrushPreset(state, preset);
|
||||
syncControls();
|
||||
});
|
||||
document.getElementById('ge-brush-preset-save')?.addEventListener('click', () => {
|
||||
const name = window.prompt('Brush preset name');
|
||||
if (!name?.trim()) return;
|
||||
const preset = captureBrushPreset(state, name);
|
||||
presets.push(preset);
|
||||
saveCustomBrushPresets(presets);
|
||||
renderPresets(preset.id);
|
||||
});
|
||||
document.getElementById('ge-brush-preset-delete')?.addEventListener('click', () => {
|
||||
const preset = presets.find(item => item.id === presetSelect?.value);
|
||||
if (!preset || preset.builtIn) return;
|
||||
presets = presets.filter(item => item.id !== preset.id);
|
||||
saveCustomBrushPresets(presets);
|
||||
renderPresets();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/** Direct on-canvas editor for retained text layers. */
|
||||
|
||||
function textFont(text) {
|
||||
const family = text.fontFamily.includes(',') ? text.fontFamily : `"${text.fontFamily.replace(/["\\]/g, '')}", Arial, sans-serif`;
|
||||
return `${text.fontStyle} ${text.fontWeight} ${text.fontSize}px/${text.lineHeight} ${family}`;
|
||||
}
|
||||
|
||||
export function createTextEditOverlay({ state, onInput, onCommit, onCancel }) {
|
||||
let input = null;
|
||||
let layer = null;
|
||||
let resizeObserver = null;
|
||||
let frame = 0;
|
||||
|
||||
function position() {
|
||||
if (!input || !layer || !state.mainCanvas?.isConnected) return;
|
||||
const canvasRect = state.mainCanvas.getBoundingClientRect();
|
||||
const offset = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const scaleX = canvasRect.width / Math.max(1, state.mainCanvas.width);
|
||||
const scaleY = canvasRect.height / Math.max(1, state.mainCanvas.height);
|
||||
const text = layer.text;
|
||||
input.style.left = `${canvasRect.left + offset.x * scaleX}px`;
|
||||
input.style.top = `${canvasRect.top + offset.y * scaleY}px`;
|
||||
input.style.minWidth = `${Math.max(80, text.frameWidth * scaleX)}px`;
|
||||
input.style.width = `${Math.max(80, text.frameWidth * scaleX)}px`;
|
||||
input.style.minHeight = `${Math.max(36, layer.canvas.height * scaleY)}px`;
|
||||
input.style.font = textFont({ ...text, fontSize: text.fontSize * scaleY });
|
||||
input.style.letterSpacing = `${text.letterSpacing * scaleX}px`;
|
||||
input.style.textAlign = text.align;
|
||||
input.style.color = text.color;
|
||||
}
|
||||
|
||||
function loop() {
|
||||
position();
|
||||
if (input) frame = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function close(commit = true) {
|
||||
if (!input) return;
|
||||
const value = input.value;
|
||||
resizeObserver?.disconnect();
|
||||
cancelAnimationFrame(frame);
|
||||
input.remove();
|
||||
input = null;
|
||||
const target = layer;
|
||||
layer = null;
|
||||
if (commit) onCommit?.(target, value); else onCancel?.(target);
|
||||
}
|
||||
|
||||
function open(target, { selectAll = false } = {}) {
|
||||
close(true);
|
||||
layer = target;
|
||||
input = document.createElement('textarea');
|
||||
input.className = 'ge-direct-text-editor';
|
||||
input.value = target.text.content;
|
||||
input.setAttribute('aria-label', 'Edit text on canvas');
|
||||
input.spellcheck = true;
|
||||
input.addEventListener('input', () => onInput?.(target, input.value, input));
|
||||
input.addEventListener('keydown', event => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close(false);
|
||||
} else if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
close(true);
|
||||
}
|
||||
});
|
||||
let ready = false;
|
||||
input.addEventListener('blur', () => setTimeout(() => {
|
||||
if (ready && input && document.activeElement !== input) close(true);
|
||||
}, 0));
|
||||
document.body.appendChild(input);
|
||||
resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => {
|
||||
if (!input || !layer) return;
|
||||
const canvasRect = state.mainCanvas.getBoundingClientRect();
|
||||
const scale = canvasRect.width / Math.max(1, state.mainCanvas.width);
|
||||
const width = Math.max(1, input.getBoundingClientRect().width / Math.max(0.01, scale));
|
||||
onInput?.(layer, input.value, input, width);
|
||||
}) : null;
|
||||
resizeObserver?.observe(input);
|
||||
position();
|
||||
frame = requestAnimationFrame(() => {
|
||||
if (!input) return;
|
||||
input.focus({ preventScroll: true });
|
||||
if (selectAll) input.select();
|
||||
ready = true;
|
||||
frame = requestAnimationFrame(loop);
|
||||
});
|
||||
return input;
|
||||
}
|
||||
|
||||
return { open, close, position, isOpen: () => !!input, activeLayer: () => layer };
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/** Retained text-layer model and raster-cache renderer. */
|
||||
|
||||
export const DEFAULT_TEXT_DATA = Object.freeze({
|
||||
content: 'Text',
|
||||
fontFamily: 'Arial',
|
||||
fontSize: 48,
|
||||
fontWeight: '400',
|
||||
fontStyle: 'normal',
|
||||
align: 'left',
|
||||
color: '#ffffff',
|
||||
strokeColor: '#000000',
|
||||
strokeWidth: 0,
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: 0,
|
||||
frameWidth: 320,
|
||||
frameHeight: 0,
|
||||
autoWidth: true,
|
||||
verticalAlign: 'top',
|
||||
transform: Object.freeze({ scaleX: 1, scaleY: 1, rotation: 0, flipH: false, flipV: false }),
|
||||
});
|
||||
|
||||
function finite(value, fallback, min = -Infinity, max = Infinity) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
|
||||
}
|
||||
|
||||
export function normalizeTextData(value = {}) {
|
||||
const transform = value.transform || {};
|
||||
return {
|
||||
content: String(value.content ?? DEFAULT_TEXT_DATA.content),
|
||||
fontFamily: String(value.fontFamily || DEFAULT_TEXT_DATA.fontFamily),
|
||||
fontSize: finite(value.fontSize, DEFAULT_TEXT_DATA.fontSize, 1, 2000),
|
||||
fontWeight: String(value.fontWeight || DEFAULT_TEXT_DATA.fontWeight),
|
||||
fontStyle: value.fontStyle === 'italic' ? 'italic' : 'normal',
|
||||
align: ['left', 'center', 'right'].includes(value.align) ? value.align : 'left',
|
||||
color: String(value.color || DEFAULT_TEXT_DATA.color),
|
||||
strokeColor: String(value.strokeColor || DEFAULT_TEXT_DATA.strokeColor),
|
||||
strokeWidth: finite(value.strokeWidth, DEFAULT_TEXT_DATA.strokeWidth, 0, 100),
|
||||
lineHeight: finite(value.lineHeight, DEFAULT_TEXT_DATA.lineHeight, 0.5, 5),
|
||||
letterSpacing: finite(value.letterSpacing, DEFAULT_TEXT_DATA.letterSpacing, -100, 500),
|
||||
frameWidth: finite(value.frameWidth, DEFAULT_TEXT_DATA.frameWidth, 1, 10000),
|
||||
frameHeight: finite(value.frameHeight, DEFAULT_TEXT_DATA.frameHeight, 0, 10000),
|
||||
autoWidth: value.autoWidth !== false,
|
||||
verticalAlign: ['top', 'middle', 'bottom'].includes(value.verticalAlign) ? value.verticalAlign : 'top',
|
||||
transform: {
|
||||
scaleX: finite(transform.scaleX, 1, 0.01, 100),
|
||||
scaleY: finite(transform.scaleY, 1, 0.01, 100),
|
||||
rotation: finite(transform.rotation, 0, -36000, 36000),
|
||||
flipH: !!transform.flipH,
|
||||
flipV: !!transform.flipV,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fontString(text) {
|
||||
const family = text.fontFamily.includes(',')
|
||||
? text.fontFamily
|
||||
: `"${text.fontFamily.replace(/["\\]/g, '')}", Arial, sans-serif`;
|
||||
return `${text.fontStyle} ${text.fontWeight} ${text.fontSize}px ${family}`;
|
||||
}
|
||||
|
||||
function measureSpacedText(ctx, value, spacing) {
|
||||
const chars = [...String(value || '')];
|
||||
if (!chars.length) return 0;
|
||||
return chars.reduce((width, char) => width + ctx.measureText(char).width, 0) + spacing * Math.max(0, chars.length - 1);
|
||||
}
|
||||
|
||||
function drawSpacedText(ctx, value, x, y, spacing, stroke = false) {
|
||||
const chars = [...String(value || '')];
|
||||
if (!chars.length) return;
|
||||
const width = measureSpacedText(ctx, value, spacing);
|
||||
let cursor = ctx.textAlign === 'center' ? x - width / 2 : ctx.textAlign === 'right' ? x - width : x;
|
||||
const draw = stroke ? ctx.strokeText.bind(ctx) : ctx.fillText.bind(ctx);
|
||||
for (const char of chars) {
|
||||
draw(char, cursor, y);
|
||||
cursor += ctx.measureText(char).width + spacing;
|
||||
}
|
||||
}
|
||||
|
||||
function wrapText(ctx, content, width, spacing) {
|
||||
const result = [];
|
||||
for (const paragraph of String(content).split('\n')) {
|
||||
if (!width) {
|
||||
result.push(paragraph);
|
||||
continue;
|
||||
}
|
||||
const words = paragraph.split(/(\s+)/).filter(Boolean);
|
||||
if (!words.length) {
|
||||
result.push('');
|
||||
continue;
|
||||
}
|
||||
let line = '';
|
||||
for (const word of words) {
|
||||
const candidate = line + word;
|
||||
if (line && measureSpacedText(ctx, candidate, spacing) > width) {
|
||||
result.push(line.trimEnd());
|
||||
line = word.trimStart();
|
||||
} else {
|
||||
line = candidate;
|
||||
}
|
||||
}
|
||||
result.push(line);
|
||||
}
|
||||
return result.length ? result : [''];
|
||||
}
|
||||
|
||||
function baseTextCanvas(text) {
|
||||
const measure = document.createElement('canvas');
|
||||
const measureCtx = measure.getContext('2d');
|
||||
measureCtx.font = fontString(text);
|
||||
const padding = Math.ceil(Math.max(4, text.strokeWidth + 3));
|
||||
const requestedWidth = text.autoWidth ? 0 : Math.max(1, text.frameWidth);
|
||||
const lines = wrapText(measureCtx, text.content, requestedWidth, text.letterSpacing);
|
||||
const measuredWidth = Math.max(1, ...lines.map(line => measureSpacedText(measureCtx, line || ' ', text.letterSpacing)));
|
||||
const contentWidth = text.autoWidth ? measuredWidth : requestedWidth;
|
||||
const lineHeightPx = text.fontSize * text.lineHeight;
|
||||
const contentHeight = Math.max(1, lines.length * lineHeightPx);
|
||||
const frameHeight = text.frameHeight > 0 ? Math.max(contentHeight, text.frameHeight) : contentHeight;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.ceil(contentWidth + padding * 2));
|
||||
canvas.height = Math.max(1, Math.ceil(frameHeight + padding * 2));
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.font = fontString(text);
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.textAlign = text.align;
|
||||
ctx.fillStyle = text.color;
|
||||
ctx.strokeStyle = text.strokeColor;
|
||||
ctx.lineWidth = text.strokeWidth * 2;
|
||||
ctx.lineJoin = 'round';
|
||||
const x = text.align === 'center' ? canvas.width / 2 : text.align === 'right' ? canvas.width - padding : padding;
|
||||
const verticalOffset = text.verticalAlign === 'middle'
|
||||
? (frameHeight - contentHeight) / 2
|
||||
: text.verticalAlign === 'bottom' ? frameHeight - contentHeight : 0;
|
||||
lines.forEach((line, index) => {
|
||||
const y = padding + verticalOffset + index * lineHeightPx;
|
||||
if (text.strokeWidth > 0) drawSpacedText(ctx, line, x, y, text.letterSpacing, true);
|
||||
drawSpacedText(ctx, line, x, y, text.letterSpacing, false);
|
||||
});
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function renderTextLayer(layer) {
|
||||
if (!layer?.canvas || !layer?.ctx) return null;
|
||||
layer.kind = 'text';
|
||||
layer.text = normalizeTextData(layer.text);
|
||||
const base = baseTextCanvas(layer.text);
|
||||
const transform = layer.text.transform;
|
||||
const scaledWidth = Math.max(1, base.width * transform.scaleX);
|
||||
const scaledHeight = Math.max(1, base.height * transform.scaleY);
|
||||
const radians = (transform.rotation * Math.PI) / 180;
|
||||
const cos = Math.abs(Math.cos(radians));
|
||||
const sin = Math.abs(Math.sin(radians));
|
||||
const width = Math.max(1, Math.ceil(scaledWidth * cos + scaledHeight * sin));
|
||||
const height = Math.max(1, Math.ceil(scaledWidth * sin + scaledHeight * cos));
|
||||
const rendered = document.createElement('canvas');
|
||||
rendered.width = width;
|
||||
rendered.height = height;
|
||||
const ctx = rendered.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.translate(width / 2, height / 2);
|
||||
ctx.rotate(radians);
|
||||
ctx.scale(transform.flipH ? -1 : 1, transform.flipV ? -1 : 1);
|
||||
ctx.drawImage(base, -scaledWidth / 2, -scaledHeight / 2, scaledWidth, scaledHeight);
|
||||
layer.canvas.width = width;
|
||||
layer.canvas.height = height;
|
||||
layer.ctx = layer.canvas.getContext('2d');
|
||||
layer.ctx.clearRect(0, 0, width, height);
|
||||
layer.ctx.drawImage(rendered, 0, 0);
|
||||
layer._adjCache = null;
|
||||
layer._adjCacheKey = null;
|
||||
layer._adjFinal = null;
|
||||
layer._adjFinalKey = null;
|
||||
return layer.canvas;
|
||||
}
|
||||
|
||||
/** Mark a retained text cache as ordinary pixels after a destructive edit. */
|
||||
export function rasterizeTextLayer(layer) {
|
||||
if (!layer || layer.kind !== 'text') return false;
|
||||
layer.kind = 'raster';
|
||||
layer.text = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function scaleTextLayer(layer, scaleX, scaleY) {
|
||||
layer.text = normalizeTextData(layer.text);
|
||||
layer.text.transform.scaleX *= scaleX;
|
||||
layer.text.transform.scaleY *= scaleY;
|
||||
return renderTextLayer(layer);
|
||||
}
|
||||
|
||||
export function rotateTextLayer(layer, degrees) {
|
||||
layer.text = normalizeTextData(layer.text);
|
||||
layer.text.transform.rotation += degrees;
|
||||
return renderTextLayer(layer);
|
||||
}
|
||||
|
||||
export function flipTextLayer(layer, axis) {
|
||||
layer.text = normalizeTextData(layer.text);
|
||||
if (axis === 'h') layer.text.transform.flipH = !layer.text.transform.flipH;
|
||||
else layer.text.transform.flipV = !layer.text.transform.flipV;
|
||||
return renderTextLayer(layer);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/* Encode a completed document surface without blocking the editor thread. */
|
||||
self.onmessage = async event => {
|
||||
const { bitmap, maxDim = 160, quality = 0.6 } = event.data || {};
|
||||
try {
|
||||
if (!bitmap?.width || !bitmap?.height) throw new Error('Thumbnail source is empty');
|
||||
const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height));
|
||||
const width = Math.max(1, Math.round(bitmap.width * scale));
|
||||
const height = Math.max(1, Math.round(bitmap.height * scale));
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
canvas.getContext('2d').drawImage(bitmap, 0, 0, width, height);
|
||||
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality });
|
||||
self.postMessage({ blob });
|
||||
} catch (error) {
|
||||
self.postMessage({ error: String(error?.message || error) });
|
||||
} finally {
|
||||
bitmap?.close?.();
|
||||
}
|
||||
};
|
||||
@@ -13,12 +13,14 @@
|
||||
* saveState: (label?: string) => void,
|
||||
* strokeTo: (x: number, y: number) => void,
|
||||
* showToast: (msg: string) => void,
|
||||
* onSourceChanged?: () => void,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { isLayerPixelLocked } from '../layer-groups.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
export function createCloneTool({ activeLayer, saveState, strokeTo, showToast }) {
|
||||
export function createCloneTool({ activeLayer, saveState, beginStroke, showToast, onSourceChanged, getSampleCanvas }) {
|
||||
return {
|
||||
begin(e) {
|
||||
const layer = activeLayer();
|
||||
@@ -49,33 +51,68 @@ export function createCloneTool({ activeLayer, saveState, strokeTo, showToast })
|
||||
state.cloneSourceX = coords.x;
|
||||
state.cloneSourceY = coords.y;
|
||||
state.cloneSourceLayerId = (layer && layer.id) || state.activeLayerId;
|
||||
const sampledCanvas = getSampleCanvas?.({ layer, mode: state.cloneSampleMode });
|
||||
const sourceLayer = layer && (
|
||||
state.cloneSampleMode === 'active-layer' || !sampledCanvas
|
||||
) ? layer : null;
|
||||
const sourceOffset = sourceLayer ? (state.layerOffsets.get(sourceLayer.id) || { x: 0, y: 0 }) : { x: 0, y: 0 };
|
||||
state.cloneSourceOffsetX = sourceOffset.x;
|
||||
state.cloneSourceOffsetY = sourceOffset.y;
|
||||
state.cloneSourceSnapshot = null; // captured at first stroke
|
||||
onSourceChanged?.();
|
||||
showToast('Clone source set');
|
||||
return;
|
||||
}
|
||||
// Healing defaults to source-free spot correction. Once a source is
|
||||
// chosen, the same tool switches to sampled healing in the pipeline.
|
||||
if (state.tool === 'heal' && (state.cloneSourceX === null || state.cloneSourceY === null)) {
|
||||
if (!layer || isLayerPixelLocked(state, layer)) {
|
||||
showToast('Unlock image pixels before healing');
|
||||
return;
|
||||
}
|
||||
if (layer.kind === 'placed') {
|
||||
showToast('Rasterize the placed layer before healing its pixels');
|
||||
return;
|
||||
}
|
||||
saveState('Healing stroke');
|
||||
state.drawing = true;
|
||||
state.lastX = coords.x;
|
||||
state.lastY = coords.y;
|
||||
beginStroke({ x: coords.x, y: coords.y, pressure: Number(e.pressure) > 0 ? e.pressure : 1 }, state.tool);
|
||||
return;
|
||||
}
|
||||
if (state.cloneSourceX === null || state.cloneSourceY === null) {
|
||||
showToast(isTouchEvt
|
||||
? 'Double-tap first to set a clone source'
|
||||
: 'Alt-click first to set a clone source');
|
||||
return;
|
||||
}
|
||||
if (!layer || layer.locked) return;
|
||||
saveState('Clone stroke');
|
||||
if (!layer || isLayerPixelLocked(state, layer)) {
|
||||
showToast('Unlock image pixels before cloning');
|
||||
return;
|
||||
}
|
||||
if (layer.kind === 'placed') {
|
||||
showToast('Rasterize the placed layer before cloning into it');
|
||||
return;
|
||||
}
|
||||
const toolName = state.tool === 'heal' ? 'Healing stroke' : 'Clone stroke';
|
||||
saveState(toolName);
|
||||
// Snapshot the source layer's pixels at stroke-start so the
|
||||
// brush samples clean source pixels even after it has painted
|
||||
// over them. Otherwise we'd cascade-clone the same ring.
|
||||
const srcLayer = state.layers.find(l => l.id === state.cloneSourceLayerId) || layer;
|
||||
const sourceCanvas = getSampleCanvas?.({ layer: srcLayer, mode: state.cloneSampleMode }) || srcLayer.canvas;
|
||||
const snap = document.createElement('canvas');
|
||||
snap.width = srcLayer.canvas.width;
|
||||
snap.height = srcLayer.canvas.height;
|
||||
snap.getContext('2d').drawImage(srcLayer.canvas, 0, 0);
|
||||
snap.width = sourceCanvas.width;
|
||||
snap.height = sourceCanvas.height;
|
||||
snap.getContext('2d').drawImage(sourceCanvas, 0, 0);
|
||||
state.cloneSourceSnapshot = snap;
|
||||
state.cloneStrokeStartX = coords.x;
|
||||
state.cloneStrokeStartY = coords.y;
|
||||
state.drawing = true;
|
||||
state.lastX = coords.x;
|
||||
state.lastY = coords.y;
|
||||
strokeTo(coords.x, coords.y);
|
||||
beginStroke({ x: coords.x, y: coords.y, pressure: Number(e.pressure) > 0 ? e.pressure : 1 }, state.tool);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,11 +17,30 @@
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import { drawCheckerboard } from '../checkerboard.js';
|
||||
import { createDirectManipulationSession } from '../direct-manipulation-session.js';
|
||||
|
||||
export function createCropTool({ composite, showCropApply }) {
|
||||
export function createCropTool({ composite, showCropApply, snapFrame }) {
|
||||
const gesture = createDirectManipulationSession({
|
||||
name: 'crop',
|
||||
getContext: () => ({ canvas: state.mainCanvas, tool: state.tool }),
|
||||
isContextCurrent: context => context.canvas === state.mainCanvas && state.tool === 'crop',
|
||||
onCancel: (data, reason) => {
|
||||
state.cropping = false;
|
||||
state.cropMoving = false;
|
||||
state.cropStart = null;
|
||||
state.cropEnd = null;
|
||||
state.cropMoveStart = null;
|
||||
state.cropAspectLock = null;
|
||||
state.activeSnapGuides = null;
|
||||
state.cropRect = data?.previousRect ? { ...data.previousRect } : null;
|
||||
composite();
|
||||
if (state.cropRect && reason !== 'tool-switch') showCropApply();
|
||||
},
|
||||
});
|
||||
return {
|
||||
begin(e) {
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
const previousRect = state.cropRect ? { ...state.cropRect } : null;
|
||||
// Click inside an existing crop rect → switch to move-mode so
|
||||
// the user can reposition without redrawing.
|
||||
if (state.cropRect &&
|
||||
@@ -29,6 +48,7 @@ export function createCropTool({ composite, showCropApply }) {
|
||||
coords.y >= state.cropRect.y && coords.y <= state.cropRect.y + state.cropRect.h) {
|
||||
state.cropMoving = true;
|
||||
state.cropMoveStart = { x: coords.x, y: coords.y, rx: state.cropRect.x, ry: state.cropRect.y };
|
||||
gesture.begin(e, { mode: 'move', previousRect }, { captureTarget: e.currentTarget });
|
||||
return;
|
||||
}
|
||||
state.cropping = true;
|
||||
@@ -39,9 +59,11 @@ export function createCropTool({ composite, showCropApply }) {
|
||||
// Tear down the size panel while the user is drawing a new rect.
|
||||
const old = state.container?.querySelector('.ge-crop-apply');
|
||||
if (old) old.remove();
|
||||
gesture.begin(e, { mode: 'draw', previousRect }, { captureTarget: e.currentTarget });
|
||||
},
|
||||
|
||||
drag(e) {
|
||||
if (!gesture.update(e)) return;
|
||||
// Move-mode: drag the existing rect around the canvas.
|
||||
if (state.cropMoving && state.cropRect && state.cropMoveStart) {
|
||||
e.preventDefault();
|
||||
@@ -50,6 +72,22 @@ export function createCropTool({ composite, showCropApply }) {
|
||||
const dy = c.y - state.cropMoveStart.y;
|
||||
let nx = state.cropMoveStart.rx + dx;
|
||||
let ny = state.cropMoveStart.ry + dy;
|
||||
const modifier = e.ctrlKey || e.metaKey;
|
||||
const snapping = state.snapEnabled ? !modifier : modifier;
|
||||
if (snapping && snapFrame) {
|
||||
const snapped = snapFrame({
|
||||
centerX: state.cropMoveStart.rx + state.cropRect.w / 2,
|
||||
centerY: state.cropMoveStart.ry + state.cropRect.h / 2,
|
||||
width: state.cropRect.w,
|
||||
height: state.cropRect.h,
|
||||
rotation: 0,
|
||||
}, { x: nx + state.cropRect.w / 2, y: ny + state.cropRect.h / 2 });
|
||||
nx = snapped.centerX - state.cropRect.w / 2;
|
||||
ny = snapped.centerY - state.cropRect.h / 2;
|
||||
state.activeSnapGuides = snapped.guides || null;
|
||||
} else {
|
||||
state.activeSnapGuides = null;
|
||||
}
|
||||
// Clamp to canvas bounds so the rect stays fully visible.
|
||||
nx = Math.max(0, Math.min(nx, state.mainCanvas.width - state.cropRect.w));
|
||||
ny = Math.max(0, Math.min(ny, state.mainCanvas.height - state.cropRect.h));
|
||||
@@ -119,7 +157,9 @@ export function createCropTool({ composite, showCropApply }) {
|
||||
state.cropRect = { x, y, w, h };
|
||||
},
|
||||
|
||||
end() {
|
||||
end(e) {
|
||||
if (!gesture.commit(e)) return;
|
||||
state.activeSnapGuides = null;
|
||||
// Move-mode wrap-up: refresh the floating panel so Apply follows
|
||||
// the rect to its new spot.
|
||||
if (state.cropMoving) {
|
||||
@@ -133,5 +173,8 @@ export function createCropTool({ composite, showCropApply }) {
|
||||
showCropApply();
|
||||
}
|
||||
},
|
||||
cancel(reason) {
|
||||
return gesture.cancel(reason);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
function hexByte(value) {
|
||||
return Math.max(0, Math.min(255, value)).toString(16).padStart(2, '0');
|
||||
}
|
||||
|
||||
export function createEyedropperTool({ activeLayer, composite, syncColor, syncPreview, showToast }) {
|
||||
function sample(e, refreshComposite = false) {
|
||||
const point = canvasCoords(e, state.mainCanvas);
|
||||
let canvas = state.mainCanvas;
|
||||
let x = point.x;
|
||||
let y = point.y;
|
||||
if (state.eyedropperSample === 'layer') {
|
||||
const layer = activeLayer();
|
||||
if (!layer?.canvas) return null;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
canvas = layer.canvas;
|
||||
x -= off.x;
|
||||
y -= off.y;
|
||||
} else if (refreshComposite) {
|
||||
composite();
|
||||
}
|
||||
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) return null;
|
||||
const pixel = canvas.getContext('2d').getImageData(Math.floor(x), Math.floor(y), 1, 1).data;
|
||||
const rgb = [pixel[0], pixel[1], pixel[2]];
|
||||
if (!pixel[3]) return { color: null, rgb, canvas, x, y };
|
||||
return { color: `#${hexByte(pixel[0])}${hexByte(pixel[1])}${hexByte(pixel[2])}`, rgb, canvas, x, y };
|
||||
}
|
||||
return {
|
||||
preview(e) {
|
||||
const result = sample(e);
|
||||
syncPreview?.(result?.color || null, result);
|
||||
return result?.color || null;
|
||||
},
|
||||
clearPreview() {
|
||||
syncPreview?.(null, null);
|
||||
},
|
||||
pick(e) {
|
||||
const result = sample(e, true);
|
||||
const color = result?.color;
|
||||
if (!color) {
|
||||
showToast?.('That pixel is transparent');
|
||||
return;
|
||||
}
|
||||
state.color = color;
|
||||
syncColor?.(state.color);
|
||||
syncPreview?.(state.color, result);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Local two-color gradient tool.
|
||||
*
|
||||
* The drag becomes a retained effect on completion, so previews never mutate
|
||||
* source pixels or compound across pointer-move events.
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import { isLayerPixelLocked } from '../layer-groups.js';
|
||||
|
||||
export function createGradientTool({ activeLayer, saveState, composite, schedulePersist, renderLayerPanel, getSettings }) {
|
||||
let start = null;
|
||||
let lastPoint = null;
|
||||
let activeLayerId = null;
|
||||
let historySaved = false;
|
||||
|
||||
function settings() {
|
||||
const values = getSettings?.() || {};
|
||||
return {
|
||||
start: values.start || state.gradientStart || state.color,
|
||||
mid: values.mid || state.gradientMid || '#808080',
|
||||
midPosition: values.midPosition ?? state.gradientMidPosition ?? 50,
|
||||
midEnabled: values.midEnabled ?? state.gradientMidEnabled ?? false,
|
||||
extraStops: Array.isArray(values.extraStops) ? values.extraStops : (state.gradientStops || []),
|
||||
end: values.end || state.gradientEnd || '#ffffff',
|
||||
endAlpha: values.endAlpha ?? state.gradientEndAlpha ?? 100,
|
||||
opacity: values.opacity ?? state.gradientOpacity ?? 100,
|
||||
type: values.type || state.gradientType || 'linear-gradient',
|
||||
};
|
||||
}
|
||||
|
||||
function preview(point) {
|
||||
const layer = activeLayer();
|
||||
if (!start || !layer || layer.id !== activeLayerId) return false;
|
||||
const dx = point.x - start.x;
|
||||
const dy = point.y - start.y;
|
||||
if (Math.hypot(dx, dy) < 0.5) return false;
|
||||
const values = settings();
|
||||
const stops = [
|
||||
{ position: 0, color: values.start, alpha: 1 },
|
||||
...(values.midEnabled ? [{ position: Number(values.midPosition), color: values.mid, alpha: 1 }] : []),
|
||||
...values.extraStops.slice(0, 10).map(stop => ({
|
||||
position: Math.max(1, Math.min(99, Number(stop.position) || 50)),
|
||||
color: stop.color || '#808080',
|
||||
alpha: 1,
|
||||
})),
|
||||
{ position: 100, color: values.end, alpha: Math.max(0, Math.min(100, Number(values.endAlpha))) / 100 },
|
||||
].sort((a, b) => a.position - b.position);
|
||||
layer._effectPreview = {
|
||||
id: 'gradient-preview', type: values.type, name: values.type === 'radial-gradient' ? 'Radial Gradient' : 'Gradient', visible: true, opacity: 1,
|
||||
params: { x1: start.x, y1: start.y, x2: point.x, y2: point.y, stops, opacity: Math.max(0, Math.min(100, Number(values.opacity))) / 100 },
|
||||
};
|
||||
composite();
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
begin(e) {
|
||||
const layer = activeLayer();
|
||||
if (!layer) return false;
|
||||
if (isLayerPixelLocked(state, layer)) return false;
|
||||
if (layer.kind === 'placed') return false;
|
||||
activeLayerId = layer.id;
|
||||
start = canvasCoords(e, state.mainCanvas);
|
||||
lastPoint = { ...start };
|
||||
state.gradientActive = true;
|
||||
historySaved = false;
|
||||
return true;
|
||||
},
|
||||
|
||||
drag(e) {
|
||||
if (!state.gradientActive) return false;
|
||||
lastPoint = canvasCoords(e, state.mainCanvas);
|
||||
preview(lastPoint);
|
||||
return true;
|
||||
},
|
||||
|
||||
end(e) {
|
||||
if (!state.gradientActive) return false;
|
||||
const point = e && (Number.isFinite(e.clientX) || (e.touches && e.touches.length))
|
||||
? canvasCoords(e, state.mainCanvas)
|
||||
: lastPoint;
|
||||
const changed = point ? preview(point) : false;
|
||||
const layer = activeLayer();
|
||||
state.gradientActive = false;
|
||||
if (layer?._effectPreview && changed) {
|
||||
if (!historySaved) {
|
||||
saveState('Gradient');
|
||||
historySaved = true;
|
||||
}
|
||||
layer.effects = Array.isArray(layer.effects) ? layer.effects : [];
|
||||
layer.effects.push(layer._effectPreview);
|
||||
}
|
||||
if (layer) layer._effectPreview = null;
|
||||
start = null;
|
||||
lastPoint = null;
|
||||
activeLayerId = null;
|
||||
historySaved = false;
|
||||
if (changed) {
|
||||
renderLayerPanel?.();
|
||||
schedulePersist?.();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
cancel() {
|
||||
if (!state.gradientActive) return false;
|
||||
const layer = activeLayer();
|
||||
if (layer) layer._effectPreview = null;
|
||||
state.gradientActive = false;
|
||||
start = null;
|
||||
lastPoint = null;
|
||||
activeLayerId = null;
|
||||
historySaved = false;
|
||||
composite();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,10 +14,18 @@
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import { buildLassoMask } from './lasso-mask.js';
|
||||
|
||||
export function createLassoTool({ composite, drawLassoOverlay, syncToolClearIndicators }) {
|
||||
export function createLassoTool({
|
||||
activeLayer, saveState, commitSelectionMask,
|
||||
composite, drawLassoOverlay, syncToolClearIndicators,
|
||||
}) {
|
||||
let pendingMode = 'replace';
|
||||
return {
|
||||
begin(e) {
|
||||
pendingMode = state.wandMode || 'replace';
|
||||
if (e.shiftKey) pendingMode = 'add';
|
||||
else if (e.altKey) pendingMode = 'subtract';
|
||||
state.lassoPoints = [];
|
||||
state.lassoActive = true;
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
@@ -56,10 +64,33 @@ export function createLassoTool({ composite, drawLassoOverlay, syncToolClearIndi
|
||||
syncToolClearIndicators();
|
||||
return;
|
||||
}
|
||||
// Keep the selection drawn — the panel's action buttons use it.
|
||||
const layer = activeLayer?.();
|
||||
if (!layer) return;
|
||||
saveState?.('Lasso selection');
|
||||
const mask = buildLassoMask(
|
||||
state.lassoPoints,
|
||||
state.imgWidth,
|
||||
state.imgHeight,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
commitSelectionMask?.(mask, layer, pendingMode, 'lasso', { x: 0, y: 0 });
|
||||
state.wandMaskVisible = true;
|
||||
state.wandLastSeed = null;
|
||||
state.lassoPoints = [];
|
||||
composite();
|
||||
drawLassoOverlay();
|
||||
syncToolClearIndicators();
|
||||
},
|
||||
|
||||
cancel() {
|
||||
if (!state.lassoActive && !state.lassoPoints.length) return false;
|
||||
state.lassoActive = false;
|
||||
state.lassoPoints = [];
|
||||
composite();
|
||||
syncToolClearIndicators();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/** Rectangle and ellipse marquee interaction producing a document-space mask. */
|
||||
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import {
|
||||
createMarqueeMask,
|
||||
mergeSelectionMasks,
|
||||
normalizeConstrainedSelectionRect,
|
||||
selectionMaskBounds,
|
||||
selectionMaskContains,
|
||||
selectionMaskToDocument,
|
||||
translateSelectionMask,
|
||||
} from '../selection-mask.js';
|
||||
import { createDirectManipulationSession } from '../direct-manipulation-session.js';
|
||||
|
||||
export function createMarqueeTool({
|
||||
activeLayer, saveState, composite, drawOverlay, syncSelectionUi,
|
||||
ensureDocumentSelection, snapFrame,
|
||||
}) {
|
||||
let pendingMode = 'replace';
|
||||
let moveHistorySaved = false;
|
||||
const gesture = createDirectManipulationSession({
|
||||
name: 'selection-boundary',
|
||||
getContext: () => ({ canvas: state.mainCanvas, tool: state.tool }),
|
||||
isContextCurrent: context => context.canvas === state.mainCanvas && state.tool === 'marquee',
|
||||
onCancel: data => {
|
||||
if (data?.mode === 'move' && state.selectionMoveOrigin) {
|
||||
state.wandMask = translateSelectionMask(
|
||||
state.selectionMoveOrigin, 0, 0, state.imgWidth, state.imgHeight,
|
||||
);
|
||||
state.wandMaskSpace = 'document';
|
||||
if (moveHistorySaved && state.undoStack?.at(-1)?._label === 'Move selection boundary') {
|
||||
state.undoStack.pop();
|
||||
}
|
||||
}
|
||||
state.selectionMoving = false;
|
||||
state.selectionMoveStart = null;
|
||||
state.selectionMoveOrigin = null;
|
||||
state.marqueeActive = false;
|
||||
state.marqueeStart = null;
|
||||
state.marqueeRect = null;
|
||||
state.activeSnapGuides = null;
|
||||
moveHistorySaved = false;
|
||||
composite();
|
||||
syncSelectionUi();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
begin(e) {
|
||||
const layer = activeLayer();
|
||||
if (!layer) return;
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
const selection = ensureDocumentSelection?.() || null;
|
||||
if (selection && !e.shiftKey && !e.altKey && selectionMaskContains(selection, coords.x, coords.y)) {
|
||||
state.selectionMoving = true;
|
||||
state.selectionMoveStart = coords;
|
||||
state.selectionMoveOrigin = translateSelectionMask(selection, 0, 0, state.imgWidth, state.imgHeight);
|
||||
moveHistorySaved = false;
|
||||
gesture.begin(e, { mode: 'move' }, { captureTarget: e.currentTarget });
|
||||
return;
|
||||
}
|
||||
pendingMode = state.wandMode || 'replace';
|
||||
if (e.shiftKey) pendingMode = 'add';
|
||||
else if (e.altKey) pendingMode = 'subtract';
|
||||
state.marqueeStart = coords;
|
||||
state.marqueeRect = normalizeConstrainedSelectionRect(
|
||||
coords,
|
||||
coords,
|
||||
state.imgWidth,
|
||||
state.imgHeight,
|
||||
{
|
||||
mode: state.marqueeConstraint,
|
||||
ratioWidth: state.marqueeRatioWidth,
|
||||
ratioHeight: state.marqueeRatioHeight,
|
||||
fixedWidth: state.marqueeFixedWidth,
|
||||
fixedHeight: state.marqueeFixedHeight,
|
||||
},
|
||||
);
|
||||
state.marqueeActive = true;
|
||||
gesture.begin(e, { mode: 'draw' }, { captureTarget: e.currentTarget });
|
||||
},
|
||||
|
||||
drag(e) {
|
||||
if (!gesture.update(e)) return;
|
||||
if (state.selectionMoving && state.selectionMoveStart && state.selectionMoveOrigin) {
|
||||
e.preventDefault();
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
let dx = Math.round(coords.x - state.selectionMoveStart.x);
|
||||
let dy = Math.round(coords.y - state.selectionMoveStart.y);
|
||||
if (!dx && !dy) return;
|
||||
const bounds = selectionMaskBounds(state.selectionMoveOrigin);
|
||||
const modifier = e.ctrlKey || e.metaKey;
|
||||
const snapping = state.snapEnabled ? !modifier : modifier;
|
||||
if (bounds && snapping && snapFrame) {
|
||||
const center = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
|
||||
const snapped = snapFrame({
|
||||
centerX: center.x,
|
||||
centerY: center.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
rotation: 0,
|
||||
}, { x: center.x + dx, y: center.y + dy });
|
||||
dx = Math.round(snapped.centerX - center.x);
|
||||
dy = Math.round(snapped.centerY - center.y);
|
||||
state.activeSnapGuides = snapped.guides || null;
|
||||
} else {
|
||||
state.activeSnapGuides = null;
|
||||
}
|
||||
if (!moveHistorySaved) {
|
||||
saveState('Move selection boundary');
|
||||
moveHistorySaved = true;
|
||||
}
|
||||
state.wandMask = translateSelectionMask(
|
||||
state.selectionMoveOrigin,
|
||||
dx,
|
||||
dy,
|
||||
state.imgWidth,
|
||||
state.imgHeight,
|
||||
);
|
||||
state.wandMaskSpace = 'document';
|
||||
state.wandLastSeed = null;
|
||||
composite();
|
||||
return;
|
||||
}
|
||||
if (!state.marqueeActive || !state.marqueeStart) return;
|
||||
e.preventDefault();
|
||||
state.marqueeRect = normalizeConstrainedSelectionRect(
|
||||
state.marqueeStart,
|
||||
canvasCoords(e, state.mainCanvas),
|
||||
state.imgWidth,
|
||||
state.imgHeight,
|
||||
{
|
||||
mode: state.marqueeConstraint,
|
||||
ratioWidth: state.marqueeRatioWidth,
|
||||
ratioHeight: state.marqueeRatioHeight,
|
||||
fixedWidth: state.marqueeFixedWidth,
|
||||
fixedHeight: state.marqueeFixedHeight,
|
||||
square: !!e.shiftKey,
|
||||
},
|
||||
);
|
||||
composite();
|
||||
drawOverlay();
|
||||
},
|
||||
|
||||
end(e) {
|
||||
if (!gesture.commit(e)) return;
|
||||
state.activeSnapGuides = null;
|
||||
if (state.selectionMoving) {
|
||||
state.selectionMoving = false;
|
||||
state.selectionMoveStart = null;
|
||||
state.selectionMoveOrigin = null;
|
||||
moveHistorySaved = false;
|
||||
composite();
|
||||
syncSelectionUi();
|
||||
return;
|
||||
}
|
||||
if (!state.marqueeActive) return;
|
||||
state.marqueeActive = false;
|
||||
const rect = state.marqueeRect;
|
||||
state.marqueeStart = null;
|
||||
state.marqueeRect = null;
|
||||
if (!rect || rect.w < 1 || rect.h < 1) {
|
||||
if (pendingMode === 'replace' && state.wandMask) {
|
||||
saveState('Clear selection');
|
||||
state.wandMask = null;
|
||||
state.wandLayerId = null;
|
||||
state.wandMaskSpace = 'layer';
|
||||
state.selectionSource = null;
|
||||
state.wandLastSeed = null;
|
||||
}
|
||||
composite();
|
||||
syncSelectionUi();
|
||||
return;
|
||||
}
|
||||
|
||||
saveState(`${state.marqueeShape === 'ellipse' ? 'Ellipse' : 'Rectangle'} selection`);
|
||||
const active = activeLayer();
|
||||
const selectedLayer = state.layers.find(layer => layer.id === state.wandLayerId);
|
||||
const selectedOffset = selectedLayer ? (state.layerOffsets.get(selectedLayer.id) || { x: 0, y: 0 }) : { x: 0, y: 0 };
|
||||
const current = selectionMaskToDocument(
|
||||
state.wandMask,
|
||||
state.wandMaskSpace || 'layer',
|
||||
selectedOffset,
|
||||
state.imgWidth,
|
||||
state.imgHeight,
|
||||
);
|
||||
const incoming = createMarqueeMask(
|
||||
state.imgWidth,
|
||||
state.imgHeight,
|
||||
rect,
|
||||
state.marqueeShape,
|
||||
);
|
||||
state.wandMask = mergeSelectionMasks(current, incoming, pendingMode);
|
||||
state.wandLayerId = active?.id || state.activeLayerId;
|
||||
state.wandMaskSpace = 'document';
|
||||
state.selectionSource = 'marquee';
|
||||
state.wandLastSeed = null;
|
||||
state.wandMaskVisible = true;
|
||||
state.lassoPoints = [];
|
||||
state.lassoActive = false;
|
||||
composite();
|
||||
syncSelectionUi();
|
||||
},
|
||||
cancel(reason) {
|
||||
return gesture.cancel(reason);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Move tool — drag a layer around the canvas, with optional snap-on-Ctrl
|
||||
* Move tool — drag a layer around the canvas, with configurable snapping
|
||||
* to other layers' edges/centers and to canvas edges/center.
|
||||
*
|
||||
* Owns its own input handlers (begin/drag/end) and reads/writes the
|
||||
@@ -12,40 +12,74 @@
|
||||
* activeLayer: () => {id: string, canvas: HTMLCanvasElement, locked?: boolean} | null,
|
||||
* saveState: (label?: string) => void,
|
||||
* composite: () => void,
|
||||
* onPositionChange?: () => void,
|
||||
* }} deps
|
||||
* @returns {{ begin: (e: Event) => void, drag: (e: Event) => void, end: () => void }}
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import { computeSnap as computeSnapImpl } from '../snap.js';
|
||||
import { selectedLayers, selectionLabel } from '../layer-selection.js';
|
||||
import { isLayerPositionLocked } from '../layer-groups.js';
|
||||
import { clonePlacedData, translatePlacedData } from '../placed-layer.js';
|
||||
|
||||
export function createMoveTool({ activeLayer, saveState, composite }) {
|
||||
export function createMoveTool({ activeLayer, saveState, composite, onPositionChange }) {
|
||||
function computeSnap(layer, nx, ny) {
|
||||
const selectedIds = new Set(state.selectedLayerIds || []);
|
||||
return computeSnapImpl(layer, nx, ny, {
|
||||
zoom: state.zoom,
|
||||
canvasW: state.imgWidth,
|
||||
canvasH: state.imgHeight,
|
||||
otherLayers: state.layers.map(l => ({
|
||||
otherLayers: state.layers.filter(l => !selectedIds.has(l.id)).map(l => ({
|
||||
visible: l.visible,
|
||||
id: l.id,
|
||||
canvas: l.canvas,
|
||||
offset: state.layerOffsets.get(l.id) || { x: 0, y: 0 },
|
||||
})),
|
||||
verticalGuides: state.guides?.vertical || [],
|
||||
horizontalGuides: state.guides?.horizontal || [],
|
||||
snapToGrid: !!state.snapToGrid,
|
||||
gridSize: state.gridSize || 16,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
begin(e) {
|
||||
const layer = activeLayer();
|
||||
if (!layer || layer.locked) return;
|
||||
saveState();
|
||||
if (!layer || isLayerPositionLocked(state, layer)) return;
|
||||
const activeMask = (layer.masks || []).find(mask =>
|
||||
mask.id === layer.activeMaskId && mask.mode === 'layer' && mask.linked === false);
|
||||
state.moving = true;
|
||||
state.moveHistorySaved = false;
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
state.moveStartX = coords.x;
|
||||
state.moveStartY = coords.y;
|
||||
state.moveLayerOffsetX = off.x;
|
||||
state.moveLayerOffsetY = off.y;
|
||||
state.moveMaskTarget = activeMask || null;
|
||||
state.moveMaskOffsetX = Number(activeMask?.offset?.x) || 0;
|
||||
state.moveMaskOffsetY = Number(activeMask?.offset?.y) || 0;
|
||||
state.moveLayerOffsets = new Map(selectedLayers(state)
|
||||
.filter(item => !isLayerPositionLocked(state, item))
|
||||
.map(item => [
|
||||
item.id,
|
||||
{ ...(state.layerOffsets.get(item.id) || { x: 0, y: 0 }) },
|
||||
]));
|
||||
state.movePlacedSources = new Map(selectedLayers(state)
|
||||
.filter(item => item.kind === 'placed' && item.placed)
|
||||
.map(item => [item.id, clonePlacedData(item.placed)]));
|
||||
state.moveUnlinkedMaskOffsets = new Map();
|
||||
for (const item of selectedLayers(state)) {
|
||||
for (const mask of item.masks || []) {
|
||||
if (mask.mode !== 'layer' || mask.linked !== false) continue;
|
||||
state.moveUnlinkedMaskOffsets.set(mask.id, {
|
||||
mask,
|
||||
x: Number(mask.offset?.x) || 0,
|
||||
y: Number(mask.offset?.y) || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
drag(e) {
|
||||
if (!state.moving) return;
|
||||
@@ -55,12 +89,27 @@ export function createMoveTool({ activeLayer, saveState, composite }) {
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
const dx = coords.x - state.moveStartX;
|
||||
const dy = coords.y - state.moveStartY;
|
||||
if (state.moveMaskTarget) {
|
||||
const mask = state.moveMaskTarget;
|
||||
const nx = Math.round(state.moveMaskOffsetX + dx);
|
||||
const ny = Math.round(state.moveMaskOffsetY + dy);
|
||||
const before = mask.offset || { x: 0, y: 0 };
|
||||
if (nx === before.x && ny === before.y) return;
|
||||
if (!state.moveHistorySaved) {
|
||||
saveState(`Move mask "${mask.name || 'Layer Mask'}"`);
|
||||
state.moveHistorySaved = true;
|
||||
}
|
||||
mask.offset = { x: nx, y: ny };
|
||||
state.activeSnapGuides = null;
|
||||
composite();
|
||||
return;
|
||||
}
|
||||
let nx = state.moveLayerOffsetX + dx;
|
||||
let ny = state.moveLayerOffsetY + dy;
|
||||
// Ctrl held = snap to canvas edges/center and to every other
|
||||
// visible layer's edges/center. Opt-in to avoid a "sticky" feel
|
||||
// during normal drags.
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Ctrl/Cmd temporarily inverts the persistent Snap preference.
|
||||
const modifier = e.ctrlKey || e.metaKey;
|
||||
const snapping = state.snapEnabled ? !modifier : modifier;
|
||||
if (snapping) {
|
||||
const snapped = computeSnap(layer, nx, ny);
|
||||
nx = snapped.x;
|
||||
ny = snapped.y;
|
||||
@@ -68,11 +117,40 @@ export function createMoveTool({ activeLayer, saveState, composite }) {
|
||||
} else {
|
||||
state.activeSnapGuides = null;
|
||||
}
|
||||
state.layerOffsets.set(layer.id, { x: nx, y: ny });
|
||||
const before = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
if (nx === before.x && ny === before.y) return;
|
||||
if (!state.moveHistorySaved) {
|
||||
// Transform already owns one pre-session snapshot. A second Move
|
||||
// snapshot would make Cancel restore the staged transform instead of
|
||||
// the original layer.
|
||||
if (!state.transformActive) saveState(`Move ${selectionLabel(state, `"${layer.name || 'layer'}"`)}`);
|
||||
state.moveHistorySaved = true;
|
||||
}
|
||||
const appliedDx = nx - state.moveLayerOffsetX;
|
||||
const appliedDy = ny - state.moveLayerOffsetY;
|
||||
for (const [id, start] of state.moveLayerOffsets) {
|
||||
state.layerOffsets.set(id, { x: start.x + appliedDx, y: start.y + appliedDy });
|
||||
const placedStart = state.movePlacedSources?.get(id);
|
||||
const movedLayer = state.layers.find(item => item.id === id);
|
||||
if (placedStart && movedLayer) movedLayer.placed = translatePlacedData(placedStart, appliedDx, appliedDy);
|
||||
}
|
||||
// An unlinked mask stays in the same document position while its layer
|
||||
// moves. Its layer-relative offset therefore changes by the inverse
|
||||
// layer delta. Linked masks retain their relative offset and move along.
|
||||
for (const entry of state.moveUnlinkedMaskOffsets.values()) {
|
||||
entry.mask.offset = { x: entry.x - appliedDx, y: entry.y - appliedDy };
|
||||
}
|
||||
const next = state.layerOffsets.get(layer.id) || { x: nx, y: ny };
|
||||
composite();
|
||||
onPositionChange?.(layer, before, next);
|
||||
},
|
||||
end() {
|
||||
state.moving = false;
|
||||
state.moveHistorySaved = false;
|
||||
state.moveLayerOffsets = new Map();
|
||||
state.movePlacedSources = new Map();
|
||||
state.moveMaskTarget = null;
|
||||
state.moveUnlinkedMaskOffsets = new Map();
|
||||
state.activeSnapGuides = null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
*
|
||||
* @param {{
|
||||
* saveState: (label: string) => void,
|
||||
* undo: () => void,
|
||||
* strokeTo: (x: number, y: number) => void,
|
||||
* composite: () => void,
|
||||
* getActiveMaskLayer: () => object | null,
|
||||
@@ -31,20 +32,34 @@
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import { isLayerPixelLocked, isLayerTransparencyLocked } from '../layer-groups.js';
|
||||
|
||||
const STROKE_TOOLS = new Set(['brush', 'eraser', 'inpaint']);
|
||||
const STROKE_TOOLS = new Set(['brush', 'eraser', 'smudge', 'dodge', 'burn', 'inpaint']);
|
||||
|
||||
function strokeLabel(tool) {
|
||||
if (tool === 'brush') return 'Brush stroke';
|
||||
if (tool === 'eraser') return 'Eraser stroke';
|
||||
if (tool === 'inpaint') return state.inpaintEraseStroke ? 'Erase mask' : 'Paint mask';
|
||||
if (tool === 'smudge') return 'Smudge stroke';
|
||||
if (tool === 'dodge') return 'Dodge stroke';
|
||||
if (tool === 'burn') return 'Burn stroke';
|
||||
return 'Stroke';
|
||||
}
|
||||
|
||||
function pointerPressure(e) {
|
||||
if (e?.touches?.[0] && Number.isFinite(e.touches[0].force) && e.touches[0].force > 0) return e.touches[0].force;
|
||||
return Number.isFinite(e?.pressure) && e.pressure > 0 ? e.pressure : 1;
|
||||
}
|
||||
|
||||
function eventSample(e) {
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
return { x: coords.x, y: coords.y, pressure: pointerPressure(e) };
|
||||
}
|
||||
|
||||
export function createStrokeTool({
|
||||
saveState, strokeTo, composite,
|
||||
saveState, undo, beginStroke, strokeTo, endStroke, composite,
|
||||
getActiveMaskLayer, activeParentLayer, ensureActiveMaskLayer, createLayer,
|
||||
renderLayerPanel, syncToolClearIndicators,
|
||||
renderLayerPanel, syncToolClearIndicators, showToast,
|
||||
}) {
|
||||
return {
|
||||
/**
|
||||
@@ -53,6 +68,22 @@ export function createStrokeTool({
|
||||
*/
|
||||
tryBegin(e) {
|
||||
if (!STROKE_TOOLS.has(state.tool)) return false;
|
||||
const parent = activeParentLayer();
|
||||
const activeMask = state.quickMaskActive && state.wandMask
|
||||
? { mode: 'quick-selection', canvas: state.wandMask, ctx: state.wandMask.getContext('2d') }
|
||||
: getActiveMaskLayer();
|
||||
if (['brush', 'eraser', 'smudge', 'dodge', 'burn'].includes(state.tool) && !activeMask && parent?.kind === 'placed') {
|
||||
showToast?.('Rasterize the placed layer before painting its pixels');
|
||||
return true;
|
||||
}
|
||||
if (['brush', 'eraser', 'smudge', 'dodge', 'burn'].includes(state.tool) && !activeMask && isLayerPixelLocked(state, parent)) {
|
||||
showToast?.('Unlock image pixels before painting');
|
||||
return true;
|
||||
}
|
||||
if (state.tool === 'eraser' && !activeMask && isLayerTransparencyLocked(state, parent)) {
|
||||
showToast?.('Unlock transparent pixels before erasing');
|
||||
return true;
|
||||
}
|
||||
// Capture the inpaint-erase flag for this stroke. Ctrl+Alt
|
||||
// pressed at pointerdown flips the persistent toggle for one
|
||||
// stroke only.
|
||||
@@ -89,10 +120,7 @@ export function createStrokeTool({
|
||||
}
|
||||
saveState(strokeLabel(state.tool));
|
||||
state.drawing = true;
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
state.lastX = coords.x;
|
||||
state.lastY = coords.y;
|
||||
strokeTo(coords.x, coords.y);
|
||||
beginStroke(eventSample(e), state.tool);
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -103,21 +131,39 @@ export function createStrokeTool({
|
||||
tryContinue(e) {
|
||||
if (!state.drawing) return false;
|
||||
e.preventDefault();
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
strokeTo(coords.x, coords.y);
|
||||
const events = typeof e.getCoalescedEvents === 'function' ? e.getCoalescedEvents() : [];
|
||||
const samples = events.length ? events : [e];
|
||||
for (const sampleEvent of samples) strokeTo(eventSample(sampleEvent));
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Wrap up an in-progress stroke. Returns true if there was one.
|
||||
*/
|
||||
tryEnd() {
|
||||
tryEnd(e) {
|
||||
if (!state.drawing) return false;
|
||||
const wasDrawingInpaint = state.tool === 'inpaint';
|
||||
state.drawing = false;
|
||||
endStroke(e ? eventSample(e) : null);
|
||||
composite();
|
||||
// Strokes composite every frame while drawing, but the layer panel is
|
||||
// intentionally not rebuilt at that frequency. Refresh once at the end
|
||||
// so inline layer/group thumbnails reflect the completed edit.
|
||||
renderLayerPanel();
|
||||
if (wasDrawingInpaint) syncToolClearIndicators();
|
||||
return true;
|
||||
},
|
||||
|
||||
cancel() {
|
||||
if (!state.drawing) return false;
|
||||
state.drawing = false;
|
||||
state.cloneSourceSnapshot = null;
|
||||
undo?.();
|
||||
state.redoStack = [];
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
syncToolClearIndicators();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,21 +13,37 @@
|
||||
* tool and was handled (so the dispatcher can short-circuit).
|
||||
*
|
||||
* @param {{
|
||||
* beginMove: (e: Event) => void,
|
||||
* composite: () => void,
|
||||
* drawTransformHandles: () => void,
|
||||
* reapplyTransform: () => void,
|
||||
* getTransformHandle: (x: number, y: number) => string | null,
|
||||
* cursorForHandle: (id: string | null) => string,
|
||||
* pointInTransformFrame: (x: number, y: number) => boolean,
|
||||
* snapTransformFrame?: (frame: object, center: {x:number,y:number}) => object,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import { resizeTransformFrame } from '../transform-frame-geometry.js';
|
||||
import { createDirectManipulationSession } from '../direct-manipulation-session.js';
|
||||
|
||||
export function createTransformDragTool({
|
||||
beginMove, composite, drawTransformHandles, reapplyTransform,
|
||||
getTransformHandle, cursorForHandle,
|
||||
composite, drawTransformHandles, reapplyTransform,
|
||||
getTransformHandle, cursorForHandle, pointInTransformFrame,
|
||||
snapTransformFrame,
|
||||
}) {
|
||||
const gesture = createDirectManipulationSession({
|
||||
name: 'transform-frame',
|
||||
getContext: () => ({ canvas: state.mainCanvas, items: state.transformItems }),
|
||||
isContextCurrent: context => state.transformActive &&
|
||||
context.canvas === state.mainCanvas && context.items === state.transformItems,
|
||||
onCancel: () => {
|
||||
state.transformHandle = null;
|
||||
state.activeSnapGuides = null;
|
||||
composite();
|
||||
drawTransformHandles();
|
||||
},
|
||||
});
|
||||
return {
|
||||
/**
|
||||
* Called on pointerdown. Returns true if the transform tool handled
|
||||
@@ -36,33 +52,36 @@ export function createTransformDragTool({
|
||||
tryBegin(e) {
|
||||
if (!state.transformActive) return false;
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
state.transformHandle = getTransformHandle(coords.x, coords.y);
|
||||
const pointerType = e.pointerType || (e.touches ? 'touch' : 'mouse');
|
||||
state.transformHandle = getTransformHandle(coords.x, coords.y, { pointerType });
|
||||
if (state.transformHandle) {
|
||||
state.transformStartX = coords.x;
|
||||
state.transformStartY = coords.y;
|
||||
// Snapshot offset + size at drag-start so each frame computes
|
||||
// "start + dx" (correct delta) rather than accumulating off the
|
||||
// running offset, which was making top/left grabs drift.
|
||||
const layer = state.transformLayer;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
state.transformStartOffX = off.x;
|
||||
state.transformStartOffY = off.y;
|
||||
state.transformOrigW = layer.canvas.width;
|
||||
state.transformOrigH = layer.canvas.height;
|
||||
const center = state.transformCenter || { x: 0, y: 0 };
|
||||
state.transformStartCenter = { ...center };
|
||||
state.transformOrigW = state.transformPendingW;
|
||||
state.transformOrigH = state.transformPendingH;
|
||||
state.transformStartRotation = state.transformPendingRot;
|
||||
state.transformStartFlipH = state.transformPendingFlipH;
|
||||
state.transformStartFlipV = state.transformPendingFlipV;
|
||||
state.transformStartOffX = center.x - state.transformOrigW / 2;
|
||||
state.transformStartOffY = center.y - state.transformOrigH / 2;
|
||||
gesture.begin(e, { handle: state.transformHandle }, { captureTarget: e.currentTarget });
|
||||
return true;
|
||||
}
|
||||
// No corner hit — if click inside the layer's bounding box, act
|
||||
// like Move so the user can drag the layer around without
|
||||
// switching tools.
|
||||
if (state.transformLayer) {
|
||||
const off = state.layerOffsets.get(state.transformLayer.id) || { x: 0, y: 0 };
|
||||
const w = state.transformLayer.canvas.width;
|
||||
const h = state.transformLayer.canvas.height;
|
||||
if (coords.x >= off.x && coords.x <= off.x + w &&
|
||||
coords.y >= off.y && coords.y <= off.y + h) {
|
||||
beginMove(e);
|
||||
return true;
|
||||
}
|
||||
// No handle hit: dragging inside the staged frame moves the transform
|
||||
// itself. Keeping this in the transform session is essential for
|
||||
// selection transforms, which must never fall through to layer Move.
|
||||
if (pointInTransformFrame?.(coords.x, coords.y)) {
|
||||
state.transformHandle = 'move';
|
||||
state.transformStartX = coords.x;
|
||||
state.transformStartY = coords.y;
|
||||
state.transformStartCenter = { ...(state.transformCenter || { x: 0, y: 0 }) };
|
||||
gesture.begin(e, { handle: 'move' }, { captureTarget: e.currentTarget });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
@@ -79,8 +98,12 @@ export function createTransformDragTool({
|
||||
// No drag in progress — just hover-cursor + pulse.
|
||||
if (!state.transformHandle && state.mainCanvas) {
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
const hovered = getTransformHandle(coords.x, coords.y);
|
||||
state.mainCanvas.style.cursor = hovered ? cursorForHandle(hovered) : 'default';
|
||||
const pointerType = e.pointerType || (e.touches ? 'touch' : 'mouse');
|
||||
const hovered = getTransformHandle(coords.x, coords.y, { pointerType });
|
||||
const bodyHovered = !hovered && pointInTransformFrame?.(coords.x, coords.y);
|
||||
state.mainCanvas.style.cursor = hovered
|
||||
? cursorForHandle(hovered, state.transformPendingRot)
|
||||
: bodyHovered ? 'move' : 'default';
|
||||
if (hovered !== state.hoveredHandle) {
|
||||
state.hoveredHandle = hovered;
|
||||
composite();
|
||||
@@ -88,15 +111,38 @@ export function createTransformDragTool({
|
||||
return false; // didn't fully consume the event
|
||||
}
|
||||
if (!state.transformHandle) return false;
|
||||
if (!gesture.update(e)) return false;
|
||||
e.preventDefault();
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
if (state.transformHandle === 'move') {
|
||||
let center = {
|
||||
x: state.transformStartCenter.x + coords.x - state.transformStartX,
|
||||
y: state.transformStartCenter.y + coords.y - state.transformStartY,
|
||||
};
|
||||
const modifier = e.ctrlKey || e.metaKey;
|
||||
const snapping = state.snapEnabled ? !modifier : modifier;
|
||||
if (snapping && snapTransformFrame) {
|
||||
const snapped = snapTransformFrame({
|
||||
centerX: state.transformStartCenter.x,
|
||||
centerY: state.transformStartCenter.y,
|
||||
width: state.transformPendingW,
|
||||
height: state.transformPendingH,
|
||||
rotation: state.transformPendingRot,
|
||||
}, center);
|
||||
center = { x: snapped.centerX, y: snapped.centerY };
|
||||
state.activeSnapGuides = snapped.guides || null;
|
||||
} else {
|
||||
state.activeSnapGuides = null;
|
||||
}
|
||||
state.transformCenter = center;
|
||||
reapplyTransform();
|
||||
return true;
|
||||
}
|
||||
// Rotation grip — angle measured from the layer's geometric
|
||||
// centre to the cursor. Mirror into the popup if it's open.
|
||||
if (state.transformHandle === 'rot') {
|
||||
const layer = state.transformLayer;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const cx = off.x + layer.canvas.width / 2;
|
||||
const cy = off.y + layer.canvas.height / 2;
|
||||
const cx = state.transformCenter?.x || 0;
|
||||
const cy = state.transformCenter?.y || 0;
|
||||
const rad = Math.atan2(coords.y - cy, coords.x - cx) + Math.PI / 2;
|
||||
let deg = Math.round((rad * 180) / Math.PI);
|
||||
if (e.shiftKey) deg = Math.round(deg / 15) * 15; // 15° snap
|
||||
@@ -110,50 +156,35 @@ export function createTransformDragTool({
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Resize via corner / edge handle.
|
||||
const dx = coords.x - state.transformStartX;
|
||||
const dy = coords.y - state.transformStartY;
|
||||
const layer = state.transformLayer;
|
||||
let newW = layer.canvas.width;
|
||||
let newH = layer.canvas.height;
|
||||
if (state.transformHandle.includes('r')) newW = state.transformOrigW + dx;
|
||||
if (state.transformHandle.includes('l')) newW = state.transformOrigW - dx;
|
||||
if (state.transformHandle.includes('b')) newH = state.transformOrigH + dy;
|
||||
if (state.transformHandle.includes('t')) newH = state.transformOrigH - dy;
|
||||
// Shift = lock aspect ratio. Use whichever axis moved more
|
||||
// (relative to the original) as the driver.
|
||||
if (e.shiftKey && state.transformOrigW > 0 && state.transformOrigH > 0) {
|
||||
const aspect = state.transformOrigW / state.transformOrigH;
|
||||
const wDelta = Math.abs(newW - state.transformOrigW);
|
||||
const hDelta = Math.abs(newH - state.transformOrigH);
|
||||
if (wDelta >= hDelta) {
|
||||
newH = Math.max(1, Math.round(newW / aspect));
|
||||
} else {
|
||||
newW = Math.max(1, Math.round(newH * aspect));
|
||||
}
|
||||
}
|
||||
newW = Math.max(1, Math.round(newW));
|
||||
newH = Math.max(1, Math.round(newH));
|
||||
// Route through the popup-driven pipeline so popup + drag stay
|
||||
// in sync. Anchor the opposite corner via transformOrigOffset so
|
||||
// handles don't slide while the user drags.
|
||||
state.transformPendingW = newW;
|
||||
state.transformPendingH = newH;
|
||||
const anchorOffX = state.transformStartOffX +
|
||||
(state.transformHandle.includes('l') ? (state.transformOrigW - newW) : 0);
|
||||
const anchorOffY = state.transformStartOffY +
|
||||
(state.transformHandle.includes('t') ? (state.transformOrigH - newH) : 0);
|
||||
state.transformOrigOffset = {
|
||||
x: anchorOffX + newW / 2 - state.transformOrigW / 2,
|
||||
y: anchorOffY + newH / 2 - state.transformOrigH / 2,
|
||||
};
|
||||
// Resize in the frame's LOCAL axes. This keeps rotated side handles
|
||||
// attached to the pointer and fixes the opposite edge in document space.
|
||||
const resized = resizeTransformFrame({
|
||||
centerX: state.transformStartCenter.x,
|
||||
centerY: state.transformStartCenter.y,
|
||||
width: state.transformOrigW,
|
||||
height: state.transformOrigH,
|
||||
rotation: state.transformStartRotation,
|
||||
}, state.transformHandle, {
|
||||
x: state.transformStartX,
|
||||
y: state.transformStartY,
|
||||
}, coords, {
|
||||
centered: !!e.altKey,
|
||||
lockAspect: !!e.shiftKey,
|
||||
flipH: state.transformStartFlipH,
|
||||
flipV: state.transformStartFlipV,
|
||||
});
|
||||
state.transformPendingW = resized.width;
|
||||
state.transformPendingH = resized.height;
|
||||
state.transformPendingFlipH = resized.flipH;
|
||||
state.transformPendingFlipV = resized.flipV;
|
||||
state.transformCenter = { x: resized.centerX, y: resized.centerY };
|
||||
reapplyTransform();
|
||||
// Mirror the new W/H into the popup if it's open.
|
||||
if (state.transformPopup) {
|
||||
const wIn = state.transformPopup.querySelector('#ge-transform-w');
|
||||
const hIn = state.transformPopup.querySelector('#ge-transform-h');
|
||||
if (wIn) wIn.value = String(state.transformPendingFlipH ? -newW : newW);
|
||||
if (hIn) hIn.value = String(state.transformPendingFlipV ? -newH : newH);
|
||||
if (wIn) wIn.value = String(state.transformPendingFlipH ? -resized.width : resized.width);
|
||||
if (hIn) hIn.value = String(state.transformPendingFlipV ? -resized.height : resized.height);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
@@ -161,14 +192,19 @@ export function createTransformDragTool({
|
||||
/**
|
||||
* Called on pointerup. Returns true if handled.
|
||||
*/
|
||||
tryEnd() {
|
||||
tryEnd(e) {
|
||||
if (!(state.transformActive && state.transformHandle)) return false;
|
||||
if (!gesture.commit(e)) return false;
|
||||
state.transformHandle = null;
|
||||
state.transformOrigW = state.transformLayer?.canvas.width || 0;
|
||||
state.transformOrigH = state.transformLayer?.canvas.height || 0;
|
||||
state.transformOrigW = state.transformPendingW;
|
||||
state.transformOrigH = state.transformPendingH;
|
||||
state.activeSnapGuides = null;
|
||||
composite();
|
||||
drawTransformHandles();
|
||||
return true;
|
||||
},
|
||||
cancel(reason) {
|
||||
return gesture.cancel(reason);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
*
|
||||
* - `syncOverlay(margin)` positions the overlay canvas + sizes its
|
||||
* bitmap based on the main canvas + zoom.
|
||||
* - `drawHandles(margin)` draws the rotated bounding outline + 4
|
||||
* corner handles + the rotation knob (with
|
||||
* - `drawHandles(margin)` draws the rotated bounding outline + 8
|
||||
* resize handles + the rotation knob (with
|
||||
* hover / active visual states).
|
||||
* - `getHandleAt(x, y)` returns the handle id under (x, y), or
|
||||
* null. Geometry MUST mirror `drawHandles`
|
||||
@@ -20,13 +20,16 @@
|
||||
* pointer events.
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import {
|
||||
hitTestTransformHandle,
|
||||
pointInTransformFrame,
|
||||
transformFrameGeometry,
|
||||
} from '../transform-frame-geometry.js';
|
||||
|
||||
/**
|
||||
* Position the transform overlay canvas + size its backing bitmap.
|
||||
* Margin is the image-space slack each side so handles can render
|
||||
* outside the main canvas (matches _TRANSFORM_OVERLAY_MARGIN in
|
||||
* galleryEditor.js — kept as a parameter so this module has no
|
||||
* dependency on a magic number defined elsewhere).
|
||||
* Margin is screen-space slack on each side so controls remain visible
|
||||
* at every zoom. It is converted to image pixels for the backing canvas.
|
||||
*/
|
||||
export function syncOverlay(margin) {
|
||||
if (!state.transformOverlay || !state.mainCanvas) return;
|
||||
@@ -34,8 +37,9 @@ export function syncOverlay(margin) {
|
||||
state.transformOverlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const W = state.mainCanvas.width + 2 * margin;
|
||||
const H = state.mainCanvas.height + 2 * margin;
|
||||
const imageMargin = margin / Math.max(0.0001, state.zoom);
|
||||
const W = Math.ceil(state.mainCanvas.width + 2 * imageMargin);
|
||||
const H = Math.ceil(state.mainCanvas.height + 2 * imageMargin);
|
||||
if (state.transformOverlay.width !== W) state.transformOverlay.width = W;
|
||||
if (state.transformOverlay.height !== H) state.transformOverlay.height = H;
|
||||
// Overlay must scale with state.zoom so its handles render at the
|
||||
@@ -55,103 +59,100 @@ export function syncOverlay(margin) {
|
||||
// both canvas + overlay), so pan moves them together. Reading the
|
||||
// layout offset (not getBoundingClientRect, which includes the pan
|
||||
// transform) is what avoids the double-pan "bounce".
|
||||
state.transformOverlay.style.left = Math.round(state.mainCanvas.offsetLeft - margin * state.zoom) + 'px';
|
||||
state.transformOverlay.style.top = Math.round(state.mainCanvas.offsetTop - margin * state.zoom) + 'px';
|
||||
state.transformOverlay.style.left = Math.round(state.mainCanvas.offsetLeft - margin) + 'px';
|
||||
state.transformOverlay.style.top = Math.round(state.mainCanvas.offsetTop - margin) + 'px';
|
||||
state.transformOverlay.style.transform = state.mainCanvas.style.transform || 'none';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Compute the on-screen position of the rotation knob given the
|
||||
* layer's bbox center + rotation. The knob normally sits OUTSIDE the
|
||||
* top edge of the rotated layer; if that would land beyond the canvas
|
||||
* viewport, flip it INSIDE.
|
||||
*
|
||||
* Returned by `_knobPosition` and shared by drawHandles + getHandleAt
|
||||
* so both compute the same point.
|
||||
*/
|
||||
function knobPosition(cxh, cyh, rotRad, baseInnerR, rotOffset) {
|
||||
let rotInside = false;
|
||||
const outsideR = baseInnerR + rotOffset;
|
||||
const knobLocalX = cxh + Math.sin(rotRad) * outsideR;
|
||||
const knobLocalY = cyh - Math.cos(rotRad) * outsideR;
|
||||
// Primary check: anything drawn outside the main canvas's pixel
|
||||
// buffer is invisible (canvas operations clip silently).
|
||||
if (
|
||||
knobLocalX < 0 || knobLocalY < 0 ||
|
||||
knobLocalX > state.mainCanvas.width || knobLocalY > state.mainCanvas.height
|
||||
) {
|
||||
rotInside = true;
|
||||
}
|
||||
// Secondary check: even if the knob is inside the canvas bitmap, the
|
||||
// viewport may have scrolled the canvas such that the knob falls
|
||||
// outside the visible canvas-area window.
|
||||
function rotationHandleInside(frame) {
|
||||
let inside = false;
|
||||
const outside = transformFrameGeometry(frame, { zoom: state.zoom }).rotationHandle;
|
||||
// The overlay deliberately extends beyond the image, so only fold the
|
||||
// rotation control inward when the surrounding viewport would clip it.
|
||||
try {
|
||||
const area = state.container && state.container.querySelector('.ge-canvas-area');
|
||||
if (area && !rotInside) {
|
||||
if (area && !inside) {
|
||||
const aRect = area.getBoundingClientRect();
|
||||
const mRect = state.mainCanvas.getBoundingClientRect();
|
||||
const scaleX = mRect.width / state.mainCanvas.width;
|
||||
const scaleY = mRect.height / state.mainCanvas.height;
|
||||
const knobClientX = mRect.left + knobLocalX * scaleX;
|
||||
const knobClientY = mRect.top + knobLocalY * scaleY;
|
||||
if (knobClientY < aRect.top + 6) rotInside = true;
|
||||
if (knobClientX < aRect.left + 6 || knobClientX > aRect.right - 6) rotInside = true;
|
||||
const knobClientX = mRect.left + outside.x * scaleX;
|
||||
const knobClientY = mRect.top + outside.y * scaleY;
|
||||
if (knobClientY < aRect.top + 6) inside = true;
|
||||
if (knobClientX < aRect.left + 6 || knobClientX > aRect.right - 6) inside = true;
|
||||
const popupRect = state.transformPopup?.getBoundingClientRect?.();
|
||||
if (popupRect
|
||||
&& knobClientX >= popupRect.left
|
||||
&& knobClientX <= popupRect.right
|
||||
&& knobClientY >= popupRect.top
|
||||
&& knobClientY <= popupRect.bottom) {
|
||||
inside = true;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
const innerR = rotInside ? Math.max(4, baseInnerR - rotOffset) : baseInnerR;
|
||||
const rotR = rotInside ? innerR : baseInnerR + rotOffset;
|
||||
return inside;
|
||||
}
|
||||
|
||||
function transformFrame() {
|
||||
if (state.transformTarget === 'selection') {
|
||||
if (!state.transformSelectionBounds) return null;
|
||||
return {
|
||||
centerX: state.transformCenter?.x ?? state.transformSelectionBounds.centerX,
|
||||
centerY: state.transformCenter?.y ?? state.transformSelectionBounds.centerY,
|
||||
width: state.transformPendingW || state.transformSelectionBounds.width,
|
||||
height: state.transformPendingH || state.transformSelectionBounds.height,
|
||||
rotation: state.transformPendingRot || 0,
|
||||
};
|
||||
}
|
||||
const layer = state.transformLayer;
|
||||
if (!layer) return null;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const fallbackCenter = {
|
||||
x: off.x + layer.canvas.width / 2,
|
||||
y: off.y + layer.canvas.height / 2,
|
||||
};
|
||||
return {
|
||||
rotInside,
|
||||
innerR,
|
||||
rotX: cxh + Math.sin(rotRad) * rotR,
|
||||
rotY: cyh - Math.cos(rotRad) * rotR,
|
||||
centerX: state.transformCenter?.x ?? fallbackCenter.x,
|
||||
centerY: state.transformCenter?.y ?? fallbackCenter.y,
|
||||
width: state.transformPendingW || layer.canvas.width,
|
||||
height: state.transformPendingH || layer.canvas.height,
|
||||
rotation: state.transformPendingRot || 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Draw the rotated bounding outline + 4 corner handles + the rotation
|
||||
* Draw the rotated bounding outline + 8 resize handles + the rotation
|
||||
* knob into the overlay canvas. The overlay is translated by `margin`
|
||||
* so image (0,0) maps to overlay (margin, margin).
|
||||
*/
|
||||
export function drawHandles(margin) {
|
||||
if (!state.transformActive || !state.transformLayer) return;
|
||||
if (!state.transformActive || (state.transformTarget !== 'selection' && !state.transformLayer)) return;
|
||||
syncOverlay(margin);
|
||||
if (!state.transformOverlayCtx) return;
|
||||
const layer = state.transformLayer;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const w = layer.canvas.width;
|
||||
const h = layer.canvas.height;
|
||||
const frame = transformFrame();
|
||||
if (!frame) return;
|
||||
const rotInside = rotationHandleInside(frame);
|
||||
const geometry = transformFrameGeometry(frame, {
|
||||
zoom: state.zoom,
|
||||
rotationInside: rotInside,
|
||||
});
|
||||
const ctx = state.transformOverlayCtx;
|
||||
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#e06c75';
|
||||
const imageMargin = margin / Math.max(0.0001, state.zoom);
|
||||
// Clear + shift drawing by margin so image (0,0) maps to overlay (M,M).
|
||||
ctx.clearRect(0, 0, state.transformOverlay.width, state.transformOverlay.height);
|
||||
ctx.save();
|
||||
ctx.translate(margin, margin);
|
||||
ctx.translate(imageMargin, imageMargin);
|
||||
// Zoom-corrected handle size + stroke so they stay readable at any zoom.
|
||||
const sz = 10 / state.zoom;
|
||||
const stroke = 1.5 / state.zoom;
|
||||
|
||||
// Pre-rotation rectangle dims (what the user sees the layer as).
|
||||
// Falls back to layer bbox before any popup values exist.
|
||||
const preW = state.transformPendingW || w;
|
||||
const preH = state.transformPendingH || h;
|
||||
const cxBox = off.x + w / 2;
|
||||
const cyBox = off.y + h / 2;
|
||||
const rotRadBox = ((state.transformPendingRot || 0) * Math.PI) / 180;
|
||||
const cosBox = Math.cos(rotRadBox);
|
||||
const sinBox = Math.sin(rotRadBox);
|
||||
const rotPt = (dx, dy) => ({
|
||||
x: cxBox + dx * cosBox - dy * sinBox,
|
||||
y: cyBox + dx * sinBox + dy * cosBox,
|
||||
});
|
||||
const tl = rotPt(-preW / 2, -preH / 2);
|
||||
const tr = rotPt( preW / 2, -preH / 2);
|
||||
const br = rotPt( preW / 2, preH / 2);
|
||||
const bl = rotPt(-preW / 2, preH / 2);
|
||||
const { tl, tr, br, bl } = geometry.corners;
|
||||
|
||||
// Outline of the rotated rectangle — solid white inner line with a
|
||||
// thin black halo for contrast on light AND dark backgrounds.
|
||||
// Use the editor accent instead of white so the frame remains distinct
|
||||
// from both the canvas and the ruler/UI chrome.
|
||||
const drawRectOutline = () => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tl.x, tl.y);
|
||||
@@ -161,54 +162,41 @@ export function drawHandles(margin) {
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
};
|
||||
ctx.lineWidth = 1 / state.zoom;
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.45)';
|
||||
ctx.lineWidth = 3 / state.zoom;
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.82)';
|
||||
ctx.setLineDash([6 / state.zoom, 4 / state.zoom]);
|
||||
ctx.lineDashOffset = 1 / state.zoom;
|
||||
drawRectOutline();
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 1.5 / state.zoom;
|
||||
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#e06c75';
|
||||
ctx.lineDashOffset = 0;
|
||||
drawRectOutline();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Corner handles + rotation knob anchored to the rotated layer's
|
||||
// Resize handles + rotation knob anchored to the rotated layer's
|
||||
// top-center (not bbox top), so the knob stays attached to the
|
||||
// visible content as it spins.
|
||||
const rotOffset = 24 / state.zoom;
|
||||
const cxh = off.x + w / 2;
|
||||
const cyh = off.y + h / 2;
|
||||
const rotRad = ((state.transformPendingRot || 0) * Math.PI) / 180;
|
||||
const baseInnerR = (state.transformPendingH || h) / 2;
|
||||
const knob = knobPosition(cxh, cyh, rotRad, baseInnerR, rotOffset);
|
||||
// Tether line collapses to a point when knob is inside the layer.
|
||||
const drawTether = !knob.rotInside;
|
||||
const innerX = cxh + Math.sin(rotRad) * baseInnerR;
|
||||
const innerY = cyh - Math.cos(rotRad) * baseInnerR;
|
||||
const corners = [
|
||||
{ x: tl.x, y: tl.y, id: 'tl' },
|
||||
{ x: tr.x, y: tr.y, id: 'tr' },
|
||||
{ x: br.x, y: br.y, id: 'br' },
|
||||
{ x: bl.x, y: bl.y, id: 'bl' },
|
||||
{ x: knob.rotX, y: knob.rotY, id: 'rot' },
|
||||
];
|
||||
if (drawTether) {
|
||||
const topHandle = geometry.resizeHandles.find(handle => handle.id === 't');
|
||||
if (!rotInside) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(innerX, innerY);
|
||||
ctx.lineTo(knob.rotX, knob.rotY);
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
|
||||
ctx.moveTo(topHandle.x, topHandle.y);
|
||||
ctx.lineTo(geometry.rotationHandle.x, geometry.rotationHandle.y);
|
||||
ctx.strokeStyle = accent;
|
||||
ctx.lineWidth = 1 / state.zoom;
|
||||
ctx.stroke();
|
||||
}
|
||||
for (const c of corners) {
|
||||
for (const c of geometry.handles) {
|
||||
const active = c.id === state.transformHandle;
|
||||
const hovered = !active && c.id === state.hoveredHandle;
|
||||
const radius = (active ? sz * 0.75 : hovered ? sz * 0.6 : sz / 2);
|
||||
const edge = c.id.length === 1 && c.id !== 'rot';
|
||||
const baseRadius = edge ? sz * 0.42 : sz / 2;
|
||||
const radius = active ? sz * 0.75 : hovered ? sz * 0.6 : baseRadius;
|
||||
ctx.beginPath();
|
||||
ctx.arc(c.x, c.y, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = active ? '#e06c75' : hovered ? '#ffd' : '#fff';
|
||||
ctx.fillStyle = active ? '#fff' : hovered ? '#ffd' : accent;
|
||||
ctx.fill();
|
||||
ctx.lineWidth = stroke;
|
||||
ctx.strokeStyle = active ? '#fff' : 'rgba(0, 0, 0, 0.5)';
|
||||
ctx.strokeStyle = active ? accent : 'rgba(0, 0, 0, 0.72)';
|
||||
ctx.stroke();
|
||||
if (hovered) {
|
||||
// Subtle red ring around the hovered handle for visual feedback.
|
||||
@@ -219,53 +207,56 @@ export function drawHandles(margin) {
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
// The center marker communicates the rotation pivot without becoming an
|
||||
// interactive handle until pivot dragging lands in the transform workflow.
|
||||
const pivotRadius = 3 / state.zoom;
|
||||
ctx.beginPath();
|
||||
ctx.arc(geometry.pivot.x, geometry.pivot.y, pivotRadius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = accent;
|
||||
ctx.lineWidth = 1 / state.zoom;
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Hit-test (x, y) against the transform handles. Returns the handle
|
||||
* id ('tl' | 'tr' | 'br' | 'bl' | 'rot') or null.
|
||||
* id ('tl' | 't' | 'tr' | 'r' | 'br' | 'b' | 'bl' | 'l' | 'rot') or null.
|
||||
*
|
||||
* Geometry MUST mirror `drawHandles` exactly, otherwise the user
|
||||
* grabs phantom points.
|
||||
*/
|
||||
export function getHandleAt(x, y) {
|
||||
if (!state.transformLayer) return null;
|
||||
const layer = state.transformLayer;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const w = layer.canvas.width;
|
||||
const h = layer.canvas.height;
|
||||
const threshold = 8 / state.zoom;
|
||||
const rotOffset = 24 / state.zoom;
|
||||
const cxh = off.x + w / 2;
|
||||
const cyh = off.y + h / 2;
|
||||
const rotRad = ((state.transformPendingRot || 0) * Math.PI) / 180;
|
||||
const baseInnerR = (state.transformPendingH || h) / 2;
|
||||
const knob = knobPosition(cxh, cyh, rotRad, baseInnerR, rotOffset);
|
||||
export function getHandleAt(x, y, options = {}) {
|
||||
if (state.transformTarget !== 'selection' && !state.transformLayer) return null;
|
||||
const frame = transformFrame();
|
||||
if (!frame) return null;
|
||||
const rotationInside = rotationHandleInside(frame);
|
||||
const geometry = transformFrameGeometry(frame, {
|
||||
zoom: state.zoom,
|
||||
rotationInside,
|
||||
});
|
||||
const hit = hitTestTransformHandle(geometry, x, y, {
|
||||
zoom: state.zoom,
|
||||
pointerType: options.pointerType,
|
||||
});
|
||||
if (hit || !rotationInside) return hit;
|
||||
|
||||
// Rotate corners around centre — must match drawHandles.
|
||||
const preW = state.transformPendingW || w;
|
||||
const preH = state.transformPendingH || h;
|
||||
const cosA = Math.cos(rotRad);
|
||||
const sinA = Math.sin(rotRad);
|
||||
const rotCorner = (dx, dy) => ({
|
||||
x: cxh + dx * cosA - dy * sinA,
|
||||
y: cyh + dx * sinA + dy * cosA,
|
||||
// Keep the clipped grip reachable during viewport-edge transitions. The
|
||||
// frame is drawn with the inward position, but a touch can begin from the
|
||||
// previously rendered outward position for one frame after a resize/pan.
|
||||
const unclippedGeometry = transformFrameGeometry(frame, {
|
||||
zoom: state.zoom,
|
||||
rotationInside: false,
|
||||
});
|
||||
return hitTestTransformHandle(unclippedGeometry, x, y, {
|
||||
zoom: state.zoom,
|
||||
pointerType: options.pointerType,
|
||||
});
|
||||
const tlH = rotCorner(-preW / 2, -preH / 2);
|
||||
const trH = rotCorner( preW / 2, -preH / 2);
|
||||
const brH = rotCorner( preW / 2, preH / 2);
|
||||
const blH = rotCorner(-preW / 2, preH / 2);
|
||||
const handles = [
|
||||
{ x: tlH.x, y: tlH.y, id: 'tl' },
|
||||
{ x: trH.x, y: trH.y, id: 'tr' },
|
||||
{ x: brH.x, y: brH.y, id: 'br' },
|
||||
{ x: blH.x, y: blH.y, id: 'bl' },
|
||||
{ x: knob.rotX, y: knob.rotY, id: 'rot' },
|
||||
];
|
||||
for (const c of handles) {
|
||||
if (Math.abs(x - c.x) < threshold && Math.abs(y - c.y) < threshold) return c.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True only for the visible rotated frame, not its empty AABB corners. */
|
||||
export function containsFramePoint(x, y) {
|
||||
const frame = transformFrame();
|
||||
if (!frame) return false;
|
||||
return pointInTransformFrame(transformFrameGeometry(frame, { zoom: state.zoom }), x, y);
|
||||
}
|
||||
|
||||
@@ -23,31 +23,134 @@
|
||||
* hideCanvasLoading: () => void,
|
||||
* undo: () => void,
|
||||
* uiModule: object | null,
|
||||
* syncGeometryControls?: () => void,
|
||||
* getDocumentSelection?: () => HTMLCanvasElement | null,
|
||||
* syncSelectionUi?: () => void,
|
||||
* schedulePersist?: () => void,
|
||||
* }} deps
|
||||
*
|
||||
* @returns {{
|
||||
* startTransform, openTransformPopup, closeTransformPopup,
|
||||
* reapplyTransform, confirmTransform, cancelTransform,
|
||||
* reapplyTransform, nudgeTransform, confirmTransform, cancelTransform,
|
||||
* }}
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { normalizeTextData, renderTextLayer } from '../text-layer.js';
|
||||
import { normalizeShapeData, renderShapeLayer } from '../shape-layer.js';
|
||||
import { isLayerPositionLocked } from '../layer-groups.js';
|
||||
import { selectedLayers, selectionLabel } from '../layer-selection.js';
|
||||
import {
|
||||
planLayerTransform,
|
||||
selectionBounds,
|
||||
transformedSelectionBounds,
|
||||
} from '../multi-transform.js';
|
||||
import {
|
||||
EDITOR_MAX_DIMENSION,
|
||||
EDITOR_MAX_SURFACE_PIXELS,
|
||||
} from '../document-codec.js';
|
||||
import {
|
||||
transformPopupHTML,
|
||||
attachSpinRepeat,
|
||||
} from '../build/transform-popup.js';
|
||||
import {
|
||||
selectionMaskBounds,
|
||||
transformSelectionMask,
|
||||
} from '../selection-mask.js';
|
||||
import {
|
||||
clonePlacedData,
|
||||
createPlacedData,
|
||||
frameTransformMatrix,
|
||||
renderPlacedLayer,
|
||||
transformPlacedData,
|
||||
} from '../placed-layer.js';
|
||||
|
||||
export function createTransformSession({
|
||||
activeLayer, saveState, composite, fitZoom, drawTransformHandles,
|
||||
showCanvasLoading, hideCanvasLoading, undo, uiModule,
|
||||
syncGeometryControls, getDocumentSelection, syncSelectionUi, schedulePersist,
|
||||
}) {
|
||||
let lastValidLayerTarget = null;
|
||||
let lastTransformLimitMessage = '';
|
||||
|
||||
const formatTransformValue = value => {
|
||||
const number = Number(value) || 0;
|
||||
return Number.isInteger(number) ? String(number) : String(Number(number.toFixed(2)));
|
||||
};
|
||||
|
||||
function syncTransformPopupValues(force = false) {
|
||||
const pop = state.transformPopup;
|
||||
if (!pop || !state.transformCenter) return;
|
||||
const values = {
|
||||
'#ge-transform-x': state.transformCenter.x,
|
||||
'#ge-transform-y': state.transformCenter.y,
|
||||
'#ge-transform-w': state.transformPendingFlipH ? -state.transformPendingW : state.transformPendingW,
|
||||
'#ge-transform-h': state.transformPendingFlipV ? -state.transformPendingH : state.transformPendingH,
|
||||
'#ge-transform-rot': state.transformPendingRot,
|
||||
};
|
||||
for (const [selector, value] of Object.entries(values)) {
|
||||
const input = pop.querySelector(selector);
|
||||
if (input && (force || document.activeElement !== input)) input.value = formatTransformValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
function copyCanvas(source) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = source.width;
|
||||
canvas.height = source.height;
|
||||
canvas.getContext('2d').drawImage(source, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function startTransform() {
|
||||
const layer = activeLayer();
|
||||
if (!layer || layer.locked) { uiModule.showToast('Select an unlocked layer'); return; }
|
||||
const layers = selectedLayers(state);
|
||||
if (!layer || !layers.length) { uiModule.showToast('Select an unlocked layer'); return; }
|
||||
if (layers.some(item => isLayerPositionLocked(state, item))) {
|
||||
uiModule.showToast(layers.length > 1 ? 'Unlock the position of every selected layer before transforming' : 'Unlock the layer position before transforming');
|
||||
return;
|
||||
}
|
||||
if (state.transformActive) { cancelTransform(); return; } // toggle off
|
||||
const bounds = selectionBounds(state, layers);
|
||||
state.transformActive = true;
|
||||
state.transformTarget = 'layers';
|
||||
state.transformLayer = layer;
|
||||
state.transformOrigW = layer.canvas.width;
|
||||
state.transformOrigH = layer.canvas.height;
|
||||
state.transformLayers = [...layers];
|
||||
state.transformItems = layers.map(item => ({
|
||||
layer: item,
|
||||
kind: item.kind || 'raster',
|
||||
canvas: copyCanvas(item.canvas),
|
||||
width: item.canvas.width,
|
||||
height: item.canvas.height,
|
||||
offset: { ...(state.layerOffsets.get(item.id) || { x: 0, y: 0 }) },
|
||||
wasPlaced: item.kind === 'placed',
|
||||
placed: item.kind === 'placed'
|
||||
? clonePlacedData(item.placed)
|
||||
: (item.kind === 'raster' ? createPlacedData(item.canvas, [1, 0, 0, 1, 0, 0], item.name || 'Raster layer') : null),
|
||||
masks: (item.masks || [])
|
||||
.filter(mask => (mask.space || 'layer') === 'layer' && mask.canvas)
|
||||
.map(mask => {
|
||||
const maskOffset = {
|
||||
x: Number(mask.offset?.x) || 0,
|
||||
y: Number(mask.offset?.y) || 0,
|
||||
};
|
||||
const layerOffset = state.layerOffsets.get(item.id) || { x: 0, y: 0 };
|
||||
return {
|
||||
mask,
|
||||
canvas: copyCanvas(mask.canvas),
|
||||
linked: mask.linked !== false,
|
||||
offset: maskOffset,
|
||||
documentOffset: {
|
||||
x: layerOffset.x + maskOffset.x,
|
||||
y: layerOffset.y + maskOffset.y,
|
||||
},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
const activeSnapshot = state.transformItems.find(item => item.layer.id === layer.id);
|
||||
state.transformOrigW = bounds.width;
|
||||
state.transformOrigH = bounds.height;
|
||||
state.transformSessionStartW = bounds.width;
|
||||
state.transformSessionStartH = bounds.height;
|
||||
state.transformPendingW = state.transformOrigW;
|
||||
state.transformPendingH = state.transformOrigH;
|
||||
state.transformPendingRot = 0;
|
||||
@@ -56,12 +159,16 @@ export function createTransformSession({
|
||||
// Snapshot the layer so live preview can re-derive from the
|
||||
// original pixels on every keystroke instead of stacking
|
||||
// destructive edits.
|
||||
state.transformOrigCanvas = document.createElement('canvas');
|
||||
state.transformOrigCanvas.width = state.transformOrigW;
|
||||
state.transformOrigCanvas.height = state.transformOrigH;
|
||||
state.transformOrigCanvas.getContext('2d').drawImage(layer.canvas, 0, 0);
|
||||
state.transformOrigOffset = { ...(state.layerOffsets.get(layer.id) || { x: 0, y: 0 }) };
|
||||
saveState();
|
||||
state.transformOrigCanvas = activeSnapshot?.canvas || null;
|
||||
state.transformOrigMasks = activeSnapshot?.masks || [];
|
||||
state.transformOrigOffset = { ...(activeSnapshot?.offset || { x: 0, y: 0 }) };
|
||||
state.transformSelectionBounds = { ...bounds };
|
||||
state.transformBounds = { ...bounds };
|
||||
state.transformCenter = { x: bounds.centerX, y: bounds.centerY };
|
||||
state.transformStartCenter = { ...state.transformCenter };
|
||||
lastValidLayerTarget = currentTransformTarget();
|
||||
lastTransformLimitMessage = '';
|
||||
saveState(`Transform ${selectionLabel(state, `"${layer.name || 'layer'}"`)}`);
|
||||
// Fit canvas to viewport so the corner handles are visible —
|
||||
// without this, a layer larger than the viewport leaves the grab
|
||||
// markers off-screen.
|
||||
@@ -69,6 +176,48 @@ export function createTransformSession({
|
||||
composite();
|
||||
drawTransformHandles();
|
||||
openTransformPopup();
|
||||
syncGeometryControls?.();
|
||||
}
|
||||
|
||||
function startSelectionTransform() {
|
||||
if (state.transformActive) cancelTransform();
|
||||
const selection = getDocumentSelection?.();
|
||||
const bounds = selectionMaskBounds(selection);
|
||||
if (!selection || !bounds) {
|
||||
uiModule?.showToast('Make a selection before transforming it');
|
||||
return false;
|
||||
}
|
||||
const frame = {
|
||||
...bounds,
|
||||
centerX: bounds.x + bounds.width / 2,
|
||||
centerY: bounds.y + bounds.height / 2,
|
||||
};
|
||||
state.transformActive = true;
|
||||
state.transformTarget = 'selection';
|
||||
state.transformLayer = null;
|
||||
state.transformLayers = [];
|
||||
state.transformItems = [];
|
||||
state.transformSelectionCanvas = copyCanvas(selection);
|
||||
state.transformSelectionBounds = { ...frame };
|
||||
state.transformBounds = { ...frame };
|
||||
state.transformCenter = { x: frame.centerX, y: frame.centerY };
|
||||
state.transformStartCenter = { ...state.transformCenter };
|
||||
state.transformOrigW = frame.width;
|
||||
state.transformOrigH = frame.height;
|
||||
state.transformSessionStartW = frame.width;
|
||||
state.transformSessionStartH = frame.height;
|
||||
state.transformPendingW = frame.width;
|
||||
state.transformPendingH = frame.height;
|
||||
state.transformPendingRot = 0;
|
||||
state.transformPendingFlipH = false;
|
||||
state.transformPendingFlipV = false;
|
||||
saveState('Transform selection');
|
||||
try { fitZoom(); } catch {}
|
||||
composite();
|
||||
drawTransformHandles();
|
||||
openTransformPopup();
|
||||
syncSelectionUi?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeTransformPopup() {
|
||||
@@ -85,19 +234,39 @@ export function createTransformSession({
|
||||
function openTransformPopup() {
|
||||
closeTransformPopup();
|
||||
if (!state.container) return;
|
||||
// Global tours and toasts sit above tool popups and can cover the compact
|
||||
// mobile header or Apply/Cancel controls. Starting a new edit is a stronger
|
||||
// signal than those transient messages, so clear them first.
|
||||
document.querySelector('.tour-hint .tour-hint-dismiss')?.click();
|
||||
document.querySelector('#toast.show .toast-close-btn')?.click();
|
||||
const pop = document.createElement('div');
|
||||
pop.className = 'ge-transform-popup';
|
||||
pop.innerHTML = transformPopupHTML();
|
||||
const title = pop.querySelector('.ge-adj-title');
|
||||
if (title && state.transformTarget === 'selection') title.textContent = 'Transform Selection';
|
||||
else if (title && state.transformLayers.length > 1) title.textContent = `Transform ${state.transformLayers.length} layers`;
|
||||
state.container.appendChild(pop);
|
||||
state.transformPopup = pop;
|
||||
wireTransformDrag(pop);
|
||||
const wInput = pop.querySelector('#ge-transform-w');
|
||||
const hInput = pop.querySelector('#ge-transform-h');
|
||||
const rotInput = pop.querySelector('#ge-transform-rot');
|
||||
const xInput = pop.querySelector('#ge-transform-x');
|
||||
const yInput = pop.querySelector('#ge-transform-y');
|
||||
const aspectBtn = pop.querySelector('#ge-transform-aspect');
|
||||
wInput.value = String(state.transformOrigW);
|
||||
hInput.value = String(state.transformOrigH);
|
||||
rotInput.value = '0';
|
||||
const preserveSource = pop.querySelector('#ge-transform-preserve-source');
|
||||
const preserveField = pop.querySelector('.ge-transform-preserve-field');
|
||||
const canPreserveSource = state.transformTarget !== 'selection'
|
||||
&& state.transformItems.some(item => item.layer?.kind === 'raster' || item.wasPlaced);
|
||||
if (preserveField) preserveField.hidden = !canPreserveSource;
|
||||
if (preserveSource) {
|
||||
preserveSource.checked = state.transformPreserveSource !== false;
|
||||
preserveSource.addEventListener('change', () => {
|
||||
state.transformPreserveSource = preserveSource.checked;
|
||||
reapplyTransform();
|
||||
});
|
||||
}
|
||||
syncTransformPopupValues();
|
||||
aspectBtn.classList.toggle('active', state.transformAspectLock);
|
||||
aspectBtn.setAttribute('aria-pressed', state.transformAspectLock ? 'true' : 'false');
|
||||
|
||||
@@ -125,6 +294,8 @@ export function createTransformSession({
|
||||
let w = parseInt(wInput.value, 10);
|
||||
let h = parseInt(hInput.value, 10);
|
||||
const rot = parseInt(rotInput.value, 10) || 0;
|
||||
const x = Number.parseFloat(xInput.value);
|
||||
const y = Number.parseFloat(yInput.value);
|
||||
state.transformPendingFlipH = w < 0;
|
||||
state.transformPendingFlipV = h < 0;
|
||||
w = Math.abs(w || state.transformOrigW);
|
||||
@@ -132,8 +303,11 @@ export function createTransformSession({
|
||||
state.transformPendingW = Math.max(1, w);
|
||||
state.transformPendingH = Math.max(1, h);
|
||||
state.transformPendingRot = rot;
|
||||
if (Number.isFinite(x) && Number.isFinite(y)) state.transformCenter = { x, y };
|
||||
reapplyTransform();
|
||||
};
|
||||
xInput.addEventListener('input', refresh);
|
||||
yInput.addEventListener('input', refresh);
|
||||
wInput.addEventListener('input', () => {
|
||||
if (state.transformAspectLock) {
|
||||
driver = 'w';
|
||||
@@ -310,72 +484,265 @@ export function createTransformSession({
|
||||
document.addEventListener('touchcancel', endDrag);
|
||||
}
|
||||
|
||||
// Re-derive the active layer's pixels from the original snapshot
|
||||
// with the popup's current W/H/flip/rotation applied. Cheap —
|
||||
// paints into an off-screen canvas of the final size.
|
||||
function renderSnapshot(source, snapshot, geometry) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = geometry.width;
|
||||
canvas.height = geometry.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.save();
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||
if (geometry.rotation) ctx.rotate(geometry.radians);
|
||||
ctx.scale(geometry.signedScaleX, geometry.signedScaleY);
|
||||
ctx.drawImage(source, -snapshot.width / 2, -snapshot.height / 2, snapshot.width, snapshot.height);
|
||||
ctx.restore();
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function currentTransformTarget() {
|
||||
return {
|
||||
width: state.transformPendingW,
|
||||
height: state.transformPendingH,
|
||||
rotation: state.transformPendingRot,
|
||||
flipH: state.transformPendingFlipH,
|
||||
flipV: state.transformPendingFlipV,
|
||||
centerX: state.transformCenter?.x,
|
||||
centerY: state.transformCenter?.y,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreTransformTarget(target) {
|
||||
if (!target) return;
|
||||
state.transformPendingW = target.width;
|
||||
state.transformPendingH = target.height;
|
||||
state.transformPendingRot = target.rotation;
|
||||
state.transformPendingFlipH = target.flipH;
|
||||
state.transformPendingFlipV = target.flipV;
|
||||
state.transformCenter = { x: target.centerX, y: target.centerY };
|
||||
}
|
||||
|
||||
// Re-derive every selected layer from immutable per-session snapshots.
|
||||
function reapplyTransform() {
|
||||
const layer = state.transformLayer;
|
||||
if (!layer || !state.transformOrigCanvas) return;
|
||||
const w = state.transformPendingW;
|
||||
const h = state.transformPendingH;
|
||||
const rotDeg = state.transformPendingRot;
|
||||
const rotRad = (rotDeg * Math.PI) / 180;
|
||||
const cos = Math.abs(Math.cos(rotRad));
|
||||
const sin = Math.abs(Math.sin(rotRad));
|
||||
// Bounding box of the rotated W×H — canvas grows so corners
|
||||
// don't clip.
|
||||
const finalW = Math.max(1, Math.round(w * cos + h * sin));
|
||||
const finalH = Math.max(1, Math.round(w * sin + h * cos));
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = finalW; tmp.height = finalH;
|
||||
const tCtx = tmp.getContext('2d');
|
||||
tCtx.imageSmoothingEnabled = true;
|
||||
tCtx.imageSmoothingQuality = 'high';
|
||||
tCtx.save();
|
||||
tCtx.translate(finalW / 2, finalH / 2);
|
||||
if (rotDeg) tCtx.rotate(rotRad);
|
||||
tCtx.scale(state.transformPendingFlipH ? -1 : 1, state.transformPendingFlipV ? -1 : 1);
|
||||
tCtx.drawImage(state.transformOrigCanvas, -w / 2, -h / 2, w, h);
|
||||
tCtx.restore();
|
||||
layer.canvas.width = finalW;
|
||||
layer.canvas.height = finalH;
|
||||
layer.ctx.clearRect(0, 0, finalW, finalH);
|
||||
layer.ctx.drawImage(tmp, 0, 0);
|
||||
// Recenter the layer so the rotation pivot stays put visually.
|
||||
const origCenterX = state.transformOrigOffset.x + state.transformOrigW / 2;
|
||||
const origCenterY = state.transformOrigOffset.y + state.transformOrigH / 2;
|
||||
state.layerOffsets.set(layer.id, {
|
||||
x: Math.round(origCenterX - finalW / 2),
|
||||
y: Math.round(origCenterY - finalH / 2),
|
||||
});
|
||||
if (state.transformTarget === 'selection') {
|
||||
if (!state.transformSelectionCanvas || !state.transformSelectionBounds || !state.transformCenter) return;
|
||||
const target = {
|
||||
width: state.transformPendingW,
|
||||
height: state.transformPendingH,
|
||||
rotation: state.transformPendingRot,
|
||||
flipH: state.transformPendingFlipH,
|
||||
flipV: state.transformPendingFlipV,
|
||||
centerX: state.transformCenter.x,
|
||||
centerY: state.transformCenter.y,
|
||||
};
|
||||
state.wandMask = transformSelectionMask(
|
||||
state.transformSelectionCanvas,
|
||||
state.transformSelectionBounds,
|
||||
target,
|
||||
state.imgWidth,
|
||||
state.imgHeight,
|
||||
);
|
||||
state.wandLayerId = state.activeLayerId;
|
||||
state.wandMaskSpace = 'document';
|
||||
state.selectionSource = 'transform';
|
||||
state.wandLastSeed = null;
|
||||
state.lassoPoints = [];
|
||||
state.lassoActive = false;
|
||||
state.transformBounds = transformedSelectionBounds(state.transformSelectionBounds, target);
|
||||
composite();
|
||||
syncTransformPopupValues();
|
||||
syncSelectionUi?.();
|
||||
drawTransformHandles();
|
||||
return;
|
||||
}
|
||||
if (!state.transformItems.length || !state.transformSelectionBounds || !state.transformCenter) return;
|
||||
const target = currentTransformTarget();
|
||||
const plan = planLayerTransform(
|
||||
state,
|
||||
state.transformItems,
|
||||
state.transformSelectionBounds,
|
||||
target,
|
||||
{ maxDimension: EDITOR_MAX_DIMENSION, maxSurfacePixels: EDITOR_MAX_SURFACE_PIXELS },
|
||||
);
|
||||
if (!plan.ok) {
|
||||
restoreTransformTarget(lastValidLayerTarget);
|
||||
syncTransformPopupValues(true);
|
||||
if (lastTransformLimitMessage !== plan.reason) {
|
||||
uiModule?.showToast(plan.reason, 5000);
|
||||
lastTransformLimitMessage = plan.reason;
|
||||
}
|
||||
composite();
|
||||
drawTransformHandles();
|
||||
return false;
|
||||
}
|
||||
lastValidLayerTarget = { ...target };
|
||||
lastTransformLimitMessage = '';
|
||||
const documentTransform = frameTransformMatrix(state.transformSelectionBounds, target);
|
||||
for (const { snapshot, geometry: plannedGeometry } of plan.items) {
|
||||
const layer = snapshot.layer;
|
||||
let geometry = plannedGeometry;
|
||||
if (snapshot.placed && (snapshot.wasPlaced || state.transformPreserveSource !== false)) {
|
||||
layer.placed = transformPlacedData(snapshot.placed, documentTransform);
|
||||
const rendered = renderPlacedLayer(layer);
|
||||
geometry = {
|
||||
...plannedGeometry,
|
||||
width: rendered.canvas.width,
|
||||
height: rendered.canvas.height,
|
||||
offset: rendered.offset,
|
||||
};
|
||||
} else {
|
||||
if (snapshot.kind === 'raster') {
|
||||
layer.kind = 'raster';
|
||||
layer.placed = null;
|
||||
}
|
||||
const output = renderSnapshot(snapshot.canvas, snapshot, geometry);
|
||||
layer.canvas.width = geometry.width;
|
||||
layer.canvas.height = geometry.height;
|
||||
layer.ctx = layer.canvas.getContext('2d');
|
||||
layer.ctx.drawImage(output, 0, 0);
|
||||
}
|
||||
layer._adjFinal = null;
|
||||
layer._adjFinalKey = null;
|
||||
layer._adjCache = null;
|
||||
layer._adjCacheKey = null;
|
||||
state.layerOffsets.set(layer.id, geometry.offset);
|
||||
for (const maskSnapshot of snapshot.masks) {
|
||||
if (maskSnapshot.linked) {
|
||||
const positioned = document.createElement('canvas');
|
||||
positioned.width = snapshot.width;
|
||||
positioned.height = snapshot.height;
|
||||
positioned.getContext('2d').drawImage(
|
||||
maskSnapshot.canvas,
|
||||
maskSnapshot.offset.x,
|
||||
maskSnapshot.offset.y,
|
||||
);
|
||||
const maskOutput = renderSnapshot(positioned, snapshot, geometry);
|
||||
maskSnapshot.mask.canvas.width = geometry.width;
|
||||
maskSnapshot.mask.canvas.height = geometry.height;
|
||||
maskSnapshot.mask.ctx = maskSnapshot.mask.canvas.getContext('2d');
|
||||
maskSnapshot.mask.ctx.drawImage(maskOutput, 0, 0);
|
||||
maskSnapshot.mask.offset = { x: 0, y: 0 };
|
||||
} else {
|
||||
// Rebuild from the immutable snapshot so every preview remains
|
||||
// cancel-safe, then compensate for the layer's new document offset.
|
||||
maskSnapshot.mask.canvas.width = maskSnapshot.canvas.width;
|
||||
maskSnapshot.mask.canvas.height = maskSnapshot.canvas.height;
|
||||
maskSnapshot.mask.ctx = maskSnapshot.mask.canvas.getContext('2d');
|
||||
maskSnapshot.mask.ctx.drawImage(maskSnapshot.canvas, 0, 0);
|
||||
maskSnapshot.mask.offset = {
|
||||
x: maskSnapshot.documentOffset.x - geometry.offset.x,
|
||||
y: maskSnapshot.documentOffset.y - geometry.offset.y,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
state.transformBounds = transformedSelectionBounds(state.transformSelectionBounds, target);
|
||||
composite();
|
||||
syncTransformPopupValues();
|
||||
syncGeometryControls?.();
|
||||
drawTransformHandles();
|
||||
return true;
|
||||
}
|
||||
|
||||
function confirmTransform() {
|
||||
closeTransformPopup();
|
||||
const target = state.transformTarget;
|
||||
if (target === 'selection') {
|
||||
clearTransformState();
|
||||
composite();
|
||||
syncSelectionUi?.();
|
||||
schedulePersist?.();
|
||||
uiModule?.showToast('Selection transformed');
|
||||
return;
|
||||
}
|
||||
for (const snapshot of state.transformItems) {
|
||||
const layer = snapshot.layer;
|
||||
if (!layer || !['text', 'shape'].includes(layer.kind)) continue;
|
||||
const offset = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const center = { x: offset.x + layer.canvas.width / 2, y: offset.y + layer.canvas.height / 2 };
|
||||
const retained = layer.kind === 'text' ? normalizeTextData(layer.text) : normalizeShapeData(layer.shape);
|
||||
retained.transform.scaleX *= state.transformPendingW / Math.max(1, state.transformSessionStartW);
|
||||
retained.transform.scaleY *= state.transformPendingH / Math.max(1, state.transformSessionStartH);
|
||||
retained.transform.rotation += state.transformPendingRot;
|
||||
if (state.transformPendingFlipH) retained.transform.flipH = !retained.transform.flipH;
|
||||
if (state.transformPendingFlipV) retained.transform.flipV = !retained.transform.flipV;
|
||||
if (layer.kind === 'text') {
|
||||
layer.text = retained;
|
||||
renderTextLayer(layer);
|
||||
} else {
|
||||
layer.shape = retained;
|
||||
renderShapeLayer(layer);
|
||||
}
|
||||
state.layerOffsets.set(layer.id, {
|
||||
x: Math.round(center.x - layer.canvas.width / 2),
|
||||
y: Math.round(center.y - layer.canvas.height / 2),
|
||||
});
|
||||
}
|
||||
const count = state.transformItems.length;
|
||||
clearTransformState();
|
||||
composite();
|
||||
syncGeometryControls?.();
|
||||
schedulePersist?.();
|
||||
uiModule.showToast(count > 1 ? `${count} layers transformed` : 'Transform applied');
|
||||
}
|
||||
|
||||
function nudgeTransform(dx, dy) {
|
||||
if (!state.transformActive || !state.transformCenter) return false;
|
||||
state.transformCenter = {
|
||||
x: state.transformCenter.x + Number(dx || 0),
|
||||
y: state.transformCenter.y + Number(dy || 0),
|
||||
};
|
||||
reapplyTransform();
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearTransformState() {
|
||||
state.transformOrigCanvas = null;
|
||||
state.transformOrigMasks = [];
|
||||
state.transformOrigOffset = null;
|
||||
state.transformSelectionCanvas = null;
|
||||
state.transformLayers = [];
|
||||
state.transformItems = [];
|
||||
state.transformSelectionBounds = null;
|
||||
state.transformBounds = null;
|
||||
state.transformCenter = null;
|
||||
state.transformStartCenter = null;
|
||||
state.transformOrigW = 0;
|
||||
state.transformOrigH = 0;
|
||||
state.transformPendingW = 0;
|
||||
state.transformPendingH = 0;
|
||||
state.transformPendingRot = 0;
|
||||
state.transformPendingFlipH = false;
|
||||
state.transformPendingFlipV = false;
|
||||
state.transformActive = false;
|
||||
state.transformTarget = null;
|
||||
state.transformLayer = null;
|
||||
state.transformHandle = null;
|
||||
composite();
|
||||
uiModule.showToast('Transform applied');
|
||||
state.hoveredHandle = null;
|
||||
state.transformSessionStartW = 0;
|
||||
state.transformSessionStartH = 0;
|
||||
state.transformStartRotation = 0;
|
||||
state.transformStartFlipH = false;
|
||||
state.transformStartFlipV = false;
|
||||
lastValidLayerTarget = null;
|
||||
lastTransformLimitMessage = '';
|
||||
}
|
||||
|
||||
function cancelTransform() {
|
||||
closeTransformPopup();
|
||||
state.transformOrigCanvas = null;
|
||||
state.transformOrigOffset = null;
|
||||
if (state.transformLayer) undo(); // restore saved state
|
||||
state.transformActive = false;
|
||||
state.transformLayer = null;
|
||||
state.transformHandle = null;
|
||||
const hadTransform = state.transformActive;
|
||||
if (hadTransform) {
|
||||
undo(); // restore the one pre-session snapshot
|
||||
state.redoStack = []; // Cancel must not expose the discarded preview via Redo.
|
||||
}
|
||||
clearTransformState();
|
||||
composite();
|
||||
syncGeometryControls?.();
|
||||
syncSelectionUi?.();
|
||||
}
|
||||
|
||||
return {
|
||||
startTransform, openTransformPopup, closeTransformPopup,
|
||||
reapplyTransform, confirmTransform, cancelTransform,
|
||||
startTransform, startSelectionTransform, openTransformPopup, closeTransformPopup,
|
||||
reapplyTransform, nudgeTransform, confirmTransform, cancelTransform,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
export function createWandTool({ activeLayer, saveState, composite, wandHits, runMagicWand }) {
|
||||
export function createWandTool({ activeLayer, saveState, composite, wandHits, runMagicWand, deselectSelection }) {
|
||||
return {
|
||||
click(e) {
|
||||
const layer = activeLayer();
|
||||
@@ -33,9 +33,15 @@ export function createWandTool({ activeLayer, saveState, composite, wandHits, ru
|
||||
else if (e.altKey) mode = 'subtract';
|
||||
// Click INSIDE the existing selection with no modifier → deselect.
|
||||
if (mode === 'replace' && wandHits(coords.x, coords.y)) {
|
||||
if (deselectSelection) {
|
||||
deselectSelection();
|
||||
return;
|
||||
}
|
||||
saveState();
|
||||
state.wandMask = null;
|
||||
state.wandLayerId = null;
|
||||
state.wandMaskSpace = 'layer';
|
||||
state.selectionSource = null;
|
||||
state.wandLastSeed = null;
|
||||
composite();
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/** Pure geometry shared by transform-frame drawing and hit testing. */
|
||||
|
||||
const HANDLE_LAYOUT = [
|
||||
['tl', -0.5, -0.5],
|
||||
['t', 0, -0.5],
|
||||
['tr', 0.5, -0.5],
|
||||
['r', 0.5, 0],
|
||||
['br', 0.5, 0.5],
|
||||
['b', 0, 0.5],
|
||||
['bl', -0.5, 0.5],
|
||||
['l', -0.5, 0],
|
||||
];
|
||||
|
||||
function finite(value, fallback = 0) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
}
|
||||
|
||||
export function transformFrameGeometry(frame, options = {}) {
|
||||
const centerX = finite(frame?.centerX);
|
||||
const centerY = finite(frame?.centerY);
|
||||
const width = Math.max(1, Math.abs(finite(frame?.width, 1)));
|
||||
const height = Math.max(1, Math.abs(finite(frame?.height, 1)));
|
||||
const rotation = finite(frame?.rotation);
|
||||
const radians = rotation * Math.PI / 180;
|
||||
const cos = Math.cos(radians);
|
||||
const sin = Math.sin(radians);
|
||||
const rotateLocal = (localX, localY) => ({
|
||||
x: centerX + localX * cos - localY * sin,
|
||||
y: centerY + localX * sin + localY * cos,
|
||||
});
|
||||
const resizeHandles = HANDLE_LAYOUT.map(([id, xFactor, yFactor]) => ({
|
||||
id,
|
||||
...rotateLocal(width * xFactor, height * yFactor),
|
||||
}));
|
||||
const byId = Object.fromEntries(resizeHandles.map(handle => [handle.id, handle]));
|
||||
const zoom = Math.max(0.0001, finite(options.zoom, 1));
|
||||
const rotationOffset = Math.max(0, finite(options.rotationOffsetCss, 24)) / zoom;
|
||||
const rotationLocalY = options.rotationInside
|
||||
? -Math.max(0, height / 2 - rotationOffset)
|
||||
: -(height / 2 + rotationOffset);
|
||||
const rotationHandle = {
|
||||
id: 'rot',
|
||||
...rotateLocal(0, rotationLocalY),
|
||||
};
|
||||
|
||||
return {
|
||||
centerX,
|
||||
centerY,
|
||||
width,
|
||||
height,
|
||||
rotation,
|
||||
radians,
|
||||
resizeHandles,
|
||||
handles: [...resizeHandles, rotationHandle],
|
||||
rotationHandle,
|
||||
corners: {
|
||||
tl: { x: byId.tl.x, y: byId.tl.y },
|
||||
tr: { x: byId.tr.x, y: byId.tr.y },
|
||||
br: { x: byId.br.x, y: byId.br.y },
|
||||
bl: { x: byId.bl.x, y: byId.bl.y },
|
||||
},
|
||||
pivot: { x: centerX, y: centerY },
|
||||
};
|
||||
}
|
||||
|
||||
export function hitTestTransformHandle(geometry, x, y, options = {}) {
|
||||
if (!geometry) return null;
|
||||
const zoom = Math.max(0.0001, finite(options.zoom, 1));
|
||||
const pointerType = options.pointerType || 'mouse';
|
||||
const radiusCss = pointerType === 'touch' || pointerType === 'pen' ? 20 : 8;
|
||||
const radius = radiusCss / zoom;
|
||||
const radiusSquared = radius * radius;
|
||||
for (const handle of geometry.handles || []) {
|
||||
const dx = finite(x) - handle.x;
|
||||
const dy = finite(y) - handle.y;
|
||||
if (dx * dx + dy * dy <= radiusSquared) return handle.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function pointInTransformFrame(geometry, x, y) {
|
||||
if (!geometry) return false;
|
||||
const dx = finite(x) - geometry.centerX;
|
||||
const dy = finite(y) - geometry.centerY;
|
||||
const cos = Math.cos(-geometry.radians);
|
||||
const sin = Math.sin(-geometry.radians);
|
||||
const localX = dx * cos - dy * sin;
|
||||
const localY = dx * sin + dy * cos;
|
||||
const epsilon = 0.0001;
|
||||
return Math.abs(localX) <= geometry.width / 2 + epsilon
|
||||
&& Math.abs(localY) <= geometry.height / 2 + epsilon;
|
||||
}
|
||||
|
||||
/** Resize a frame from a screen-space pointer delta on its rotated axes. */
|
||||
export function resizeTransformFrame(frame, handle, startPoint, currentPoint, options = {}) {
|
||||
const geometry = transformFrameGeometry(frame);
|
||||
const xSign = handle?.includes('l') ? -1 : handle?.includes('r') ? 1 : 0;
|
||||
const ySign = handle?.includes('t') ? -1 : handle?.includes('b') ? 1 : 0;
|
||||
if (!xSign && !ySign) return {
|
||||
width: geometry.width,
|
||||
height: geometry.height,
|
||||
centerX: geometry.centerX,
|
||||
centerY: geometry.centerY,
|
||||
flipH: !!options.flipH,
|
||||
flipV: !!options.flipV,
|
||||
};
|
||||
|
||||
const worldDx = finite(currentPoint?.x) - finite(startPoint?.x);
|
||||
const worldDy = finite(currentPoint?.y) - finite(startPoint?.y);
|
||||
const cos = Math.cos(geometry.radians);
|
||||
const sin = Math.sin(geometry.radians);
|
||||
const localDx = worldDx * cos + worldDy * sin;
|
||||
const localDy = -worldDx * sin + worldDy * cos;
|
||||
const multiplier = options.centered ? 2 : 1;
|
||||
let rawWidth = geometry.width + xSign * localDx * multiplier;
|
||||
let rawHeight = geometry.height + ySign * localDy * multiplier;
|
||||
|
||||
if (options.lockAspect) {
|
||||
const aspect = geometry.width / geometry.height;
|
||||
const widthChange = xSign ? Math.abs(Math.abs(rawWidth) - geometry.width) / geometry.width : -1;
|
||||
const heightChange = ySign ? Math.abs(Math.abs(rawHeight) - geometry.height) / geometry.height : -1;
|
||||
if (xSign && (!ySign || widthChange >= heightChange)) {
|
||||
const sign = rawHeight < 0 ? -1 : 1;
|
||||
rawHeight = sign * Math.abs(rawWidth) / aspect;
|
||||
} else if (ySign) {
|
||||
const sign = rawWidth < 0 ? -1 : 1;
|
||||
rawWidth = sign * Math.abs(rawHeight) * aspect;
|
||||
}
|
||||
}
|
||||
|
||||
const width = Math.max(1, Math.round(Math.abs(rawWidth)));
|
||||
const height = Math.max(1, Math.round(Math.abs(rawHeight)));
|
||||
const signedWidth = (rawWidth < 0 ? -1 : 1) * width;
|
||||
const signedHeight = (rawHeight < 0 ? -1 : 1) * height;
|
||||
const localCenterX = options.centered || !xSign
|
||||
? 0
|
||||
: xSign * (signedWidth - geometry.width) / 2;
|
||||
const localCenterY = options.centered || !ySign
|
||||
? 0
|
||||
: ySign * (signedHeight - geometry.height) / 2;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
centerX: geometry.centerX + localCenterX * cos - localCenterY * sin,
|
||||
centerY: geometry.centerY + localCenterX * sin + localCenterY * cos,
|
||||
flipH: !!options.flipH !== (rawWidth < 0),
|
||||
flipV: !!options.flipV !== (rawHeight < 0),
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,11 @@
|
||||
* @returns {{ handleImportedImage: (img: HTMLImageElement) => void }}
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import {
|
||||
createPlacedData,
|
||||
initialPlacedMatrix,
|
||||
renderPlacedLayer,
|
||||
} from './placed-layer.js';
|
||||
|
||||
export function wireImport({ container, saveState, createLayer, composite, renderLayerPanel, uiModule }) {
|
||||
// Hidden <input type="file"> the topbar + File buttons both click.
|
||||
@@ -35,7 +40,7 @@ export function wireImport({ container, saveState, createLayer, composite, rende
|
||||
importFileInput.style.display = 'none';
|
||||
container.appendChild(importFileInput);
|
||||
|
||||
function handleImportedImage(img) {
|
||||
function handleImportedImage(img, sourceName = 'Imported image') {
|
||||
if (!state.editorOpen) return;
|
||||
saveState('Import image');
|
||||
// Scale down if larger than canvas.
|
||||
@@ -46,11 +51,22 @@ export function wireImport({ container, saveState, createLayer, composite, rende
|
||||
w = Math.round(w * scale);
|
||||
h = Math.round(h * scale);
|
||||
}
|
||||
const layer = createLayer('Imported', state.imgWidth, state.imgHeight);
|
||||
const source = document.createElement('canvas');
|
||||
source.width = img.naturalWidth || img.width;
|
||||
source.height = img.naturalHeight || img.height;
|
||||
source.getContext('2d').drawImage(img, 0, 0);
|
||||
const layer = createLayer('Imported', w, h);
|
||||
// Centre on the canvas.
|
||||
const ox = Math.round((state.imgWidth - w) / 2);
|
||||
const oy = Math.round((state.imgHeight - h) / 2);
|
||||
layer.ctx.drawImage(img, ox, oy, w, h);
|
||||
layer.kind = 'placed';
|
||||
layer.placed = createPlacedData(
|
||||
source,
|
||||
initialPlacedMatrix(source.width, source.height, { x: ox, y: oy, width: w, height: h }),
|
||||
sourceName,
|
||||
);
|
||||
const rendered = renderPlacedLayer(layer);
|
||||
state.layerOffsets.set(layer.id, rendered.offset);
|
||||
state.layers.push(layer);
|
||||
state.activeLayerId = layer.id;
|
||||
// Switch to move tool so the imported layer is immediately
|
||||
@@ -72,7 +88,7 @@ export function wireImport({ container, saveState, createLayer, composite, rende
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const img = new Image();
|
||||
img.onload = () => handleImportedImage(img);
|
||||
img.onload = () => handleImportedImage(img, file.name || 'Imported image');
|
||||
img.src = ev.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
@@ -128,7 +144,7 @@ export function wireImport({ container, saveState, createLayer, composite, rende
|
||||
overlay.remove();
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => handleImportedImage(img);
|
||||
img.onload = () => handleImportedImage(img, item.name || item.filename || 'Gallery image');
|
||||
img.onerror = () => { if (uiModule) uiModule.showToast('Failed to load gallery image'); };
|
||||
img.src = item.url;
|
||||
});
|
||||
|
||||
@@ -26,18 +26,20 @@
|
||||
* @param {{
|
||||
* composite: () => void,
|
||||
* applyInpaintFeather: (layer: object, featherPx: number, edgeShiftPx: number) => void,
|
||||
* autoMatchInpaint: () => void,
|
||||
* syncToolClearIndicators: () => void,
|
||||
* attachColorPicker: (el: HTMLInputElement) => void,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { invertMaskInPlace } from './mask-utils.js';
|
||||
|
||||
const EYE_OPEN_SM = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||||
const EYE_OFF_SM = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>';
|
||||
|
||||
export function wireInpaintControls({
|
||||
composite, applyInpaintFeather, syncToolClearIndicators,
|
||||
composite, applyInpaintFeather, autoMatchInpaint, syncToolClearIndicators,
|
||||
attachColorPicker, uiModule,
|
||||
}) {
|
||||
// ── Feather + Strength preview swatches ──
|
||||
@@ -93,6 +95,9 @@ export function wireInpaintControls({
|
||||
document.getElementById('ge-strength-label').textContent = (e.target.value / 100).toFixed(2);
|
||||
syncStrengthPreview(parseInt(e.target.value, 10));
|
||||
});
|
||||
document.getElementById('ge-inpaint-automatch')?.addEventListener('click', () => {
|
||||
if (typeof autoMatchInpaint === 'function') autoMatchInpaint();
|
||||
});
|
||||
syncFeatherPreview(0);
|
||||
syncStrengthPreview(75);
|
||||
|
||||
@@ -108,17 +113,7 @@ export function wireInpaintControls({
|
||||
});
|
||||
document.getElementById('ge-inpaint-invert')?.addEventListener('click', () => {
|
||||
if (!state.maskCtx || !state.maskCanvas) return;
|
||||
const imgData = state.maskCtx.getImageData(0, 0, state.maskCanvas.width, state.maskCanvas.height);
|
||||
const d = imgData.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const alpha = d[i + 3];
|
||||
if (alpha > 0) {
|
||||
d[i] = 0; d[i+1] = 0; d[i+2] = 0; d[i+3] = 0;
|
||||
} else {
|
||||
d[i] = 255; d[i+1] = 255; d[i+2] = 255; d[i+3] = 255;
|
||||
}
|
||||
}
|
||||
state.maskCtx.putImageData(imgData, 0, 0);
|
||||
invertMaskInPlace(state.maskCanvas);
|
||||
composite();
|
||||
syncToolClearIndicators();
|
||||
uiModule.showToast('Mask inverted');
|
||||
|
||||
@@ -15,45 +15,75 @@
|
||||
* createLayer: (name, w, h) => object,
|
||||
* renderLayerPanel: () => void,
|
||||
* composite: () => void,
|
||||
* renderLayer?: (layer) => HTMLCanvasElement,
|
||||
* flatten?: () => HTMLCanvasElement,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { rasterizeTextLayer } from './text-layer.js';
|
||||
import { rasterizeShapeLayer } from './shape-layer.js';
|
||||
import { drawLayerStack, normalizeLayerClipping } from './layer-clipping.js';
|
||||
import { groupForLayer, normalizeLayerGroups } from './layer-groups.js';
|
||||
|
||||
export function mergeLayerDownAtIndex(idx) {
|
||||
function _renderSource(layer, renderLayer) {
|
||||
if (!layer) return null;
|
||||
try {
|
||||
return typeof renderLayer === 'function' ? (renderLayer(layer) || layer.canvas) : layer.canvas;
|
||||
} catch {
|
||||
return layer.canvas;
|
||||
}
|
||||
}
|
||||
|
||||
function _markBakedRaster(layer) {
|
||||
if (!layer) return;
|
||||
rasterizeTextLayer(layer);
|
||||
rasterizeShapeLayer(layer);
|
||||
layer.adjLayers = [];
|
||||
layer._adjFinal = null;
|
||||
layer._adjFinalKey = '';
|
||||
layer._adjCache = null;
|
||||
layer._adjCacheKey = '';
|
||||
layer.masks = [];
|
||||
layer.activeMaskId = null;
|
||||
layer.clipped = false;
|
||||
}
|
||||
|
||||
export function mergeLayerDownAtIndex(idx, renderLayer = null) {
|
||||
if (idx < 1 || idx >= state.layers.length) return null;
|
||||
const upper = state.layers[idx];
|
||||
const lower = state.layers[idx - 1];
|
||||
const upperOff = state.layerOffsets.get(upper.id) || { x: 0, y: 0 };
|
||||
const lowerOff = state.layerOffsets.get(lower.id) || { x: 0, y: 0 };
|
||||
lower.ctx.save();
|
||||
lower.ctx.globalAlpha = upper.opacity;
|
||||
lower.ctx.drawImage(
|
||||
upper.canvas,
|
||||
upperOff.x - lowerOff.x,
|
||||
upperOff.y - lowerOff.y,
|
||||
);
|
||||
lower.ctx.restore();
|
||||
if (lower.clipped || groupForLayer(state, upper.id)?.id !== groupForLayer(state, lower.id)?.id) return null;
|
||||
const merged = document.createElement('canvas');
|
||||
merged.width = state.imgWidth;
|
||||
merged.height = state.imgHeight;
|
||||
const mctx = merged.getContext('2d');
|
||||
drawLayerStack(mctx, state, [lower, upper], layer => _renderSource(layer, renderLayer));
|
||||
lower.canvas = merged;
|
||||
lower.ctx = lower.canvas.getContext('2d');
|
||||
lower.opacity = 1;
|
||||
lower.visible = true;
|
||||
lower.blendMode = 'source-over';
|
||||
state.layerOffsets.set(lower.id, { x: 0, y: 0 });
|
||||
_markBakedRaster(lower);
|
||||
state.layers.splice(idx, 1);
|
||||
state.layerOffsets.delete(upper.id);
|
||||
for (const group of state.layerGroups || []) group.layerIds = group.layerIds.filter(id => id !== upper.id);
|
||||
normalizeLayerGroups(state);
|
||||
normalizeLayerClipping(state);
|
||||
state.activeLayerId = lower.id;
|
||||
return lower;
|
||||
}
|
||||
|
||||
export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, composite, uiModule }) {
|
||||
export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, composite, renderLayer, flatten, uiModule }) {
|
||||
// Flatten Copy.
|
||||
document.getElementById('ge-flatten')?.addEventListener('click', () => {
|
||||
if (state.layers.length < 2) return;
|
||||
saveState('Flatten copy');
|
||||
const merged = createLayer('Flattened', state.imgWidth, state.imgHeight);
|
||||
const ctx = merged.ctx;
|
||||
for (const l of state.layers) {
|
||||
if (!l.visible) continue;
|
||||
const off = state.layerOffsets.get(l.id) || { x: 0, y: 0 };
|
||||
ctx.globalAlpha = l.opacity;
|
||||
ctx.drawImage(l.canvas, off.x, off.y);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
if (typeof flatten === 'function') merged.ctx.drawImage(flatten(), 0, 0);
|
||||
else drawLayerStack(merged.ctx, state, state.layers.filter(layer => layer.visible), layer => _renderSource(layer, renderLayer));
|
||||
_markBakedRaster(merged);
|
||||
state.layers.push(merged);
|
||||
state.activeLayerId = merged.id;
|
||||
renderLayerPanel();
|
||||
@@ -70,20 +100,26 @@ export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, com
|
||||
}
|
||||
saveState('Merge all');
|
||||
const base = visibleLayers[0];
|
||||
const baseCtx = base.ctx;
|
||||
for (let i = 1; i < visibleLayers.length; i++) {
|
||||
const l = visibleLayers[i];
|
||||
const off = state.layerOffsets.get(l.id) || { x: 0, y: 0 };
|
||||
baseCtx.globalAlpha = l.opacity;
|
||||
baseCtx.drawImage(l.canvas, off.x, off.y);
|
||||
baseCtx.globalAlpha = 1;
|
||||
const merged = typeof flatten === 'function' ? flatten() : document.createElement('canvas');
|
||||
if (typeof flatten !== 'function') {
|
||||
merged.width = state.imgWidth;
|
||||
merged.height = state.imgHeight;
|
||||
drawLayerStack(merged.getContext('2d'), state, visibleLayers, layer => _renderSource(layer, renderLayer));
|
||||
}
|
||||
base.canvas = merged;
|
||||
base.ctx = base.canvas.getContext('2d');
|
||||
base.opacity = 1;
|
||||
base.visible = true;
|
||||
base.blendMode = 'source-over';
|
||||
state.layerOffsets.set(base.id, { x: 0, y: 0 });
|
||||
_markBakedRaster(base);
|
||||
// Free offset entries for the discarded layers; keep base.
|
||||
for (const l of state.layers) {
|
||||
if (l === base) continue;
|
||||
state.layerOffsets.delete(l.id);
|
||||
}
|
||||
state.layers = [base];
|
||||
state.layerGroups = [];
|
||||
state.activeLayerId = base.id;
|
||||
renderLayerPanel();
|
||||
composite();
|
||||
@@ -94,8 +130,15 @@ export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, com
|
||||
document.getElementById('ge-merge-down')?.addEventListener('click', () => {
|
||||
const idx = state.layers.findIndex(l => l.id === state.activeLayerId);
|
||||
if (idx < 1) return; // can't merge the bottom layer
|
||||
const upper = state.layers[idx];
|
||||
const lower = state.layers[idx - 1];
|
||||
if (lower.clipped || groupForLayer(state, upper.id)?.id !== groupForLayer(state, lower.id)?.id) {
|
||||
uiModule?.showToast?.('Release the lower clipping mask or keep both layers in the same group before merging');
|
||||
return;
|
||||
}
|
||||
saveState('Merge down');
|
||||
mergeLayerDownAtIndex(idx);
|
||||
const merged = mergeLayerDownAtIndex(idx, renderLayer);
|
||||
if (!merged) return;
|
||||
renderLayerPanel();
|
||||
composite();
|
||||
uiModule.showToast('Layer merged down');
|
||||
|
||||
@@ -51,7 +51,66 @@ export function wireSelectionControls({
|
||||
wandClear, wandDeleteSelection, wandCopyToNewLayer, wandToMask,
|
||||
buildSelectionHintMask, applyImageTool,
|
||||
uiModule,
|
||||
toggleQuickMask,
|
||||
}) {
|
||||
document.querySelectorAll('.ge-quick-mask-toggle').forEach(button => {
|
||||
button.addEventListener('click', toggleQuickMask);
|
||||
});
|
||||
// ── Rectangle / ellipse marquee ──
|
||||
document.querySelectorAll('.ge-marquee-shape-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
state.marqueeShape = btn.dataset.marqueeShape === 'ellipse' ? 'ellipse' : 'rectangle';
|
||||
document.querySelectorAll('.ge-marquee-shape-btn').forEach(candidate => {
|
||||
const active = candidate.dataset.marqueeShape === state.marqueeShape;
|
||||
candidate.classList.toggle('active', active);
|
||||
candidate.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
});
|
||||
});
|
||||
});
|
||||
const constraint = document.getElementById('ge-marquee-constraint');
|
||||
const dimensions = document.querySelector('.ge-marquee-dimensions');
|
||||
const widthInput = document.getElementById('ge-marquee-width');
|
||||
const heightInput = document.getElementById('ge-marquee-height');
|
||||
const syncMarqueeConstraint = () => {
|
||||
state.marqueeConstraint = ['ratio', 'size'].includes(constraint?.value) ? constraint.value : 'free';
|
||||
if (dimensions) dimensions.hidden = state.marqueeConstraint === 'free';
|
||||
if (widthInput) widthInput.step = state.marqueeConstraint === 'size' ? '1' : '0.01';
|
||||
if (heightInput) heightInput.step = state.marqueeConstraint === 'size' ? '1' : '0.01';
|
||||
if (widthInput) widthInput.value = String(state.marqueeConstraint === 'size' ? state.marqueeFixedWidth : state.marqueeRatioWidth);
|
||||
if (heightInput) heightInput.value = String(state.marqueeConstraint === 'size' ? state.marqueeFixedHeight : state.marqueeRatioHeight);
|
||||
};
|
||||
const commitMarqueeDimensions = () => {
|
||||
const w = Math.max(1, Number(widthInput?.value) || 1);
|
||||
const h = Math.max(1, Number(heightInput?.value) || 1);
|
||||
if (state.marqueeConstraint === 'size') {
|
||||
state.marqueeFixedWidth = Math.round(w);
|
||||
state.marqueeFixedHeight = Math.round(h);
|
||||
} else {
|
||||
state.marqueeRatioWidth = w;
|
||||
state.marqueeRatioHeight = h;
|
||||
}
|
||||
if (widthInput) widthInput.value = String(state.marqueeConstraint === 'size' ? state.marqueeFixedWidth : state.marqueeRatioWidth);
|
||||
if (heightInput) heightInput.value = String(state.marqueeConstraint === 'size' ? state.marqueeFixedHeight : state.marqueeRatioHeight);
|
||||
};
|
||||
constraint?.addEventListener('change', syncMarqueeConstraint);
|
||||
widthInput?.addEventListener('change', commitMarqueeDimensions);
|
||||
heightInput?.addEventListener('change', commitMarqueeDimensions);
|
||||
document.getElementById('ge-marquee-swap')?.addEventListener('click', () => {
|
||||
const oldWidth = widthInput?.value;
|
||||
if (widthInput && heightInput) {
|
||||
widthInput.value = heightInput.value;
|
||||
heightInput.value = oldWidth;
|
||||
commitMarqueeDimensions();
|
||||
}
|
||||
});
|
||||
if (constraint) constraint.value = state.marqueeConstraint || 'free';
|
||||
syncMarqueeConstraint();
|
||||
document.getElementById('ge-marquee-clear')?.addEventListener('click', wandClear);
|
||||
document.getElementById('ge-marquee-invert')?.addEventListener('click', invertSelection);
|
||||
document.getElementById('ge-marquee-delete')?.addEventListener('click', wandDeleteSelection);
|
||||
document.getElementById('ge-marquee-copy')?.addEventListener('click', wandCopyToNewLayer);
|
||||
document.getElementById('ge-marquee-mask')?.addEventListener('click', wandToMask);
|
||||
|
||||
// ── Lasso section ──
|
||||
const lassoFPrev = document.getElementById('ge-lasso-feather-preview');
|
||||
function syncLassoFeather(v) {
|
||||
@@ -137,16 +196,19 @@ export function wireSelectionControls({
|
||||
});
|
||||
|
||||
// Toggle the translucent red overlay for the wand selection.
|
||||
document.getElementById('ge-wand-vis')?.addEventListener('click', () => {
|
||||
const toggleSelectionOverlay = () => {
|
||||
state.wandMaskVisible = !state.wandMaskVisible;
|
||||
const btn = document.getElementById('ge-wand-vis');
|
||||
if (btn) {
|
||||
['ge-wand-vis', 'ge-marquee-vis', 'ge-sam-vis'].forEach(id => {
|
||||
const btn = document.getElementById(id);
|
||||
if (!btn) return;
|
||||
btn.innerHTML = state.wandMaskVisible ? EYE_OPEN : EYE_OFF;
|
||||
btn.title = state.wandMaskVisible ? 'Hide selection overlay' : 'Show selection overlay';
|
||||
btn.classList.toggle('visible', state.wandMaskVisible);
|
||||
}
|
||||
});
|
||||
composite();
|
||||
});
|
||||
};
|
||||
document.getElementById('ge-wand-vis')?.addEventListener('click', toggleSelectionOverlay);
|
||||
document.getElementById('ge-marquee-vis')?.addEventListener('click', toggleSelectionOverlay);
|
||||
|
||||
document.getElementById('ge-wand-clear')?.addEventListener('click', wandClear);
|
||||
document.getElementById('ge-wand-invert')?.addEventListener('click', invertSelection);
|
||||
|
||||
@@ -21,71 +21,99 @@
|
||||
* saveState: (label?: string) => void,
|
||||
* composite: () => void,
|
||||
* fitZoom: () => void,
|
||||
* renderLayerPanel: () => void,
|
||||
* promptCanvasSize: (opts: object) => Promise<{w, h} | null>,
|
||||
* doFillSelection: () => void,
|
||||
* rotateAllLayers: (deg: number) => void,
|
||||
* flipAllLayers: (axis: 'h' | 'v') => void,
|
||||
* applyGaussianBlur: () => void,
|
||||
* applyZoomBlur: () => void,
|
||||
* addRetainedGaussianBlur: () => void,
|
||||
* addRetainedEffect: (type: string) => void,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*
|
||||
* @returns {{
|
||||
* applyResize: (newW: number, newH: number) => void,
|
||||
* resizeCustomPrompt: () => Promise<void>,
|
||||
* applyCanvasResize: (newW: number, newH: number) => void,
|
||||
* applyImageResize: (newW: number, newH: number) => void,
|
||||
* canvasSizePrompt: () => Promise<void>,
|
||||
* imageSizePrompt: () => Promise<void>,
|
||||
* }}
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { resizeCanvasDocument, resizeImageDocument } from './document-geometry.js';
|
||||
|
||||
export function wireTopbarMenus({
|
||||
closeOtherTopbarMenus, registerDocClickAway,
|
||||
saveState, composite, fitZoom,
|
||||
saveState, composite, fitZoom, renderLayerPanel,
|
||||
promptCanvasSize, doFillSelection,
|
||||
rotateAllLayers, flipAllLayers,
|
||||
applyGaussianBlur, applyZoomBlur,
|
||||
applyGaussianBlur, applyZoomBlur, addRetainedGaussianBlur, addRetainedEffect,
|
||||
selectAll, deselectSelection, reselectSelection, invertSelection,
|
||||
transformSelection, refineSelection,
|
||||
saveNamedSelection, loadNamedSelection, deleteNamedSelection,
|
||||
uiModule,
|
||||
}) {
|
||||
// ── Resize canvas ──
|
||||
// Extracted so both the popup presets and the Ctrl+Shift+T shortcut
|
||||
// can call it.
|
||||
function applyResize(newW, newH) {
|
||||
function finishGeometryChange(newW, newH, message) {
|
||||
const sizeLabel = document.getElementById('ge-canvas-size');
|
||||
if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`;
|
||||
fitZoom();
|
||||
composite();
|
||||
renderLayerPanel?.();
|
||||
uiModule.showToast(message);
|
||||
}
|
||||
|
||||
// Canvas Size changes document bounds without resampling layer pixels.
|
||||
function applyCanvasResize(newW, newH, options = {}) {
|
||||
if (!newW || !newH || newW < 1 || newH < 1) {
|
||||
uiModule.showToast('Invalid size');
|
||||
return;
|
||||
}
|
||||
saveState('Resize canvas');
|
||||
// Only resize the main canvas — layers keep their original size.
|
||||
// Content outside the new bounds is clipped during composite, not
|
||||
// destroyed.
|
||||
if (state.maskCanvas) {
|
||||
const tmpMask = document.createElement('canvas');
|
||||
tmpMask.width = state.maskCanvas.width;
|
||||
tmpMask.height = state.maskCanvas.height;
|
||||
tmpMask.getContext('2d').drawImage(state.maskCanvas, 0, 0);
|
||||
state.maskCanvas.width = newW;
|
||||
state.maskCanvas.height = newH;
|
||||
state.maskCtx.drawImage(tmpMask, 0, 0);
|
||||
}
|
||||
state.imgWidth = newW;
|
||||
state.imgHeight = newH;
|
||||
state.mainCanvas.width = newW;
|
||||
state.mainCanvas.height = newH;
|
||||
const sizeLabel = document.getElementById('ge-canvas-size');
|
||||
if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`;
|
||||
fitZoom();
|
||||
composite();
|
||||
uiModule.showToast(`Canvas resized to ${newW}×${newH}`);
|
||||
const result = resizeCanvasDocument(state, newW, newH, options);
|
||||
finishGeometryChange(result.width, result.height, `Canvas resized to ${result.width}×${result.height}`);
|
||||
}
|
||||
|
||||
async function resizeCustomPrompt() {
|
||||
function applyImageResize(newW, newH, options = {}) {
|
||||
if (!newW || !newH || newW < 1 || newH < 1) {
|
||||
uiModule.showToast('Invalid size');
|
||||
return;
|
||||
}
|
||||
saveState('Resize image');
|
||||
const result = resizeImageDocument(state, newW, newH, {
|
||||
smoothingQuality: options.interpolation || 'high',
|
||||
});
|
||||
finishGeometryChange(result.width, result.height, `Image resized to ${result.width}×${result.height}`);
|
||||
}
|
||||
|
||||
async function canvasSizePrompt() {
|
||||
const result = await promptCanvasSize({
|
||||
title: 'Canvas size',
|
||||
okLabel: 'Apply',
|
||||
initialW: state.imgWidth,
|
||||
initialH: state.imgHeight,
|
||||
showUnits: true,
|
||||
showInterpolation: true,
|
||||
keepProportions: true,
|
||||
showAnchor: true,
|
||||
});
|
||||
if (!result) return;
|
||||
applyResize(result.w, result.h);
|
||||
applyCanvasResize(result.w, result.h, result);
|
||||
}
|
||||
|
||||
async function imageSizePrompt() {
|
||||
const result = await promptCanvasSize({
|
||||
title: 'Image size',
|
||||
okLabel: 'Resample',
|
||||
initialW: state.imgWidth,
|
||||
initialH: state.imgHeight,
|
||||
showUnits: true,
|
||||
showInterpolation: true,
|
||||
keepProportions: true,
|
||||
showPresets: false,
|
||||
});
|
||||
if (!result) return;
|
||||
applyImageResize(result.w, result.h, result);
|
||||
}
|
||||
|
||||
// ── Image menu ──
|
||||
@@ -104,7 +132,8 @@ export function wireTopbarMenus({
|
||||
if (!item || item.disabled) return;
|
||||
menu.hidden = true;
|
||||
const action = item.dataset.imageAction;
|
||||
if (action === 'resize') resizeCustomPrompt();
|
||||
if (action === 'canvas-size') canvasSizePrompt();
|
||||
else if (action === 'image-size') imageSizePrompt();
|
||||
else if (action === 'selection') document.getElementById('ge-edge-menu-btn')?.click();
|
||||
else if (action === 'fill') doFillSelection();
|
||||
else if (action === 'rotate-90') rotateAllLayers(90);
|
||||
@@ -118,6 +147,96 @@ export function wireTopbarMenus({
|
||||
}
|
||||
}
|
||||
|
||||
// ── Select menu ──
|
||||
const renderSelectionMenu = () => {
|
||||
const hasSelection = !!state.wandMask || (state.lassoPoints?.length >= 3 && !state.lassoActive);
|
||||
const menu = document.getElementById('ge-selection-menu');
|
||||
if (!menu) return;
|
||||
menu.querySelector('[data-selection-action="deselect"]')?.toggleAttribute('disabled', !hasSelection);
|
||||
menu.querySelector('[data-selection-action="invert"]')?.toggleAttribute('disabled', !hasSelection);
|
||||
menu.querySelector('[data-selection-action="transform"]')?.toggleAttribute('disabled', !hasSelection);
|
||||
menu.querySelector('[data-selection-action="refine"]')?.toggleAttribute('disabled', !hasSelection);
|
||||
menu.querySelector('[data-selection-action="save"]')?.toggleAttribute('disabled', !hasSelection);
|
||||
menu.querySelector('[data-selection-action="reselect"]')?.toggleAttribute('disabled', !state.lastSelection?.canvas);
|
||||
const list = document.getElementById('ge-saved-selection-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
for (const saved of state.savedSelections || []) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ge-saved-selection-row';
|
||||
const load = document.createElement('button');
|
||||
load.type = 'button';
|
||||
load.className = 'ge-saved-selection-load';
|
||||
load.dataset.selectionAction = 'load';
|
||||
load.dataset.selectionId = saved.id;
|
||||
load.textContent = saved.name;
|
||||
load.title = `Load ${saved.name}`;
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'ge-saved-selection-delete';
|
||||
remove.dataset.selectionAction = 'delete';
|
||||
remove.dataset.selectionId = saved.id;
|
||||
remove.textContent = '×';
|
||||
remove.title = `Delete ${saved.name}`;
|
||||
remove.setAttribute('aria-label', `Delete ${saved.name}`);
|
||||
row.append(load, remove);
|
||||
list.appendChild(row);
|
||||
}
|
||||
if (!list.children.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'ge-saved-selection-empty';
|
||||
empty.textContent = 'None saved';
|
||||
list.appendChild(empty);
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
const btn = document.getElementById('ge-selection-menu-btn');
|
||||
const menu = document.getElementById('ge-selection-menu');
|
||||
const nameInput = document.getElementById('ge-selection-name');
|
||||
if (btn && menu) {
|
||||
btn.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
const willOpen = menu.hidden;
|
||||
if (willOpen) {
|
||||
closeOtherTopbarMenus('ge-selection-menu');
|
||||
renderSelectionMenu();
|
||||
}
|
||||
menu.hidden = !menu.hidden;
|
||||
});
|
||||
menu.addEventListener('click', event => {
|
||||
const item = event.target.closest('[data-selection-action]');
|
||||
if (!item || item.disabled) return;
|
||||
const action = item.dataset.selectionAction;
|
||||
let handled = false;
|
||||
if (action === 'all') handled = selectAll?.();
|
||||
else if (action === 'deselect') handled = deselectSelection?.();
|
||||
else if (action === 'reselect') handled = reselectSelection?.();
|
||||
else if (action === 'invert') handled = invertSelection?.();
|
||||
else if (action === 'transform') handled = transformSelection?.();
|
||||
else if (action === 'refine') handled = refineSelection?.();
|
||||
else if (action === 'save') {
|
||||
handled = saveNamedSelection?.(nameInput?.value);
|
||||
if (handled && nameInput) nameInput.value = '';
|
||||
} else if (action === 'load') handled = loadNamedSelection?.(item.dataset.selectionId);
|
||||
else if (action === 'delete') handled = deleteNamedSelection?.(item.dataset.selectionId);
|
||||
if (handled) renderSelectionMenu();
|
||||
if (handled && !['save', 'delete'].includes(action)) menu.hidden = true;
|
||||
});
|
||||
nameInput?.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
if (saveNamedSelection?.(nameInput.value)) {
|
||||
nameInput.value = '';
|
||||
renderSelectionMenu();
|
||||
}
|
||||
});
|
||||
registerDocClickAway(event => {
|
||||
if (!menu.hidden && !menu.contains(event.target) && event.target !== btn) menu.hidden = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filter menu (Blur sub-menu — Gaussian / Zoom) ──
|
||||
{
|
||||
const btn = document.getElementById('ge-filter-menu-btn');
|
||||
@@ -134,7 +253,16 @@ export function wireTopbarMenus({
|
||||
if (!item) return;
|
||||
menu.hidden = true;
|
||||
const action = item.dataset.filterAction;
|
||||
if (action === 'blur-gaussian') applyGaussianBlur();
|
||||
if (action === 'effect-blur-gaussian') addRetainedGaussianBlur?.();
|
||||
else if (action === 'effect-sharpen') addRetainedEffect?.('sharpen');
|
||||
else if (action === 'effect-color-overlay') addRetainedEffect?.('color-overlay');
|
||||
else if (action === 'effect-drop-shadow') addRetainedEffect?.('drop-shadow');
|
||||
else if (action === 'effect-stroke') addRetainedEffect?.('stroke');
|
||||
else if (action === 'effect-preset-soft-blur') addRetainedGaussianBlur?.('soft-blur');
|
||||
else if (action === 'effect-preset-crisp-detail') addRetainedEffect?.('sharpen', 'crisp-detail');
|
||||
else if (action === 'effect-preset-soft-shadow') addRetainedEffect?.('drop-shadow', 'soft-shadow');
|
||||
else if (action === 'effect-preset-white-outline') addRetainedEffect?.('stroke', 'white-outline');
|
||||
else if (action === 'blur-gaussian') applyGaussianBlur();
|
||||
else if (action === 'blur-zoom') applyZoomBlur();
|
||||
});
|
||||
registerDocClickAway((e) => {
|
||||
@@ -157,12 +285,12 @@ export function wireTopbarMenus({
|
||||
menu.querySelectorAll('[data-resize-w]').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
menu.hidden = true;
|
||||
applyResize(parseInt(item.dataset.resizeW, 10), parseInt(item.dataset.resizeH, 10));
|
||||
applyCanvasResize(parseInt(item.dataset.resizeW, 10), parseInt(item.dataset.resizeH, 10));
|
||||
});
|
||||
});
|
||||
menu.querySelector('[data-resize-custom]')?.addEventListener('click', () => {
|
||||
menu.hidden = true;
|
||||
resizeCustomPrompt();
|
||||
canvasSizePrompt();
|
||||
});
|
||||
registerDocClickAway((e) => {
|
||||
if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) menu.hidden = true;
|
||||
@@ -170,5 +298,13 @@ export function wireTopbarMenus({
|
||||
}
|
||||
}
|
||||
|
||||
return { applyResize, resizeCustomPrompt };
|
||||
return {
|
||||
applyCanvasResize,
|
||||
applyImageResize,
|
||||
canvasSizePrompt,
|
||||
imageSizePrompt,
|
||||
renderSelectionMenu,
|
||||
// Compatibility alias consumed by the existing Ctrl+Shift+T shortcut.
|
||||
resizeCustomPrompt: canvasSizePrompt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,13 +30,27 @@ export function wireTopbarOverflow({ container }) {
|
||||
container.querySelector('#ge-ai-model'),
|
||||
...container.querySelectorAll('.ge-topbar span[style*="font-size:9px"]'),
|
||||
].filter(Boolean);
|
||||
let lastWidth = 0;
|
||||
|
||||
function syncOverflow() {
|
||||
if (!topbar) return;
|
||||
// The reflow changes the topbar height but not its width. Ignore that
|
||||
// follow-up ResizeObserver notification so the class does not oscillate.
|
||||
const width = topbar.clientWidth;
|
||||
if (width === lastWidth && topbar.classList.contains('ge-topbar-overflow')) return;
|
||||
lastWidth = width;
|
||||
topbar.classList.remove('ge-topbar-overflow');
|
||||
aiGroup.forEach(el => { el.style.display = ''; });
|
||||
if (topbar.scrollWidth > topbar.clientWidth) {
|
||||
// Hide AI group first — bulky and least essential at narrow widths.
|
||||
aiGroup.forEach(el => { el.style.display = 'none'; });
|
||||
// If the essential controls still do not fit, make the right side a
|
||||
// second row instead of letting Save and its menu fall outside the
|
||||
// editor window.
|
||||
const isMobile = window.matchMedia?.('(max-width: 700px)').matches;
|
||||
if (!isMobile && topbar.scrollWidth > topbar.clientWidth) {
|
||||
topbar.classList.add('ge-topbar-overflow');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* coordination (close-others + global outside-click).
|
||||
*
|
||||
* #ge-undo / #ge-redo / #ge-history-btn
|
||||
* #ge-compare-btn
|
||||
* #ge-save-menu-btn + #ge-save-menu (Save / Save as / Download /
|
||||
* Save project / Load project)
|
||||
* #ge-zoom-out / #ge-zoom-in / #ge-zoom-fit / #ge-zoom-100
|
||||
@@ -20,6 +21,7 @@
|
||||
* undo: () => void,
|
||||
* redo: () => void,
|
||||
* toggleHistoryPanel: () => void,
|
||||
* toggleCompare: () => void,
|
||||
* fitZoom: () => void,
|
||||
* applyZoom: () => void,
|
||||
* exportToGallery: () => void,
|
||||
@@ -35,9 +37,10 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { isLayerPixelLocked, isLayerTransparencyLocked } from './layer-groups.js';
|
||||
|
||||
const TOPBAR_MENU_IDS = ['ge-image-menu', 'ge-filter-menu', 'ge-resize-menu', 'ge-save-menu'];
|
||||
const TOPBAR_TRIGGER_IDS = ['ge-image-menu-btn', 'ge-filter-menu-btn', 'ge-resize-menu-btn', 'ge-save-menu-btn'];
|
||||
const TOPBAR_MENU_IDS = ['ge-view-menu', 'ge-image-menu', 'ge-selection-menu', 'ge-filter-menu', 'ge-resize-menu', 'ge-save-menu'];
|
||||
const TOPBAR_TRIGGER_IDS = ['ge-view-menu-btn', 'ge-image-menu-btn', 'ge-selection-menu-btn', 'ge-filter-menu-btn', 'ge-resize-menu-btn', 'ge-save-menu-btn'];
|
||||
|
||||
/**
|
||||
* Close every topbar dropdown except an optional "keep open" one.
|
||||
@@ -54,7 +57,7 @@ export function closeOtherTopbarMenus(keepId) {
|
||||
|
||||
export function wireTopbar(deps) {
|
||||
const {
|
||||
undo, redo, toggleHistoryPanel,
|
||||
undo, redo, toggleHistoryPanel, toggleCompare,
|
||||
fitZoom, applyZoom,
|
||||
exportToGallery, downloadPNG, saveProject, loadProjectPrompt,
|
||||
activeLayer, saveState, applyEdgeFeather, composite,
|
||||
@@ -65,16 +68,22 @@ export function wireTopbar(deps) {
|
||||
document.getElementById('ge-undo')?.addEventListener('click', undo);
|
||||
document.getElementById('ge-redo')?.addEventListener('click', redo);
|
||||
document.getElementById('ge-history-btn')?.addEventListener('click', toggleHistoryPanel);
|
||||
document.getElementById('ge-compare-btn')?.addEventListener('click', toggleCompare);
|
||||
|
||||
// Save dropdown — "Save ▾" toggles a small menu (Save / Save-as /
|
||||
// Download / Save project / Load project). Inner items keep their
|
||||
// original IDs so the standalone handlers below wire to them
|
||||
// unchanged.
|
||||
{
|
||||
const saveBtn = document.getElementById('ge-save-menu-btn');
|
||||
const saveMenu = document.getElementById('ge-save-menu');
|
||||
const editorRoot = document.getElementById('gallery-editor-container') || document;
|
||||
const saveBtn = editorRoot.querySelector('#ge-save-menu-btn');
|
||||
const saveWrap = saveBtn?.closest('.ge-save-wrap');
|
||||
const saveMenu = saveWrap?.querySelector('#ge-save-menu');
|
||||
if (saveBtn && saveMenu) {
|
||||
const saveTopbar = saveBtn.closest('.ge-topbar');
|
||||
document.querySelectorAll('body > #ge-save-menu').forEach((menu) => {
|
||||
if (menu !== saveMenu) menu.remove();
|
||||
});
|
||||
// Reparent the menu to <body>. Without this, the menu inherits
|
||||
// the gallery modal's containing block (the modal applies a
|
||||
// `transform: scale(...)` for its enter animation — and any
|
||||
@@ -105,7 +114,7 @@ export function wireTopbar(deps) {
|
||||
saveMenu.addEventListener('click', () => { setSaveMenuOpen(false); });
|
||||
window.addEventListener('resize', () => { if (!saveMenu.hidden) positionSaveMenu(); });
|
||||
registerDocClickAway((e) => {
|
||||
if (!saveMenu.hidden && !saveMenu.contains(e.target) && e.target !== saveBtn) {
|
||||
if (!saveMenu.hidden && !saveMenu.contains(e.target) && !saveBtn.contains(e.target)) {
|
||||
setSaveMenuOpen(false);
|
||||
}
|
||||
});
|
||||
@@ -143,7 +152,10 @@ export function wireTopbar(deps) {
|
||||
// Edge popup — Width input + Feather / Delete action buttons.
|
||||
function applyEdgeAction(hardDelete) {
|
||||
const layer = activeLayer();
|
||||
if (!layer || layer.locked) { uiModule.showToast('Select an unlocked layer'); return; }
|
||||
if (!layer || isLayerPixelLocked(state, layer) || isLayerTransparencyLocked(state, layer)) {
|
||||
uiModule.showToast('Unlock image and transparent pixels before changing edges');
|
||||
return;
|
||||
}
|
||||
const widthInput = document.getElementById('ge-edge-width');
|
||||
const width = parseInt(widthInput?.value || '8');
|
||||
if (isNaN(width) || width < 1) { uiModule.showToast('Invalid width'); return; }
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/** View menu wiring for rulers, guides, grid, and snapping preferences. */
|
||||
import { state } from './state.js';
|
||||
|
||||
export function wireViewMenu({ closeOtherTopbarMenus, registerDocClickAway, precisionGuides, composite, schedulePersist }) {
|
||||
const button = document.getElementById('ge-view-menu-btn');
|
||||
const menu = document.getElementById('ge-view-menu');
|
||||
if (!button || !menu) return;
|
||||
|
||||
const sync = () => {
|
||||
const values = { rulers: state.rulersVisible, grid: state.gridVisible, snap: state.snapEnabled, 'snap-grid': state.snapToGrid };
|
||||
for (const [action, enabled] of Object.entries(values)) {
|
||||
const item = menu.querySelector(`[data-view-action="${action}"]`);
|
||||
item?.classList.toggle('active', !!enabled);
|
||||
item?.setAttribute('aria-checked', enabled ? 'true' : 'false');
|
||||
}
|
||||
const size = menu.querySelector('#ge-grid-size');
|
||||
if (size && document.activeElement !== size) size.value = String(state.gridSize || 16);
|
||||
const clear = menu.querySelector('[data-view-action="clear-guides"]');
|
||||
if (clear) clear.disabled = (state.guides?.vertical?.length || 0) + (state.guides?.horizontal?.length || 0) === 0;
|
||||
};
|
||||
|
||||
button.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
const opening = menu.hidden;
|
||||
if (opening) closeOtherTopbarMenus('ge-view-menu');
|
||||
menu.hidden = !opening;
|
||||
if (opening) sync();
|
||||
});
|
||||
menu.addEventListener('click', event => {
|
||||
const item = event.target.closest('[data-view-action]');
|
||||
if (!item || item.disabled) return;
|
||||
const action = item.dataset.viewAction;
|
||||
if (action === 'rulers') state.rulersVisible = !state.rulersVisible;
|
||||
else if (action === 'grid') state.gridVisible = !state.gridVisible;
|
||||
else if (action === 'snap') state.snapEnabled = !state.snapEnabled;
|
||||
else if (action === 'snap-grid') state.snapToGrid = !state.snapToGrid;
|
||||
else if (action === 'clear-guides') precisionGuides.clearGuides();
|
||||
if (action !== 'clear-guides') {
|
||||
precisionGuides.syncVisibility();
|
||||
schedulePersist?.();
|
||||
}
|
||||
sync();
|
||||
});
|
||||
menu.querySelector('#ge-grid-size')?.addEventListener('input', event => {
|
||||
event.stopPropagation();
|
||||
state.gridSize = Math.max(2, Math.min(1000, Math.round(Number(event.target.value) || 16)));
|
||||
schedulePersist?.();
|
||||
composite();
|
||||
});
|
||||
registerDocClickAway(event => {
|
||||
if (!menu.hidden && !menu.contains(event.target) && !button.contains(event.target)) menu.hidden = true;
|
||||
});
|
||||
sync();
|
||||
}
|
||||
Reference in New Issue
Block a user