Odysseus v1.0
This commit is contained in:
374
static/js/editor/ai-inpaint.js
Normal file
374
static/js/editor/ai-inpaint.js
Normal file
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* AI inpaint subsystem — Generate, Remove, and Outpaint variants
|
||||
* all share a single `runInpaint` core; only the prompt, strength,
|
||||
* and button-target differ. Returns a wireInpaintButtons() function
|
||||
* to attach handlers to the three buttons (#ge-inpaint-run,
|
||||
* #ge-inpaint-remove, #ge-inpaint-outpaint).
|
||||
*
|
||||
* runInpaint:
|
||||
* - Build a union mask from every visible mask sub-layer (across
|
||||
* all parent layers) — the model sees the COMBINED region,
|
||||
* not just the currently-active mask.
|
||||
* - Dilate the mask ~padPx so the model fills a buffer zone the
|
||||
* post-gen Feather/Edge slider can fade into.
|
||||
* - POST flattened canvas + dilated mask to /api/image/inpaint.
|
||||
* - Drop the result as a new layer, snapshot the AI image + hard
|
||||
* mask on the layer for live edge tuning, hide every
|
||||
* contributing mask sub-layer, reveal the post-gen Feather +
|
||||
* Edge Stroke sliders capped at ±padPx.
|
||||
*
|
||||
* Remove: detects OpenAI vs SDXL backend and swaps the prompt
|
||||
* (gpt-image-1 follows "remove …" semantically; SDXL has to be
|
||||
* prompted with a fill description + strength 0.99).
|
||||
*
|
||||
* Outpaint: auto-generates a mask covering empty (transparent)
|
||||
* regions of the flattened composite, dilates it 12px inward
|
||||
* so the model sees adjacent opaque pixels as context, runs
|
||||
* inpaint, then restores the user's previous mask drawing.
|
||||
*
|
||||
* @param {{
|
||||
* buildMergedMaskCanvas: () => HTMLCanvasElement | null,
|
||||
* dilateMask: (src: HTMLCanvasElement, px: number) => HTMLCanvasElement,
|
||||
* applyInpaintFeather: (layer: object, featherPx: number, edgeShiftPx: number) => void,
|
||||
* getSelectedAIEndpoint: (type: string) => { endpoint?: string, model?: string },
|
||||
* ensureActiveMaskLayer: () => object | null,
|
||||
* saveState: (label?: string) => void,
|
||||
* createLayer: (name: string, w: number, h: number) => object,
|
||||
* composite: () => void,
|
||||
* renderLayerPanel: () => void,
|
||||
* spinnerModule: object,
|
||||
* uiModule: object | null,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
export function wireInpaintButtons({
|
||||
buildMergedMaskCanvas, dilateMask, applyInpaintFeather,
|
||||
getSelectedAIEndpoint, ensureActiveMaskLayer,
|
||||
saveState, createLayer, composite, renderLayerPanel,
|
||||
spinnerModule, uiModule,
|
||||
}) {
|
||||
// Shared inpaint runner — used by Generate, Remove, and Outpaint.
|
||||
async function runInpaint({ prompt, strength, btnId, labelId, idleLabel, busyLabel }) {
|
||||
// Pre-check: build the union mask the AI will receive and verify
|
||||
// at least one pixel is painted.
|
||||
const preMerged = buildMergedMaskCanvas();
|
||||
if (!preMerged) { if (uiModule) uiModule.showToast('Draw the area you want to inpaint first'); return; }
|
||||
const pmCtx = preMerged.getContext('2d');
|
||||
const maskData = pmCtx.getImageData(0, 0, preMerged.width, preMerged.height).data;
|
||||
let hasMask = false;
|
||||
for (let i = 3; i < maskData.length; i += 4) { if (maskData[i] > 0) { hasMask = true; break; } }
|
||||
if (!hasMask) { if (uiModule) uiModule.showToast('Draw the area you want to inpaint first'); return; }
|
||||
const btn = document.getElementById(btnId);
|
||||
const btnLabel = labelId ? document.getElementById(labelId) : null;
|
||||
btn.disabled = true;
|
||||
if (btnLabel) btnLabel.textContent = busyLabel;
|
||||
let runWp = null;
|
||||
try {
|
||||
runWp = spinnerModule.createWhirlpool(14);
|
||||
runWp.element.style.cssText = 'margin:0;flex-shrink:0;';
|
||||
btn.appendChild(runWp.element);
|
||||
} catch (_) { /* spinner is optional */ }
|
||||
// Canvas-overlay whirlpool — visual feedback right where the
|
||||
// user's working, since the run button lives in the side panel
|
||||
// and may be out of view at high zoom. Positioned over the
|
||||
// mask's centroid in viewport coords.
|
||||
let canvasWp = null;
|
||||
let canvasWpEl = null;
|
||||
try {
|
||||
const area = state.container && state.container.querySelector('.ge-canvas-area');
|
||||
const mainRect = state.mainCanvas.getBoundingClientRect();
|
||||
if (area && mainRect.width && mainRect.height) {
|
||||
// Find the mask's bbox so we can centre the whirlpool over it.
|
||||
let cx = state.imgWidth / 2, cy = state.imgHeight / 2;
|
||||
try {
|
||||
const merged = buildMergedMaskCanvas();
|
||||
if (merged) {
|
||||
const d = merged.getContext('2d').getImageData(0, 0, merged.width, merged.height).data;
|
||||
let minX = merged.width, maxX = 0, minY = merged.height, maxY = 0;
|
||||
for (let y = 0; y < merged.height; y += 4) {
|
||||
for (let x = 0; x < merged.width; x += 4) {
|
||||
if (d[(y * merged.width + x) * 4 + 3] > 0) {
|
||||
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxX >= minX) { cx = (minX + maxX) / 2; cy = (minY + maxY) / 2; }
|
||||
}
|
||||
} catch {}
|
||||
const scaleX = mainRect.width / state.mainCanvas.width;
|
||||
const scaleY = mainRect.height / state.mainCanvas.height;
|
||||
const vpX = mainRect.left + cx * scaleX;
|
||||
const vpY = mainRect.top + cy * scaleY;
|
||||
canvasWp = spinnerModule.create('', 'clean', 'whirlpool');
|
||||
canvasWpEl = canvasWp.createElement();
|
||||
canvasWpEl.style.cssText = `position:fixed;left:${vpX}px;top:${vpY}px;transform:translate(-50%,-50%);z-index:12;pointer-events:none;`;
|
||||
document.body.appendChild(canvasWpEl);
|
||||
canvasWp.start();
|
||||
}
|
||||
} 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;
|
||||
// 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
|
||||
// instead of fading straight to the original. The ORIGINAL
|
||||
// (un-dilated) mask is cached on the layer — the feather blur
|
||||
// expands outward from that boundary into the dilated AI region.
|
||||
const padPx = Math.min(80, Math.max(20, Math.round(Math.min(state.imgWidth, state.imgHeight) * 0.04)));
|
||||
// Merge every visible mask sub-layer (across all parent
|
||||
// layers) into a single union mask before sending to the AI.
|
||||
// This way, if the user built up the inpaint region across
|
||||
// multiple masks, the final generation sees the combined
|
||||
// region instead of just the currently-active mask.
|
||||
const mergedMask = buildMergedMaskCanvas() || state.maskCanvas;
|
||||
const dilatedMask = dilateMask(mergedMask, padPx);
|
||||
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
|
||||
const maskB64 = dilatedMask.toDataURL('image/png').split(',')[1];
|
||||
const res = await fetch('/api/image/inpaint', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify((() => {
|
||||
const sel = getSelectedAIEndpoint('inpaint');
|
||||
return { image: imageB64, mask: maskB64, prompt, width: state.imgWidth, height: state.imgHeight, strength, feather: 0, _endpoint: sel.endpoint, _model: sel.model };
|
||||
})()),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let errDetail = res.statusText;
|
||||
try { const errBody = await res.json(); errDetail = errBody.detail || errBody.error || errDetail; } catch {}
|
||||
throw new Error(errDetail);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
if (!data.image) throw new Error('No image returned from inpaint endpoint');
|
||||
// Load result as a new layer and clip with the user-drawn mask
|
||||
// so only the inpainted region is visible. Cache the
|
||||
// unfeathered (AI image + hard mask) on the layer so the live
|
||||
// Feather slider can re-derive the alpha on each input event
|
||||
// without re-running the model.
|
||||
const resultImg = new Image();
|
||||
resultImg.onload = () => {
|
||||
if (!state.editorOpen) return; // user closed mid-decode
|
||||
try {
|
||||
saveState('Inpaint result');
|
||||
// OpenAI returns at one of its allowed sizes (1024²,
|
||||
// 1024×1536, 1536×1024) which often differs from our
|
||||
// canvas. Scale to canvas size with smoothing so the
|
||||
// inpaint blends in regardless of source dims.
|
||||
const shortPrompt = (prompt || '').trim().replace(/\s+/g, ' ').slice(0, 40);
|
||||
const layerName = shortPrompt ? `Inpaint: ${shortPrompt}` : 'Inpaint Result';
|
||||
const resultLayer = createLayer(layerName, state.imgWidth, state.imgHeight);
|
||||
resultLayer.ctx.imageSmoothingEnabled = true;
|
||||
resultLayer.ctx.imageSmoothingQuality = 'high';
|
||||
resultLayer.ctx.drawImage(resultImg, 0, 0, state.imgWidth, state.imgHeight);
|
||||
// Snapshot the AI result + hard mask used for this run.
|
||||
const aiSnap = document.createElement('canvas');
|
||||
aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight;
|
||||
aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0);
|
||||
const maskSnap = document.createElement('canvas');
|
||||
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 };
|
||||
// Apply initial alpha = hard mask (no feather, no edge shift).
|
||||
applyInpaintFeather(resultLayer, 0, 0);
|
||||
state.layers.push(resultLayer);
|
||||
state.activeLayerId = resultLayer.id;
|
||||
state.lastInpaintLayerId = resultLayer.id;
|
||||
// Hide every mask sub-layer that contributed to the
|
||||
// generation so the red overlay doesn't cover the result —
|
||||
// but KEEP the mask pixels intact, and reflect "hidden"
|
||||
// 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;
|
||||
}
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
// Reveal post-generation Feather + Edge Stroke sliders.
|
||||
// Cap Edge Stroke at ±padPx so the slider can't ask for
|
||||
// more AI buffer than we generated.
|
||||
const fRow = document.getElementById('ge-inpaint-postfeather-row');
|
||||
const fSlider = document.getElementById('ge-feather-slider');
|
||||
const fLabel = document.getElementById('ge-feather-label');
|
||||
// Divider + heading are always visible; once Generate
|
||||
// succeeds we hide the "Available after Generate" hint.
|
||||
const divEl = document.getElementById('ge-inpaint-postedge-divider');
|
||||
const titleEl = document.getElementById('ge-inpaint-postedge-title');
|
||||
const hintEl = document.getElementById('ge-inpaint-postedge-hint');
|
||||
if (divEl) divEl.style.display = '';
|
||||
if (titleEl) titleEl.style.display = '';
|
||||
if (hintEl) hintEl.style.display = 'none';
|
||||
if (fRow) fRow.style.display = '';
|
||||
if (fSlider) fSlider.value = '0';
|
||||
if (fLabel) fLabel.textContent = '0px';
|
||||
const eRow = document.getElementById('ge-inpaint-edgestroke-row');
|
||||
const eSlider = document.getElementById('ge-edgestroke-slider');
|
||||
const eLabel = document.getElementById('ge-edgestroke-label');
|
||||
if (eRow) eRow.style.display = '';
|
||||
if (eSlider) {
|
||||
eSlider.max = String(padPx);
|
||||
eSlider.min = String(-padPx);
|
||||
eSlider.value = '0';
|
||||
}
|
||||
if (eLabel) eLabel.textContent = '0px';
|
||||
if (uiModule) uiModule.showToast('Inpaint complete — drag Edge feather / Edge stroke to blend', 5000);
|
||||
} catch (renderErr) {
|
||||
console.error('[inpaint] render error', renderErr);
|
||||
if (uiModule) uiModule.showToast('Inpaint render failed: ' + (renderErr.message || renderErr), 6000);
|
||||
}
|
||||
};
|
||||
resultImg.onerror = (e) => {
|
||||
console.error('[inpaint] base64 decode failed', e);
|
||||
if (uiModule) uiModule.showToast('Inpaint result failed to decode', 6000);
|
||||
};
|
||||
resultImg.src = 'data:image/png;base64,' + data.image;
|
||||
} catch (e) {
|
||||
if (uiModule) uiModule.showToast('Inpaint failed: ' + e.message, 6000);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
if (btnLabel) btnLabel.textContent = idleLabel;
|
||||
if (runWp) { try { runWp.destroy(); } catch (_) {} }
|
||||
if (canvasWp) { try { canvasWp.destroy(); } catch (_) {} }
|
||||
if (canvasWpEl) { try { canvasWpEl.remove(); } catch (_) {} }
|
||||
window.dispatchEvent(new CustomEvent('ge:inpaint-done'));
|
||||
}
|
||||
}
|
||||
|
||||
// Generate.
|
||||
document.getElementById('ge-inpaint-run').addEventListener('click', async () => {
|
||||
const prompt = document.getElementById('ge-inpaint-prompt')?.value?.trim();
|
||||
if (!prompt) { if (uiModule) uiModule.showToast('Enter a prompt for inpainting'); return; }
|
||||
const strength = (parseInt(document.getElementById('ge-strength-slider')?.value || '75')) / 100;
|
||||
await runInpaint({
|
||||
prompt, strength,
|
||||
btnId: 'ge-inpaint-run',
|
||||
labelId: 'ge-inpaint-run-label',
|
||||
idleLabel: 'Generate', busyLabel: 'Generating',
|
||||
});
|
||||
});
|
||||
|
||||
// Remove — detects backend type and substitutes a content-aware
|
||||
// fill prompt. gpt-image-1 understands "remove …" semantically;
|
||||
// SDXL inpaint pipelines literally try to draw the prompt, so we
|
||||
// send a generic surroundings-matching prompt and crank strength.
|
||||
document.getElementById('ge-inpaint-remove').addEventListener('click', async () => {
|
||||
const sel = getSelectedAIEndpoint('inpaint');
|
||||
const ep = (sel.endpoint || '').toLowerCase();
|
||||
const isOpenAI = ep.includes('api.openai.com');
|
||||
let prompt, strength;
|
||||
if (isOpenAI) {
|
||||
const userP = document.getElementById('ge-inpaint-prompt')?.value?.trim();
|
||||
prompt = userP
|
||||
? `Remove ${userP}. Fill seamlessly with the surrounding background, photorealistic, no objects, no people.`
|
||||
: 'Remove the masked area. Fill seamlessly with the surrounding background, photorealistic, no objects, no people.';
|
||||
strength = (parseInt(document.getElementById('ge-strength-slider')?.value || '75')) / 100;
|
||||
} else {
|
||||
// SDXL inpaint: describe the surroundings, not what's there.
|
||||
// Crank strength to ensure the model fully overwrites the
|
||||
// masked region — at low strength it would denoise toward
|
||||
// what was there.
|
||||
prompt = 'seamless natural background, photorealistic, continuation of surrounding scene, empty area, no objects, no people, no text, clean';
|
||||
strength = 0.99;
|
||||
}
|
||||
await runInpaint({
|
||||
prompt, strength,
|
||||
btnId: 'ge-inpaint-remove',
|
||||
labelId: 'ge-inpaint-remove-label',
|
||||
idleLabel: 'Remove', busyLabel: 'Removing',
|
||||
});
|
||||
});
|
||||
|
||||
// Outpaint — auto-generate a mask covering empty (transparent)
|
||||
// areas of the flattened composite, then run inpaint to fill them
|
||||
// seamlessly. Mask is dilated ~12px so the AI sees adjacent
|
||||
// opaque pixels as context. Ignores the user's drawn mask.
|
||||
document.getElementById('ge-inpaint-outpaint').addEventListener('click', async () => {
|
||||
// 1) Flatten visible layers to detect alpha=0 (empty) regions.
|
||||
const flat = document.createElement('canvas');
|
||||
flat.width = state.imgWidth; flat.height = state.imgHeight;
|
||||
const fctx = flat.getContext('2d');
|
||||
for (const layer of state.layers) {
|
||||
if (!layer.visible) continue;
|
||||
fctx.globalAlpha = layer.opacity;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
fctx.drawImage(layer.canvas, off.x, off.y);
|
||||
}
|
||||
fctx.globalAlpha = 1;
|
||||
const flatData = fctx.getImageData(0, 0, state.imgWidth, state.imgHeight).data;
|
||||
// 2) White wherever the composite is transparent.
|
||||
const maskRaw = document.createElement('canvas');
|
||||
maskRaw.width = state.imgWidth; maskRaw.height = state.imgHeight;
|
||||
const mrCtx = maskRaw.getContext('2d');
|
||||
const mrImg = mrCtx.createImageData(state.imgWidth, state.imgHeight);
|
||||
let emptyCount = 0;
|
||||
for (let i = 0; i < flatData.length; i += 4) {
|
||||
if (flatData[i + 3] === 0) {
|
||||
mrImg.data[i] = 255;
|
||||
mrImg.data[i + 1] = 255;
|
||||
mrImg.data[i + 2] = 255;
|
||||
mrImg.data[i + 3] = 255;
|
||||
emptyCount++;
|
||||
}
|
||||
}
|
||||
if (emptyCount === 0) {
|
||||
if (uiModule) uiModule.showToast('No empty areas to outpaint — canvas is fully covered.');
|
||||
return;
|
||||
}
|
||||
mrCtx.putImageData(mrImg, 0, 0);
|
||||
// 3) Dilate the mask outward 12px so it overlaps a band of
|
||||
// opaque pixels — context for the model to blend cleanly.
|
||||
const expanded = document.createElement('canvas');
|
||||
expanded.width = state.imgWidth; expanded.height = state.imgHeight;
|
||||
const ectx = expanded.getContext('2d');
|
||||
ectx.filter = 'blur(12px)';
|
||||
ectx.drawImage(maskRaw, 0, 0);
|
||||
ectx.filter = 'none';
|
||||
const expData = ectx.getImageData(0, 0, state.imgWidth, state.imgHeight);
|
||||
for (let i = 0; i < expData.data.length; i += 4) {
|
||||
const a = expData.data[i + 3];
|
||||
const v = a > 6 ? 255 : 0;
|
||||
expData.data[i] = v;
|
||||
expData.data[i + 1] = v;
|
||||
expData.data[i + 2] = v;
|
||||
expData.data[i + 3] = v;
|
||||
}
|
||||
ectx.putImageData(expData, 0, 0);
|
||||
// 4) Temporarily replace the active mask sub-layer with the
|
||||
// outpaint mask. Snapshot the previous so we can restore.
|
||||
const mask = ensureActiveMaskLayer();
|
||||
if (!mask) { if (uiModule) uiModule.showToast('No active layer for outpaint'); return; }
|
||||
const savedMask = mask.ctx.getImageData(0, 0, mask.canvas.width, mask.canvas.height);
|
||||
mask.ctx.clearRect(0, 0, mask.canvas.width, mask.canvas.height);
|
||||
mask.ctx.drawImage(expanded, 0, 0);
|
||||
// 5) Prompt: prefer user input, else a generic fill.
|
||||
const userP = document.getElementById('ge-inpaint-prompt')?.value?.trim();
|
||||
const prompt = userP || 'seamless natural continuation of the surrounding image, photorealistic, matching style, no objects, no people, no text';
|
||||
const strength = 0.99;
|
||||
try {
|
||||
await runInpaint({
|
||||
prompt, strength,
|
||||
btnId: 'ge-inpaint-outpaint',
|
||||
labelId: 'ge-inpaint-outpaint-label',
|
||||
idleLabel: 'Outpaint', busyLabel: 'Outpainting',
|
||||
});
|
||||
} finally {
|
||||
// Restore the user's previous mask drawing so subsequent
|
||||
// Generate/Remove operates on what they actually drew.
|
||||
mask.ctx.clearRect(0, 0, mask.canvas.width, mask.canvas.height);
|
||||
mask.ctx.putImageData(savedMask, 0, 0);
|
||||
composite();
|
||||
}
|
||||
});
|
||||
}
|
||||
272
static/js/editor/ai-models.js
Normal file
272
static/js/editor/ai-models.js
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* AI model dropdown loader — fetches available model endpoints from
|
||||
* the backend and populates the editor's three model-select surfaces:
|
||||
*
|
||||
* #ge-ai-model — global Gen picker
|
||||
* #ge-ai-inpaint — inpaint picker
|
||||
* select.ge-tool-model[data-ge-tool-model="…"]
|
||||
* — per-tool pickers (harmonize / upscale / style /
|
||||
* sharpen / etc.)
|
||||
*
|
||||
* Each model is filtered through a small capability classifier so the
|
||||
* Gen dropdown only sees text-to-image models, the inpaint dropdown
|
||||
* only sees image+mask edit models, and the per-tool dropdowns get
|
||||
* everything img2img-capable.
|
||||
*
|
||||
* Every picker ends with a "+ Serve a model in Cookbook…" sentinel —
|
||||
* choosing it opens Cookbook → Serve filtered to image models, then
|
||||
* reverts the picker to its prior value (so it's an action, not a
|
||||
* selectable model).
|
||||
*
|
||||
* @param {{
|
||||
* container: HTMLElement,
|
||||
* apiBase: string,
|
||||
* openCookbookForImg2img: () => void,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
// Heuristic classifier on a model id + endpoint name. A model can be:
|
||||
// - gen: text-to-image generation
|
||||
// - inpaint: image+mask edit (inpaint / img2img)
|
||||
// Some models do only one (e.g. dall-e-3 = gen-only, no edits API).
|
||||
function modelCaps(modelId, endpointName, endpointType) {
|
||||
const id = (modelId || '').toLowerCase();
|
||||
const name = (endpointName || '').toLowerCase();
|
||||
const type = (endpointType || '').toLowerCase();
|
||||
// Reject anything obviously text-only.
|
||||
const textOnly = /(?:^|[/\-_:])(gpt-?[345]|gpt-oss|claude|llama|qwen[^-]*chat|chat$|instruct$|coder)/i;
|
||||
if (textOnly.test(id) && !/image/i.test(id)) return { gen: false, inpaint: false };
|
||||
// OpenAI image family.
|
||||
if (/dall-e-3/.test(id)) return { gen: true, inpaint: false };
|
||||
if (/dall-e-2/.test(id)) return { gen: true, inpaint: true };
|
||||
if (/gpt-image/.test(id)) return { gen: true, inpaint: true };
|
||||
// Diffusion families — most generic SD/SDXL/Flux base models
|
||||
// support both via diffusers.
|
||||
if (/(?:^|[/\-_])(?:sd-?xl|sdxl|sd3|sd-|stable[\s-]*diffusion|flux|playground|pixart|kandinsky)/i.test(id)) {
|
||||
const isInpaintModel = /inpaint|edit|fill/i.test(id) || /inpaint|edit|fill/i.test(name);
|
||||
return { gen: !isInpaintModel || /base/i.test(id), inpaint: true };
|
||||
}
|
||||
// Self-hosted diffusion server: model id often matches the repo
|
||||
// name; trust the endpoint name hint.
|
||||
if (type === 'image') {
|
||||
if (/inpaint|edit|fill/i.test(name)) return { gen: false, inpaint: true };
|
||||
return { gen: true, inpaint: true };
|
||||
}
|
||||
if (/inpaint|edit|fill/i.test(name)) return { gen: false, inpaint: true };
|
||||
if (/diffus|flux|sd|image/i.test(name)) return { gen: true, inpaint: true };
|
||||
// Editor image tools should be conservative. Unknown LLM/chat models
|
||||
// do not belong in image generation or inpaint pickers.
|
||||
return { gen: false, inpaint: false };
|
||||
}
|
||||
|
||||
export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2img }) {
|
||||
// Delegated handler for the "+ Serve a model in Cookbook…" sentinel
|
||||
// option — catches clicks regardless of whether loadAIModels has
|
||||
// rewired the individual select yet and survives any innerHTML
|
||||
// reset later.
|
||||
container.addEventListener('change', (e) => {
|
||||
const sel = e.target.closest('select');
|
||||
if (!sel) return;
|
||||
if (sel.value !== '__serve_cookbook__') return;
|
||||
// Revert to the previous selection so the sentinel isn't "stuck".
|
||||
const prev = sel._prevServeValue ?? '';
|
||||
sel.value = prev;
|
||||
openCookbookForImg2img();
|
||||
});
|
||||
// Track prior value so we can restore it after the sentinel fires.
|
||||
container.addEventListener('focus', (e) => {
|
||||
const sel = e.target.closest('select');
|
||||
if (sel && sel.value !== '__serve_cookbook__') sel._prevServeValue = sel.value;
|
||||
}, true);
|
||||
|
||||
const aiGenSelect = document.getElementById('ge-ai-model');
|
||||
const aiInpaintSelect = document.getElementById('ge-ai-inpaint');
|
||||
// The global Gen model dropdown was removed from the editor topbar;
|
||||
// only bail if there's nothing to populate at all (neither the Gen
|
||||
// select nor the inpaint select nor any per-tool select).
|
||||
if (!aiGenSelect && !aiInpaintSelect &&
|
||||
!document.querySelector('select.ge-tool-model')) return;
|
||||
|
||||
async function loadAIModels(opts = {}) {
|
||||
try {
|
||||
const selectBaseUrl = opts.selectBaseUrl || '';
|
||||
const prevGenValue = aiGenSelect?.value || '';
|
||||
const prevInpaintValue = aiInpaintSelect?.value || '';
|
||||
const res = await fetch(`${apiBase}/api/model-endpoints`);
|
||||
const endpoints = await res.json();
|
||||
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
|
||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
|
||||
const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model'));
|
||||
for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>';
|
||||
let firstGen = null;
|
||||
let firstInpaint = null;
|
||||
let selectedGen = null;
|
||||
let selectedInpaint = null;
|
||||
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 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
|
||||
// Cookbook model as "(offline)" in the editor picker.
|
||||
const epUsable = !!ep.online || isImageEndpoint;
|
||||
for (const modelId of models) {
|
||||
const caps = modelCaps(modelId || ep.name, ep.name, ep.model_type);
|
||||
if (!caps.gen && !caps.inpaint) continue;
|
||||
// Encode "<base_url>::<model_id>" so the value carries both pieces.
|
||||
const value = `${ep.base_url}::${modelId}`;
|
||||
const shortModel = modelId ? String(modelId).split('/').pop() : (ep.name || ep.base_url);
|
||||
const epHint = modelId && ep.name && ep.name !== modelId ? ` · ${ep.name}` : '';
|
||||
const label = `${shortModel}${epHint}${epUsable ? '' : ' (offline)'}`;
|
||||
if (caps.gen && aiGenSelect) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.textContent = label;
|
||||
opt.disabled = !epUsable;
|
||||
aiGenSelect.appendChild(opt);
|
||||
if (epUsable && !firstGen) firstGen = value;
|
||||
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedGen) selectedGen = value;
|
||||
}
|
||||
if (caps.inpaint && aiInpaintSelect) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.textContent = label;
|
||||
opt.disabled = !epUsable;
|
||||
aiInpaintSelect.appendChild(opt);
|
||||
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedInpaint) selectedInpaint = value;
|
||||
// Prefer dedicated inpaint/edit models for default selection.
|
||||
if (epUsable && !firstInpaint && (!modelId || /inpaint|edit|fill|gpt-image/i.test(modelId) || /inpaint|edit|fill/i.test(ep.name || ''))) {
|
||||
firstInpaint = value;
|
||||
}
|
||||
}
|
||||
// Per-tool selectors get every img2img-capable entry. Both
|
||||
// caps.inpaint AND caps.gen models work for harmonize /
|
||||
// style / upscale (anything that can do img2img).
|
||||
if (caps.inpaint || caps.gen) {
|
||||
for (const ts of perToolSelects) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.textContent = label;
|
||||
opt.disabled = !epUsable;
|
||||
ts.appendChild(opt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const hasValue = (sel, value) => !!value && [...sel.options].some(o => o.value === value);
|
||||
if (aiGenSelect) {
|
||||
if (selectedGen) aiGenSelect.value = selectedGen;
|
||||
else if (hasValue(aiGenSelect, prevGenValue)) aiGenSelect.value = prevGenValue;
|
||||
else if (firstGen) aiGenSelect.value = firstGen;
|
||||
}
|
||||
if (aiInpaintSelect) {
|
||||
if (selectedInpaint) aiInpaintSelect.value = selectedInpaint;
|
||||
else if (hasValue(aiInpaintSelect, prevInpaintValue)) aiInpaintSelect.value = prevInpaintValue;
|
||||
else if (firstInpaint) aiInpaintSelect.value = firstInpaint;
|
||||
}
|
||||
// Append the "Serve a model in Cookbook…" sentinel at the
|
||||
// bottom of every model dropdown.
|
||||
const appendServeSentinel = (sel) => {
|
||||
const sep = document.createElement('option');
|
||||
sep.disabled = true;
|
||||
sep.textContent = '──────────';
|
||||
sel.appendChild(sep);
|
||||
const serveOpt = document.createElement('option');
|
||||
serveOpt.value = '__serve_cookbook__';
|
||||
serveOpt.textContent = '+ Serve a model in Cookbook…';
|
||||
sel.appendChild(serveOpt);
|
||||
};
|
||||
for (const ts of perToolSelects) appendServeSentinel(ts);
|
||||
if (aiGenSelect) appendServeSentinel(aiGenSelect);
|
||||
if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect);
|
||||
// Wire the sentinel on the Gen + Inpaint selects too.
|
||||
const wireServeSentinel = (sel) => {
|
||||
if (!sel) return;
|
||||
let prev = sel.value;
|
||||
sel.addEventListener('change', () => {
|
||||
if (sel.value === '__serve_cookbook__') {
|
||||
sel.value = prev;
|
||||
openCookbookForImg2img();
|
||||
return;
|
||||
}
|
||||
prev = sel.value;
|
||||
});
|
||||
};
|
||||
wireServeSentinel(aiGenSelect);
|
||||
wireServeSentinel(aiInpaintSelect);
|
||||
// Restore each per-tool selection from localStorage.
|
||||
for (const ts of perToolSelects) {
|
||||
const key = 'ge-tool-model-' + ts.dataset.geToolModel;
|
||||
try {
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved && [...ts.options].some(o => o.value === saved)) {
|
||||
ts.value = saved;
|
||||
}
|
||||
} catch {}
|
||||
let prevValue = ts.value;
|
||||
ts.addEventListener('change', () => {
|
||||
if (ts.value === '__serve_cookbook__') {
|
||||
ts.value = prevValue;
|
||||
openCookbookForImg2img();
|
||||
return;
|
||||
}
|
||||
prevValue = ts.value;
|
||||
try { localStorage.setItem(key, ts.value); } catch {}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Fetch failed — still give the user the affordance to set up
|
||||
// a model. Otherwise the dropdown shows only "Auto" with no
|
||||
// hint about what to do next.
|
||||
const fallback = '<option value="">Auto</option><option value="" disabled>──────────</option><option value="__serve_cookbook__">+ Serve a model in Cookbook…</option>';
|
||||
if (aiGenSelect) aiGenSelect.innerHTML = fallback;
|
||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback;
|
||||
document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; });
|
||||
const wireServe = (sel) => {
|
||||
if (!sel) return;
|
||||
let prev = sel.value;
|
||||
sel.addEventListener('change', () => {
|
||||
if (sel.value === '__serve_cookbook__') {
|
||||
sel.value = prev;
|
||||
openCookbookForImg2img();
|
||||
return;
|
||||
}
|
||||
prev = sel.value;
|
||||
});
|
||||
};
|
||||
wireServe(aiGenSelect);
|
||||
wireServe(aiInpaintSelect);
|
||||
document.querySelectorAll('select.ge-tool-model').forEach(wireServe);
|
||||
}
|
||||
}
|
||||
loadAIModels();
|
||||
const onModelEndpointsUpdated = (e) => {
|
||||
if (!container.isConnected) {
|
||||
window.removeEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
|
||||
return;
|
||||
}
|
||||
loadAIModels({ selectBaseUrl: e.detail?.baseUrl || '' });
|
||||
};
|
||||
window.addEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
|
||||
// Re-fetch the model list when the user opens the inpaint dropdown,
|
||||
// so a model served via Cookbook mid-edit shows up without having to
|
||||
// close and reopen the editor. Debounced to one refresh per 3s so
|
||||
// rapid open/close doesn't hammer the endpoint. Preserves the
|
||||
// current selection across the reload.
|
||||
let _lastModelRefresh = 0;
|
||||
const refreshOnOpen = (e) => {
|
||||
const sel = e.target.closest('#ge-ai-inpaint, select.ge-tool-model');
|
||||
if (!sel) return;
|
||||
const now = Date.now();
|
||||
if (now - _lastModelRefresh < 3000) return;
|
||||
_lastModelRefresh = now;
|
||||
const keep = sel.value;
|
||||
loadAIModels().then(() => {
|
||||
// Restore the prior selection if it still exists.
|
||||
if ([...sel.options].some(o => o.value === keep)) sel.value = keep;
|
||||
});
|
||||
};
|
||||
container.addEventListener('mousedown', refreshOnOpen, true);
|
||||
}
|
||||
230
static/js/editor/ai-rembg.js
Normal file
230
static/js/editor/ai-rembg.js
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Background Remove (rembg) + Sharpen wiring + the live edge-cleanup
|
||||
* tuner that runs on the most-recent rembg cutout.
|
||||
*
|
||||
* rembg-run button: flatten + POST to /api/image/remove-bg with an
|
||||
* optional hint_mask if the user has a wand/lasso selection
|
||||
* active. After the new layer lands, hides every previously-
|
||||
* visible layer so the cutout reads cleanly, and binds the
|
||||
* live-tuner to the new layer.
|
||||
*
|
||||
* Live edge-cleanup tuner: snapshots the pristine cutout the
|
||||
* moment it lands; subsequent feather/grow slider tweaks
|
||||
* rebuild the layer's alpha from that snapshot WITHOUT
|
||||
* re-running the model.
|
||||
* - Grow > 0 → blur snap alpha, threshold low (32) → grow.
|
||||
* - Grow < 0 → blur snap alpha, threshold high (200) → shrink.
|
||||
* - Feather > 0 → blur the whole layer (alpha + RGB) so the
|
||||
* edge softens AND the residual color fringe from the
|
||||
* original background gets blurred away.
|
||||
*
|
||||
* Sharpen: small slider + button; just calls _applyImageTool
|
||||
* against /api/image/sharpen.
|
||||
*
|
||||
* buildSelectionHintMask: pure-ish utility — returns a base64 PNG
|
||||
* (no data: prefix) of the active wand or lasso selection, or
|
||||
* null. Returned so other wand-rembg call sites can use it.
|
||||
*
|
||||
* @param {{
|
||||
* applyImageTool: (endpoint, payload, layerName, btn, opts?) => Promise<void>,
|
||||
* openCookbookForDependency: (pkg: string) => void,
|
||||
* composite: () => void,
|
||||
* renderLayerPanel: () => void,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*
|
||||
* @returns {{ buildSelectionHintMask: () => string | null }}
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
export function wireRembgAndSharpen({
|
||||
applyImageTool, openCookbookForDependency,
|
||||
composite, renderLayerPanel, uiModule,
|
||||
}) {
|
||||
// ── Sharpen ──
|
||||
const sharpenPrev = document.getElementById('ge-sharpen-preview');
|
||||
if (sharpenPrev) sharpenPrev.style.opacity = '0.5';
|
||||
document.getElementById('ge-sharpen-amount')?.addEventListener('input', (e) => {
|
||||
document.getElementById('ge-sharpen-label').textContent = e.target.value + '%';
|
||||
if (sharpenPrev) sharpenPrev.style.opacity = (parseInt(e.target.value, 10) / 100).toFixed(2);
|
||||
});
|
||||
document.getElementById('ge-sharpen-run')?.addEventListener('click', () => {
|
||||
const amount = parseInt(document.getElementById('ge-sharpen-amount')?.value || '50');
|
||||
applyImageTool('/api/image/sharpen', { amount }, 'Sharpened', document.getElementById('ge-sharpen-run'));
|
||||
});
|
||||
|
||||
// ── Bg Remove ──
|
||||
document.getElementById('ge-rembg-install-link')?.addEventListener('click', () => {
|
||||
openCookbookForDependency('rembg');
|
||||
});
|
||||
document.getElementById('ge-rembg-run')?.addEventListener('click', async () => {
|
||||
const payload = {};
|
||||
const hint = buildSelectionHintMask();
|
||||
if (hint) payload.hint_mask = hint;
|
||||
// NB: edge_feather / edge_grow are applied CLIENT-side so the
|
||||
// sliders can re-tune the cutout without re-running the model.
|
||||
const btn = document.getElementById('ge-rembg-run');
|
||||
const before = state.layers.length;
|
||||
// Snapshot which layers were visible BEFORE the run so we know
|
||||
// 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.
|
||||
let frames = 0;
|
||||
while (state.layers.length <= before && frames < 60) {
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
frames++;
|
||||
}
|
||||
if (state.layers.length > before) {
|
||||
const newLayer = state.layers[state.layers.length - 1];
|
||||
bindRembgLiveTuner(newLayer);
|
||||
// Auto-hide underlying layers so the user sees just the
|
||||
// cutout — the eye toggles back on if they re-enable manually.
|
||||
for (const layer of state.layers) {
|
||||
if (prevVisible.includes(layer.id) && layer.id !== newLayer.id) {
|
||||
layer.visible = false;
|
||||
}
|
||||
}
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
}
|
||||
// Reset sliders so the new cutout starts clean.
|
||||
const f = document.getElementById('ge-rembg-feather');
|
||||
const g = document.getElementById('ge-rembg-grow');
|
||||
if (f) { f.value = 0; document.getElementById('ge-rembg-feather-label').textContent = '0px'; syncRembgFeather(0); }
|
||||
if (g) { g.value = 0; document.getElementById('ge-rembg-grow-label').textContent = '0px'; syncRembgGrow(0); }
|
||||
});
|
||||
|
||||
// ── Live edge-cleanup tuner ──
|
||||
// Snapshots the pristine cutout the moment it lands; slider tweaks
|
||||
// rebuild alpha from that snapshot.
|
||||
function bindRembgLiveTuner(layer) {
|
||||
if (!layer) return;
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
const snap = document.createElement('canvas');
|
||||
snap.width = w; snap.height = h;
|
||||
snap.getContext('2d').drawImage(layer.canvas, 0, 0);
|
||||
state.rembgLiveLayer = layer;
|
||||
state.rembgLiveSnap = snap;
|
||||
rembgApplyEdgeNow(); // initial pass (no-op at 0/0)
|
||||
}
|
||||
let rembgRaf = null;
|
||||
function scheduleRembgApply() {
|
||||
if (rembgRaf) return;
|
||||
rembgRaf = requestAnimationFrame(() => { rembgRaf = null; rembgApplyEdgeNow(); });
|
||||
}
|
||||
function rembgApplyEdgeNow() {
|
||||
if (!state.rembgLiveLayer || !state.rembgLiveSnap) return;
|
||||
const feather = parseInt(document.getElementById('ge-rembg-feather')?.value || '0', 10);
|
||||
const grow = parseInt(document.getElementById('ge-rembg-grow')?.value || '0', 10);
|
||||
const layer = state.rembgLiveLayer;
|
||||
const snap = state.rembgLiveSnap;
|
||||
const w = snap.width, h = snap.height;
|
||||
const lctx = layer.ctx;
|
||||
|
||||
// 1) Start fresh from the pristine cutout snapshot.
|
||||
lctx.clearRect(0, 0, w, h);
|
||||
lctx.drawImage(snap, 0, 0);
|
||||
|
||||
// 2) Edge ±N — dilate / erode alpha via blur+threshold:
|
||||
// grow > 0 → low threshold (32) → halo counts as opaque → grows.
|
||||
// grow < 0 → high threshold (200) → only solid interior → shrinks.
|
||||
// RGB is kept; only alpha is replaced.
|
||||
if (grow !== 0) {
|
||||
const blurC = document.createElement('canvas');
|
||||
blurC.width = w; blurC.height = h;
|
||||
const bctx = blurC.getContext('2d');
|
||||
bctx.filter = `blur(${Math.abs(grow)}px)`;
|
||||
bctx.drawImage(snap, 0, 0);
|
||||
bctx.filter = 'none';
|
||||
const blurred = bctx.getImageData(0, 0, w, h).data;
|
||||
const layerData = lctx.getImageData(0, 0, w, h);
|
||||
const out = layerData.data;
|
||||
const thr = grow > 0 ? 32 : 200;
|
||||
for (let i = 0; i < out.length; i += 4) {
|
||||
out[i + 3] = blurred[i + 3] >= thr ? 255 : 0;
|
||||
}
|
||||
lctx.putImageData(layerData, 0, 0);
|
||||
}
|
||||
|
||||
// 3) Feather softens whatever edge we have now. Blur the entire
|
||||
// layer (alpha + RGB) — alpha gets smooth falloff, RGB gets a
|
||||
// faint blur at the edge which actually helps hide residual
|
||||
// colour fringing from the original background.
|
||||
if (feather > 0) {
|
||||
const fC = document.createElement('canvas');
|
||||
fC.width = w; fC.height = h;
|
||||
const fctx = fC.getContext('2d');
|
||||
fctx.filter = `blur(${feather}px)`;
|
||||
fctx.drawImage(layer.canvas, 0, 0);
|
||||
fctx.filter = 'none';
|
||||
lctx.clearRect(0, 0, w, h);
|
||||
lctx.drawImage(fC, 0, 0);
|
||||
}
|
||||
composite();
|
||||
}
|
||||
|
||||
// ── Slider preview swatches + wiring ──
|
||||
const rembgFeatherPrev = document.getElementById('ge-rembg-feather-preview');
|
||||
const rembgGrowPrev = document.getElementById('ge-rembg-grow-preview');
|
||||
function syncRembgFeather(v) {
|
||||
if (!rembgFeatherPrev) return;
|
||||
const inner = Math.max(0, 50 - v * 2.5);
|
||||
rembgFeatherPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${inner}%, transparent 75%)`;
|
||||
}
|
||||
function syncRembgGrow(v) {
|
||||
if (!rembgGrowPrev) return;
|
||||
// -10..+10 → scale 0.6 .. 1.4 so the swatch visibly grows/shrinks.
|
||||
const s = 1 + v * 0.04;
|
||||
rembgGrowPrev.style.transform = `scale(${s})`;
|
||||
rembgGrowPrev.style.background = v < 0 ? 'color-mix(in srgb, var(--fg) 40%, transparent)' : 'var(--fg)';
|
||||
}
|
||||
syncRembgFeather(2);
|
||||
syncRembgGrow(0);
|
||||
document.getElementById('ge-rembg-feather')?.addEventListener('input', (e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
document.getElementById('ge-rembg-feather-label').textContent = v + 'px';
|
||||
syncRembgFeather(v);
|
||||
scheduleRembgApply();
|
||||
});
|
||||
document.getElementById('ge-rembg-grow')?.addEventListener('input', (e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
document.getElementById('ge-rembg-grow-label').textContent = (v >= 0 ? '+' : '') + v + 'px';
|
||||
syncRembgGrow(v);
|
||||
scheduleRembgApply();
|
||||
});
|
||||
|
||||
// ── Selection-hint mask builder (used here + by wand-rembg) ──
|
||||
// Full-image white-on-transparent mask PNG (base64, no `data:`
|
||||
// prefix) of whichever selection is active — wand first, lasso
|
||||
// second. Returns null if neither has a selection.
|
||||
function buildSelectionHintMask() {
|
||||
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);
|
||||
return c.toDataURL('image/png').split(',')[1];
|
||||
}
|
||||
if (state.lassoPoints.length >= 3 && !state.lassoActive) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(state.lassoPoints[0].x, state.lassoPoints[0].y);
|
||||
for (let i = 1; i < state.lassoPoints.length; i++) {
|
||||
ctx.lineTo(state.lassoPoints[i].x, state.lassoPoints[i].y);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
return c.toDataURL('image/png').split(',')[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return { buildSelectionHintMask };
|
||||
}
|
||||
147
static/js/editor/ai-tool-runner.js
Normal file
147
static/js/editor/ai-tool-runner.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Shared AI-tool runner. Used by Sharpen / Harmonize / Upscale / Style /
|
||||
* Bg-Remove / etc. — every tool that flattens the document, POSTs the
|
||||
* PNG to a server-side image endpoint, and drops the result back in
|
||||
* as a new layer.
|
||||
*
|
||||
* Handles all the orchestration around the request:
|
||||
*
|
||||
* - Button busy state: swap label for "<verbing>…" + whirlpool
|
||||
* spinner, lock width so the button doesn't visually jump.
|
||||
* - Endpoint+model selection from the tool's own picker (or the
|
||||
* global fallback) so the backend knows which model to invoke.
|
||||
* - Response handling: decode the returned PNG, push it as a new
|
||||
* layer, save state, composite, refresh the layer panel.
|
||||
* - Error reporting: surface failures via toast. Detects "needs
|
||||
* img2img server" and "package not installed" failure modes and
|
||||
* surfaces an action-toast that opens Cookbook to fix.
|
||||
*
|
||||
* @param {{
|
||||
* flatten: () => HTMLCanvasElement,
|
||||
* saveState: (label?: string) => void,
|
||||
* createLayer: (name: string, w: number, h: number) => object,
|
||||
* composite: () => void,
|
||||
* renderLayerPanel: () => void,
|
||||
* deriveBusyLabel: (layerName: string) => string,
|
||||
* getSelectedAIEndpoint: (type: string | null) => { endpoint?: string, model?: string },
|
||||
* openCookbookForDependency: (pkg: string) => void,
|
||||
* openCookbookForImg2img: () => void,
|
||||
* spinnerModule: object,
|
||||
* uiModule: object | null,
|
||||
* }} deps
|
||||
*
|
||||
* @returns {(endpoint: string, extraPayload: object, layerName: string, btn: HTMLButtonElement, opts?: { busyLabel?: string }) => Promise<void>}
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
const KNOWN_DEPS = ['realesrgan', 'rembg'];
|
||||
|
||||
export function createApplyImageTool({
|
||||
flatten, saveState, createLayer, composite, renderLayerPanel,
|
||||
deriveBusyLabel, getSelectedAIEndpoint,
|
||||
openCookbookForDependency, openCookbookForImg2img,
|
||||
spinnerModule, uiModule,
|
||||
}) {
|
||||
return async function applyImageTool(endpoint, extraPayload, layerName, btn, opts) {
|
||||
const origHTML = btn.innerHTML;
|
||||
const origWidth = btn.offsetWidth; // lock width so the button doesn't jump
|
||||
btn.disabled = true;
|
||||
btn.classList.add('ge-btn-processing');
|
||||
btn.style.minWidth = origWidth + 'px';
|
||||
// Swap label for a "<verbing>…" text + whirlpool while the
|
||||
// request runs. Falls back to deriving a busy label from
|
||||
// layerName when the caller didn't supply one.
|
||||
const busyLabel = (opts && opts.busyLabel) || deriveBusyLabel(layerName);
|
||||
btn.innerHTML = '';
|
||||
let btnSpinner = null;
|
||||
try {
|
||||
btnSpinner = spinnerModule.create('', 'clean', 'whirlpool');
|
||||
const sp = btnSpinner.createElement();
|
||||
btn.appendChild(sp);
|
||||
const txt = document.createElement('span');
|
||||
txt.className = 'ge-btn-busy-label';
|
||||
txt.textContent = busyLabel;
|
||||
btn.appendChild(txt);
|
||||
btnSpinner.start();
|
||||
} catch { btn.textContent = busyLabel; }
|
||||
// Tool-specific model picker — pulled from the per-tool select
|
||||
// (harmonize/style) if available, otherwise the global
|
||||
// fallback. Derived from the endpoint URL.
|
||||
if (!extraPayload._endpoint) {
|
||||
const m = /\/api\/image\/([\w-]+)/.exec(endpoint || '');
|
||||
const type = m ? m[1].replace('upscale-ai', 'upscale').replace('remove-bg', 'rembg') : null;
|
||||
const sel = getSelectedAIEndpoint(type);
|
||||
if (sel.endpoint) extraPayload._endpoint = sel.endpoint;
|
||||
if (sel.model && !extraPayload._model) extraPayload._model = sel.model;
|
||||
}
|
||||
try {
|
||||
const flatCanvas = flatten();
|
||||
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
|
||||
const body = { image: imageB64, ...extraPayload };
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let err = res.statusText;
|
||||
try { const e = await res.json(); err = e.detail || e.error || err; } catch {}
|
||||
throw new Error(err);
|
||||
}
|
||||
const data = await res.json();
|
||||
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)
|
||||
saveState();
|
||||
const layer = createLayer(layerName, state.imgWidth, state.imgHeight);
|
||||
layer.ctx.drawImage(img, 0, 0);
|
||||
state.layers.push(layer);
|
||||
state.activeLayerId = layer.id;
|
||||
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();
|
||||
const needsImg2Img = (
|
||||
msg.includes('img2img') ||
|
||||
msg.includes('diffusion server') ||
|
||||
msg.includes("doesn't expose")
|
||||
);
|
||||
let depMatch = null;
|
||||
for (const pkg of KNOWN_DEPS) {
|
||||
if (msg.includes(`${pkg} not installed`) || msg.includes(`no module named '${pkg}'`)) {
|
||||
depMatch = pkg; break;
|
||||
}
|
||||
}
|
||||
if (uiModule) {
|
||||
if (depMatch && uiModule.showToast.length >= 2) {
|
||||
uiModule.showToast(layerName + ' failed: ' + depMatch + ' is not installed on the server.', {
|
||||
duration: 9000,
|
||||
action: `Install ${depMatch}`,
|
||||
onAction: () => openCookbookForDependency(depMatch),
|
||||
});
|
||||
} else if (needsImg2Img && uiModule.showToast.length >= 2) {
|
||||
uiModule.showToast(layerName + ' failed: ' + e.message, {
|
||||
duration: 9000,
|
||||
action: 'Open Cookbook',
|
||||
onAction: () => openCookbookForImg2img(),
|
||||
});
|
||||
} else {
|
||||
uiModule.showToast(layerName + ' failed: ' + e.message, 6000);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('ge-btn-processing');
|
||||
try { btnSpinner?.destroy(); } catch {}
|
||||
btn.innerHTML = origHTML;
|
||||
btn.style.minWidth = '';
|
||||
}
|
||||
};
|
||||
}
|
||||
216
static/js/editor/ai-tools-misc.js
Normal file
216
static/js/editor/ai-tools-misc.js
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Misc AI-tool wiring — the three AI tools that don't share the
|
||||
* inpaint pipeline:
|
||||
*
|
||||
* Harmonize: Reinhard color transfer on a body mask (no AI redraw)
|
||||
* + an optional narrow inpaint on a seam mask if the
|
||||
* "Seam fix" slider > 0.
|
||||
* Canvas 2×/4× Upscale: in-browser bicubic resampling, no server.
|
||||
* AI Upscale: Real-ESRGAN via /api/image/upscale-local.
|
||||
* Style Transfer: img2img via /api/gallery/style-transfer.
|
||||
*
|
||||
* Plus the small `_addEmptyLayer` helper and its toolbar wiring,
|
||||
* since it lived next to these.
|
||||
*
|
||||
* @param {{
|
||||
* apiBase: string,
|
||||
* buildLayerBodyMask: (featherPx: number) => string | null,
|
||||
* buildSeamMask: (featherPx: number) => string | null,
|
||||
* applyImageTool: (endpoint, payload, layerName, btn, opts?) => Promise<void>,
|
||||
* flatten: () => HTMLCanvasElement,
|
||||
* saveState: (label?: string) => void,
|
||||
* fitZoom: () => void,
|
||||
* composite: () => void,
|
||||
* createLayer: (name, w, h) => object,
|
||||
* renderLayerPanel: () => void,
|
||||
* spinnerModule: object,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*
|
||||
* @returns {{ addEmptyLayer: () => void }}
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
export function wireAIToolsMisc({
|
||||
apiBase, buildLayerBodyMask, buildSeamMask, applyImageTool,
|
||||
flatten, saveState, fitZoom, composite, createLayer, renderLayerPanel,
|
||||
spinnerModule, uiModule,
|
||||
}) {
|
||||
// ── Harmonize sliders — Color match + Seam fix ──
|
||||
const harmColorPrev = document.getElementById('ge-harmonize-color-preview');
|
||||
const harmSeamPrev = document.getElementById('ge-harmonize-seam-preview');
|
||||
document.getElementById('ge-harmonize-color')?.addEventListener('input', (e) => {
|
||||
document.getElementById('ge-harmonize-color-label').textContent = (e.target.value / 100).toFixed(2);
|
||||
if (harmColorPrev) harmColorPrev.style.opacity = (parseInt(e.target.value, 10) / 100).toFixed(2);
|
||||
});
|
||||
document.getElementById('ge-harmonize-seam')?.addEventListener('input', (e) => {
|
||||
document.getElementById('ge-harmonize-seam-label').textContent = (e.target.value / 100).toFixed(2);
|
||||
if (harmSeamPrev) harmSeamPrev.style.opacity = (parseInt(e.target.value, 10) / 100).toFixed(2);
|
||||
});
|
||||
|
||||
// Harmonize button — two-stage:
|
||||
// 1) Reinhard color transfer on body mask (no AI redraw)
|
||||
// 2) Optional narrow inpaint on seam mask (if seam_fix > 0)
|
||||
document.getElementById('ge-harmonize-run')?.addEventListener('click', () => {
|
||||
const prompt = document.getElementById('ge-harmonize-prompt')?.value?.trim() || 'photorealistic, natural lighting, seamless blend';
|
||||
const color_match = (parseInt(document.getElementById('ge-harmonize-color')?.value || '65')) / 100;
|
||||
const seam_fix = (parseInt(document.getElementById('ge-harmonize-seam')?.value || '0')) / 100;
|
||||
const bodyFeather = Math.max(6, Math.round(Math.min(state.imgWidth, state.imgHeight) * 0.012));
|
||||
const seamFeather = Math.max(8, Math.round(Math.min(state.imgWidth, state.imgHeight) * 0.015));
|
||||
const body_mask = buildLayerBodyMask(bodyFeather);
|
||||
const seam_mask = seam_fix > 0.01 ? buildSeamMask(seamFeather) : null;
|
||||
// Harmonize needs a non-base layer to color-match against the
|
||||
// background. Without one, the server would fall back to legacy
|
||||
// whole-image img2img — i.e. regenerate the whole photo. Block
|
||||
// that and tell the user what's missing.
|
||||
if (!body_mask) {
|
||||
if (uiModule) uiModule.showToast('Harmonize needs a second layer pasted/imported over the base photo — nothing to color-match against.', 6000);
|
||||
return;
|
||||
}
|
||||
const payload = { prompt, color_match, seam_fix, body_mask };
|
||||
if (seam_mask) payload.seam_mask = seam_mask;
|
||||
applyImageTool('/api/image/harmonize', payload, 'Harmonized', document.getElementById('ge-harmonize-run'));
|
||||
});
|
||||
|
||||
// ── Canvas upscale (bicubic) ──
|
||||
function canvasUpscale(factor) {
|
||||
saveState(`Upscale ${factor}×`);
|
||||
const newW = state.imgWidth * factor;
|
||||
const newH = state.imgHeight * factor;
|
||||
state.layers.forEach(l => {
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = newW; tmp.height = newH;
|
||||
const tCtx = tmp.getContext('2d');
|
||||
tCtx.imageSmoothingEnabled = true;
|
||||
tCtx.imageSmoothingQuality = 'high';
|
||||
tCtx.drawImage(l.canvas, 0, 0, newW, newH);
|
||||
l.canvas.width = newW; l.canvas.height = newH;
|
||||
l.ctx.drawImage(tmp, 0, 0);
|
||||
});
|
||||
if (state.maskCanvas) { state.maskCanvas.width = newW; state.maskCanvas.height = newH; }
|
||||
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(`Upscaled ${factor}× to ${newW}×${newH}`);
|
||||
}
|
||||
document.getElementById('ge-upscale-2x')?.addEventListener('click', () => canvasUpscale(2));
|
||||
document.getElementById('ge-upscale-4x')?.addEventListener('click', () => canvasUpscale(4));
|
||||
|
||||
// ── AI upscale (Real-ESRGAN, no diffusion server required) ──
|
||||
document.getElementById('ge-upscale-ai')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('ge-upscale-ai');
|
||||
const origHTML = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
let upWp = null;
|
||||
try {
|
||||
upWp = spinnerModule.createWhirlpool(14);
|
||||
upWp.element.style.cssText = 'display:inline-block;vertical-align:middle;position:relative;top:1px;margin-right:6px;width:14px;height:14px;';
|
||||
btn.innerHTML = '';
|
||||
btn.appendChild(upWp.element);
|
||||
const lbl = document.createElement('span');
|
||||
lbl.textContent = 'Upscaling…';
|
||||
btn.appendChild(lbl);
|
||||
} catch (_) { btn.textContent = 'Upscaling…'; }
|
||||
try {
|
||||
const flat = flatten();
|
||||
const imageB64 = flat.toDataURL('image/png').split(',')[1];
|
||||
const res = await fetch('/api/image/upscale-local', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image: imageB64, scale: 2 }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Server returned ' + res.status);
|
||||
const data = await res.json();
|
||||
if (data.image) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (!state.editorOpen) return;
|
||||
saveState();
|
||||
const newW = img.width, newH = img.height;
|
||||
const layer = createLayer('AI Upscaled', newW, newH);
|
||||
layer.ctx.drawImage(img, 0, 0);
|
||||
state.layers.push(layer);
|
||||
state.activeLayerId = layer.id;
|
||||
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 sizeLabel = document.getElementById('ge-canvas-size');
|
||||
if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`;
|
||||
fitZoom();
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
uiModule.showToast(`AI upscaled to ${newW}×${newH}`);
|
||||
};
|
||||
img.src = 'data:image/png;base64,' + data.image;
|
||||
} else {
|
||||
throw new Error(data.error || 'No image returned');
|
||||
}
|
||||
} catch (e) {
|
||||
uiModule.showToast('AI upscale failed: ' + e.message);
|
||||
}
|
||||
try { upWp?.destroy(); } catch (_) {}
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHTML;
|
||||
});
|
||||
|
||||
// ── Style transfer ──
|
||||
document.getElementById('ge-style-strength')?.addEventListener('input', (e) => {
|
||||
document.getElementById('ge-style-strength-label').textContent = (parseInt(e.target.value) / 100).toFixed(2);
|
||||
});
|
||||
document.getElementById('ge-style-run')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('ge-style-run');
|
||||
const prompt = document.getElementById('ge-style-prompt').value.trim();
|
||||
if (!prompt) { uiModule.showToast('Enter a style prompt'); return; }
|
||||
const strength = parseInt(document.getElementById('ge-style-strength').value) / 100;
|
||||
btn.disabled = true; btn.textContent = 'Applying...';
|
||||
try {
|
||||
const flat = flatten();
|
||||
const blob = await new Promise(r => flat.toBlob(r, 'image/png'));
|
||||
const fd = new FormData();
|
||||
fd.append('image', blob, 'style.png');
|
||||
fd.append('prompt', prompt);
|
||||
fd.append('strength', String(strength));
|
||||
const res = await fetch(`${apiBase}/api/gallery/style-transfer`, { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
if (!res.ok) throw new Error('Server returned ' + res.status);
|
||||
const data = await res.json();
|
||||
if (data.image) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (!state.editorOpen) return;
|
||||
saveState();
|
||||
const layer = createLayer('Styled: ' + prompt.substring(0, 20), state.imgWidth, state.imgHeight);
|
||||
layer.ctx.drawImage(img, 0, 0, state.imgWidth, state.imgHeight);
|
||||
state.layers.push(layer);
|
||||
state.activeLayerId = layer.id;
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
uiModule.showToast('Style applied');
|
||||
};
|
||||
img.src = 'data:image/png;base64,' + data.image;
|
||||
} else {
|
||||
throw new Error(data.error || 'No image returned');
|
||||
}
|
||||
} catch (e) {
|
||||
uiModule.showToast('Style transfer failed: ' + e.message);
|
||||
}
|
||||
btn.disabled = false; btn.textContent = 'Apply Style';
|
||||
});
|
||||
|
||||
// ── Add empty layer (used by the layer-panel header button + the
|
||||
// Ctrl+Alt+J keyboard shortcut). Returned so keyboard-shortcuts.js
|
||||
// can call it through the same path. ──
|
||||
function addEmptyLayer() {
|
||||
saveState('Add layer');
|
||||
const layer = createLayer('Layer ' + state.layers.length, state.imgWidth, state.imgHeight);
|
||||
state.layers.push(layer);
|
||||
state.activeLayerId = layer.id;
|
||||
renderLayerPanel();
|
||||
composite();
|
||||
}
|
||||
document.getElementById('ge-add-layer')?.addEventListener('click', addEmptyLayer);
|
||||
|
||||
return { addEmptyLayer };
|
||||
}
|
||||
366
static/js/editor/build/controls.js
vendored
Normal file
366
static/js/editor/build/controls.js
vendored
Normal file
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Build the editor's right-panel controls innerHTML.
|
||||
*
|
||||
* Returns the string — caller creates the wrapper element, attaches its
|
||||
* own touch / swipe-to-dismiss listeners, then sets innerHTML. Per-tool
|
||||
* sections are all toggled `display:none` here; the tool-switch handler
|
||||
* in galleryEditor.js shows the section matching the active tool.
|
||||
*
|
||||
* @param {{ color: string, brushSize: number, wandTolerance: number }} ctx
|
||||
* @returns {string}
|
||||
*/
|
||||
export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
const brushSliderValue = Math.round(Math.log(Math.max(1, brushSize)) / Math.log(800) * 1000);
|
||||
return `
|
||||
<div id="ge-brush-controls">
|
||||
<div class="ge-control-row" id="ge-color-row">
|
||||
<label>Color</label>
|
||||
<input type="color" class="ge-color-picker" value="${color}" />
|
||||
</div>
|
||||
<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>
|
||||
<div class="ge-lasso-section" id="ge-lasso-section" style="display:none;">
|
||||
<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>
|
||||
<input type="range" id="ge-lasso-feather" min="0" max="200" value="0" title="Soften the selection edge — feathers the mask alpha." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-grow" style="display:none;">
|
||||
<span class="ge-eraser-preview" id="ge-lasso-grow-preview" aria-hidden="true"></span>
|
||||
<label>Edge stroke <span id="ge-lasso-grow-label">0px</span></label>
|
||||
<input type="range" id="ge-lasso-grow" min="-40" max="40" value="0" title="Expand (+) or contract (−) the selection before baking." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-invert" title="Invert selection (Ctrl+Alt+I)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
||||
Invert
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-delete" title="Delete selected pixels from the layer">
|
||||
<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>
|
||||
Delete
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-copy" title="Copy selection to new layer">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Copy Layer
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-mask" title="Convert selection to 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>
|
||||
</div>
|
||||
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Draw a freehand selection. Esc to cancel.</p>
|
||||
</div>
|
||||
<div class="ge-wand-section" id="ge-wand-section" style="display:none;">
|
||||
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next click 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 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>
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-wand-tol-preview" aria-hidden="true"></span>
|
||||
<label>Tolerance <span id="ge-wand-tol-label">${wandTolerance}</span></label>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-live-btn" id="ge-wand-live" title="Retune selection while dragging tolerance" aria-pressed="false">Live</button>
|
||||
<input type="range" id="ge-wand-tolerance" min="0" max="100" value="${wandTolerance}" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-wand-refine-feather" style="display:none;">
|
||||
<span class="ge-eraser-preview" id="ge-wand-feather-preview" aria-hidden="true"></span>
|
||||
<label>Feather <span id="ge-wand-feather-label">0px</span></label>
|
||||
<input type="range" id="ge-wand-feather" min="0" max="200" value="0" title="Soften the selection edge — feathers the mask alpha." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-wand-refine-grow" style="display:none;">
|
||||
<span class="ge-eraser-preview" id="ge-wand-grow-preview" aria-hidden="true"></span>
|
||||
<label>Edge stroke <span id="ge-wand-grow-label">0px</span></label>
|
||||
<input type="range" id="ge-wand-grow" min="-40" max="40" value="0" title="Expand (+) or contract (−) the selection before baking." />
|
||||
</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-wand-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-wand-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-wand-invert" title="Invert selection (Ctrl+Alt+I)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
||||
Invert
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-delete" title="Delete selected pixels from the layer">
|
||||
<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="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
|
||||
Erase
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-copy" title="Copy selection to a new layer">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Copy Layer
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-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>
|
||||
</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-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>
|
||||
<button class="ge-inpaint-popover-close" id="ge-inpaint-popover-close" type="button" title="Close inpaint panel" aria-label="Close inpaint panel">×</button>
|
||||
</div>
|
||||
<div class="ge-section-title ge-section-title-with-help"><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>
|
||||
<p class="ge-section-hint" style="margin-top:0;">
|
||||
Generates or removes from the mask you have selected. Set <strong>Strength</strong> before and adjust <strong>Edge feather / stroke</strong> after.
|
||||
</p>
|
||||
<div class="ge-section-title" style="margin-top:8px;display:flex;align-items:center;gap:6px;">
|
||||
<span>Mask Brush</span>
|
||||
<input type="color" class="ge-color-picker ge-inpaint-mask-color" value="#ff6e6e" title="Mask overlay color — purely visual, the model still sees a hard mask either way." />
|
||||
</div>
|
||||
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="Hold Ctrl+Alt to flip temporarily for a single stroke.">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-inpaint-mode-btn active" id="ge-inpaint-mode-paint" style="flex:1 1 0;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>
|
||||
Paint
|
||||
</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-inpaint-mode-btn" id="ge-inpaint-mode-erase" style="flex:1 1 0;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>
|
||||
Erase
|
||||
</button>
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-inpaint-brush-preview" aria-hidden="true"></span>
|
||||
<label>Mask Brush Size <span id="ge-inpaint-brush-label">${brushSize}px</span></label>
|
||||
<input type="range" id="ge-inpaint-brush-slider" min="0" max="1000" value="${brushSliderValue}" title="Brush diameter (log scale 1→800px). Use [ and ] for ±10%." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions ge-inpaint-mask-row" style="margin-top:4px;">
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel ge-mask-vis-btn visible" id="ge-mask-vis" title="Hide 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="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>
|
||||
<span id="ge-mask-vis-label">Hide</span>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-inpaint-invert" title="Invert mask">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
||||
Invert
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-inpaint-clear" title="Clear mask">
|
||||
<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>
|
||||
</div>
|
||||
<hr class="ge-section-divider" />
|
||||
<div class="ge-section-title" style="margin-top:8px;"><span>PROMPT</span></div>
|
||||
<input type="text" class="ge-inpaint-prompt" id="ge-inpaint-prompt" placeholder="What to fill the masked area with..." />
|
||||
<div class="ge-control-row ge-inpaint-model-row" style="margin-top:6px;">
|
||||
<label for="ge-ai-inpaint">Model</label>
|
||||
<select id="ge-ai-inpaint" class="ge-ai-model" title="Model for inpainting">
|
||||
<option value="">Auto</option>
|
||||
<option value="" disabled>──────────</option>
|
||||
<option value="__serve_cookbook__">+ Serve a model in Cookbook…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row" style="margin-top:6px;">
|
||||
<span class="ge-eraser-preview" id="ge-strength-preview" aria-hidden="true"></span>
|
||||
<label>Strength <span id="ge-strength-label">0.75</span><span class="ge-section-help" tabindex="0" role="img" aria-label="Strength help" title="How much the AI redraws inside the mask. 0 = no change · 1 = full re-generation from your prompt. Recommended: 0.9–1.0 to add/replace an object, 0.6–0.8 to change material or color, 0.3–0.5 for subtle touch-ups. Default 0.75 works for most edits.">?</span></label>
|
||||
<input type="range" id="ge-strength-slider" min="10" max="100" value="75" title="How much the AI redraws inside the mask (0 = no change, 1 = full diffusion)." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:6px;display:flex;gap:6px;align-items:center;min-width:0;">
|
||||
<button class="ge-btn ge-btn-primary ge-btn-ai" id="ge-inpaint-run" style="flex:1 1 0;display:inline-flex;align-items:center;justify-content:center;gap:6px;" title="Fill the masked area with what your prompt describes.">
|
||||
<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>
|
||||
<span id="ge-inpaint-run-label">Generate</span>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-ai" id="ge-inpaint-remove" style="flex:1 1 0;display:inline-flex;align-items:center;justify-content:center;gap:6px;" title="Erase the masked content and fill with the surrounding background. Ignores your prompt.">
|
||||
<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>
|
||||
<span id="ge-inpaint-remove-label">Remove</span>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-ai" id="ge-inpaint-outpaint" style="flex:1 1 0;display:inline-flex;align-items:center;justify-content:center;gap:6px;" title="Fill the empty (transparent) areas of the canvas with AI-generated content that blends with the existing image. Ignores your brush mask.">
|
||||
<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>
|
||||
<span id="ge-inpaint-outpaint-label">Outpaint</span>
|
||||
</button>
|
||||
</div>
|
||||
<hr class="ge-section-divider" id="ge-inpaint-postedge-divider" style="margin-top:14px;" />
|
||||
<div class="ge-section-title ge-section-title-with-help" id="ge-inpaint-postedge-title"><span>POSTPROCESS</span><span class="ge-section-help" tabindex="0" role="img" aria-label="What this does" title="Live edge trimming for the last Inpaint Result layer. Edge feather softens the alpha boundary; Edge stroke expands (+) or contracts (−) the visible edge into the AI buffer that was generated around your brush.">?</span></div>
|
||||
<p class="ge-section-hint" id="ge-inpaint-postedge-hint" style="margin-top:0;opacity:0.45;">
|
||||
Available after Generate.
|
||||
</p>
|
||||
<div class="ge-control-row ge-eraser-row" id="ge-inpaint-postfeather-row" style="display:none;">
|
||||
<span class="ge-eraser-preview" id="ge-feather-preview" aria-hidden="true"></span>
|
||||
<label>Edge feather <span id="ge-feather-label">0px</span></label>
|
||||
<input type="range" id="ge-feather-slider" min="0" max="200" value="0" title="Blurs the inpaint result's alpha edge — drag to blend the AI fill into the surrounding image. Updates live." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row" id="ge-inpaint-edgestroke-row" style="display:none;">
|
||||
<span class="ge-eraser-preview" id="ge-edgestroke-preview" aria-hidden="true"></span>
|
||||
<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>
|
||||
<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-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>
|
||||
<input type="range" id="ge-clone-opacity" min="10" max="100" value="100" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-clone-preview-flow" aria-hidden="true"></span>
|
||||
<label>Flow <span id="ge-clone-flow-label">100%</span></label>
|
||||
<input type="range" id="ge-clone-flow" min="5" max="100" value="100" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-clone-preview-softness" aria-hidden="true"></span>
|
||||
<label>Softness <span id="ge-clone-softness-label">100%</span></label>
|
||||
<input type="range" id="ge-clone-softness" min="0" max="300" value="100" title="Soft brush edge — blurs each stamp for a feathered fade." />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-eraser-section" id="ge-brush-section" style="display:none;">
|
||||
<div class="ge-section-title">Brush</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>
|
||||
<input type="range" id="ge-brush-opacity" min="10" max="100" value="100" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-brush-preview-flow" aria-hidden="true"></span>
|
||||
<label>Flow <span id="ge-brush-flow-label">100%</span></label>
|
||||
<input type="range" id="ge-brush-flow" min="5" max="100" value="100" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
|
||||
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
|
||||
<input type="range" id="ge-brush-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha for a feathered fade at the perimeter." />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-eraser-section" id="ge-eraser-section" style="display:none;">
|
||||
<div class="ge-section-title">Eraser</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-eraser-preview-opacity" aria-hidden="true"></span>
|
||||
<label>Opacity <span id="ge-eraser-opacity-label">100%</span></label>
|
||||
<input type="range" id="ge-eraser-opacity" min="10" max="100" value="100" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-eraser-preview-flow" aria-hidden="true"></span>
|
||||
<label>Flow <span id="ge-eraser-flow-label">100%</span></label>
|
||||
<input type="range" id="ge-eraser-flow" min="5" max="100" value="100" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-eraser-preview-softness" aria-hidden="true"></span>
|
||||
<label>Softness <span id="ge-eraser-softness-label">100%</span></label>
|
||||
<input type="range" id="ge-eraser-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha so the eraser fades out at the perimeter." />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-sharpen-section" id="ge-sharpen-section" style="display:none;">
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-sharpen-preview" aria-hidden="true"></span>
|
||||
<label>Amount <span id="ge-sharpen-label">50%</span></label>
|
||||
<input type="range" id="ge-sharpen-amount" min="10" max="100" value="50" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:4px;">
|
||||
<button class="ge-btn ge-btn-primary" id="ge-sharpen-run">Sharpen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-rembg-section" id="ge-rembg-section" style="display:none;">
|
||||
<div class="ge-section-title ge-section-title-with-help"><span>Background Remove</span><span class="ge-section-help" tabindex="0" role="img" aria-label="What this does" title="Runs an ML model that keeps whatever it learned to call the foreground (usually a person, product, or animal). If you have a Lasso or Wand selection active, it's used as a hint — the model only looks inside that region and anything outside is forced transparent.">?</span></div>
|
||||
<div class="ge-dep-notice" id="ge-rembg-dep-missing" style="display:none;">
|
||||
<div class="ge-dep-notice-text">
|
||||
<strong>rembg not installed.</strong>
|
||||
Background Remove needs the <code>rembg</code> package on this
|
||||
server. Click to install it from Cookbook → Dependencies.
|
||||
</div>
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-rembg-install-link">Install rembg</button>
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" id="ge-rembg-run-row">
|
||||
<button class="ge-btn ge-btn-primary ge-btn-ai" id="ge-rembg-run">
|
||||
<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>
|
||||
Bg Remove
|
||||
</button>
|
||||
</div>
|
||||
<hr class="ge-section-divider" />
|
||||
<div class="ge-section-title ge-section-title-with-help"><span>Edge cleanup</span><span class="ge-section-help" tabindex="0" role="img" aria-label="What this does" title="Live-applied to the last Bg Removed layer. Feather softens the edge; Edge nudges it inward (−) or outward (+).">?</span></div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-rembg-feather-preview" aria-hidden="true"></span>
|
||||
<label>Feather <span id="ge-rembg-feather-label">0px</span></label>
|
||||
<input type="range" id="ge-rembg-feather" min="0" max="20" value="0" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-rembg-grow-preview" aria-hidden="true"></span>
|
||||
<label>Edge <span id="ge-rembg-grow-label">0px</span></label>
|
||||
<input type="range" id="ge-rembg-grow" min="-10" max="10" value="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-import-section" id="ge-import-section" style="display:none;">
|
||||
<p style="font-size:10px;opacity:0.5;margin:0 0 6px;">Import an image as a new layer. Drag to position it.</p>
|
||||
<div class="ge-control-row ge-actions">
|
||||
<button class="ge-btn" id="ge-import-file">File</button>
|
||||
<button class="ge-btn" id="ge-import-paste">Clipboard</button>
|
||||
<button class="ge-btn" id="ge-import-gallery">Gallery</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-harmonize-section" id="ge-harmonize-section" style="display:none;">
|
||||
<div class="ge-section-title">Harmonize <span class="ge-section-help" tabindex="0" role="img" title="Blends pasted layers into the base photo. Color match shifts the layer's lighting/tone to match its surroundings (no pixel redraw). Seam fix uses inpaint to clean jagged cutout edges (needs a self-hosted img2img/inpaint model).">?</span></div>
|
||||
<div class="ge-control-row ge-tool-model-row">
|
||||
<label>Model</label>
|
||||
<select class="ge-tool-model" data-ge-tool-model="harmonize" title="Model for harmonize">
|
||||
<option value="">Auto</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ge-control-row">
|
||||
<label style="font-size:11px;opacity:0.6;">Prompt (only used if Seam fix > 0)</label>
|
||||
</div>
|
||||
<input type="text" class="ge-inpaint-prompt" id="ge-harmonize-prompt" placeholder="photorealistic, natural lighting, seamless blend..." />
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-harmonize-color-preview" aria-hidden="true"></span>
|
||||
<label>Color match <span id="ge-harmonize-color-label">0.65</span></label>
|
||||
<input type="range" id="ge-harmonize-color" min="0" max="100" value="65" title="How much of the Reinhard color/luminance shift to apply. 0 = no shift, 1 = fully match surroundings." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-harmonize-seam-preview" aria-hidden="true"></span>
|
||||
<label>Seam fix <span id="ge-harmonize-seam-label">0.00</span></label>
|
||||
<input type="range" id="ge-harmonize-seam" min="0" max="100" value="0" title="Strength of the narrow inpaint pass on the alpha edge band. 0 = off, 1 = max blend at boundary." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:4px;">
|
||||
<button class="ge-btn ge-btn-primary" id="ge-harmonize-run">Harmonize</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-style-section" id="ge-style-section" style="display:none;">
|
||||
<p style="font-size:10px;opacity:0.5;margin:0 0 6px;">Apply an art style to the image using img2img. Requires a running diffusion model.</p>
|
||||
<div class="ge-control-row ge-tool-model-row">
|
||||
<label>Model</label>
|
||||
<select class="ge-tool-model" data-ge-tool-model="style" title="Model for Style transfer">
|
||||
<option value="">Auto</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ge-control-row">
|
||||
<label style="font-size:11px;opacity:0.6;">Style prompt</label>
|
||||
</div>
|
||||
<input type="text" class="ge-inpaint-prompt" id="ge-style-prompt" placeholder="oil painting, impressionist, Van Gogh..." />
|
||||
<div class="ge-control-row">
|
||||
<label style="font-size:11px;opacity:0.6;">Strength <span id="ge-style-strength-label">0.55</span></label>
|
||||
<input type="range" id="ge-style-strength" min="10" max="90" value="55" style="flex:1;" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:4px;">
|
||||
<button class="ge-btn ge-btn-primary" id="ge-style-run">Apply Style</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Layer-panel header markup. Static; static IDs are wired by the caller.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function layerPanelHTML() {
|
||||
return `<div class="ge-layers-header">
|
||||
<span class="ge-layers-grab"></span>
|
||||
<span class="ge-layers-title">Layers</span>
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-merge-down" title="Merge down" aria-label="Merge down">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="6 13 12 19 18 13"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-merge-all" title="Merge all" aria-label="Merge all">
|
||||
<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 3v6M9 6l3-3 3 3M3 14h18M12 14v7M9 18l3 3 3-3"/></svg>
|
||||
</button>
|
||||
<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" id="ge-add-layer" title="Add empty layer">+ Add</button>
|
||||
</div><div class="ge-layers-list" id="ge-layers-list"></div>`;
|
||||
}
|
||||
112
static/js/editor/build/popups.js
Normal file
112
static/js/editor/build/popups.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Static markup for misc floating popups that live above the canvas.
|
||||
*
|
||||
* All pure DOM. Caller wires every ID via document.getElementById /
|
||||
* el.querySelector after appending.
|
||||
*/
|
||||
|
||||
/** Keyboard-shortcuts popover. */
|
||||
export function shortcutsPopupHTML() {
|
||||
return `
|
||||
<div id="ge-shortcuts-handle" style="display:flex;align-items:center;gap:6px;margin:-4px -6px 4px;padding:4px 6px;cursor:grab;user-select:none;touch-action:none;">
|
||||
<span style="display:inline-flex;flex-direction:column;gap:2px;margin-right:2px;opacity:0.35;">
|
||||
<span style="display:block;width:18px;height:2px;border-radius:1px;background:currentColor;"></span>
|
||||
<span style="display:block;width:18px;height:2px;border-radius:1px;background:currentColor;"></span>
|
||||
</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.8"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg>
|
||||
<strong style="font-size:12px;letter-spacing:0.3px;">Editor Shortcuts</strong>
|
||||
<span style="flex:1"></span>
|
||||
<button id="ge-shortcuts-close" class="ge-btn ge-btn-sm" style="padding:0 6px;height:20px;line-height:1;background:none;border:none;opacity:0.55;cursor:pointer;color:var(--fg);">✖</button>
|
||||
</div>
|
||||
<div class="ge-shortcuts-grid">
|
||||
<div class="ge-shortcuts-col">
|
||||
<h5>Tools</h5>
|
||||
<div><kbd>V</kbd> Move</div>
|
||||
<div><kbd>T</kbd> Transform</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>
|
||||
<div class="ge-shortcuts-col">
|
||||
<h5>Edit</h5>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Z</kbd> Undo</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> Redo</div>
|
||||
<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>T</kbd> Free Transform</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>T</kbd> Canvas size…</div>
|
||||
</div>
|
||||
<div class="ge-shortcuts-col">
|
||||
<h5>Selection</h5>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>A</kbd> Select All</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>D</kbd> Deselect</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>C</kbd> Copy to layer</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>X</kbd> Cut lasso</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>D</kbd> Delete pixels</div>
|
||||
<div><kbd>Esc</kbd> Cancel selection / crop</div>
|
||||
</div>
|
||||
<div class="ge-shortcuts-col">
|
||||
<h5>Brush / Mask</h5>
|
||||
<div><kbd>[</kbd> Brush size −</div>
|
||||
<div><kbd>]</kbd> Brush size +</div>
|
||||
<div>Drag tolerance slider → live wand retune</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:10px;opacity:0.5;text-align:center;">Press <kbd>?</kbd> or click the keyboard icon to toggle.</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* History panel — sidebar listing all undo entries.
|
||||
* @param {string} historyIcon Inline SVG markup for the title icon.
|
||||
*/
|
||||
export function historyPanelHTML(historyIcon) {
|
||||
return `
|
||||
<div class="ge-history-head" data-history-drag>
|
||||
<span class="ge-adj-icon">${historyIcon}</span>
|
||||
<span class="ge-history-title">History</span>
|
||||
<span class="ge-head-btns">
|
||||
<button class="ge-adj-min" type="button" title="Minimise">−</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-history-list" id="ge-history-list"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Empty-canvas size-prompt modal — body markup (caller controls show /
|
||||
* hide and wires the Cancel / Create buttons).
|
||||
*/
|
||||
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-body">
|
||||
<div class="ge-canvas-prompt-row">
|
||||
<label class="ge-canvas-prompt-field">
|
||||
<span>Width</span>
|
||||
<input type="text" id="ge-canvas-prompt-w" inputmode="numeric" value="1024">
|
||||
</label>
|
||||
<span class="ge-canvas-prompt-x">×</span>
|
||||
<label class="ge-canvas-prompt-field">
|
||||
<span>Height</span>
|
||||
<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>
|
||||
<div class="modal-footer">
|
||||
<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>
|
||||
</div>`;
|
||||
}
|
||||
200
static/js/editor/build/right-panel.js
Normal file
200
static/js/editor/build/right-panel.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Build the right-hand panel (controls + layers) — DOM creation,
|
||||
* controls innerHTML population, mobile bottom-sheet swipe behavior,
|
||||
* controls-panel re-parenting on mobile, slider value-chip layout
|
||||
* normalization, layer-panel header + mobile peek/expand swipe, and
|
||||
* the panel-width drag-resize handle.
|
||||
*
|
||||
* Owns its own event listeners (touch swipe gestures, mouse resize
|
||||
* drag). Returns the `rightPanel` element + the `panelResize` handle
|
||||
* + the inner `controls` element so the caller can wire any post-
|
||||
* mount tweaks. Reads state.container (for mobile re-parenting) and
|
||||
* state.color / state.brushSize / state.wandTolerance (initial slider
|
||||
* values).
|
||||
*
|
||||
* @param {{
|
||||
* controlsHTML: (ctx: {color, brushSize, wandTolerance}) => string,
|
||||
* layerPanelHTML: () => string,
|
||||
* }} build
|
||||
*
|
||||
* @returns {{
|
||||
* rightPanel: HTMLDivElement,
|
||||
* controls: HTMLDivElement,
|
||||
* layerPanel: HTMLDivElement,
|
||||
* panelResize: HTMLDivElement,
|
||||
* }}
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
|
||||
export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
|
||||
const rightPanel = document.createElement('div');
|
||||
rightPanel.className = 'ge-right-panel';
|
||||
|
||||
// Controls section.
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'ge-controls';
|
||||
// Swipe-down to dismiss on mobile. Tap the same tool again to bring
|
||||
// the sheet back. Only the top ~40 px (grab handle area) initiates
|
||||
// the gesture so taps on inputs/sliders inside the panel still work.
|
||||
{
|
||||
let sy = 0, dragging = false;
|
||||
controls.addEventListener('touchstart', (e) => {
|
||||
if (window.innerWidth > 700) return;
|
||||
const rect = controls.getBoundingClientRect();
|
||||
const t = e.touches[0];
|
||||
// Only engage if touch starts in the top grab zone.
|
||||
if (t.clientY - rect.top > 40) return;
|
||||
sy = t.clientY;
|
||||
dragging = true;
|
||||
controls.style.transition = 'none';
|
||||
}, { passive: true });
|
||||
controls.addEventListener('touchmove', (e) => {
|
||||
if (!dragging) return;
|
||||
const dy = e.touches[0].clientY - sy;
|
||||
if (dy > 0) controls.style.transform = `translateY(${dy}px)`;
|
||||
}, { passive: true });
|
||||
controls.addEventListener('touchend', (e) => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
const dy = e.changedTouches[0].clientY - sy;
|
||||
controls.style.transition = '';
|
||||
controls.style.transform = '';
|
||||
if (dy > 60) controls.classList.add('dismissed');
|
||||
});
|
||||
}
|
||||
controls.innerHTML = controlsHTML({
|
||||
color: state.color,
|
||||
brushSize: state.brushSize,
|
||||
wandTolerance: state.wandTolerance,
|
||||
});
|
||||
rightPanel.appendChild(controls);
|
||||
// Mobile only (≤ 700 px — matches the .ge-editor-body column-stack
|
||||
// breakpoint): the right panel becomes a transformed bottom-sheet,
|
||||
// so any position:fixed descendant gets trapped by the transform
|
||||
// and rides along with the panel. Re-parent the controls panel to
|
||||
// the editor root so it can truly fix to the viewport bottom
|
||||
// regardless of the layers-sheet state. On desktop, controls stay
|
||||
// docked inside the right panel above the layers list.
|
||||
if (window.innerWidth <= 700 && state.container) {
|
||||
state.container.appendChild(controls);
|
||||
}
|
||||
|
||||
// Move every slider-row's value chip out of its <label> and place
|
||||
// it AFTER the slider, so the value sits on the right edge of the
|
||||
// row instead of being smashed against the slider track on the left.
|
||||
controls.querySelectorAll('.ge-eraser-row').forEach(row => {
|
||||
const valueSpan = row.querySelector('label > span[id$="-label"]');
|
||||
const slider = row.querySelector('input[type="range"]');
|
||||
if (valueSpan && slider) {
|
||||
valueSpan.classList.add('ge-slider-value');
|
||||
slider.after(valueSpan);
|
||||
}
|
||||
});
|
||||
|
||||
// Layer panel.
|
||||
const layerPanel = document.createElement('div');
|
||||
layerPanel.className = 'ge-layers';
|
||||
layerPanel.innerHTML = layerPanelHTML();
|
||||
rightPanel.appendChild(layerPanel);
|
||||
// Mobile: tap the header grab handle or swipe up/down to toggle
|
||||
// the layers sheet between peek and expanded. The peek state
|
||||
// always shows the active layer so users never lose access to it.
|
||||
{
|
||||
const header = layerPanel.querySelector('.ge-layers-header');
|
||||
if (header) {
|
||||
let sy = 0, sx = 0, dragging = false, didSwipe = false;
|
||||
header.addEventListener('touchstart', (e) => {
|
||||
if (window.innerWidth > 700) return;
|
||||
if (e.target.closest('button')) return;
|
||||
sy = e.touches[0].clientY;
|
||||
sx = e.touches[0].clientX;
|
||||
dragging = true;
|
||||
didSwipe = false;
|
||||
}, { passive: true });
|
||||
header.addEventListener('touchend', (e) => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
const dy = e.changedTouches[0].clientY - sy;
|
||||
const dx = Math.abs(e.changedTouches[0].clientX - sx);
|
||||
// Real swipe — three states cycle by direction:
|
||||
// minimized → peek → expanded (swipe up)
|
||||
// expanded → peek → minimized (swipe down)
|
||||
if (Math.abs(dy) > 20 && Math.abs(dy) > dx) {
|
||||
didSwipe = true;
|
||||
const isExpanded = rightPanel.classList.contains('expanded');
|
||||
const isMinimized = rightPanel.classList.contains('minimized');
|
||||
if (dy < 0) {
|
||||
if (isMinimized) {
|
||||
rightPanel.classList.remove('minimized');
|
||||
} else if (!isExpanded) {
|
||||
rightPanel.classList.add('expanded');
|
||||
}
|
||||
} else {
|
||||
if (isExpanded) {
|
||||
rightPanel.classList.remove('expanded');
|
||||
} else if (!isMinimized) {
|
||||
rightPanel.classList.add('minimized');
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
header.addEventListener('click', (e) => {
|
||||
if (window.innerWidth > 700) return;
|
||||
if (e.target.closest('button')) return;
|
||||
if (didSwipe) { didSwipe = false; return; }
|
||||
// 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).
|
||||
if (rightPanel.classList.contains('minimized')) {
|
||||
rightPanel.classList.remove('minimized');
|
||||
} else {
|
||||
rightPanel.classList.toggle('expanded');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal drag handle on the LEFT edge of the right panel — drag
|
||||
// left to widen, right to narrow. Persists chosen width in
|
||||
// localStorage so it survives reopens. (Earlier version was a
|
||||
// vertical-drag for height; horizontal feels more natural since
|
||||
// cramped LAYER ROWS are about width, not height.)
|
||||
const panelResize = document.createElement('div');
|
||||
panelResize.className = 'ge-panel-resize';
|
||||
panelResize.title = 'Drag to resize panel';
|
||||
rightPanel.appendChild(panelResize);
|
||||
try {
|
||||
const savedW = parseInt(localStorage.getItem('ge-right-panel-width') || '', 10);
|
||||
if (savedW && savedW > 160 && savedW < 800) rightPanel.style.flex = `0 0 ${savedW}px`;
|
||||
} catch {}
|
||||
let panelResizing = false;
|
||||
let panelStartX = 0;
|
||||
let panelStartW = 0;
|
||||
panelResize.addEventListener('mousedown', (e) => {
|
||||
panelResizing = true;
|
||||
panelStartX = e.clientX;
|
||||
panelStartW = rightPanel.getBoundingClientRect().width;
|
||||
e.preventDefault();
|
||||
document.body.style.cursor = 'ew-resize';
|
||||
});
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!panelResizing) return;
|
||||
// Dragging left → wider panel (the panel sits on the right of
|
||||
// the editor, so a leftward drag pulls its left edge left).
|
||||
const delta = panelStartX - e.clientX;
|
||||
const next = Math.max(160, Math.min(window.innerWidth - 200, panelStartW + delta));
|
||||
rightPanel.style.flex = `0 0 ${next}px`;
|
||||
});
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (!panelResizing) return;
|
||||
panelResizing = false;
|
||||
document.body.style.cursor = '';
|
||||
try {
|
||||
const w = Math.round(rightPanel.getBoundingClientRect().width);
|
||||
localStorage.setItem('ge-right-panel-width', String(w));
|
||||
} catch {}
|
||||
});
|
||||
|
||||
return { rightPanel, controls, layerPanel, panelResize };
|
||||
}
|
||||
73
static/js/editor/build/toolbar.js
Normal file
73
static/js/editor/build/toolbar.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Build the editor's left-side tool palette.
|
||||
*
|
||||
* Pure DOM construction — no module state. The big tool-switch logic
|
||||
* (cursor swap, control-section toggle, transform entry, inpaint
|
||||
* mask plumbing, etc.) stays in the caller and arrives here as the
|
||||
* `onSelectTool` callback.
|
||||
*
|
||||
* @param {{
|
||||
* currentTool: string,
|
||||
* onSelectTool: (toolId: string, btn: HTMLButtonElement, toolbar: HTMLDivElement) => void,
|
||||
* onClearSelection: (which: 'lasso'|'wand') => void,
|
||||
* }} ctx
|
||||
* @returns {{ toolbar: HTMLDivElement, toolKeyMap: Record<string,string> }}
|
||||
*/
|
||||
export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'ge-toolbar';
|
||||
const tools = [
|
||||
{ id: 'move', label: 'Move', icon: '✥', key: 'V' },
|
||||
{ id: 'crop', label: 'Crop', icon: '✂', key: 'C' },
|
||||
{ id: 'transform', label: 'Transform', icon: '⤢', key: 'T' },
|
||||
{ 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: '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' },
|
||||
{ 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: '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' },
|
||||
{ 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: '✄' },
|
||||
{ id: 'sharpen', ai: true, label: 'Sharpen', icon: '◈', key: 'S' },
|
||||
];
|
||||
const toolKeyMap = {};
|
||||
for (const t of tools) {
|
||||
if (t.sep) {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'ge-tool-sep';
|
||||
sep.textContent = t.label;
|
||||
toolbar.appendChild(sep);
|
||||
continue;
|
||||
}
|
||||
if (t.key) toolKeyMap[t.key.toLowerCase()] = t.id;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'ge-tool-btn' + (t.id === currentTool ? ' active' : '');
|
||||
btn.dataset.tool = t.id;
|
||||
btn.title = t.label + (t.key ? ` (${t.key})` : '');
|
||||
// Heavy 4-point AI star marker for AI-backed tools — sits just to
|
||||
// the left of the icon so the user can spot AI vs local tools at a
|
||||
// glance now that the "AI Tools" separator is gone.
|
||||
const aiStar = t.ai ? '<span class="ge-tool-ai" title="AI">✦</span>' : '';
|
||||
btn.classList.toggle('is-ai', !!t.ai);
|
||||
// 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 + '">' +
|
||||
'<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>'
|
||||
: '';
|
||||
btn.innerHTML = `${aiStar}<span class="ge-tool-icon"${t.small ? ' style="font-size:14px"' : ''}>${t.icon}</span><span class="ge-tool-label">${t.label}</span>${clearBadge}`;
|
||||
// Clear-badge click stops propagation so the tool itself doesn't
|
||||
// toggle; the actual clear is handled by the caller.
|
||||
btn.querySelector('.ge-tool-clear')?.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
onClearSelection(ev.currentTarget.dataset.clearTool);
|
||||
});
|
||||
btn.addEventListener('click', () => onSelectTool(t.id, btn, toolbar));
|
||||
toolbar.appendChild(btn);
|
||||
}
|
||||
return { toolbar, toolKeyMap };
|
||||
}
|
||||
131
static/js/editor/build/topbar.js
Normal file
131
static/js/editor/build/topbar.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Build the editor's top bar (undo/redo/history, zoom group, Image
|
||||
* menu, Filter menu, Selection-edge menu, Shortcuts, Import, Save).
|
||||
*
|
||||
* Pure DOM — no module state, no event listeners. All wiring is done
|
||||
* by the caller via `document.getElementById(...)` against the IDs
|
||||
* baked into the markup.
|
||||
*
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
export function buildTopbar() {
|
||||
const topBar = document.createElement('div');
|
||||
topBar.className = 'ge-topbar';
|
||||
topBar.innerHTML = `
|
||||
<div class="ge-topbar-left">
|
||||
<span class="ge-alpha-badge" title="This editor is in active development — expect rough edges">ALPHA</span>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-undo" title="Undo">
|
||||
<span class="ge-stacked-glyph">↩</span>
|
||||
<span class="ge-stacked-label">UNDO</span>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-redo" title="Redo">
|
||||
<span class="ge-stacked-glyph">↪</span>
|
||||
<span class="ge-stacked-label">REDO</span>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-history-btn" title="History — click an entry to jump to that state" aria-label="History">
|
||||
<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>
|
||||
<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">
|
||||
<span class="ge-zoom-glyph">
|
||||
<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="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</span>
|
||||
<span class="ge-zoom-label">100%</span>
|
||||
</span>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-zoom-in" title="Zoom in">+</button>
|
||||
<span class="ge-topbar-sep"></span>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-zoom-fit" title="Fit to view" 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"><polyline points="4 14 4 20 10 20"/><polyline points="20 10 20 4 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg></span>
|
||||
<span class="ge-stacked-label">FIT</span>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-zoom-100" title="Actual size" aria-pressed="false">
|
||||
<span class="ge-stacked-glyph">1:1</span>
|
||||
<span class="ge-stacked-label">SCALE</span>
|
||||
</button>
|
||||
<span class="ge-topbar-sep"></span>
|
||||
</div>
|
||||
<div class="ge-topbar-right">
|
||||
<span class="ge-canvas-size" id="ge-canvas-size" title="Canvas size" hidden></span>
|
||||
<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>
|
||||
<div class="ge-image-menu dropdown" id="ge-image-menu" hidden>
|
||||
<button class="dropdown-item-compact" data-image-action="resize">
|
||||
<span class="dropdown-icon">⤢</span>
|
||||
<span>Canvas…</span>
|
||||
</button>
|
||||
<div class="ge-filter-submenu-label">Transform</div>
|
||||
<button class="dropdown-item-compact" data-image-action="rotate-90">
|
||||
<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="M21 12a9 9 0 1 1-3-6.7"/><polyline points="21 3 21 9 15 9"/></svg></span>
|
||||
<span>Rotate 90° CW</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-image-action="rotate-180">
|
||||
<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"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></span>
|
||||
<span>Rotate 180°</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-image-action="flip-h">
|
||||
<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="M21 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7"/><line x1="12" y1="3" x2="12" y2="21"/><polyline points="7 11 4 7 7 3"/><polyline points="17 11 20 7 17 3"/></svg></span>
|
||||
<span>Flip horizontal</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-image-action="flip-v">
|
||||
<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="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h10"/><line x1="3" y1="12" x2="21" y2="12"/><polyline points="11 7 7 4 3 7"/><polyline points="11 17 7 20 3 17"/></svg></span>
|
||||
<span>Flip vertical</span>
|
||||
</button>
|
||||
</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>
|
||||
<div class="ge-filter-menu dropdown" id="ge-filter-menu" hidden>
|
||||
<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>
|
||||
<span>Gaussian Blur…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="blur-zoom">
|
||||
<span class="dropdown-icon ge-blur-icon ge-blur-zoom" aria-hidden="true"></span>
|
||||
<span>Zoom Blur…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="ge-topbar-sep"></span>
|
||||
<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>
|
||||
<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>
|
||||
<div class="ge-save-menu dropdown" id="ge-save-menu" hidden>
|
||||
<div class="dropdown-section-label">Image</div>
|
||||
<button class="dropdown-item-compact" id="ge-save" title="Overwrite the original image">
|
||||
<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="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>
|
||||
<span>Save over original</span>
|
||||
<span class="dropdown-shortcut">Ctrl+S</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" id="ge-export-gallery" title="Save as a new image in the gallery">
|
||||
<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="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><line x1="12" y1="11" x2="12" y2="17"/><line x1="9" y1="14" x2="15" y2="14"/></svg></span>
|
||||
<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">
|
||||
<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>
|
||||
</button>
|
||||
<div class="dropdown-section-divider"></div>
|
||||
<div class="dropdown-section-label">Project</div>
|
||||
<button class="dropdown-item-compact" id="ge-save-project" title="Save layered project (.json) — keeps every layer editable for later">
|
||||
<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"><rect x="3" y="3" width="13" height="13" rx="1"/><rect x="8" y="8" width="13" height="13" rx="1"/></svg></span>
|
||||
<span>Save project (.json)</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" id="ge-load-project" title="Open a previously-saved project file">
|
||||
<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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
|
||||
<span>Load project…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return topBar;
|
||||
}
|
||||
109
static/js/editor/build/transform-popup.js
Normal file
109
static/js/editor/build/transform-popup.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Static markup for the Transform popup that floats over the canvas
|
||||
* when the user activates the Resize/Transform tool.
|
||||
*
|
||||
* Pure DOM — no module state, no event listeners. The caller wires all
|
||||
* IDs via document.getElementById / pop.querySelector.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transformPopupHTML() {
|
||||
return `
|
||||
<div class="ge-adj-head ge-transform-popup-head" data-transform-drag>
|
||||
<span class="ge-adj-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 7 7 3 11 7"/><line x1="7" y1="3" x2="7" y2="21"/><polyline points="21 17 17 21 13 17"/><line x1="17" y1="21" x2="17" y2="3"/></svg>
|
||||
</span>
|
||||
<span class="ge-adj-title">Transform</span>
|
||||
<button type="button" id="ge-transform-aspect" class="ge-transform-aspect-btn" 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"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
</button>
|
||||
<span class="ge-head-btns">
|
||||
<button class="ge-adj-min" type="button" title="Minimise" id="ge-transform-min">−</button>
|
||||
<button class="ge-adj-close" type="button" title="Cancel" id="ge-transform-cancel">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-transform-popup-body">
|
||||
<div class="ge-transform-field">
|
||||
<label>W</label>
|
||||
<input type="number" class="ge-transform-popup-input" id="ge-transform-w" step="1" />
|
||||
<span class="ge-transform-spin" data-spin-for="ge-transform-w">
|
||||
<button type="button" data-spin="down" tabindex="-1" aria-label="Decrease width">−</button>
|
||||
<button type="button" data-spin="up" tabindex="-1" aria-label="Increase width">+</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-transform-field">
|
||||
<label>H</label>
|
||||
<input type="number" class="ge-transform-popup-input" id="ge-transform-h" step="1" />
|
||||
<span class="ge-transform-spin" data-spin-for="ge-transform-h">
|
||||
<button type="button" data-spin="down" tabindex="-1" aria-label="Decrease height">−</button>
|
||||
<button type="button" data-spin="up" tabindex="-1" aria-label="Increase height">+</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-row-break"></div>
|
||||
<div class="ge-transform-field">
|
||||
<label>↻</label>
|
||||
<input type="number" class="ge-transform-popup-input ge-transform-popup-input-rot" id="ge-transform-rot" step="1" value="0" />
|
||||
<span class="ge-transform-spin" data-spin-for="ge-transform-rot">
|
||||
<button type="button" data-spin="down" tabindex="-1" aria-label="Rotate -1°">−</button>
|
||||
<button type="button" data-spin="up" tabindex="-1" aria-label="Rotate +1°">+</button>
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
<p class="ge-transform-popup-hint">Type <strong>-</strong> before W / H to flip.</p>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wire a `<span class="ge-transform-spin">…<button data-spin="up|down"/>…</span>`
|
||||
* group with tap-to-tick + hold-to-repeat. After 1.5 s the repeat
|
||||
* accelerates from 70ms→30ms intervals so users can rapidly scrub a
|
||||
* numeric field without mashing the button.
|
||||
*
|
||||
* On each tick, the helper looks up the target `<input>` by the
|
||||
* spin-group's `data-spin-for` attribute and dispatches an `input`
|
||||
* event so the rest of the popup's wiring picks up the change.
|
||||
*
|
||||
* @param {HTMLElement} root Element that owns one or more spin groups
|
||||
* (e.g. the transform popup).
|
||||
*/
|
||||
export function attachSpinRepeat(root) {
|
||||
root.querySelectorAll('.ge-transform-spin button').forEach(btn => {
|
||||
const tick = (shift) => {
|
||||
const targetId = btn.parentElement?.dataset?.spinFor;
|
||||
if (!targetId) return;
|
||||
const input = root.querySelector('#' + CSS.escape(targetId));
|
||||
if (!input || input.readOnly) return;
|
||||
const step = shift ? 10 : 1;
|
||||
const cur = parseInt(input.value, 10) || 0;
|
||||
const next = btn.dataset.spin === 'up' ? cur + step : cur - step;
|
||||
input.value = String(next);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
};
|
||||
let holdTimeout = null, repeatInterval = null, started = 0;
|
||||
btn.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
tick(e.shiftKey);
|
||||
started = Date.now();
|
||||
holdTimeout = setTimeout(() => {
|
||||
repeatInterval = setInterval(() => {
|
||||
tick(false);
|
||||
if (Date.now() - started > 1500 && repeatInterval) {
|
||||
clearInterval(repeatInterval);
|
||||
repeatInterval = setInterval(() => tick(false), 30);
|
||||
}
|
||||
}, 70);
|
||||
}, 350);
|
||||
});
|
||||
const endHold = () => {
|
||||
if (holdTimeout) clearTimeout(holdTimeout);
|
||||
if (repeatInterval) clearInterval(repeatInterval);
|
||||
holdTimeout = null; repeatInterval = null;
|
||||
};
|
||||
btn.addEventListener('pointerup', endHold);
|
||||
btn.addEventListener('pointerleave', endHold);
|
||||
btn.addEventListener('pointercancel', endHold);
|
||||
});
|
||||
}
|
||||
21
static/js/editor/canvas-coords.js
Normal file
21
static/js/editor/canvas-coords.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Convert a pointer event's client coordinates into the canvas's
|
||||
* internal pixel coordinates, accounting for current display scale.
|
||||
*
|
||||
* Handles both mouse and the first finger of a touch event.
|
||||
*
|
||||
* @param {MouseEvent|TouchEvent} e
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @returns {{x: number, y: number}}
|
||||
*/
|
||||
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;
|
||||
return {
|
||||
x: (clientX - rect.left) * scaleX,
|
||||
y: (clientY - rect.top) * scaleY,
|
||||
};
|
||||
}
|
||||
197
static/js/editor/canvas-events.js
Normal file
197
static/js/editor/canvas-events.js
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Canvas event wiring — mouse, touch (including pinch-zoom on two
|
||||
* fingers), and the canvas-area pan handler.
|
||||
*
|
||||
* Mouse:
|
||||
* mousedown on canvas → beginDraw
|
||||
* mousemove on window → continueDraw (window so a drag can
|
||||
* continue past the canvas edge)
|
||||
* mouseup on window → endDraw
|
||||
* mouseenter/mouseleave → show/hide the brush-cursor overlay
|
||||
* mousedown on canvas-area (NOT on the canvas itself, lasso only)
|
||||
* → beginDraw (lasso starts outside canvas)
|
||||
*
|
||||
* Touch:
|
||||
* touchstart 1 finger → beginDraw
|
||||
* touchmove 1 finger → continueDraw
|
||||
* touchend / touchcancel → endDraw
|
||||
* 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.
|
||||
*
|
||||
* Exposes `canvasArea._resetPan()` so the zoom/fit reset can clear
|
||||
* the pan offset.
|
||||
*
|
||||
* @param {{
|
||||
* canvasArea: HTMLDivElement,
|
||||
* beginDraw: (e: Event) => void,
|
||||
* continueDraw: (e: Event) => void,
|
||||
* endDraw: (e?: Event) => void,
|
||||
* updateBrushCursor: (e: Event) => void,
|
||||
* syncZoomControls?: () => void,
|
||||
* }} ctx
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, updateBrushCursor, syncZoomControls }) {
|
||||
// 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);
|
||||
// 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.
|
||||
canvasArea.addEventListener('mousedown', (e) => {
|
||||
if (state.tool !== 'lasso') return;
|
||||
if (e.target === state.mainCanvas) return; // already handled
|
||||
beginDraw(e);
|
||||
});
|
||||
state.mainCanvas.addEventListener('mouseenter', (e) => {
|
||||
if (['brush', 'eraser', 'inpaint', 'lasso', 'clone'].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';
|
||||
});
|
||||
|
||||
// Touch — single finger draws; two fingers pan + pinch-zoom.
|
||||
let multiActive = false;
|
||||
let multiStartDist = 0;
|
||||
let multiStartZoom = 1;
|
||||
let multiStartCenter = { x: 0, y: 0 };
|
||||
let multiStartPan = { x: 0, y: 0 };
|
||||
const touchInfo = (e) => {
|
||||
const t1 = e.touches[0], t2 = e.touches[1];
|
||||
const cx = (t1.clientX + t2.clientX) / 2;
|
||||
const cy = (t1.clientY + t2.clientY) / 2;
|
||||
const dx = t2.clientX - t1.clientX;
|
||||
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;
|
||||
};
|
||||
state.mainCanvas.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
if (e.touches.length >= 2) {
|
||||
// End any in-progress single-finger draw before switching modes.
|
||||
if (!multiActive) endDraw();
|
||||
multiActive = true;
|
||||
const info = touchInfo(e);
|
||||
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,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (multiActive) return;
|
||||
beginDraw(e);
|
||||
}, { passive: false });
|
||||
state.mainCanvas.addEventListener('touchmove', (e) => {
|
||||
e.preventDefault();
|
||||
if (multiActive && e.touches.length >= 2) {
|
||||
const info = touchInfo(e);
|
||||
const ratio = info.dist / Math.max(1, multiStartDist);
|
||||
const newZoom = Math.max(0.1, Math.min(5, multiStartZoom * ratio));
|
||||
if (Math.abs(newZoom - state.zoom) > 0.001) {
|
||||
state.zoom = newZoom;
|
||||
state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px';
|
||||
state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px';
|
||||
const label = state.container.querySelector('.ge-zoom-label');
|
||||
if (label) label.textContent = Math.round(state.zoom * 100) + '%';
|
||||
syncZoomControls?.();
|
||||
}
|
||||
const dx = info.cx - multiStartCenter.x;
|
||||
const dy = info.cy - multiStartCenter.y;
|
||||
applyCanvasOffset(multiStartPan.x + dx, multiStartPan.y + dy);
|
||||
return;
|
||||
}
|
||||
if (multiActive) return;
|
||||
continueDraw(e);
|
||||
}, { passive: false });
|
||||
state.mainCanvas.addEventListener('touchend', (e) => {
|
||||
if (multiActive) {
|
||||
if (e.touches.length < 2) multiActive = false;
|
||||
return;
|
||||
}
|
||||
endDraw(e);
|
||||
});
|
||||
state.mainCanvas.addEventListener('touchcancel', () => {
|
||||
multiActive = false;
|
||||
endDraw();
|
||||
});
|
||||
|
||||
// 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.
|
||||
let panning = false;
|
||||
let pid = 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 };
|
||||
};
|
||||
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;
|
||||
};
|
||||
canvasArea.addEventListener('pointerdown', (e) => {
|
||||
if (state.tool === 'lasso') return;
|
||||
if (e.target === state.mainCanvas || e.target === state.transformOverlay) 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;
|
||||
// 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) {
|
||||
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;
|
||||
}
|
||||
const off = getOffset();
|
||||
panning = true;
|
||||
pid = e.pointerId;
|
||||
startX = e.clientX - off.x;
|
||||
startY = e.clientY - off.y;
|
||||
try { canvasArea.setPointerCapture(pid); } catch {}
|
||||
canvasArea.style.cursor = 'grabbing';
|
||||
e.preventDefault();
|
||||
});
|
||||
canvasArea.addEventListener('pointermove', (e) => {
|
||||
if (!panning || e.pointerId !== pid) return;
|
||||
applyOffset(e.clientX - startX, e.clientY - startY);
|
||||
});
|
||||
const endPan = () => {
|
||||
if (!panning) return;
|
||||
panning = false;
|
||||
try { canvasArea.releasePointerCapture(pid); } catch {}
|
||||
pid = null;
|
||||
canvasArea.style.cursor = '';
|
||||
};
|
||||
canvasArea.addEventListener('pointerup', endPan);
|
||||
canvasArea.addEventListener('pointercancel', endPan);
|
||||
// Reset offset whenever zoom/fit changes the canvas size.
|
||||
canvasArea._resetPan = () => applyOffset(0, 0);
|
||||
}
|
||||
132
static/js/editor/canvas-transforms.js
Normal file
132
static/js/editor/canvas-transforms.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Whole-document transforms: rotate by 90/180/270° or flip horizontal/
|
||||
* vertical. These mutate every layer's canvas + the offset map + the
|
||||
* document's overall width/height so the result feels like the whole
|
||||
* image rotated as one piece.
|
||||
*
|
||||
* Pure-ish — reads/writes shared state directly; the factory takes a
|
||||
* small dep bag for the orchestration plumbing (undo snapshot, canvas
|
||||
* loading overlay, fit-zoom-to-viewport, composite redraw).
|
||||
*
|
||||
* @param {{
|
||||
* saveState: (label?: string) => void,
|
||||
* composite: () => void,
|
||||
* fitZoom: () => void,
|
||||
* showCanvasLoading: (label: string) => void,
|
||||
* hideCanvasLoading: () => void,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
export function createCanvasTransforms({ saveState, composite, fitZoom, showCanvasLoading, hideCanvasLoading }) {
|
||||
return {
|
||||
/**
|
||||
* Rotate the entire document by `deg` (90 / 180 / 270). 90 and 270
|
||||
* swap canvas dimensions. Each layer is rotated around its own
|
||||
* centre, then its centre is rotated around the old image centre
|
||||
* and translated into the new image's frame.
|
||||
*
|
||||
* Wrapped in requestAnimationFrame because the rotation pass can
|
||||
* block the UI for 0.5–2 s on big images — the spinner overlay
|
||||
* paints before we block.
|
||||
*/
|
||||
rotateAll(deg) {
|
||||
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 sizeLabel = document.getElementById('ge-canvas-size');
|
||||
if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`;
|
||||
fitZoom();
|
||||
composite();
|
||||
} finally {
|
||||
hideCanvasLoading();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Mirror every layer horizontally ('h') or vertically ('v').
|
||||
* Canvas dimensions don't change. Each layer offset is reflected
|
||||
* around the image centre.
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
}
|
||||
composite();
|
||||
},
|
||||
};
|
||||
}
|
||||
24
static/js/editor/checkerboard.js
Normal file
24
static/js/editor/checkerboard.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Paint a transparency-checkerboard pattern across the given canvas
|
||||
* context. The editor uses this beneath every layer pass so empty
|
||||
* (transparent) areas of the document are visible.
|
||||
*
|
||||
* Pure function — depends only on its arguments.
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} w Width in canvas pixels.
|
||||
* @param {number} h Height in canvas pixels.
|
||||
*/
|
||||
export function drawCheckerboard(ctx, w, h) {
|
||||
const size = 10;
|
||||
ctx.fillStyle = '#ccc';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.fillStyle = '#fff';
|
||||
for (let y = 0; y < h; y += size) {
|
||||
for (let x = 0; x < w; x += size) {
|
||||
if ((Math.floor(x / size) + Math.floor(y / size)) % 2 === 0) {
|
||||
ctx.fillRect(x, y, size, size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
133
static/js/editor/clipboard-and-drop.js
Normal file
133
static/js/editor/clipboard-and-drop.js
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Paste + drag-and-drop import handlers. Both add an image to the
|
||||
* editor as a new layer:
|
||||
*
|
||||
* - Paste (Ctrl+V): checks `state.internalClipboard` first (set by
|
||||
* lasso copy/cut), then falls back to the system clipboard's
|
||||
* `image/*` items. Layer is named "Pasted Selection" or "Pasted"
|
||||
* and becomes active; the tool snaps to Move so the user can
|
||||
* reposition it immediately.
|
||||
* - Drop: any `image/*` file dragged from the OS / another tab.
|
||||
* Shows a "Drop image to add as new layer" overlay mid-drag. Each
|
||||
* dropped image is routed through `handleImportedImage` so canvas-
|
||||
* resize prompts + undo history work the same as the toolbar
|
||||
* Import button.
|
||||
*
|
||||
* Both gated by `state.editorOpen` so they're inert when the editor
|
||||
* is closed (other listeners on the page get first dibs).
|
||||
*
|
||||
* @param {{
|
||||
* container: HTMLElement,
|
||||
* saveState: (label?: string) => void,
|
||||
* createLayer: (name: string, w: number, h: number) => object,
|
||||
* renderLayerPanel: () => void,
|
||||
* composite: () => void,
|
||||
* handleImportedImage: (img: HTMLImageElement) => void,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
export function wireClipboardAndDrop({
|
||||
container, saveState, createLayer, renderLayerPanel, composite,
|
||||
handleImportedImage, uiModule,
|
||||
}) {
|
||||
// ── Paste ──
|
||||
window.addEventListener('paste', (e) => {
|
||||
if (!state.editorOpen) return;
|
||||
|
||||
function pasteAsLayer(imgSource, label) {
|
||||
if (!state.editorOpen) return; // user closed mid-paste
|
||||
saveState();
|
||||
const layer = createLayer(label || 'Pasted', imgSource.width, imgSource.height);
|
||||
layer.ctx.drawImage(imgSource, 0, 0);
|
||||
state.layers.push(layer);
|
||||
state.activeLayerId = layer.id;
|
||||
state.tool = 'move';
|
||||
const tb = state.container?.querySelector('.ge-toolbar');
|
||||
if (tb) tb.querySelectorAll('.ge-tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === 'move'));
|
||||
renderLayerPanel();
|
||||
composite();
|
||||
uiModule.showToast('Pasted as new layer');
|
||||
}
|
||||
|
||||
// Check internal clipboard first (from Ctrl+C lasso/wand).
|
||||
if (state.internalClipboard) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
pasteAsLayer(state.internalClipboard, 'Pasted Selection');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to system clipboard.
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of items) {
|
||||
if (!item.type.startsWith('image/')) continue;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
const blob = item.getAsFile();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = () => { pasteAsLayer(img, 'Pasted'); URL.revokeObjectURL(url); };
|
||||
img.src = url;
|
||||
break;
|
||||
}
|
||||
}, true); // capture phase so we beat chat input
|
||||
|
||||
// ── Drag-and-drop ──
|
||||
// Visual drop-zone overlay appears mid-drag; routes via
|
||||
// handleImportedImage so the import respects canvas resizing rules
|
||||
// + saves history (same path as the toolbar Import button).
|
||||
const dropZone = container;
|
||||
if (!dropZone) return;
|
||||
let dragDepth = 0;
|
||||
const hasFileType = (dt) => dt && Array.from(dt.types || []).some(t => t === 'Files');
|
||||
const showOverlay = () => {
|
||||
if (!state.editorOpen) return;
|
||||
let ov = dropZone.querySelector('.ge-drop-overlay');
|
||||
if (!ov) {
|
||||
ov = document.createElement('div');
|
||||
ov.className = 'ge-drop-overlay';
|
||||
ov.innerHTML = '<div class="ge-drop-overlay-msg">Drop image to add as new layer</div>';
|
||||
dropZone.appendChild(ov);
|
||||
}
|
||||
ov.style.display = '';
|
||||
};
|
||||
const hideOverlay = () => {
|
||||
const ov = dropZone.querySelector('.ge-drop-overlay');
|
||||
if (ov) ov.style.display = 'none';
|
||||
};
|
||||
dropZone.addEventListener('dragenter', (e) => {
|
||||
if (!state.editorOpen || !hasFileType(e.dataTransfer)) return;
|
||||
e.preventDefault();
|
||||
dragDepth++;
|
||||
showOverlay();
|
||||
});
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
if (!state.editorOpen || !hasFileType(e.dataTransfer)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
if (!state.editorOpen) return;
|
||||
dragDepth = Math.max(0, dragDepth - 1);
|
||||
if (dragDepth === 0) hideOverlay();
|
||||
});
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
if (!state.editorOpen) return;
|
||||
dragDepth = 0;
|
||||
hideOverlay();
|
||||
const files = Array.from(e.dataTransfer?.files || []).filter(f => f.type.startsWith('image/'));
|
||||
if (!files.length) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
for (const f of files) {
|
||||
const url = URL.createObjectURL(f);
|
||||
const img = new Image();
|
||||
img.onload = () => { handleImportedImage(img); URL.revokeObjectURL(url); };
|
||||
img.onerror = () => URL.revokeObjectURL(url);
|
||||
img.src = url;
|
||||
}
|
||||
});
|
||||
}
|
||||
83
static/js/editor/composite-helpers.js
Normal file
83
static/js/editor/composite-helpers.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Pure composite helpers — flatten a layer list into a single canvas
|
||||
* for thumbnails / merged-mask use.
|
||||
*
|
||||
* Both helpers are stateless: the caller passes everything they need
|
||||
* (layer list, canvas dimensions, an offsets lookup). The legacy
|
||||
* gallery editor's module-level functions wrap these with their own
|
||||
* state.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cheap downscaled preview composited from all visible layers.
|
||||
* Returns a JPEG dataURL, or null when there's nothing to draw.
|
||||
*
|
||||
* @param {Array<{visible: boolean, opacity: number, id: string, canvas: HTMLCanvasElement}>} layers
|
||||
* @param {number} imgW Document width in canvas pixels.
|
||||
* @param {number} imgH Document height in canvas pixels.
|
||||
* @param {Map<string,{x:number,y:number}>} offsets Layer offsets, keyed by id.
|
||||
* @param {number} maxDim Longest-edge target in CSS pixels.
|
||||
* @param {number} quality JPEG quality 0..1.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function buildThumbnail(layers, imgW, imgH, offsets, maxDim, quality = 0.6) {
|
||||
if (!imgW || !imgH) return null;
|
||||
try {
|
||||
const scale = Math.min(1, maxDim / Math.max(imgW, imgH));
|
||||
const tw = Math.max(1, Math.round(imgW * scale));
|
||||
const th = Math.max(1, Math.round(imgH * scale));
|
||||
const c = document.createElement('canvas');
|
||||
c.width = tw; c.height = th;
|
||||
const ctx = c.getContext('2d');
|
||||
for (const layer of layers) {
|
||||
if (!layer.visible) continue;
|
||||
ctx.globalAlpha = layer.opacity;
|
||||
const off = offsets.get(layer.id) || { x: 0, y: 0 };
|
||||
ctx.drawImage(
|
||||
layer.canvas,
|
||||
off.x * scale, off.y * scale,
|
||||
layer.canvas.width * scale, layer.canvas.height * scale,
|
||||
);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
return c.toDataURL('image/jpeg', quality);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Union of every visible mask sub-layer across `layers`, rendered as a
|
||||
* binary white canvas the size of the document.
|
||||
*
|
||||
* `lighter` composite = additive — overlapping pixels stay clamped at
|
||||
* 255, so wherever any mask painted, the result is solid white.
|
||||
* Returns null when no mask layer contributed any pixels (so the caller
|
||||
* can early-out cleanly).
|
||||
*
|
||||
* @param {Array<{masks?: Array<{visible: boolean, canvas: HTMLCanvasElement}>}>} layers
|
||||
* @param {number} imgW
|
||||
* @param {number} imgH
|
||||
* @returns {HTMLCanvasElement|null}
|
||||
*/
|
||||
export function buildMergedMaskCanvas(layers, imgW, imgH) {
|
||||
if (!imgW || !imgH) return null;
|
||||
const out = document.createElement('canvas');
|
||||
out.width = imgW;
|
||||
out.height = imgH;
|
||||
const ctx = out.getContext('2d');
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
let anyMask = false;
|
||||
for (const ly of layers) {
|
||||
if (!ly.masks || !ly.masks.length) continue;
|
||||
for (const mk of ly.masks) {
|
||||
if (!mk.visible) continue;
|
||||
if (!mk.canvas || !mk.canvas.width || !mk.canvas.height) continue;
|
||||
ctx.drawImage(mk.canvas, 0, 0);
|
||||
anyMask = true;
|
||||
}
|
||||
}
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
return anyMask ? out : null;
|
||||
}
|
||||
118
static/js/editor/filters/blur.js
Normal file
118
static/js/editor/filters/blur.js
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Pure blur renderers shared by the editor's live-preview popups.
|
||||
*
|
||||
* Each export matches the `renderer(snap, params, dst)` signature
|
||||
* expected by `_applyLiveBlur` in galleryEditor.js — `snap` is the
|
||||
* pre-blur snapshot canvas, `params` is the slider values object, and
|
||||
* `dst` is the 2D context to draw the final result into. No module
|
||||
* state.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gaussian blur with clamp-to-edge sampling.
|
||||
*
|
||||
* Canvas `filter: blur()` naively blends with TRANSPARENT pixels outside
|
||||
* the image which fades the borders out. To match Photoshop's
|
||||
* "Edge: Clamp" Gaussian we pad the source onto a larger buffer with
|
||||
* the edge pixels stretched into the margin (4 strips + 4 corners),
|
||||
* blur the padded buffer, then copy only the original-size centre back.
|
||||
*
|
||||
* @param {HTMLCanvasElement} snap
|
||||
* @param {{ radius: number }} v
|
||||
* @param {CanvasRenderingContext2D} dst
|
||||
*/
|
||||
export function gaussianBlur(snap, v, dst) {
|
||||
if (!v.radius || v.radius <= 0) { dst.drawImage(snap, 0, 0); return; }
|
||||
const r = v.radius;
|
||||
const w = snap.width, h = snap.height;
|
||||
// Margin needs to cover the kernel's effective reach — most
|
||||
// engines saturate within ~2× the radius.
|
||||
const m = Math.ceil(r * 2 + 4);
|
||||
const pad = document.createElement('canvas');
|
||||
pad.width = w + m * 2;
|
||||
pad.height = h + m * 2;
|
||||
const pctx = pad.getContext('2d');
|
||||
pctx.drawImage(snap, m, m);
|
||||
// Edge strips: drawImage with src height=1 (or width=1) into a
|
||||
// dst region of size `m` stretches the edge pixels into the
|
||||
// margin — same effect as clamp-to-edge sampling.
|
||||
pctx.drawImage(snap, 0, 0, w, 1, m, 0, w, m);
|
||||
pctx.drawImage(snap, 0, h - 1, w, 1, m, m + h, w, m);
|
||||
pctx.drawImage(snap, 0, 0, 1, h, 0, m, m, h);
|
||||
pctx.drawImage(snap, w - 1, 0, 1, h, m + w, m, m, h);
|
||||
// Corners — stretch the corner pixel into an m×m block.
|
||||
pctx.drawImage(snap, 0, 0, 1, 1, 0, 0, m, m);
|
||||
pctx.drawImage(snap, w - 1, 0, 1, 1, m + w, 0, m, m);
|
||||
pctx.drawImage(snap, 0, h - 1, 1, 1, 0, m + h, m, m);
|
||||
pctx.drawImage(snap, w - 1, h - 1, 1, 1, m + w, m + h, m, m);
|
||||
// Blur the padded buffer and crop the original-size centre back.
|
||||
const out = document.createElement('canvas');
|
||||
out.width = pad.width;
|
||||
out.height = pad.height;
|
||||
const octx = out.getContext('2d');
|
||||
octx.filter = `blur(${r}px)`;
|
||||
octx.drawImage(pad, 0, 0);
|
||||
octx.filter = 'none';
|
||||
dst.drawImage(out, m, m, w, h, 0, 0, w, h);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Zoom blur — radial smear from the canvas centre. 16 scaled copies at
|
||||
* low alpha approximate a Gaussian zoom blur.
|
||||
*
|
||||
* @param {HTMLCanvasElement} snap
|
||||
* @param {{ strength: number }} v
|
||||
* @param {CanvasRenderingContext2D} dst
|
||||
*/
|
||||
export function zoomBlur(snap, v, dst) {
|
||||
const w = snap.width, h = snap.height;
|
||||
const steps = 16;
|
||||
dst.drawImage(snap, 0, 0);
|
||||
dst.globalAlpha = 0.18;
|
||||
for (let s = 1; s <= steps; s++) {
|
||||
const t = s / steps;
|
||||
const scale = 1 + (v.strength / 200) * t;
|
||||
const sw = w * scale, sh = h * scale;
|
||||
dst.drawImage(snap, (w - sw) / 2, (h - sh) / 2, sw, sh);
|
||||
}
|
||||
dst.globalAlpha = 1;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Motion blur — directional smear along a user-chosen angle.
|
||||
*
|
||||
* Each shifted stamp is rendered at globalAlpha = 1/steps with
|
||||
* globalCompositeOperation = 'lighter' (additive) into an offscreen
|
||||
* accumulator, then blitted onto `dst`. Lighter adds premultiplied src
|
||||
* to dst, so N stamps each contributing snap.RGB/N sum to snap.RGB and
|
||||
* alpha sums to 1. Source-over blending would cause colour wash-out
|
||||
* because each stamp would blend over the dst instead of summing into
|
||||
* it. Using an accumulator keeps `dst` clean if anything throws mid-way.
|
||||
*
|
||||
* @param {HTMLCanvasElement} snap
|
||||
* @param {{ length: number, angle: number }} v
|
||||
* @param {CanvasRenderingContext2D} dst
|
||||
*/
|
||||
export function motionBlur(snap, v, dst) {
|
||||
const w = snap.width, h = snap.height;
|
||||
const rad = (v.angle * Math.PI) / 180;
|
||||
const dx = Math.cos(rad);
|
||||
const dy = Math.sin(rad);
|
||||
// Step count = roughly one sample per pixel of length, capped
|
||||
// so very long blurs don't tank performance.
|
||||
const steps = Math.max(4, Math.min(80, Math.round(v.length)));
|
||||
const acc = document.createElement('canvas');
|
||||
acc.width = w; acc.height = h;
|
||||
const actx = acc.getContext('2d');
|
||||
actx.globalCompositeOperation = 'lighter';
|
||||
actx.globalAlpha = 1 / steps;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const t = (i / Math.max(1, steps - 1)) - 0.5;
|
||||
actx.drawImage(snap, dx * v.length * t, dy * v.length * t);
|
||||
}
|
||||
actx.globalCompositeOperation = 'source-over';
|
||||
actx.globalAlpha = 1;
|
||||
dst.drawImage(acc, 0, 0);
|
||||
}
|
||||
70
static/js/editor/filters/edge-feather.js
Normal file
70
static/js/editor/filters/edge-feather.js
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Edge feather / edge delete via a two-pass chamfer distance transform.
|
||||
*
|
||||
* Operates in-place on the supplied ImageData. For each opaque pixel,
|
||||
* compute the (approximate) distance to the nearest transparent pixel
|
||||
* OR canvas edge. Pixels within `width` of that boundary either get
|
||||
* faded (`hardDelete=false`) or fully cleared (`hardDelete=true`).
|
||||
*
|
||||
* @param {ImageData} imgData
|
||||
* @param {number} width Feather radius in pixels.
|
||||
* @param {boolean} hardDelete If true, clear pixels inside the band
|
||||
* instead of fading.
|
||||
*/
|
||||
export function edgeFeather(imgData, width, hardDelete) {
|
||||
const w = imgData.width;
|
||||
const h = imgData.height;
|
||||
const d = imgData.data;
|
||||
const dist = new Float32Array(w * h);
|
||||
dist.fill(width + 1);
|
||||
|
||||
// Seed: transparent pixels are at distance 0.
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
if (d[i * 4 + 3] === 0) dist[i] = 0;
|
||||
}
|
||||
|
||||
// Two-pass chamfer distance transform.
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = y * w + x;
|
||||
if (dist[i] === 0) continue;
|
||||
let min = dist[i];
|
||||
if (x > 0) min = Math.min(min, dist[i - 1] + 1);
|
||||
if (y > 0) min = Math.min(min, dist[(y - 1) * w + x] + 1);
|
||||
dist[i] = min;
|
||||
}
|
||||
}
|
||||
for (let y = h - 1; y >= 0; y--) {
|
||||
for (let x = w - 1; x >= 0; x--) {
|
||||
const i = y * w + x;
|
||||
if (dist[i] === 0) continue;
|
||||
let min = dist[i];
|
||||
if (x < w - 1) min = Math.min(min, dist[i + 1] + 1);
|
||||
if (y < h - 1) min = Math.min(min, dist[(y + 1) * w + x] + 1);
|
||||
dist[i] = min;
|
||||
}
|
||||
}
|
||||
|
||||
// Treat the canvas border itself as a boundary.
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const edgeDist = Math.min(x, y, w - 1 - x, h - 1 - y);
|
||||
const i = y * w + x;
|
||||
dist[i] = Math.min(dist[i], edgeDist);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply.
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
if (d[i * 4 + 3] === 0) continue;
|
||||
const edgeDist = dist[i];
|
||||
if (edgeDist < width) {
|
||||
if (hardDelete) {
|
||||
d[i * 4 + 3] = 0;
|
||||
} else {
|
||||
const fade = edgeDist / width;
|
||||
d[i * 4 + 3] = Math.round(d[i * 4 + 3] * fade);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
677
static/js/editor/fx/adj-popup.js
Normal file
677
static/js/editor/fx/adj-popup.js
Normal file
@@ -0,0 +1,677 @@
|
||||
/**
|
||||
* FX / adjustment-popup machinery — the per-layer Brightness/Contrast,
|
||||
* Hue/Saturation, Levels, and Color-Balance editor.
|
||||
*
|
||||
* Self-contained subsystem with three external touchpoints:
|
||||
*
|
||||
* - `composite()` redraw the canvas after every staged change
|
||||
* - `saveState(label)` push an undo entry on Apply
|
||||
* - `renderLayerPanel()` refresh the layer panel after add/edit
|
||||
*
|
||||
* Lifecycle:
|
||||
*
|
||||
* FX button on layer row → openFxPopup(layer, anchor)
|
||||
* → small chooser menu (B/C, H/S, Levels, Color Balance)
|
||||
* → openAdjPopup(layer, type, anchor[, existingAdj])
|
||||
* → buildAdjBody renders the type-specific sliders + histogram
|
||||
* → sliders / histogram handles mutate `layer._stagedAdj.params`
|
||||
* → composite() previews live via the adjLayers stack
|
||||
* → Apply commits to layer.adjLayers + saveState() + renderLayerPanel()
|
||||
* → Cancel / Esc drops the staged state
|
||||
*
|
||||
* Popups can be minimised → modalManager dock chip → click chip to
|
||||
* restore. Re-opening a committed sub-layer (from the layer panel's
|
||||
* adj-row click) calls `editAdjLayer` which re-opens openAdjPopup
|
||||
* with the existing sub-layer's params staged for editing.
|
||||
*
|
||||
* @param {{
|
||||
* composite: () => void,
|
||||
* saveState: (label?: string) => void,
|
||||
* renderLayerPanel: () => void,
|
||||
* }} deps
|
||||
*
|
||||
* @returns {{
|
||||
* openFxPopup, openAdjPopup, editAdjLayer,
|
||||
* closeFxPopup, closeFxMenu, closeAdjPopup,
|
||||
* ensureFxDock, ensureAdjustments,
|
||||
* syncFxPanelToActiveLayerIfPresent,
|
||||
* minimiseAdjPopup,
|
||||
* }}
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import modalManager from '../../modalManager.js';
|
||||
import {
|
||||
ADJ_ICONS,
|
||||
adjLayerLabel,
|
||||
defaultAdjParams,
|
||||
} from '../layer-helpers.js';
|
||||
import { drawHistogram } from './histogram.js';
|
||||
|
||||
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel }) {
|
||||
function suppressLayerGhostTap() {
|
||||
window.__geSuppressLayerTapUntil = Date.now() + 650;
|
||||
}
|
||||
|
||||
function closeFxPopup() {
|
||||
if (state.fxPopupEl) {
|
||||
state.fxPopupEl.remove();
|
||||
state.fxPopupEl = null;
|
||||
state.fxPopupLayerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAdjustments(layer) {
|
||||
// Older layers (loaded from saved projects) may be missing the
|
||||
// adjustments structure entirely. Pad with identity values.
|
||||
if (!layer.adjustments) layer.adjustments = {};
|
||||
const a = layer.adjustments;
|
||||
if (a.brightness === undefined) a.brightness = 1;
|
||||
if (a.contrast === undefined) a.contrast = 1;
|
||||
if (a.saturation === undefined) a.saturation = 1;
|
||||
if (a.hue === undefined) a.hue = 0;
|
||||
if (!a.levels) a.levels = { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 };
|
||||
if (!a.colorBalance) a.colorBalance = {
|
||||
shadows: { r: 0, g: 0, b: 0 },
|
||||
midtones: { r: 0, g: 0, b: 0 },
|
||||
highlights: { r: 0, g: 0, b: 0 },
|
||||
};
|
||||
return a;
|
||||
}
|
||||
|
||||
// Floating dock for minimised FX popups — lives at bottom-right.
|
||||
function ensureFxDock() {
|
||||
let dock = document.getElementById('ge-fx-dock');
|
||||
if (!dock) {
|
||||
dock = document.createElement('div');
|
||||
dock.id = 'ge-fx-dock';
|
||||
document.body.appendChild(dock);
|
||||
}
|
||||
return dock;
|
||||
}
|
||||
|
||||
function closeFxMenu() {
|
||||
if (state.fxMenuEl) {
|
||||
if (state.fxMenuEl._escHandler) {
|
||||
document.removeEventListener('keydown', state.fxMenuEl._escHandler, true);
|
||||
}
|
||||
if (state.fxMenuEl._awayHandler) {
|
||||
document.removeEventListener('pointerdown', state.fxMenuEl._awayHandler, true);
|
||||
}
|
||||
state.fxMenuEl.remove();
|
||||
state.fxMenuEl = null;
|
||||
}
|
||||
document.getElementById('ge-fx-menu-backdrop')?.remove();
|
||||
}
|
||||
|
||||
function openFxPopup(layer, anchorEl) {
|
||||
// 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
|
||||
// element still carries a now-recycled `_layerId`) used to make
|
||||
// this guard fire and silently swallow the first click. Verify the
|
||||
// element is still in the document before treating it as "open".
|
||||
if (state.fxMenuEl && document.body.contains(state.fxMenuEl) &&
|
||||
state.fxMenuEl._layerId === layer.id) { closeFxMenu(); return; }
|
||||
closeFxMenu();
|
||||
if (!layer.adjLayers) layer.adjLayers = [];
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.id = 'ge-fx-menu-backdrop';
|
||||
backdrop.style.cssText = 'position:fixed;inset:0;z-index:10001;background:transparent;pointer-events:auto;touch-action:none;';
|
||||
document.body.appendChild(backdrop);
|
||||
backdrop.addEventListener('pointerdown', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
closeFxMenu();
|
||||
}, true);
|
||||
backdrop.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}, true);
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'ge-fx-menu ge-frosted';
|
||||
menu._layerId = layer.id;
|
||||
menu._ignoreActivationUntil = Date.now() + 350;
|
||||
menu.style.zIndex = '10002';
|
||||
menu.style.pointerEvents = 'auto';
|
||||
const items = [
|
||||
{ type: 'brightness-contrast', label: 'Brightness / Contrast' },
|
||||
{ type: 'hue-saturation', label: 'Hue / Saturation' },
|
||||
{ type: 'levels', label: 'Levels' },
|
||||
{ type: 'color-balance', label: 'Color Balance' },
|
||||
];
|
||||
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>`
|
||||
).join('');
|
||||
document.body.appendChild(menu);
|
||||
state.fxMenuEl = menu;
|
||||
const activateMenuItem = (btn, ev) => {
|
||||
ev?.preventDefault?.();
|
||||
ev?.stopPropagation?.();
|
||||
if (Date.now() < (menu._ignoreActivationUntil || 0)) return;
|
||||
if (!btn || btn.dataset.opening === '1') return;
|
||||
btn.dataset.opening = '1';
|
||||
const type = btn.dataset.fxType;
|
||||
closeFxMenu();
|
||||
openAdjPopup(layer, type, anchorEl);
|
||||
};
|
||||
menu.addEventListener('pointerdown', (ev) => {
|
||||
ev.stopPropagation();
|
||||
}, true);
|
||||
menu.addEventListener('pointerup', (ev) => {
|
||||
const btn = ev.target.closest('.ge-fx-menu-item');
|
||||
if (btn) activateMenuItem(btn, ev);
|
||||
else ev.stopPropagation();
|
||||
}, true);
|
||||
menu.addEventListener('click', (ev) => {
|
||||
const btn = ev.target.closest('.ge-fx-menu-item');
|
||||
if (btn) activateMenuItem(btn, ev);
|
||||
else ev.stopPropagation();
|
||||
}, true);
|
||||
|
||||
const isMobile = window.matchMedia('(max-width: 820px)').matches;
|
||||
const r = isMobile ? null : anchorEl?.getBoundingClientRect?.();
|
||||
if (isMobile) {
|
||||
menu.style.left = '';
|
||||
menu.style.top = '';
|
||||
menu.style.right = '';
|
||||
menu.style.bottom = '';
|
||||
} else if (r) {
|
||||
const menuW = 220;
|
||||
const menuH = menu.offsetHeight || 200;
|
||||
const rightX = r.right + 4;
|
||||
const leftX = r.left - menuW - 4;
|
||||
const fitsRight = rightX + menuW <= window.innerWidth - 8;
|
||||
let left = fitsRight ? rightX : Math.max(8, leftX);
|
||||
left = Math.min(window.innerWidth - menuW - 8, Math.max(8, left));
|
||||
menu.style.left = left + 'px';
|
||||
let top = r.top;
|
||||
if (top + menuH > window.innerHeight - 8) top = r.bottom - menuH;
|
||||
top = Math.min(window.innerHeight - menuH - 8, Math.max(8, top));
|
||||
menu.style.top = top + 'px';
|
||||
}
|
||||
menu.querySelectorAll('.ge-fx-menu-item').forEach(btn => {
|
||||
const activate = (ev) => {
|
||||
activateMenuItem(btn, ev);
|
||||
};
|
||||
btn.addEventListener('pointerup', activate);
|
||||
btn.addEventListener('click', activate);
|
||||
});
|
||||
// Esc closes the menu, capture-phase + stopPropagation so the
|
||||
// gallery modal's own Esc handler doesn't fire too.
|
||||
const onKey = (ev) => {
|
||||
if (ev.key === 'Escape') {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
closeFxMenu();
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
menu._escHandler = onKey;
|
||||
}
|
||||
|
||||
// Hide an adj popup and drop a chip into the FX dock. Click the chip
|
||||
// to restore the popup in its previous position with staged state
|
||||
// intact (we do NOT clear staged on minimise).
|
||||
function minimiseAdjPopup(pop) {
|
||||
if (!pop) return;
|
||||
const type = pop._type;
|
||||
const r = pop.getBoundingClientRect();
|
||||
pop._stashLeft = r.left;
|
||||
pop._stashTop = r.top;
|
||||
pop.style.display = 'none';
|
||||
if (state.adjPopupEl === pop) state.adjPopupEl = null;
|
||||
const popupId = pop._modalId || `ge-fx-popup-${Math.random().toString(36).slice(2, 8)}`;
|
||||
pop._modalId = popupId;
|
||||
modalManager.register(popupId, {
|
||||
label: adjLayerLabel(type),
|
||||
icon: ADJ_ICONS[type] || '',
|
||||
restoreFn: () => {
|
||||
pop.style.left = pop._stashLeft + 'px';
|
||||
pop.style.top = pop._stashTop + 'px';
|
||||
pop.style.display = '';
|
||||
if (state.adjPopupEl && state.adjPopupEl !== pop) {
|
||||
const other = state.adjPopupEl;
|
||||
state.adjPopupEl = other;
|
||||
closeAdjPopup();
|
||||
}
|
||||
state.adjPopupEl = pop;
|
||||
},
|
||||
closeFn: () => {
|
||||
state.adjPopupEl = pop;
|
||||
closeAdjPopup();
|
||||
modalManager.unregister(popupId);
|
||||
},
|
||||
});
|
||||
modalManager.minimize(popupId);
|
||||
}
|
||||
|
||||
// Re-open an existing committed adjustment sub-layer for editing.
|
||||
// Pre-loads its params as the staged state; Apply updates in place.
|
||||
function editAdjLayer(layer, adj, anchorEl) {
|
||||
openAdjPopup(layer, adj.type, anchorEl, adj);
|
||||
}
|
||||
|
||||
function closeAdjPopup() {
|
||||
if (state.adjPopupEl) {
|
||||
suppressLayerGhostTap();
|
||||
const layer = state.adjPopupEl._layer;
|
||||
if (layer) {
|
||||
if (layer._stagedAdj) layer._stagedAdj = null;
|
||||
if (layer._editingAdjId) layer._editingAdjId = null;
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
}
|
||||
if (state.adjPopupEl._escHandler) {
|
||||
document.removeEventListener('keydown', state.adjPopupEl._escHandler, true);
|
||||
}
|
||||
if (state.adjPopupEl._modalId) {
|
||||
try { modalManager.unregister(state.adjPopupEl._modalId); } catch {}
|
||||
}
|
||||
state.adjPopupEl.remove();
|
||||
state.adjPopupEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
function openAdjPopup(layer, type, anchorEl, existingAdj) {
|
||||
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 editing = !!existingAdj;
|
||||
const startParams = editing
|
||||
? JSON.parse(JSON.stringify(existingAdj.params))
|
||||
: defaultAdjParams(type);
|
||||
layer._stagedAdj = { type, params: startParams };
|
||||
if (editing) {
|
||||
// Hide the existing sub-layer from the render stack so the
|
||||
// staged preview shows correctly without doubling the effect.
|
||||
layer._editingAdjId = existingAdj.id;
|
||||
layer._adjFinalKey = null;
|
||||
}
|
||||
const pop = document.createElement('div');
|
||||
pop.className = 'ge-adj-popup ge-frosted';
|
||||
pop.style.zIndex = '10003';
|
||||
pop._layer = layer;
|
||||
pop._type = type;
|
||||
pop._anchorEl = anchorEl;
|
||||
pop._existingAdj = existingAdj || null;
|
||||
pop.innerHTML = `
|
||||
<div class="ge-adj-head" data-adj-drag>
|
||||
<span class="ge-adj-icon">${ADJ_ICONS[type] || ''}</span>
|
||||
<span class="ge-adj-title">${adjLayerLabel(type)}</span>
|
||||
<span class="ge-head-btns">
|
||||
<button class="ge-adj-min" type="button" title="Minimise">−</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-adj-body" data-adj-body></div>
|
||||
<div class="ge-adj-foot">
|
||||
<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>
|
||||
`;
|
||||
document.body.appendChild(pop);
|
||||
state.adjPopupEl = pop;
|
||||
|
||||
const r = anchorEl?.getBoundingClientRect?.();
|
||||
const pw = type === 'color-balance' ? 340 : 320;
|
||||
// Prefer right of anchor; fall back to left if no room.
|
||||
let left;
|
||||
if (r) {
|
||||
const rightX = r.right + 8;
|
||||
const leftX = r.left - pw - 8;
|
||||
const fitsRight = rightX + pw <= window.innerWidth - 8;
|
||||
left = fitsRight ? rightX : Math.max(8, leftX);
|
||||
} else {
|
||||
left = (window.innerWidth - pw) / 2;
|
||||
}
|
||||
const top = r ? Math.max(8, r.top - 20) : 60;
|
||||
pop.style.left = left + 'px';
|
||||
pop.style.top = top + 'px';
|
||||
|
||||
const body = pop.querySelector('[data-adj-body]');
|
||||
buildAdjBody(layer, type, body, pop);
|
||||
|
||||
pop.querySelector('.ge-adj-close')?.addEventListener('click', closeAdjPopup);
|
||||
pop.querySelector('.ge-adj-min')?.addEventListener('click', () => minimiseAdjPopup(pop));
|
||||
// Drag by head — anywhere except buttons. Mobile pins via !important
|
||||
// rules; setProperty with 'important' lets inline styles win during drag.
|
||||
const head = pop.querySelector('[data-adj-drag]');
|
||||
if (head) {
|
||||
const isMobile = window.matchMedia('(max-width: 820px)').matches;
|
||||
const setPos = (x, y) => {
|
||||
if (isMobile) {
|
||||
pop.style.setProperty('left', x + 'px', 'important');
|
||||
pop.style.setProperty('top', y + 'px', 'important');
|
||||
pop.style.setProperty('right', 'auto', 'important');
|
||||
pop.style.setProperty('bottom', 'auto', 'important');
|
||||
pop.style.setProperty('width', 'auto', 'important');
|
||||
pop.style.setProperty('max-width', 'calc(100vw - 16px)', 'important');
|
||||
} else {
|
||||
pop.style.left = x + 'px';
|
||||
pop.style.top = y + 'px';
|
||||
}
|
||||
};
|
||||
head.style.touchAction = 'none';
|
||||
head.addEventListener('pointerdown', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
e.preventDefault();
|
||||
const startX = e.clientX, startY = e.clientY;
|
||||
const r0 = pop.getBoundingClientRect();
|
||||
head.setPointerCapture(e.pointerId);
|
||||
head.style.cursor = 'grabbing';
|
||||
const onMove = (ev) => {
|
||||
const nx = Math.max(0, Math.min(window.innerWidth - 60, r0.left + (ev.clientX - startX)));
|
||||
const ny = Math.max(0, Math.min(window.innerHeight - 30, r0.top + (ev.clientY - startY)));
|
||||
setPos(nx, ny);
|
||||
};
|
||||
const onUp = () => {
|
||||
head.releasePointerCapture(e.pointerId);
|
||||
head.style.cursor = '';
|
||||
head.removeEventListener('pointermove', onMove);
|
||||
head.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
head.addEventListener('pointermove', onMove);
|
||||
head.addEventListener('pointerup', onUp);
|
||||
});
|
||||
}
|
||||
// Esc closes; capture-phase + stopPropagation so the gallery modal's
|
||||
// own Esc handler doesn't fire too.
|
||||
const onKey = (ev) => {
|
||||
if (ev.key === 'Escape') {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
closeAdjPopup();
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
pop._escHandler = onKey;
|
||||
pop.querySelector('[data-adj-action="cancel"]')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeAdjPopup();
|
||||
});
|
||||
pop.querySelector('[data-adj-action="ok"]')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
suppressLayerGhostTap();
|
||||
saveState(editing ? `Edit ${adjLayerLabel(type)}` : `Add ${adjLayerLabel(type)}`);
|
||||
const params = layer._stagedAdj.params;
|
||||
layer._stagedAdj = null;
|
||||
if (editing) {
|
||||
const existing = (layer.adjLayers || []).find(a => a.id === existingAdj.id);
|
||||
if (existing) existing.params = params;
|
||||
layer._editingAdjId = null;
|
||||
} else {
|
||||
if (!layer.adjLayers) layer.adjLayers = [];
|
||||
layer.adjLayers.push({
|
||||
id: 'adj-' + Math.random().toString(36).slice(2, 9),
|
||||
type,
|
||||
name: adjLayerLabel(type),
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
params,
|
||||
});
|
||||
}
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
closeAdjPopup();
|
||||
});
|
||||
}
|
||||
|
||||
// rAF-throttled live preview while sliders are dragged.
|
||||
function scheduleAdjRefresh(layer) {
|
||||
if (state.adjRafPending) return;
|
||||
state.adjRafPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
state.adjRafPending = false;
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
});
|
||||
}
|
||||
|
||||
function buildAdjBody(layer, type, body, popEl) {
|
||||
const p = layer._stagedAdj.params;
|
||||
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}">
|
||||
<label>${label}</label>
|
||||
<input type="range" min="${min}" max="${max}" value="${value}" data-key="${key}" />
|
||||
<span class="ge-adj-value">${value}${suffix || ''}</span>
|
||||
<button class="ge-adj-revert" type="button" title="Reset this slider" data-revert-key="${key}">${revertIcon}</button>
|
||||
</div>
|
||||
`;
|
||||
if (type === 'brightness-contrast') {
|
||||
const bSlider = Math.round((p.brightness - 1) * 100);
|
||||
const cSlider = Math.round((p.contrast - 1) * 100);
|
||||
body.innerHTML = `
|
||||
${sliderRow('brightness', 'Brightness', -100, 100, bSlider, '')}
|
||||
${sliderRow('contrast', 'Contrast', -100, 100, cSlider, '')}
|
||||
`;
|
||||
} 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, '')}
|
||||
`;
|
||||
} else if (type === 'levels') {
|
||||
// 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.
|
||||
// <details> wrapper is collapsed by default on mobile to save
|
||||
// vertical space; open by default on desktop.
|
||||
const isMobile = window.matchMedia('(max-width: 820px)').matches;
|
||||
body.innerHTML = `
|
||||
<details class="ge-adj-hist-details"${isMobile ? '' : ' open'}>
|
||||
<summary>Histogram</summary>
|
||||
<div class="ge-adj-hist-wrap">
|
||||
<canvas class="ge-adj-histogram" width="280" height="80"></canvas>
|
||||
<div class="ge-adj-hist-handles">
|
||||
<div class="ge-adj-hist-handle hist-h-black" data-handle="inBlack" title="Input black — drag"></div>
|
||||
<div class="ge-adj-hist-handle hist-h-gamma" data-handle="gamma" title="Gamma — drag"></div>
|
||||
<div class="ge-adj-hist-handle hist-h-white" data-handle="inWhite" title="Input white — drag"></div>
|
||||
</div>
|
||||
</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, '')}
|
||||
`;
|
||||
const hist = body.querySelector('.ge-adj-histogram');
|
||||
drawHistogram(hist, layer);
|
||||
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);
|
||||
});
|
||||
} else if (type === 'color-balance') {
|
||||
// Color-tinted slider ends so the user sees what direction does what.
|
||||
const cbRow = (key, leftCol, rightCol, label, value) => `
|
||||
<div class="ge-adj-row ge-adj-cb-row" data-adj-key="${key}">
|
||||
<span class="ge-adj-cb-dot" style="background:${leftCol}"></span>
|
||||
<input type="range" min="-100" max="100" value="${value}" data-key="${key}" />
|
||||
<span class="ge-adj-cb-dot" style="background:${rightCol}"></span>
|
||||
<span class="ge-adj-value">${value}</span>
|
||||
<button class="ge-adj-revert" type="button" title="Reset this slider" data-revert-key="${key}">${revertIcon}</button>
|
||||
</div>
|
||||
`;
|
||||
// Tone picker: one tone group visible at a time. Remember the
|
||||
// last picked tone on the popup so re-renders (revert button
|
||||
// etc.) keep it.
|
||||
const tone = popEl._cbTone || 'shadows';
|
||||
popEl._cbTone = tone;
|
||||
const toneSliders = (t) => `
|
||||
${cbRow(`${t}-r`, '#00d2d2', '#ff5555', 'Cyan ↔ Red', p[t].r)}
|
||||
${cbRow(`${t}-g`, '#d855d8', '#55d855', 'Magenta ↔ Green', p[t].g)}
|
||||
${cbRow(`${t}-b`, '#e6e64a', '#4a78ff', 'Yellow ↔ Blue', p[t].b)}
|
||||
`;
|
||||
body.innerHTML = `
|
||||
<div class="ge-adj-cb-tone-picker">
|
||||
<select class="ge-adj-cb-tone-select">
|
||||
<option value="shadows"${tone === 'shadows' ? ' selected' : ''}>Shadows</option>
|
||||
<option value="midtones"${tone === 'midtones' ? ' selected' : ''}>Midtones</option>
|
||||
<option value="highlights"${tone === 'highlights' ? ' selected' : ''}>Highlights</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ge-adj-cb-sliders" data-cb-tone="${tone}">
|
||||
${toneSliders(tone)}
|
||||
</div>
|
||||
`;
|
||||
body.querySelector('.ge-adj-cb-tone-select')?.addEventListener('change', (e) => {
|
||||
popEl._cbTone = e.target.value;
|
||||
body.innerHTML = '';
|
||||
buildAdjBody(layer, type, body, popEl);
|
||||
});
|
||||
}
|
||||
// Wire all sliders.
|
||||
body.querySelectorAll('input[type="range"]').forEach(sl => {
|
||||
sl.addEventListener('input', () => onAdjSliderInput(layer, type, sl));
|
||||
});
|
||||
// Per-slider revert buttons.
|
||||
body.querySelectorAll('.ge-adj-revert').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const key = btn.dataset.revertKey;
|
||||
revertAdjKey(layer, type, key);
|
||||
// Rebuild body so values + histogram refresh.
|
||||
body.innerHTML = '';
|
||||
buildAdjBody(layer, type, body, popEl);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Reset a single slider key back to identity. Updates staged params
|
||||
// and triggers a composite refresh.
|
||||
function revertAdjKey(layer, type, key) {
|
||||
const defaults = defaultAdjParams(type);
|
||||
const p = layer._stagedAdj.params;
|
||||
if (type === 'brightness-contrast' || type === 'hue-saturation') {
|
||||
p[key] = defaults[key];
|
||||
} else if (type === 'levels') {
|
||||
p[key] = defaults[key];
|
||||
} else if (type === 'color-balance') {
|
||||
const [tone, ch] = key.split('-');
|
||||
p[tone][ch] = defaults[tone][ch];
|
||||
}
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
}
|
||||
|
||||
function onAdjSliderInput(layer, type, sl) {
|
||||
const key = sl.dataset.key;
|
||||
const raw = parseInt(sl.value, 10);
|
||||
const valEl = sl.parentElement.querySelector('.ge-adj-value');
|
||||
const p = layer._stagedAdj.params;
|
||||
let display = String(raw);
|
||||
if (type === 'brightness-contrast' || type === 'hue-saturation') {
|
||||
if (key === 'brightness' || key === 'contrast' || key === 'saturation') {
|
||||
p[key] = 1 + raw / 100;
|
||||
} else if (key === 'hue') {
|
||||
p.hue = raw; display = raw + ' °';
|
||||
}
|
||||
} else if (type === 'levels') {
|
||||
if (key === 'gamma') {
|
||||
p.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ';
|
||||
} else {
|
||||
p[key] = raw;
|
||||
}
|
||||
} else if (type === 'color-balance') {
|
||||
const [tone, ch] = key.split('-');
|
||||
p[tone][ch] = raw;
|
||||
}
|
||||
if (valEl) valEl.textContent = display;
|
||||
scheduleAdjRefresh(layer);
|
||||
}
|
||||
|
||||
// Position the three histogram triangle handles by current staged
|
||||
// values + wire pointer drags.
|
||||
function wireHistogramHandles(bodyEl, layer, type) {
|
||||
const wrap = bodyEl.querySelector('.ge-adj-hist-wrap');
|
||||
const canvas = bodyEl.querySelector('.ge-adj-histogram');
|
||||
if (!wrap || !canvas) return;
|
||||
const handles = bodyEl.querySelectorAll('.ge-adj-hist-handle');
|
||||
const placeHandles = () => {
|
||||
const w = canvas.getBoundingClientRect().width;
|
||||
const p = layer._stagedAdj.params;
|
||||
const xB = (p.inBlack / 255) * w;
|
||||
const xW = (p.inWhite / 255) * w;
|
||||
// Gamma handle sits at a fraction of the (xB..xW) span, mapped
|
||||
// from gamma's log scale (1 = midpoint, 0.1 = far right, 10 = far left).
|
||||
const gammaT = 1 - (Math.log(p.gamma || 1) / Math.log(10) * 0.5 + 0.5);
|
||||
const xG = xB + (xW - xB) * gammaT;
|
||||
const set = (sel, x) => {
|
||||
const el = bodyEl.querySelector(sel);
|
||||
if (el) el.style.left = (x - 6) + 'px';
|
||||
};
|
||||
set('.hist-h-black', xB);
|
||||
set('.hist-h-gamma', xG);
|
||||
set('.hist-h-white', xW);
|
||||
};
|
||||
placeHandles();
|
||||
handles.forEach(h => {
|
||||
h.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
h.setPointerCapture(e.pointerId);
|
||||
const which = h.dataset.handle;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
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;
|
||||
if (which === 'inBlack') {
|
||||
p.inBlack = Math.min(p.inWhite - 1, v);
|
||||
} else if (which === 'inWhite') {
|
||||
p.inWhite = Math.max(p.inBlack + 1, v);
|
||||
} else if (which === 'gamma') {
|
||||
const xB = (p.inBlack / 255) * rect.width;
|
||||
const xW = (p.inWhite / 255) * rect.width;
|
||||
const span = Math.max(1, xW - xB);
|
||||
let t = (x - xB) / span;
|
||||
t = Math.max(0.01, Math.min(0.99, t));
|
||||
// Invert the placeHandles mapping: t = 1 - (log10(g)*0.5+0.5).
|
||||
const log10g = -((t - 0.5) * 2);
|
||||
p.gamma = Math.pow(10, log10g);
|
||||
}
|
||||
placeHandles();
|
||||
// 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 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);
|
||||
scheduleAdjRefresh(layer);
|
||||
};
|
||||
const onUp = () => {
|
||||
h.releasePointerCapture(e.pointerId);
|
||||
h.removeEventListener('pointermove', onMove);
|
||||
h.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
h.addEventListener('pointermove', onMove);
|
||||
h.addEventListener('pointerup', onUp);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Legacy sidebar-FX panel sync — FX now lives in a per-layer popup;
|
||||
// stubbed so any stale callers don't error.
|
||||
function syncFxPanelToActiveLayerIfPresent() { /* no-op */ }
|
||||
|
||||
return {
|
||||
openFxPopup, openAdjPopup, editAdjLayer,
|
||||
closeFxPopup, closeFxMenu, closeAdjPopup,
|
||||
ensureFxDock, ensureAdjustments,
|
||||
syncFxPanelToActiveLayerIfPresent,
|
||||
minimiseAdjPopup,
|
||||
};
|
||||
}
|
||||
38
static/js/editor/fx/filter-string.js
Normal file
38
static/js/editor/fx/filter-string.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Pure helpers that translate between the editor's adjustment-slider
|
||||
* UI and CSS `filter` strings / canvas-filter multipliers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build a CSS `filter` string from a layer's `adjustments` object.
|
||||
* Returns '' when every value is at identity so the composite path
|
||||
* can skip the filter entirely.
|
||||
*
|
||||
* @param {{
|
||||
* brightness?: number, contrast?: number,
|
||||
* saturation?: number, hue?: number,
|
||||
* }|null|undefined} adj
|
||||
*/
|
||||
export function layerFilterString(adj) {
|
||||
if (!adj) return '';
|
||||
const parts = [];
|
||||
if (adj.brightness !== undefined && adj.brightness !== 1) parts.push(`brightness(${adj.brightness})`);
|
||||
if (adj.contrast !== undefined && adj.contrast !== 1) parts.push(`contrast(${adj.contrast})`);
|
||||
if (adj.saturation !== undefined && adj.saturation !== 1) parts.push(`saturate(${adj.saturation})`);
|
||||
if (adj.hue !== undefined && adj.hue !== 0) parts.push(`hue-rotate(${adj.hue}deg)`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Convert a stored filter multiplier (brightness/contrast/saturation
|
||||
* are 0..2 with 1.0 = identity; hue is degrees, -180..+180) into the
|
||||
* UI slider's -100..+100 (or -180..+180 for hue) range.
|
||||
*/
|
||||
export function fxFilterToSlider(key, value) {
|
||||
if (key === 'brightness' || key === 'contrast' || key === 'saturation') {
|
||||
return Math.round(((value ?? 1) - 1) * 100);
|
||||
}
|
||||
if (key === 'hue') return Math.round(value ?? 0);
|
||||
return 0;
|
||||
}
|
||||
67
static/js/editor/fx/histogram.js
Normal file
67
static/js/editor/fx/histogram.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Draw a luminance histogram of a layer's pixels onto the given
|
||||
* canvas. Sampling is capped at ~400×400 so the call stays cheap on
|
||||
* very large images.
|
||||
*
|
||||
* If the layer has a staged Levels adjustment
|
||||
* (`layer._stagedAdj.params` with `inBlack` / `inWhite`), the two
|
||||
* endpoint markers are drawn over the bars.
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas The histogram canvas to render into.
|
||||
* @param {{
|
||||
* canvas: HTMLCanvasElement,
|
||||
* _stagedAdj?: {params?: {inBlack?: number, inWhite?: number}}
|
||||
* }} layer Source layer.
|
||||
*/
|
||||
export function drawHistogram(canvas, layer) {
|
||||
if (!canvas) return;
|
||||
const w = canvas.width, h = canvas.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Down-sample huge images so the histogram stays interactive on 8k+
|
||||
// photos. ~400×400 is enough to characterise the distribution.
|
||||
const src = layer.canvas;
|
||||
const sw = src.width, sh = src.height;
|
||||
const maxSamples = 400;
|
||||
const sampleW = Math.min(maxSamples, sw);
|
||||
const sampleH = Math.min(maxSamples, sh);
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = sampleW; tmp.height = sampleH;
|
||||
const tctx = tmp.getContext('2d');
|
||||
tctx.drawImage(src, 0, 0, sampleW, sampleH);
|
||||
const img = tctx.getImageData(0, 0, sampleW, sampleH).data;
|
||||
|
||||
const hist = new Uint32Array(256);
|
||||
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)]++;
|
||||
}
|
||||
let peak = 1;
|
||||
for (let i = 0; i < 256; i++) if (hist[i] > peak) peak = hist[i];
|
||||
|
||||
// Background.
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.05)';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Bars. sqrt-scaled so the long tails (specular highlights, deep
|
||||
// shadows) stay visible even when the central mass dominates.
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.55)';
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const x = (i / 256) * w;
|
||||
const bh = Math.pow(hist[i] / peak, 0.5) * h;
|
||||
ctx.fillRect(x, h - bh, w / 256 + 0.5, bh);
|
||||
}
|
||||
|
||||
// Endpoint markers (input black / input white) from a staged Levels
|
||||
// adjustment, if one is in flight.
|
||||
const p = layer._stagedAdj?.params;
|
||||
if (p) {
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.9)';
|
||||
ctx.fillRect((p.inBlack / 256) * w, 0, 1, h);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.fillRect((p.inWhite / 256) * w, 0, 1, h);
|
||||
}
|
||||
}
|
||||
249
static/js/editor/fx/pixel-pass.js
Normal file
249
static/js/editor/fx/pixel-pass.js
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Apply a Brightness/Contrast, Hue/Saturation, Levels, or Color Balance
|
||||
* adjustment to a source canvas and return a fresh canvas with the
|
||||
* result. Pure pixel math — no DOM, no module state.
|
||||
*
|
||||
* Used by the editor's per-layer FX stack: each `adjLayer` calls
|
||||
* `applyAdjustment(prevCanvas, adjLayer)` and the result feeds the
|
||||
* next layer in the stack.
|
||||
*
|
||||
* Adjustment shape:
|
||||
* { type: 'brightness-contrast', params: { brightness, contrast } }
|
||||
* { type: 'hue-saturation', params: { hue, saturation } }
|
||||
* { type: 'levels', params: { inBlack, inWhite, gamma, outBlack, outWhite } }
|
||||
* { type: 'color-balance', params: { shadows, midtones, highlights } }
|
||||
*/
|
||||
export function applyAdjustment(srcCanvas, adj) {
|
||||
const w = srcCanvas.width, h = srcCanvas.height;
|
||||
const out = document.createElement('canvas');
|
||||
out.width = w; out.height = h;
|
||||
const octx = out.getContext('2d');
|
||||
|
||||
// B/C and H/S can use the fast browser-native CSS filter pipeline.
|
||||
if (adj.type === 'brightness-contrast') {
|
||||
const p = adj.params;
|
||||
octx.filter = `brightness(${p.brightness}) contrast(${p.contrast})`;
|
||||
octx.drawImage(srcCanvas, 0, 0);
|
||||
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.
|
||||
octx.drawImage(srcCanvas, 0, 0);
|
||||
const img = octx.getImageData(0, 0, w, h);
|
||||
const d = img.data;
|
||||
|
||||
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);
|
||||
}
|
||||
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]];
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'color-balance') {
|
||||
const cb = adj.params;
|
||||
const scale = 0.6;
|
||||
const s = cb.shadows, m = cb.midtones, hi = cb.highlights;
|
||||
const sR = s.r*scale, sG = s.g*scale, sB = s.b*scale;
|
||||
const mR = m.r*scale, mG = m.g*scale, mB = m.b*scale;
|
||||
const hR = hi.r*scale, hG = hi.g*scale, hB = hi.b*scale;
|
||||
// Bell-curve tone weights so each pixel's shift is proportional to
|
||||
// how "shadow", "midtone", or "highlight" its luminance is.
|
||||
const wS = new Float32Array(256), wM = new Float32Array(256), wH = new Float32Array(256);
|
||||
const sig = 0.25;
|
||||
for (let v = 0; v < 256; v++) {
|
||||
const t = v / 255;
|
||||
wS[v] = Math.exp(-(t*t) / (2*sig*sig));
|
||||
wM[v] = Math.exp(-((t-0.5)*(t-0.5)) / (2*sig*sig));
|
||||
wH[v] = Math.exp(-((1-t)*(1-t)) / (2*sig*sig));
|
||||
}
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
let r = d[i], g = d[i+1], b = d[i+2];
|
||||
const Y = (0.2126*r + 0.7152*g + 0.0722*b) | 0;
|
||||
const ws = wS[Y], wm = wM[Y], wh = wH[Y];
|
||||
r += sR*ws + mR*wm + hR*wh;
|
||||
g += sG*ws + mG*wm + hG*wh;
|
||||
b += sB*ws + mB*wm + hB*wh;
|
||||
d[i] = r < 0 ? 0 : r > 255 ? 255 : r;
|
||||
d[i+1] = g < 0 ? 0 : g > 255 ? 255 : g;
|
||||
d[i+2] = b < 0 ? 0 : b > 255 ? 255 : b;
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Apply a combined Levels + Color Balance pass to a layer in-place via
|
||||
* its `layer.adjustments` field. Cached on `layer._adjCache` keyed by
|
||||
* `cacheKey` so repeated composite passes don't re-run the math.
|
||||
*
|
||||
* Returns the cached output canvas.
|
||||
*
|
||||
* @param {{
|
||||
* canvas: HTMLCanvasElement,
|
||||
* adjustments: object,
|
||||
* _adjCache?: HTMLCanvasElement,
|
||||
* _adjCacheKey?: string,
|
||||
* }} layer
|
||||
* @param {string} cacheKey Stable signature of `layer.adjustments`.
|
||||
*/
|
||||
export function renderLayerPixelAdjustments(layer, cacheKey) {
|
||||
const adj = layer.adjustments;
|
||||
if (layer._adjCache && layer._adjCacheKey === cacheKey) return layer._adjCache;
|
||||
if (!layer._adjCache) {
|
||||
layer._adjCache = document.createElement('canvas');
|
||||
}
|
||||
const out = layer._adjCache;
|
||||
out.width = layer.canvas.width;
|
||||
out.height = layer.canvas.height;
|
||||
const octx = out.getContext('2d');
|
||||
octx.clearRect(0, 0, out.width, out.height);
|
||||
octx.drawImage(layer.canvas, 0, 0);
|
||||
const img = octx.getImageData(0, 0, out.width, out.height);
|
||||
const d = img.data;
|
||||
|
||||
// Single 256-entry LUT for the Levels portion (applied per R/G/B
|
||||
// channel identically — luma-style isn't right when colour balance
|
||||
// follows, per-channel is fine here).
|
||||
const l = adj.levels || { inBlack: 0, inWhite: 255, gamma: 1, outBlack: 0, outWhite: 255 };
|
||||
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);
|
||||
}
|
||||
|
||||
// Color Balance bell-curve weights (see applyAdjustment).
|
||||
const cb = adj.colorBalance || { shadows: {r:0,g:0,b:0}, midtones: {r:0,g:0,b:0}, highlights: {r:0,g:0,b:0} };
|
||||
const s = cb.shadows || {r:0,g:0,b:0};
|
||||
const m = cb.midtones || {r:0,g:0,b:0};
|
||||
const h = cb.highlights || {r:0,g:0,b:0};
|
||||
const scale = 0.6;
|
||||
const sR = s.r * scale, sG = s.g * scale, sB = s.b * scale;
|
||||
const mR = m.r * scale, mG = m.g * scale, mB = m.b * scale;
|
||||
const hR = h.r * scale, hG = h.g * scale, hB = h.b * scale;
|
||||
|
||||
const wS = new Float32Array(256);
|
||||
const wM = new Float32Array(256);
|
||||
const wH = new Float32Array(256);
|
||||
for (let v = 0; v < 256; v++) {
|
||||
const t = v / 255;
|
||||
const dS = t, wsig = 0.25;
|
||||
const dM = t - 0.5;
|
||||
const dH = 1 - t;
|
||||
wS[v] = Math.exp(-(dS * dS) / (2 * wsig * wsig));
|
||||
wM[v] = Math.exp(-(dM * dM) / (2 * wsig * wsig));
|
||||
wH[v] = Math.exp(-(dH * dH) / (2 * wsig * wsig));
|
||||
}
|
||||
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
let r = lut[d[i]];
|
||||
let g = lut[d[i + 1]];
|
||||
let b = lut[d[i + 2]];
|
||||
const Y = (0.2126 * r + 0.7152 * g + 0.0722 * b) | 0;
|
||||
const ws = wS[Y], wm = wM[Y], wh = wH[Y];
|
||||
r += sR * ws + mR * wm + hR * wh;
|
||||
g += sG * ws + mG * wm + hG * wh;
|
||||
b += sB * ws + mB * wm + hB * wh;
|
||||
d[i] = r < 0 ? 0 : r > 255 ? 255 : r;
|
||||
d[i + 1] = g < 0 ? 0 : g > 255 ? 255 : g;
|
||||
d[i + 2] = b < 0 ? 0 : b > 255 ? 255 : b;
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
layer._adjCacheKey = cacheKey;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Walk the layer's `adjLayers` stack (skipping the one currently being
|
||||
* edited, if any) plus an optional staged preview adjustment, producing
|
||||
* a final canvas the composite step can paint. The result is memoised
|
||||
* on `layer._adjFinal` keyed by a signature of all adjLayer params +
|
||||
* staged + editing id, so repeated composite passes are O(1) when
|
||||
* nothing has changed.
|
||||
*
|
||||
* If the stack is empty AND nothing is staged, returns the layer's own
|
||||
* canvas unchanged (no allocation).
|
||||
*
|
||||
* @param {{
|
||||
* canvas: HTMLCanvasElement,
|
||||
* adjLayers?: Array<{id: string, type: string, params: object, visible: boolean, opacity: number}>,
|
||||
* _stagedAdj?: {type: string, params: object} | null,
|
||||
* _editingAdjId?: string | null,
|
||||
* _adjFinal?: HTMLCanvasElement,
|
||||
* _adjFinalKey?: string,
|
||||
* }} layer
|
||||
* @returns {HTMLCanvasElement}
|
||||
*/
|
||||
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) {
|
||||
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;
|
||||
let cur = layer.canvas;
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
for (const adj of stack) {
|
||||
const adjOut = applyAdjustment(cur, adj);
|
||||
if (adj.opacity >= 0.999) {
|
||||
cur = adjOut;
|
||||
} else {
|
||||
const blend = document.createElement('canvas');
|
||||
blend.width = w; blend.height = h;
|
||||
const bctx = blend.getContext('2d');
|
||||
bctx.drawImage(cur, 0, 0);
|
||||
bctx.globalAlpha = adj.opacity;
|
||||
bctx.drawImage(adjOut, 0, 0);
|
||||
bctx.globalAlpha = 1;
|
||||
cur = blend;
|
||||
}
|
||||
}
|
||||
if (staged) {
|
||||
cur = applyAdjustment(cur, staged);
|
||||
}
|
||||
layer._adjFinal = cur;
|
||||
layer._adjFinalKey = sig;
|
||||
return cur;
|
||||
}
|
||||
116
static/js/editor/harmonize-masks.js
Normal file
116
static/js/editor/harmonize-masks.js
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Mask builders used by the AI Harmonize pipeline.
|
||||
*
|
||||
* - `layerUnionAlpha` — union of every non-base layer's alpha, as a
|
||||
* binary (0/255) mask. Used as the substrate
|
||||
* for both seam and body masks.
|
||||
* - `seamMask` — feathered band along the alpha edges of all
|
||||
* non-base layers. White = "blend here",
|
||||
* black = "leave alone". Returned as base64
|
||||
* PNG (so it can be POST'd to the diffusion
|
||||
* endpoint as JSON).
|
||||
* - `layerBodyMask` — feathered FULL shape of every non-base layer.
|
||||
* White = "AI may redraw this pixel using the
|
||||
* existing pixels as a starting point";
|
||||
* black = "preserve exactly". Returned as base64.
|
||||
*
|
||||
* Each helper takes the visible layer list + the doc dimensions; no
|
||||
* module state.
|
||||
*
|
||||
* @typedef {{ visible: boolean, id: string, canvas: HTMLCanvasElement, offset: {x: number, y: number} }} HarmLayer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build a binary alpha mask = the UNION of every non-base visible
|
||||
* layer's pixels. Returns null when fewer than 2 visible layers exist
|
||||
* or when the non-base layers are entirely transparent.
|
||||
*
|
||||
* @param {number} w / h Canvas dimensions in pixels.
|
||||
* @param {HarmLayer[]} layers All layers in stack order; the
|
||||
* first VISIBLE layer is treated as
|
||||
* the base / background.
|
||||
* @returns {HTMLCanvasElement|null}
|
||||
*/
|
||||
export function layerUnionAlpha(w, h, layers) {
|
||||
const visible = layers.filter(l => l.visible);
|
||||
if (visible.length < 2) return null;
|
||||
const bgId = visible[0].id;
|
||||
const alphaCanvas = document.createElement('canvas');
|
||||
alphaCanvas.width = w; alphaCanvas.height = h;
|
||||
const actx = alphaCanvas.getContext('2d');
|
||||
let hasFg = false;
|
||||
for (const layer of visible) {
|
||||
if (layer.id === bgId) continue;
|
||||
const off = layer.offset || { x: 0, y: 0 };
|
||||
actx.drawImage(layer.canvas, off.x, off.y);
|
||||
hasFg = true;
|
||||
}
|
||||
if (!hasFg) return null;
|
||||
const src = actx.getImageData(0, 0, w, h);
|
||||
const bin = document.createElement('canvas');
|
||||
bin.width = w; bin.height = h;
|
||||
const bctx = bin.getContext('2d');
|
||||
const binImg = bctx.createImageData(w, h);
|
||||
let any = false;
|
||||
for (let i = 0; i < src.data.length; i += 4) {
|
||||
const v = src.data[i + 3] > 0 ? 255 : 0;
|
||||
if (v) any = true;
|
||||
binImg.data[i] = binImg.data[i + 1] = binImg.data[i + 2] = v;
|
||||
binImg.data[i + 3] = 255;
|
||||
}
|
||||
if (!any) return null;
|
||||
bctx.putImageData(binImg, 0, 0);
|
||||
return bin;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build a feathered seam mask along the alpha edges of all non-base
|
||||
* visible layers. Returns base64-encoded PNG (no `data:` prefix), or
|
||||
* null if there's nothing to harmonize.
|
||||
*/
|
||||
export function seamMask(w, h, layers, featherPx = 12) {
|
||||
const bin = layerUnionAlpha(w, h, layers);
|
||||
if (!bin) return null;
|
||||
const blur = document.createElement('canvas');
|
||||
blur.width = w; blur.height = h;
|
||||
const blctx = blur.getContext('2d');
|
||||
blctx.filter = `blur(${featherPx}px)`;
|
||||
blctx.drawImage(bin, 0, 0);
|
||||
blctx.filter = 'none';
|
||||
const blurred = blctx.getImageData(0, 0, w, h);
|
||||
const mask = blctx.createImageData(w, h);
|
||||
// Triangular weight peaked at mid-grey — picks out the alpha-edge band.
|
||||
for (let i = 0; i < blurred.data.length; i += 4) {
|
||||
const v = blurred.data[i];
|
||||
const dist = Math.abs(v - 128);
|
||||
const wt = Math.max(0, 255 - dist * 2);
|
||||
mask.data[i] = mask.data[i + 1] = mask.data[i + 2] = wt;
|
||||
mask.data[i + 3] = 255;
|
||||
}
|
||||
blctx.putImageData(mask, 0, 0);
|
||||
const soft = document.createElement('canvas');
|
||||
soft.width = w; soft.height = h;
|
||||
const sctx = soft.getContext('2d');
|
||||
sctx.filter = `blur(${Math.max(2, Math.floor(featherPx / 4))}px)`;
|
||||
sctx.drawImage(blur, 0, 0);
|
||||
sctx.filter = 'none';
|
||||
return soft.toDataURL('image/png').split(',')[1];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build a feathered FULL-shape mask of every non-base visible layer.
|
||||
* Returns base64-encoded PNG, or null if there are no non-base layers.
|
||||
*/
|
||||
export function layerBodyMask(w, h, layers, featherPx = 12) {
|
||||
const bin = layerUnionAlpha(w, h, layers);
|
||||
if (!bin) return null;
|
||||
const soft = document.createElement('canvas');
|
||||
soft.width = w; soft.height = h;
|
||||
const sctx = soft.getContext('2d');
|
||||
sctx.filter = `blur(${featherPx}px)`;
|
||||
sctx.drawImage(bin, 0, 0);
|
||||
sctx.filter = 'none';
|
||||
return soft.toDataURL('image/png').split(',')[1];
|
||||
}
|
||||
176
static/js/editor/history-panel.js
Normal file
176
static/js/editor/history-panel.js
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* History-panel subsystem — the floating frosted list of labeled
|
||||
* undo/redo entries that hangs off the topbar History button.
|
||||
*
|
||||
* Same docking pattern as the FX adjustment popups: drag the head to
|
||||
* reposition, click the minimise button to dock into the modalManager
|
||||
* chip chain, click the chip to restore. Esc closes.
|
||||
*
|
||||
* @param {{
|
||||
* undo: () => void,
|
||||
* redo: () => void,
|
||||
* }} deps
|
||||
*
|
||||
* @returns {{
|
||||
* toggleHistoryPanel: () => void,
|
||||
* refreshHistoryPanelIfOpen: () => void,
|
||||
* jumpToHistory: (offset: number) => void,
|
||||
* }}
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import modalManager from '../modalManager.js';
|
||||
import { HISTORY_ICON, relTime } from './layer-helpers.js';
|
||||
import { historyPanelHTML } from './build/popups.js';
|
||||
|
||||
export function createHistoryPanel({ undo, redo }) {
|
||||
function jumpToHistory(offset) {
|
||||
if (offset === 0) return;
|
||||
if (offset < 0) {
|
||||
for (let i = 0; i < -offset; i++) undo();
|
||||
} else {
|
||||
for (let i = 0; i < offset; i++) redo();
|
||||
}
|
||||
}
|
||||
|
||||
function closeHistoryPanel() {
|
||||
if (state.historyPanelEl) {
|
||||
if (state.historyPanelEl._escHandler) {
|
||||
document.removeEventListener('keydown', state.historyPanelEl._escHandler, true);
|
||||
}
|
||||
if (state.historyPanelEl._awayHandler) {
|
||||
document.removeEventListener('pointerdown', state.historyPanelEl._awayHandler, true);
|
||||
}
|
||||
state.historyPanelEl.remove();
|
||||
state.historyPanelEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
function minimiseHistoryPanel() {
|
||||
if (!state.historyPanelEl) return;
|
||||
const panel = state.historyPanelEl;
|
||||
const r = panel.getBoundingClientRect();
|
||||
panel._stashLeft = r.left;
|
||||
panel._stashTop = r.top;
|
||||
panel.style.display = 'none';
|
||||
state.historyPanelEl = null;
|
||||
const modalId = panel._modalId || 'ge-history-panel-min';
|
||||
panel._modalId = modalId;
|
||||
modalManager.register(modalId, {
|
||||
label: 'History',
|
||||
icon: HISTORY_ICON,
|
||||
restoreFn: () => {
|
||||
panel.style.left = panel._stashLeft + 'px';
|
||||
panel.style.top = panel._stashTop + 'px';
|
||||
panel.style.display = '';
|
||||
state.historyPanelEl = panel;
|
||||
refreshHistoryPanelIfOpen();
|
||||
},
|
||||
closeFn: () => {
|
||||
panel.remove();
|
||||
modalManager.unregister(modalId);
|
||||
},
|
||||
});
|
||||
modalManager.minimize(modalId);
|
||||
}
|
||||
|
||||
function toggleHistoryPanel() {
|
||||
if (state.historyPanelEl) { closeHistoryPanel(); return; }
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'ge-history-panel';
|
||||
panel.className = 'ge-frosted';
|
||||
panel.innerHTML = historyPanelHTML(HISTORY_ICON);
|
||||
document.body.appendChild(panel);
|
||||
state.historyPanelEl = panel;
|
||||
const btn = document.getElementById('ge-history-btn');
|
||||
if (btn) {
|
||||
const r = btn.getBoundingClientRect();
|
||||
panel.style.top = (r.bottom + 6) + 'px';
|
||||
panel.style.left = Math.max(8, r.left) + 'px';
|
||||
}
|
||||
panel.querySelector('.ge-adj-min').addEventListener('click', minimiseHistoryPanel);
|
||||
// Click anywhere outside the panel (or trigger button) closes it.
|
||||
setTimeout(() => {
|
||||
const onAway = (ev) => {
|
||||
if (!state.historyPanelEl) return;
|
||||
if (state.historyPanelEl.contains(ev.target)) return;
|
||||
if (btn && (ev.target === btn || btn.contains(ev.target))) return;
|
||||
closeHistoryPanel();
|
||||
document.removeEventListener('pointerdown', onAway, true);
|
||||
};
|
||||
document.addEventListener('pointerdown', onAway, true);
|
||||
panel._awayHandler = onAway;
|
||||
}, 0);
|
||||
|
||||
const head = panel.querySelector('[data-history-drag]');
|
||||
head.addEventListener('pointerdown', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
e.preventDefault();
|
||||
const startX = e.clientX, startY = e.clientY;
|
||||
const r0 = panel.getBoundingClientRect();
|
||||
head.setPointerCapture(e.pointerId);
|
||||
head.style.cursor = 'grabbing';
|
||||
const onMove = (ev) => {
|
||||
const nx = Math.max(0, Math.min(window.innerWidth - 60, r0.left + (ev.clientX - startX)));
|
||||
const ny = Math.max(0, Math.min(window.innerHeight - 30, r0.top + (ev.clientY - startY)));
|
||||
panel.style.left = nx + 'px';
|
||||
panel.style.top = ny + 'px';
|
||||
};
|
||||
const onUp = () => {
|
||||
head.releasePointerCapture(e.pointerId);
|
||||
head.style.cursor = '';
|
||||
head.removeEventListener('pointermove', onMove);
|
||||
head.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
head.addEventListener('pointermove', onMove);
|
||||
head.addEventListener('pointerup', onUp);
|
||||
});
|
||||
|
||||
const onKey = (ev) => {
|
||||
if (ev.key === 'Escape') {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
closeHistoryPanel();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
panel._escHandler = onKey;
|
||||
|
||||
refreshHistoryPanelIfOpen();
|
||||
}
|
||||
|
||||
function refreshHistoryPanelIfOpen() {
|
||||
if (!state.historyPanelEl) return;
|
||||
const list = state.historyPanelEl.querySelector('#ge-history-list');
|
||||
if (!list) return;
|
||||
// Chronological order — oldest at top, latest at bottom. Past
|
||||
// (undo) states first, then Current, then future (redo) states.
|
||||
const rows = [];
|
||||
for (let i = 0; i < state.undoStack.length; i++) {
|
||||
const s = state.undoStack[i];
|
||||
rows.push({ offset: -(state.undoStack.length - i), label: s._label || 'Edit', ts: s._ts });
|
||||
}
|
||||
rows.push({ offset: 0, label: 'Current', ts: Date.now(), current: true });
|
||||
for (let i = state.redoStack.length - 1; i >= 0; i--) {
|
||||
const s = state.redoStack[i];
|
||||
rows.push({ offset: (state.redoStack.length - i), label: s._label || 'Edit', ts: s._ts, future: true });
|
||||
}
|
||||
list.innerHTML = rows.map(r => `
|
||||
<button class="ge-history-row${r.current ? ' current' : ''}${r.future ? ' future' : ''}" data-offset="${r.offset}">
|
||||
<span class="ge-history-row-dot"></span>
|
||||
<span class="ge-history-row-label">${(r.label || '').replace(/[<>&]/g,'')}</span>
|
||||
<span class="ge-history-row-time">${relTime(r.ts)}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
list.querySelectorAll('.ge-history-row').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const off = parseInt(btn.dataset.offset, 10);
|
||||
jumpToHistory(off);
|
||||
});
|
||||
});
|
||||
// Scroll the current marker into view.
|
||||
const cur = list.querySelector('.current');
|
||||
if (cur) cur.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
|
||||
return { toggleHistoryPanel, refreshHistoryPanelIfOpen, jumpToHistory };
|
||||
}
|
||||
266
static/js/editor/keyboard-shortcuts.js
Normal file
266
static/js/editor/keyboard-shortcuts.js
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Editor keyboard shortcuts — bound to `document` so shortcuts work
|
||||
* without first clicking into the canvas. Gated by `state.editorOpen`
|
||||
* so they don't leak into chat input when the editor is closed.
|
||||
*
|
||||
* Covers:
|
||||
* ? toggle the shortcuts cheatsheet
|
||||
* Enter confirm in-progress transform
|
||||
* Esc cancel transform / lasso / crop (in priority order)
|
||||
* Ctrl+Z undo (Shift adds redo)
|
||||
* Ctrl+Shift+D deselect (clears wand + lasso)
|
||||
* Ctrl+S save (Shift = save as / export to gallery)
|
||||
* Ctrl+Shift+T open resize popup
|
||||
* Ctrl+Alt+T start free transform
|
||||
* Ctrl+Alt+I invert wand / lasso selection
|
||||
* Ctrl+Alt+J new empty layer
|
||||
* 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
|
||||
* [ / ] shrink / grow brush size proportionally
|
||||
* D, C, M (when lasso has 3+ points) → delete / copy / convert mask
|
||||
* Delete / Backspace (wand or lasso) → delete pixels
|
||||
*
|
||||
* @param {{
|
||||
* toolbar: HTMLDivElement,
|
||||
* toolKeyMap: Record<string, string>,
|
||||
* composite: () => void,
|
||||
* saveState: (label?: string) => void,
|
||||
* undo: () => void,
|
||||
* redo: () => void,
|
||||
* toggleShortcuts: (show?: boolean) => void,
|
||||
* confirmTransform: () => void,
|
||||
* cancelTransform: () => void,
|
||||
* startTransform: () => void,
|
||||
* resizeCustomPrompt: () => void,
|
||||
* addEmptyLayer: () => void,
|
||||
* brushSizeSync: (source: HTMLInputElement | null) => void,
|
||||
* invertSelection: () => boolean,
|
||||
* wandDeleteSelection: () => void,
|
||||
* wandCopyToNewLayer: () => void,
|
||||
* lassoDeleteSelection: () => void,
|
||||
* lassoCopyToLayer: () => void,
|
||||
* lassoToMask: () => void,
|
||||
* buildLassoMask: (w: number, h: number, offX: number, offY: number, feather: number, grow: number) => HTMLCanvasElement,
|
||||
* drawLassoOverlay: () => void,
|
||||
* activeLayer: () => object | null,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
export function wireKeyboardShortcuts(deps) {
|
||||
const {
|
||||
toolbar, toolKeyMap,
|
||||
composite, saveState, undo, redo,
|
||||
toggleShortcuts, confirmTransform, cancelTransform, startTransform,
|
||||
resizeCustomPrompt, addEmptyLayer, brushSizeSync,
|
||||
invertSelection,
|
||||
wandDeleteSelection, wandCopyToNewLayer,
|
||||
lassoDeleteSelection, lassoCopyToLayer, lassoToMask,
|
||||
buildLassoMask, drawLassoOverlay,
|
||||
activeLayer, uiModule,
|
||||
} = deps;
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!state.editorOpen) 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') {
|
||||
e.preventDefault();
|
||||
toggleShortcuts();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && state.transformActive) {
|
||||
e.preventDefault();
|
||||
confirmTransform();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') return;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
// Save shortcuts — match the hints shown in the Save dropdown.
|
||||
if ((e.key === 's' || e.key === 'S') && !e.altKey) {
|
||||
e.preventDefault();
|
||||
document.getElementById(e.shiftKey ? 'ge-export-gallery' : 'ge-save')?.click();
|
||||
}
|
||||
if (e.shiftKey && e.key === 'T') { e.preventDefault(); resizeCustomPrompt(); }
|
||||
if (e.altKey && e.key === 't') { e.preventDefault(); startTransform(); }
|
||||
// Ctrl+Alt+I — invert current selection. Uses e.code so
|
||||
// Alt-modified key values (e.g. `ˆ` on Mac with Option+I)
|
||||
// don't break the match.
|
||||
if (e.altKey && e.code === 'KeyI') {
|
||||
if (invertSelection()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
// Ctrl+Alt+J — new empty layer.
|
||||
if (e.altKey && e.code === 'KeyJ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
addEmptyLayer();
|
||||
}
|
||||
// Wand selection: Delete = erase pixels. Ctrl+X = cut to
|
||||
// clipboard + new layer + erase. Ctrl+C = copy.
|
||||
// (Legacy `&& !_wandActive` clause referenced an undeclared
|
||||
// variable — removed; the wand is selection-only and has no
|
||||
// "active drag" state.)
|
||||
if (state.wandMask) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
wandDeleteSelection();
|
||||
return;
|
||||
}
|
||||
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);
|
||||
if (!src) return;
|
||||
// Clip source by wand mask into a temp canvas.
|
||||
const w = src.canvas.width, h = src.canvas.height;
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = w; tmp.height = h;
|
||||
const tCtx = tmp.getContext('2d');
|
||||
tCtx.drawImage(src.canvas, 0, 0);
|
||||
tCtx.globalCompositeOperation = 'destination-in';
|
||||
tCtx.drawImage(state.wandMask, 0, 0);
|
||||
state.internalClipboard = tmp;
|
||||
tmp.toBlob(blob => {
|
||||
if (blob && navigator.clipboard?.write) {
|
||||
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => {
|
||||
uiModule.showToast(isCut ? 'Cut to clipboard' : 'Copied to clipboard');
|
||||
}).catch(() => uiModule.showToast(isCut ? 'Cut (editor only)' : 'Copied (editor only)'));
|
||||
}
|
||||
}, 'image/png');
|
||||
if (isCut) {
|
||||
// Cut also moves the selection to a new layer + erases source.
|
||||
wandCopyToNewLayer();
|
||||
wandDeleteSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((e.key === 'x' || e.key === 'c') && state.lassoPoints.length >= 3) {
|
||||
e.preventDefault();
|
||||
const layer = activeLayer();
|
||||
if (!layer) return;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const feather = parseInt(document.getElementById('ge-lasso-feather')?.value || '0');
|
||||
const grow = parseInt(document.getElementById('ge-lasso-grow')?.value || '0');
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
const mask = buildLassoMask(w, h, off.x, off.y, feather, grow);
|
||||
const srcData = layer.ctx.getImageData(0, 0, w, h);
|
||||
const maskData = mask.getContext('2d').getImageData(0, 0, w, h);
|
||||
// Build clipped image.
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = w; tmp.height = h;
|
||||
const tCtx = tmp.getContext('2d');
|
||||
const outData = tCtx.createImageData(w, h);
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
const mv = maskData.data[i * 4] / 255;
|
||||
if (mv > 0) {
|
||||
outData.data[i*4] = srcData.data[i*4];
|
||||
outData.data[i*4+1] = srcData.data[i*4+1];
|
||||
outData.data[i*4+2] = srcData.data[i*4+2];
|
||||
outData.data[i*4+3] = Math.round(srcData.data[i*4+3] * mv);
|
||||
}
|
||||
}
|
||||
tCtx.putImageData(outData, 0, 0);
|
||||
state.internalClipboard = tmp;
|
||||
const isCut = e.key === 'x';
|
||||
tmp.toBlob(blob => {
|
||||
if (blob && navigator.clipboard?.write) {
|
||||
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => {
|
||||
uiModule.showToast(isCut ? 'Cut to clipboard' : 'Copied to clipboard');
|
||||
}).catch(() => uiModule.showToast(isCut ? 'Cut (editor only)' : 'Copied (editor only)'));
|
||||
}
|
||||
}, 'image/png');
|
||||
if (e.key === 'x') {
|
||||
const savedPts = [...state.lassoPoints];
|
||||
state.lassoPoints = savedPts;
|
||||
lassoDeleteSelection();
|
||||
} else {
|
||||
state.lassoPoints = [];
|
||||
composite();
|
||||
}
|
||||
}
|
||||
// Ctrl+C with no active selection → copy the entire active layer
|
||||
// to the system clipboard as a PNG. Gives a "just copy this image"
|
||||
// shortcut without having to lasso-select-all first. The
|
||||
// selection-aware Ctrl+C paths above run first (wand + lasso),
|
||||
// so this only fires when neither is active.
|
||||
if (e.key === 'c' && !e.shiftKey && !state.wandMask && state.lassoPoints.length < 3) {
|
||||
const layer = activeLayer();
|
||||
if (layer && layer.canvas && layer.canvas.width > 0) {
|
||||
e.preventDefault();
|
||||
layer.canvas.toBlob(blob => {
|
||||
if (blob && navigator.clipboard?.write) {
|
||||
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||
.then(() => uiModule.showToast('Layer copied to clipboard'))
|
||||
.catch(() => uiModule.showToast('Copy failed (clipboard permission denied?)'));
|
||||
}
|
||||
}, 'image/png');
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
composite();
|
||||
drawLassoOverlay();
|
||||
uiModule.showToast('All selected — Ctrl+C to copy, Del to delete');
|
||||
}
|
||||
// Ctrl+V handled by the paste event listener.
|
||||
if (e.key === 'v') { /* no-op here */ }
|
||||
return;
|
||||
}
|
||||
// Tool shortcuts (only when not typing in an input).
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
const toolId = toolKeyMap[e.key.toLowerCase()];
|
||||
if (toolId) {
|
||||
const toolBtn = toolbar.querySelector(`[data-tool="${toolId}"]`);
|
||||
if (toolBtn) toolBtn.click();
|
||||
}
|
||||
// Bracket keys for brush size — ±10% multiplier mirrors the
|
||||
// exponential slider curve so each press feels the same at any
|
||||
// size.
|
||||
if (e.key === '[' || e.key === ']') {
|
||||
const factor = e.key === '[' ? 0.9 : 1.1;
|
||||
state.brushSize = Math.max(1, Math.min(800, Math.round(state.brushSize * factor)));
|
||||
try { brushSizeSync(null); } catch {}
|
||||
}
|
||||
// Lasso shortcuts (when selection exists).
|
||||
if (state.lassoPoints.length >= 3) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); lassoDeleteSelection(); }
|
||||
if (e.key === 'd') { e.preventDefault(); lassoDeleteSelection(); }
|
||||
if (e.key === 'c') { e.preventDefault(); lassoCopyToLayer(); }
|
||||
if (e.key === 'm') { e.preventDefault(); lassoToMask(); }
|
||||
}
|
||||
});
|
||||
}
|
||||
131
static/js/editor/layer-helpers.js
Normal file
131
static/js/editor/layer-helpers.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Pure helpers + constants for layers and adjustment sub-layers.
|
||||
*
|
||||
* Everything in this module is stateless — feed in a layer object and
|
||||
* get back a value. The legacy gallery editor's module-level helpers
|
||||
* re-export from here so existing call sites keep working unchanged.
|
||||
*/
|
||||
|
||||
/** True if the layer has at least one FX/adjustment sub-layer. */
|
||||
export function layerHasAdjustments(layer) {
|
||||
return !!(layer && layer.adjLayers && layer.adjLayers.length > 0);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* True if the layer carries a non-identity Levels OR Color-Balance
|
||||
* adjustment that needs the per-pixel pass (vs the cheap CSS-filter
|
||||
* path for plain B/C/H/S).
|
||||
*/
|
||||
export function layerNeedsPixelPass(layer) {
|
||||
if (!layer || !layer.adjustments) return false;
|
||||
const a = layer.adjustments;
|
||||
if (a.levels && (a.levels.inBlack !== 0 || a.levels.inWhite !== 255 ||
|
||||
a.levels.gamma !== 1 ||
|
||||
a.levels.outBlack !== 0 || a.levels.outWhite !== 255)) return true;
|
||||
if (a.colorBalance) {
|
||||
for (const tone of ['shadows', 'midtones', 'highlights']) {
|
||||
const v = a.colorBalance[tone];
|
||||
if (v && (v.r || v.g || v.b)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Compact hash of a layer's Levels + Color-Balance values. Used to
|
||||
* key the per-pixel adjustment cache so we can skip recomputing when
|
||||
* nothing changed.
|
||||
*/
|
||||
export function adjustmentsKey(adj) {
|
||||
const l = adj.levels || {};
|
||||
const cb = adj.colorBalance || {};
|
||||
const s = cb.shadows || {}, m = cb.midtones || {}, h = cb.highlights || {};
|
||||
return [
|
||||
l.inBlack|0, l.inWhite|0, l.gamma || 1, l.outBlack|0, l.outWhite|0,
|
||||
s.r|0, s.g|0, s.b|0, m.r|0, m.g|0, m.b|0, h.r|0, h.g|0, h.b|0,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
|
||||
/** Identity params for each adjustment type. */
|
||||
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 'color-balance': return {
|
||||
shadows: { r: 0, g: 0, b: 0 },
|
||||
midtones: { r: 0, g: 0, b: 0 },
|
||||
highlights: { r: 0, g: 0, b: 0 },
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
/** Human-readable name for an adjustment type. */
|
||||
export function adjLayerLabel(type) {
|
||||
return {
|
||||
'brightness-contrast': 'Brightness/Contrast',
|
||||
'hue-saturation': 'Hue/Saturation',
|
||||
'levels': 'Levels',
|
||||
'color-balance': 'Color Balance',
|
||||
}[type] || type;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Per-type SVG icon strings. Used in popup title bars, the minimised
|
||||
* FX-dock chips, and the layer-panel sub-row name so the same glyph
|
||||
* shows up everywhere a given adjustment type appears.
|
||||
*/
|
||||
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>',
|
||||
'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>',
|
||||
'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>',
|
||||
'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>',
|
||||
};
|
||||
|
||||
|
||||
/** SVG used in the topbar/history button glyphs. */
|
||||
export const HISTORY_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="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>';
|
||||
|
||||
|
||||
/** Quick downsampled-alpha check: are there any opaque pixels on this canvas? */
|
||||
export function isMaskCanvasEmpty(canvas) {
|
||||
if (!canvas) return true;
|
||||
try {
|
||||
const w = canvas.width, h = canvas.height;
|
||||
if (!w || !h) return true;
|
||||
const sw = Math.min(200, w), sh = Math.min(200, h);
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = sw; tmp.height = sh;
|
||||
tmp.getContext('2d').drawImage(canvas, 0, 0, sw, sh);
|
||||
const d = tmp.getContext('2d').getImageData(0, 0, sw, sh).data;
|
||||
for (let i = 3; i < d.length; i += 4) if (d[i] > 0) return false;
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
|
||||
/** Same as `isMaskCanvasEmpty` but accepts a layer wrapper. */
|
||||
export function isLayerEmpty(layer) {
|
||||
if (!layer || !layer.canvas) return true;
|
||||
return isMaskCanvasEmpty(layer.canvas);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Compact "now / 30s / 12m / 4h" relative-time string. Used in the
|
||||
* editor's history panel labels.
|
||||
*/
|
||||
export function relTime(ts) {
|
||||
if (!ts) return '';
|
||||
const dt = (Date.now() - ts) / 1000;
|
||||
if (dt < 5) return 'now';
|
||||
if (dt < 60) return Math.round(dt) + 's';
|
||||
if (dt < 3600) return Math.round(dt / 60) + 'm';
|
||||
return Math.round(dt / 3600) + 'h';
|
||||
}
|
||||
600
static/js/editor/layer-panel.js
Normal file
600
static/js/editor/layer-panel.js
Normal file
@@ -0,0 +1,600 @@
|
||||
/**
|
||||
* Layer panel renderer — rebuilds the right-side layer list from
|
||||
* `state.layers` every time it's called. The full row tree per layer:
|
||||
*
|
||||
* parent row
|
||||
* [drag handle] [eye] [name] [opacity slider] [FX] [dup] [mask] [merge-down] [×]
|
||||
* adjustment sub-rows (FX entries)
|
||||
* [eye] [name+icon] [opacity slider] [merge] [×]
|
||||
* mask sub-rows
|
||||
* [eye] [name] [merge-up?] [×]
|
||||
*
|
||||
* Reads/writes shared `state` directly (layers, activeLayerId,
|
||||
* layerOffsets, imgWidth, imgHeight, lassoPoints/lassoActive,
|
||||
* wandMask, maskCanvas/maskCtx, nextLayerId). Function deps are
|
||||
* orchestration callbacks still living in galleryEditor.js.
|
||||
*
|
||||
* Returns `{ render }` so the recursive self-call works via closure
|
||||
* over `render` rather than module-state lookup.
|
||||
*
|
||||
* @param {{
|
||||
* composite: () => void,
|
||||
* saveState: (label?: string) => void,
|
||||
* showLayerThumb: (rowEl: HTMLElement, layer: object) => void,
|
||||
* hideLayerThumb: () => void,
|
||||
* loadLayerAlphaAsSelection: (layer: object) => void,
|
||||
* openFxPopup: (layer: object, anchor: HTMLElement) => void,
|
||||
* editAdjLayer: (layer: object, adj: object, anchor: HTMLElement) => void,
|
||||
* createLayer: (name: string, w: number, h: number) => object,
|
||||
* lassoToMask: () => void,
|
||||
* wandToMask: () => void,
|
||||
* getActiveMaskLayer: () => object | null,
|
||||
* syncFxPanelToActiveLayerIfPresent: () => void,
|
||||
* dragSortModule: object | null,
|
||||
* uiModule: object | null,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import {
|
||||
layerHasAdjustments,
|
||||
isLayerEmpty,
|
||||
isMaskCanvasEmpty,
|
||||
adjLayerLabel,
|
||||
ADJ_ICONS,
|
||||
} from './layer-helpers.js';
|
||||
import { applyAdjustment } from './fx/pixel-pass.js';
|
||||
import { mergeLayerDownAtIndex } from './wire-merge-buttons.js';
|
||||
|
||||
const EYE_OPEN = '<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>';
|
||||
const EYE_OFF = '<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"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>';
|
||||
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 createLayerPanelRenderer(deps) {
|
||||
const {
|
||||
composite, saveState, showLayerThumb, hideLayerThumb,
|
||||
loadLayerAlphaAsSelection, openFxPopup, editAdjLayer,
|
||||
createLayer, lassoToMask, wandToMask, getActiveMaskLayer,
|
||||
syncFxPanelToActiveLayerIfPresent,
|
||||
dragSortModule, uiModule,
|
||||
} = deps;
|
||||
|
||||
function shouldIgnoreLayerTap() {
|
||||
return Date.now() < (window.__geSuppressLayerTapUntil || 0);
|
||||
}
|
||||
|
||||
function render() {
|
||||
// FX panel mirrors the active layer's adjustments — re-sync on
|
||||
// every layer event (activation, add, delete, etc).
|
||||
try { syncFxPanelToActiveLayerIfPresent(); } catch {}
|
||||
const list = document.getElementById('ge-layers-list');
|
||||
if (!list) return;
|
||||
// Mobile bottom-sheet peek height — header + N rows, capped so a
|
||||
// 20-layer document doesn't get a peek that eats the canvas.
|
||||
const panel = document.querySelector('.ge-right-panel');
|
||||
if (panel) {
|
||||
requestAnimationFrame(() => {
|
||||
const header = panel.querySelector('.ge-layers-header');
|
||||
const firstRow = list.querySelector('.ge-layer-item');
|
||||
const headerH = header ? header.offsetHeight : 52;
|
||||
const rowH = firstRow ? firstRow.offsetHeight : 36;
|
||||
const allRows = list.querySelectorAll('.ge-layer-item').length;
|
||||
const MAX_ROWS = 2;
|
||||
const rows = Math.min(allRows, MAX_ROWS);
|
||||
panel.style.setProperty('--peek-height', `${headerH + rows * rowH + 6}px`);
|
||||
});
|
||||
}
|
||||
list.innerHTML = '';
|
||||
|
||||
// Render in reverse order (top layer first).
|
||||
for (let i = state.layers.length - 1; i >= 0; i--) {
|
||||
const layer = state.layers[i];
|
||||
const item = document.createElement('div');
|
||||
// Parent row is highlighted ONLY when it's actually the paint
|
||||
// target — activated AND no mask sub-layer is currently active.
|
||||
const parentIsPaintTarget = layer.id === state.activeLayerId &&
|
||||
!(layer.masks && layer.activeMaskId && layer.masks.some(m => m.id === layer.activeMaskId));
|
||||
item.className = 'ge-layer-item' +
|
||||
(parentIsPaintTarget ? ' active' : '') +
|
||||
(layer.id === state.activeLayerId && !parentIsPaintTarget ? ' active-parent' : '');
|
||||
item.dataset.layerId = layer.id;
|
||||
// Hover thumbnail.
|
||||
item.addEventListener('mouseenter', () => showLayerThumb(item, layer));
|
||||
item.addEventListener('mouseleave', () => hideLayerThumb());
|
||||
item.addEventListener('click', (e) => {
|
||||
if (shouldIgnoreLayerTap()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
// Shift+click → load layer transparency as wand selection.
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
loadLayerAlphaAsSelection(layer);
|
||||
return;
|
||||
}
|
||||
if (state.activeLayerId === layer.id) return;
|
||||
state.activeLayerId = layer.id;
|
||||
// Toggle the active class inline (avoid full re-render so the
|
||||
// dblclick listener on the name element stays alive between
|
||||
// clicks — a re-render destroys the element after the first
|
||||
// click and the second lands on a different node).
|
||||
document.querySelectorAll('.ge-layers-list .ge-layer-item').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.layerId === state.activeLayerId);
|
||||
});
|
||||
});
|
||||
|
||||
// Drag handle — grip dots; dragSortModule.enable() below scopes
|
||||
// drag-init to this handle so row body clicks still activate.
|
||||
const handle = document.createElement('span');
|
||||
handle.className = 'ge-layer-drag';
|
||||
handle.title = 'Drag to reorder';
|
||||
handle.innerHTML = '<svg width="8" height="14" viewBox="0 0 8 14" fill="currentColor"><circle cx="2" cy="2" r="1"/><circle cx="6" cy="2" r="1"/><circle cx="2" cy="7" r="1"/><circle cx="6" cy="7" r="1"/><circle cx="2" cy="12" r="1"/><circle cx="6" cy="12" r="1"/></svg>';
|
||||
item.appendChild(handle);
|
||||
|
||||
const visBtn = document.createElement('button');
|
||||
visBtn.className = 'ge-layer-vis' + (layer.visible ? ' visible' : '');
|
||||
visBtn.innerHTML = layer.visible ? EYE_OPEN : EYE_OFF;
|
||||
visBtn.title = layer.visible ? 'Hide layer' : 'Show layer';
|
||||
visBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
layer.visible = !layer.visible;
|
||||
composite();
|
||||
render();
|
||||
});
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'ge-layer-name';
|
||||
nameEl.textContent = layer.name + (isLayerEmpty(layer) ? ' (empty)' : '');
|
||||
nameEl.addEventListener('dblclick', () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = layer.name;
|
||||
input.className = 'ge-layer-name-input';
|
||||
nameEl.replaceWith(input);
|
||||
input.focus();
|
||||
const save = () => { layer.name = input.value || layer.name; render(); };
|
||||
input.addEventListener('blur', save);
|
||||
input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') save(); });
|
||||
});
|
||||
|
||||
const opSlider = document.createElement('input');
|
||||
opSlider.type = 'range';
|
||||
opSlider.min = '0';
|
||||
opSlider.max = '100';
|
||||
opSlider.value = String(Math.round(layer.opacity * 100));
|
||||
opSlider.className = 'ge-layer-opacity';
|
||||
opSlider.title = 'Opacity';
|
||||
opSlider.addEventListener('input', (e) => {
|
||||
e.stopPropagation();
|
||||
layer.opacity = parseInt(e.target.value) / 100;
|
||||
composite();
|
||||
});
|
||||
// Browser :active drops the moment the cursor leaves the slider
|
||||
// hit-area in some browsers; a JS-managed `dragging` class
|
||||
// survives the OS pointer-capture so the slider stays expanded
|
||||
// for the whole drag.
|
||||
opSlider.addEventListener('pointerdown', () => {
|
||||
opSlider.classList.add('dragging');
|
||||
const onUp = () => {
|
||||
opSlider.classList.remove('dragging');
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
window.addEventListener('pointerup', onUp);
|
||||
});
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'ge-layer-controls';
|
||||
|
||||
// FX (adjustments) — opens a floating popup bound to this layer.
|
||||
const fxBtn = document.createElement('button');
|
||||
fxBtn.className = 'ge-layer-btn ge-layer-fx-btn' + (layerHasAdjustments(layer) ? ' active' : '');
|
||||
fxBtn.innerHTML = '<svg width="12" height="12" 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"/></svg>';
|
||||
fxBtn.title = 'Adjust layer (Brightness, Contrast, Saturation, Hue, Levels, Color Balance)';
|
||||
fxBtn.style.touchAction = 'manipulation';
|
||||
let lastFxPointerOpenAt = 0;
|
||||
let fxOpenTimer = null;
|
||||
const openLayerFx = (e, delay = 0) => {
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation();
|
||||
window.__geSuppressLayerTapUntil = 0;
|
||||
if (fxOpenTimer) clearTimeout(fxOpenTimer);
|
||||
fxOpenTimer = setTimeout(() => {
|
||||
fxOpenTimer = null;
|
||||
openFxPopup(layer, fxBtn);
|
||||
}, delay);
|
||||
};
|
||||
fxBtn.addEventListener('pointerdown', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
fxBtn.addEventListener('pointerup', (e) => {
|
||||
lastFxPointerOpenAt = Date.now();
|
||||
const delay = e.pointerType === 'touch' || e.pointerType === 'pen' ? 120 : 0;
|
||||
openLayerFx(e, delay);
|
||||
});
|
||||
fxBtn.addEventListener('click', (e) => {
|
||||
if (Date.now() - lastFxPointerOpenAt < 500) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
openLayerFx(e);
|
||||
});
|
||||
controls.appendChild(fxBtn);
|
||||
|
||||
// Duplicate — clones pixels + offset + opacity + masks + adjLayers
|
||||
// + visibility; inserts above the original; new copy becomes
|
||||
// active.
|
||||
const dupBtn = document.createElement('button');
|
||||
dupBtn.className = 'ge-layer-btn';
|
||||
dupBtn.title = 'Duplicate layer';
|
||||
dupBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||
dupBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
saveState(`Duplicate "${layer.name}"`);
|
||||
const copy = createLayer(layer.name + ' copy', layer.canvas.width, layer.canvas.height);
|
||||
copy.ctx.drawImage(layer.canvas, 0, 0);
|
||||
copy.opacity = layer.opacity;
|
||||
copy.visible = layer.visible;
|
||||
const srcOff = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
state.layerOffsets.set(copy.id, { x: srcOff.x, y: srcOff.y });
|
||||
if (Array.isArray(layer.masks) && layer.masks.length) {
|
||||
copy.masks = layer.masks.map(m => {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = m.canvas.width; c.height = m.canvas.height;
|
||||
c.getContext('2d').drawImage(m.canvas, 0, 0);
|
||||
return {
|
||||
id: 'mask-' + (state.nextLayerId++),
|
||||
name: m.name,
|
||||
canvas: c,
|
||||
ctx: c.getContext('2d'),
|
||||
visible: m.visible !== false,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (Array.isArray(layer.adjLayers) && layer.adjLayers.length) {
|
||||
copy.adjLayers = layer.adjLayers.map(a => ({
|
||||
id: 'adj-' + Math.random().toString(36).slice(2, 9),
|
||||
type: a.type,
|
||||
name: a.name,
|
||||
visible: a.visible !== false,
|
||||
opacity: a.opacity != null ? a.opacity : 1,
|
||||
params: JSON.parse(JSON.stringify(a.params || {})),
|
||||
}));
|
||||
}
|
||||
const idx = state.layers.findIndex(l => l.id === layer.id);
|
||||
if (idx >= 0) state.layers.splice(idx + 1, 0, copy);
|
||||
else state.layers.push(copy);
|
||||
state.activeLayerId = copy.id;
|
||||
composite();
|
||||
render();
|
||||
if (uiModule) uiModule.showToast('Layer duplicated');
|
||||
});
|
||||
controls.appendChild(dupBtn);
|
||||
|
||||
// Add-mask — if a lasso/wand selection is active, bake it into a
|
||||
// mask sub-layer on this layer; otherwise create an empty mask
|
||||
// for the user to paint with the Brush tool.
|
||||
const hasLassoSelInitial = state.lassoPoints.length >= 3 && !state.lassoActive;
|
||||
const hasWandSelInitial = !!state.wandMask;
|
||||
const maskBtn = document.createElement('button');
|
||||
maskBtn.className = 'ge-layer-btn ge-layer-mask-btn' +
|
||||
((hasLassoSelInitial || hasWandSelInitial) ? ' from-selection' : '');
|
||||
maskBtn.innerHTML = '<svg width="12" height="12" 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"/><path d="M3 12c4 0 4-4 8-4s4 4 8 4-4 4-8 4-4-4-8-4z" fill="currentColor"/></svg>';
|
||||
maskBtn.title = (hasLassoSelInitial || hasWandSelInitial)
|
||||
? 'Make mask from current selection'
|
||||
: 'Add empty mask (paint with Brush)';
|
||||
maskBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// Activate this layer first so the new mask attaches here.
|
||||
state.activeLayerId = layer.id;
|
||||
// Re-check selection state AT CLICK TIME — captured vars may
|
||||
// be stale if a selection was drawn after the panel paint.
|
||||
const hasLassoSel = state.lassoPoints.length >= 3 && !state.lassoActive;
|
||||
const hasWandSel = !!state.wandMask;
|
||||
if (hasLassoSel) {
|
||||
saveState(`Mask from lasso on "${layer.name}"`);
|
||||
// Force a fresh mask sub-layer for this conversion so each
|
||||
// selection becomes its own mask instead of merging into the
|
||||
// previously active one.
|
||||
layer.activeMaskId = null;
|
||||
lassoToMask();
|
||||
} else if (hasWandSel) {
|
||||
saveState(`Mask from wand on "${layer.name}"`);
|
||||
layer.activeMaskId = null;
|
||||
wandToMask();
|
||||
} else {
|
||||
saveState(`Add mask to "${layer.name}"`);
|
||||
const c = document.createElement('canvas');
|
||||
c.width = state.imgWidth;
|
||||
c.height = state.imgHeight;
|
||||
if (!layer.masks) layer.masks = [];
|
||||
const mask = {
|
||||
id: 'mask-' + (state.nextLayerId++),
|
||||
name: 'Mask ' + (layer.masks.length + 1),
|
||||
canvas: c,
|
||||
ctx: c.getContext('2d'),
|
||||
visible: true,
|
||||
};
|
||||
layer.masks.push(mask);
|
||||
layer.activeMaskId = mask.id;
|
||||
state.maskCanvas = mask.canvas;
|
||||
state.maskCtx = mask.ctx;
|
||||
composite();
|
||||
render();
|
||||
}
|
||||
});
|
||||
controls.appendChild(maskBtn);
|
||||
|
||||
// Per-row Merge Down — bakes this layer into the one beneath.
|
||||
// Hidden on the bottom layer in the visual stack (idx 0 forward).
|
||||
if (i > 0) {
|
||||
const mergeDownBtn = document.createElement('button');
|
||||
mergeDownBtn.className = 'ge-layer-btn';
|
||||
mergeDownBtn.title = 'Merge down into layer below';
|
||||
mergeDownBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="6 13 12 19 18 13"/></svg>';
|
||||
mergeDownBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
saveState(`Merge "${layer.name}" down`);
|
||||
mergeLayerDownAtIndex(i);
|
||||
composite();
|
||||
render();
|
||||
uiModule.showToast('Layer merged down');
|
||||
});
|
||||
controls.appendChild(mergeDownBtn);
|
||||
}
|
||||
|
||||
// Delete — shown for every layer except when this is the last
|
||||
// remaining one. Base photo is deletable too; Ctrl+Z brings it
|
||||
// back from history. Extra confirm for the base layer.
|
||||
if (state.layers.length > 1) {
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'ge-layer-btn danger';
|
||||
delBtn.textContent = '×';
|
||||
delBtn.title = layer.isBase ? 'Delete original layer (Ctrl+Z to undo)' : 'Delete layer';
|
||||
delBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (layer.isBase && uiModule?.styledConfirm) {
|
||||
const ok = await uiModule.styledConfirm(
|
||||
'Delete the original photo layer? Ctrl+Z brings it back.',
|
||||
{ confirmText: 'Delete', cancelText: 'Cancel', danger: true }
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
// Snapshot BEFORE removing so Ctrl+Z can bring it back.
|
||||
saveState(`Delete layer "${layer.name}"`);
|
||||
state.layers.splice(i, 1);
|
||||
state.layerOffsets.delete(layer.id);
|
||||
if (state.activeLayerId === layer.id) {
|
||||
state.activeLayerId = state.layers[Math.min(i, state.layers.length - 1)].id;
|
||||
}
|
||||
composite();
|
||||
render();
|
||||
});
|
||||
controls.appendChild(delBtn);
|
||||
}
|
||||
|
||||
item.appendChild(visBtn);
|
||||
item.appendChild(nameEl);
|
||||
item.appendChild(opSlider);
|
||||
item.appendChild(controls);
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
if (shouldIgnoreLayerTap()) return;
|
||||
state.activeLayerId = layer.id;
|
||||
// Clicking the PARENT row makes layer pixels the paint target
|
||||
// (mask is no longer the target). Mask sub-rows stay in the
|
||||
// panel; clicking one re-targets it.
|
||||
layer.activeMaskId = null;
|
||||
state.maskCanvas = null;
|
||||
state.maskCtx = null;
|
||||
render();
|
||||
composite();
|
||||
});
|
||||
|
||||
list.appendChild(item);
|
||||
|
||||
// Adjustment sub-layer rows, indented under the parent.
|
||||
if (layer.adjLayers && layer.adjLayers.length) {
|
||||
for (const adj of layer.adjLayers) {
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'ge-layer-item ge-adj-sub-item';
|
||||
sub.dataset.adjId = adj.id;
|
||||
const sVis = document.createElement('button');
|
||||
sVis.className = 'ge-layer-vis' + (adj.visible ? ' visible' : '');
|
||||
sVis.innerHTML = adj.visible ? EYE_OPEN_SM : EYE_OFF_SM;
|
||||
sVis.title = adj.visible ? 'Hide adjustment' : 'Show adjustment';
|
||||
sVis.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
adj.visible = !adj.visible;
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
render();
|
||||
});
|
||||
const sName = document.createElement('span');
|
||||
sName.className = 'ge-layer-name ge-adj-sub-name';
|
||||
sName.innerHTML = `<span class="ge-adj-sub-icon">${ADJ_ICONS[adj.type] || ''}</span><span>${(adj.name || adjLayerLabel(adj.type)).replace(/[<>&]/g,'')}</span>`;
|
||||
const sOp = document.createElement('input');
|
||||
sOp.type = 'range';
|
||||
sOp.min = '0'; sOp.max = '100';
|
||||
sOp.value = Math.round(adj.opacity * 100);
|
||||
sOp.className = 'ge-layer-opacity';
|
||||
sOp.title = 'Adjustment opacity';
|
||||
sOp.addEventListener('input', () => {
|
||||
adj.opacity = parseInt(sOp.value, 10) / 100;
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
});
|
||||
const sControls = document.createElement('div');
|
||||
sControls.className = 'ge-layer-controls';
|
||||
const mergeBtn = document.createElement('button');
|
||||
mergeBtn.className = 'ge-layer-btn';
|
||||
mergeBtn.title = 'Merge into layer (bake)';
|
||||
mergeBtn.innerHTML = '<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="M12 19V5M5 12l7-7 7 7"/></svg>';
|
||||
mergeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// Bake just this adjustment into layer.canvas, then drop it.
|
||||
saveState(`Merge ${adjLayerLabel(adj.type)}`);
|
||||
const baked = applyAdjustment(layer.canvas, adj);
|
||||
layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
layer.ctx.drawImage(baked, 0, 0);
|
||||
layer.adjLayers = layer.adjLayers.filter(x => x.id !== adj.id);
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
render();
|
||||
});
|
||||
sControls.appendChild(mergeBtn);
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'ge-layer-btn danger';
|
||||
delBtn.textContent = '×';
|
||||
delBtn.title = 'Delete adjustment';
|
||||
delBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
saveState(`Delete ${adjLayerLabel(adj.type)}`);
|
||||
layer.adjLayers = layer.adjLayers.filter(x => x.id !== adj.id);
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
render();
|
||||
});
|
||||
sControls.appendChild(delBtn);
|
||||
|
||||
sub.appendChild(sVis);
|
||||
sub.appendChild(sName);
|
||||
sub.appendChild(sOp);
|
||||
sub.appendChild(sControls);
|
||||
// Single-click on the sub-row (outside the inline controls)
|
||||
// reopens the adj popup with this sub-layer's params staged.
|
||||
sub.addEventListener('click', (e) => {
|
||||
if (shouldIgnoreLayerTap()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('.ge-layer-vis, .ge-layer-opacity, .ge-layer-btn')) return;
|
||||
if (!e.target.closest('.ge-adj-sub-name')) return;
|
||||
e.stopPropagation();
|
||||
editAdjLayer(layer, adj, sub);
|
||||
});
|
||||
list.appendChild(sub);
|
||||
}
|
||||
}
|
||||
|
||||
// Mask sub-layer rows.
|
||||
if (layer.masks && layer.masks.length) {
|
||||
for (let mi = 0; mi < layer.masks.length; mi++) {
|
||||
const mk = layer.masks[mi];
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'ge-layer-item ge-adj-sub-item ge-mask-sub-item' +
|
||||
(layer.activeMaskId === mk.id ? ' active' : '');
|
||||
sub.dataset.maskId = mk.id;
|
||||
const sVis = document.createElement('button');
|
||||
sVis.className = 'ge-layer-vis' + (mk.visible ? ' visible' : '');
|
||||
sVis.innerHTML = mk.visible ? EYE_OPEN_SM : EYE_OFF_SM;
|
||||
sVis.title = mk.visible ? 'Hide mask' : 'Show mask';
|
||||
sVis.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
mk.visible = !mk.visible;
|
||||
composite();
|
||||
render();
|
||||
});
|
||||
const sName = document.createElement('span');
|
||||
sName.className = 'ge-layer-name ge-adj-sub-name';
|
||||
const maskIcon = '<svg width="12" height="12" 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"/><path d="M3 12c4 0 4-4 8-4s4 4 8 4-4 4-8 4-4-4-8-4z" fill="currentColor"/></svg>';
|
||||
const mkName = String(mk.name || 'Mask').replace(/[<>&]/g, '');
|
||||
const mkEmpty = isMaskCanvasEmpty(mk.canvas) ? ' <span style="opacity:0.55;">(empty)</span>' : '';
|
||||
sName.innerHTML = `<span class="ge-adj-sub-icon">${maskIcon}</span><span>${mkName}${mkEmpty}</span>`;
|
||||
const sControls = document.createElement('div');
|
||||
sControls.className = 'ge-layer-controls';
|
||||
// Merge-up — combine this mask into the one above (lower mi).
|
||||
if (mi > 0) {
|
||||
const mergeBtn = document.createElement('button');
|
||||
mergeBtn.className = 'ge-layer-btn';
|
||||
mergeBtn.title = 'Merge into mask above';
|
||||
mergeBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="6 11 12 5 18 11"/></svg>';
|
||||
mergeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const above = layer.masks[mi - 1];
|
||||
if (!above) return;
|
||||
saveState(`Merge mask "${mk.name}" into "${above.name}"`);
|
||||
// Union of alpha — `source-over` already does max for
|
||||
// fully opaque white masks; this also handles partial alpha.
|
||||
above.ctx.save();
|
||||
above.ctx.globalCompositeOperation = 'source-over';
|
||||
above.ctx.drawImage(mk.canvas, 0, 0);
|
||||
above.ctx.restore();
|
||||
layer.masks = layer.masks.filter(x => x.id !== mk.id);
|
||||
if (layer.activeMaskId === mk.id) layer.activeMaskId = above.id;
|
||||
const a = getActiveMaskLayer();
|
||||
if (a) { state.maskCanvas = a.canvas; state.maskCtx = a.ctx; }
|
||||
else { state.maskCanvas = null; state.maskCtx = null; }
|
||||
composite();
|
||||
render();
|
||||
});
|
||||
sControls.appendChild(mergeBtn);
|
||||
}
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'ge-layer-btn danger';
|
||||
delBtn.textContent = '×';
|
||||
delBtn.title = 'Delete mask';
|
||||
delBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
saveState(`Delete mask "${mk.name}"`);
|
||||
layer.masks = layer.masks.filter(x => x.id !== mk.id);
|
||||
if (layer.activeMaskId === mk.id) {
|
||||
layer.activeMaskId = layer.masks[layer.masks.length - 1]?.id || null;
|
||||
}
|
||||
// Sync global mask plumbing.
|
||||
const a = getActiveMaskLayer();
|
||||
if (a) { state.maskCanvas = a.canvas; state.maskCtx = a.ctx; }
|
||||
else { state.maskCanvas = null; state.maskCtx = null; }
|
||||
composite();
|
||||
render();
|
||||
});
|
||||
sControls.appendChild(delBtn);
|
||||
sub.appendChild(sVis);
|
||||
sub.appendChild(sName);
|
||||
sub.appendChild(sControls);
|
||||
sub.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.ge-layer-vis, .ge-layer-btn')) return;
|
||||
e.stopPropagation();
|
||||
// Activate this mask: paint/inpaint/generate target.
|
||||
layer.activeMaskId = mk.id;
|
||||
state.activeLayerId = layer.id;
|
||||
state.maskCanvas = mk.canvas;
|
||||
state.maskCtx = mk.ctx;
|
||||
render();
|
||||
composite();
|
||||
});
|
||||
list.appendChild(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wire the shared dragSort module — limit drag-init to the grip
|
||||
// handle so row body clicks still activate. Called every render
|
||||
// because `enable()` cleans up the previous instance keyed on
|
||||
// instanceKey.
|
||||
if (dragSortModule) {
|
||||
dragSortModule.enable('ge-layers-list', '.ge-layer-item', {
|
||||
instanceKey: 'ge-layers',
|
||||
handleSelector: '.ge-layer-drag',
|
||||
onReorder: (orderedItems) => {
|
||||
// DOM is top→bottom = reverse of array order, so the new
|
||||
// array is the reverse of the DOM order.
|
||||
const byId = new Map(state.layers.map(l => [l.id, l]));
|
||||
const newLayers = orderedItems
|
||||
.map(el => byId.get(el.dataset.layerId))
|
||||
.filter(Boolean)
|
||||
.reverse();
|
||||
if (newLayers.length === state.layers.length) {
|
||||
state.layers = newLayers;
|
||||
saveState();
|
||||
composite();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { render };
|
||||
}
|
||||
91
static/js/editor/mask-utils.js
Normal file
91
static/js/editor/mask-utils.js
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Mask-canvas helpers used by the inpaint pipeline.
|
||||
*
|
||||
* Pure utility functions — they take a canvas (or layer-shape) as
|
||||
* input and return a fresh canvas, with no module-level state.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dilate (positive `px`) or erode (negative `px`) a binary alpha mask.
|
||||
*
|
||||
* Strategy: blur the source by `|px|`, then re-threshold the result.
|
||||
* - Dilation keeps anything with non-trivial blurred alpha (low cutoff).
|
||||
* - Erosion keeps only pixels that retained near-full alpha after blur.
|
||||
*
|
||||
* @param {HTMLCanvasElement} src Source mask canvas.
|
||||
* @param {number} px Pixels to dilate (>0) or erode (<0). 0 = copy.
|
||||
* @returns {HTMLCanvasElement} Fresh canvas with the same dimensions.
|
||||
*/
|
||||
export function dilateMask(src, px) {
|
||||
const w = src.width, h = src.height;
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = w; tmp.height = h;
|
||||
const ctx = tmp.getContext('2d');
|
||||
if (px === 0) {
|
||||
ctx.drawImage(src, 0, 0);
|
||||
return tmp;
|
||||
}
|
||||
const dilate = px > 0;
|
||||
const radius = Math.abs(px);
|
||||
ctx.filter = `blur(${radius}px)`;
|
||||
ctx.drawImage(src, 0, 0);
|
||||
ctx.filter = 'none';
|
||||
const img = ctx.getImageData(0, 0, w, h);
|
||||
const threshold = dilate ? 8 : 247;
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
const a = img.data[i + 3];
|
||||
const keep = dilate ? a > threshold : a >= threshold;
|
||||
if (keep) {
|
||||
img.data[i] = img.data[i + 1] = img.data[i + 2] = 255;
|
||||
img.data[i + 3] = 255;
|
||||
} else {
|
||||
img.data[i + 3] = 0;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
return tmp;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Re-derive an inpaint-result layer's alpha from its cached AI image +
|
||||
* the hard mask, applying a feather + optional dilate/erode of the
|
||||
* boundary. Mutates `layer.canvas` in place via `layer.ctx`.
|
||||
*
|
||||
* The layer must carry an `inpaintSource = { ai, mask }` cache from the
|
||||
* original inpaint call so we can re-shape the alpha cheaply (no
|
||||
* second model call required).
|
||||
*
|
||||
* @param {{canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D,
|
||||
* inpaintSource?: {ai: CanvasImageSource, mask: HTMLCanvasElement}}} layer
|
||||
* @param {number} featherPx Gaussian blur radius applied to the mask alpha.
|
||||
* @param {number} [edgeShiftPx] Dilate (+) or erode (-) the mask before blurring.
|
||||
*/
|
||||
export function applyInpaintFeather(layer, featherPx, edgeShiftPx = 0) {
|
||||
if (!layer || !layer.inpaintSource) return;
|
||||
const { ai, mask } = layer.inpaintSource;
|
||||
const w = layer.canvas.width;
|
||||
const h = layer.canvas.height;
|
||||
// 1) Optional dilate/erode, then optional blur, into a fresh mask.
|
||||
let shaped = mask;
|
||||
if (edgeShiftPx !== 0) shaped = dilateMask(mask, edgeShiftPx);
|
||||
const softMask = document.createElement('canvas');
|
||||
softMask.width = w; softMask.height = h;
|
||||
const smCtx = softMask.getContext('2d');
|
||||
if (featherPx > 0) {
|
||||
smCtx.filter = `blur(${featherPx}px)`;
|
||||
smCtx.drawImage(shaped, 0, 0, w, h);
|
||||
smCtx.filter = 'none';
|
||||
} else {
|
||||
smCtx.drawImage(shaped, 0, 0, w, h);
|
||||
}
|
||||
// 2) Draw the AI image fresh, then multiply alpha by the soft mask.
|
||||
const ctx = layer.ctx;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.drawImage(ai, 0, 0);
|
||||
ctx.globalCompositeOperation = 'destination-in';
|
||||
ctx.drawImage(softMask, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
149
static/js/editor/shortcuts-popover.js
Normal file
149
static/js/editor/shortcuts-popover.js
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Shortcuts-cheatsheet popover — floating frosted-glass list of every
|
||||
* editor keyboard shortcut, anchored above the topbar keyboard icon
|
||||
* (drops below if there's no room above). Drag the header to move;
|
||||
* Esc or click outside dismisses; position is persisted in
|
||||
* localStorage so re-opening restores where the user left it.
|
||||
*
|
||||
* Public API: `toggleShortcuts(show?)` — true/false to force a state,
|
||||
* undefined to toggle.
|
||||
*
|
||||
* @returns {{ toggleShortcuts: (show?: boolean) => void }}
|
||||
*/
|
||||
import { shortcutsPopupHTML } from './build/popups.js';
|
||||
|
||||
export function createShortcutsPopover() {
|
||||
let pop = null;
|
||||
let outside = null;
|
||||
|
||||
function ensurePopover() {
|
||||
if (pop) return pop;
|
||||
const el = document.createElement('div');
|
||||
el.id = 'ge-shortcuts-popover';
|
||||
el.style.cssText = [
|
||||
'position:fixed', 'z-index:10000', 'display:none',
|
||||
// Frosted-glass background: semi-transparent + heavy blur of
|
||||
// what's behind. Layered with an inner translucent veil so
|
||||
// light themes also read clearly without losing the see-through
|
||||
// feel.
|
||||
'background:color-mix(in srgb, var(--panel, #1a1a1a) 55%, transparent)',
|
||||
'backdrop-filter:blur(18px) saturate(150%)',
|
||||
'-webkit-backdrop-filter:blur(18px) saturate(150%)',
|
||||
'color:var(--fg,#eee)',
|
||||
'border:1px solid color-mix(in srgb, var(--fg, #eee) 18%, transparent)',
|
||||
'border-radius:12px',
|
||||
'box-shadow:0 14px 36px rgba(0,0,0,0.5), inset 0 1px 0 color-mix(in srgb, var(--fg, #fff) 8%, transparent)',
|
||||
'padding:12px 14px', 'min-width:540px', 'max-width:min(720px,92vw)',
|
||||
'font-size:12px', 'line-height:1.5',
|
||||
].join(';');
|
||||
el.innerHTML = shortcutsPopupHTML();
|
||||
document.body.appendChild(el);
|
||||
el.querySelector('#ge-shortcuts-close').addEventListener('click', () => toggleShortcuts(false));
|
||||
|
||||
// Drag by the header handle. Position survives across opens
|
||||
// (localStorage).
|
||||
const handle = el.querySelector('#ge-shortcuts-handle');
|
||||
if (handle) {
|
||||
let drag = null;
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
if (e.target.closest('#ge-shortcuts-close')) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
drag = { dx: e.clientX - r.left, dy: e.clientY - r.top, w: r.width, h: r.height };
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
handle.style.cursor = 'grabbing';
|
||||
// Mark as user-positioned so subsequent toggles don't re-anchor.
|
||||
el.dataset.userPositioned = '1';
|
||||
e.preventDefault();
|
||||
});
|
||||
handle.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
let left = e.clientX - drag.dx;
|
||||
let top = e.clientY - drag.dy;
|
||||
const m = 4;
|
||||
left = Math.max(m, Math.min(left, window.innerWidth - drag.w - m));
|
||||
top = Math.max(m, Math.min(top, window.innerHeight - drag.h - m));
|
||||
el.style.left = left + 'px';
|
||||
el.style.top = top + 'px';
|
||||
});
|
||||
const endDrag = () => {
|
||||
if (!drag) return;
|
||||
drag = null;
|
||||
handle.style.cursor = 'grab';
|
||||
try {
|
||||
localStorage.setItem('ge-shortcuts-pos', JSON.stringify({
|
||||
left: el.style.left, top: el.style.top,
|
||||
}));
|
||||
} catch {}
|
||||
};
|
||||
handle.addEventListener('pointerup', endDrag);
|
||||
handle.addEventListener('pointercancel', endDrag);
|
||||
}
|
||||
pop = el;
|
||||
return pop;
|
||||
}
|
||||
|
||||
function positionPopover(el, anchor) {
|
||||
// Place ABOVE the anchor, horizontally centred but clamped to
|
||||
// viewport. Falls back to BELOW if there's no room above.
|
||||
el.style.display = 'block'; // need a layout pass for accurate size
|
||||
const ar = anchor.getBoundingClientRect();
|
||||
const pr = el.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
let left = ar.left + (ar.width / 2) - (pr.width / 2);
|
||||
let top = ar.top - pr.height - margin;
|
||||
if (top < margin) top = ar.bottom + margin;
|
||||
left = Math.max(margin, Math.min(left, window.innerWidth - pr.width - margin));
|
||||
top = Math.max(margin, Math.min(top, window.innerHeight - pr.height - margin));
|
||||
el.style.left = left + 'px';
|
||||
el.style.top = top + 'px';
|
||||
}
|
||||
|
||||
function toggleShortcuts(show) {
|
||||
const el = ensurePopover();
|
||||
const open = show === undefined ? el.style.display === 'none' : show;
|
||||
if (open) {
|
||||
// Restore the user's last-dragged position if any; otherwise
|
||||
// anchor above the button.
|
||||
let saved = null;
|
||||
try { saved = JSON.parse(localStorage.getItem('ge-shortcuts-pos') || 'null'); } catch {}
|
||||
if (saved && saved.left && saved.top) {
|
||||
el.style.display = 'block';
|
||||
el.style.left = saved.left;
|
||||
el.style.top = saved.top;
|
||||
// Re-clamp in case the viewport changed since the user dragged.
|
||||
requestAnimationFrame(() => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const m = 4;
|
||||
if (r.right > window.innerWidth) el.style.left = (window.innerWidth - r.width - m) + 'px';
|
||||
if (r.bottom > window.innerHeight) el.style.top = (window.innerHeight - r.height - m) + 'px';
|
||||
if (r.left < 0) el.style.left = m + 'px';
|
||||
if (r.top < 0) el.style.top = m + 'px';
|
||||
});
|
||||
} else {
|
||||
const anchor = document.getElementById('ge-shortcuts-btn');
|
||||
if (anchor) positionPopover(el, anchor);
|
||||
else el.style.display = 'block';
|
||||
}
|
||||
// Defer outside-click so the click that opened us doesn't close us.
|
||||
outside = (e) => {
|
||||
if (el.contains(e.target)) return;
|
||||
if (e.target.closest('#ge-shortcuts-btn')) return;
|
||||
toggleShortcuts(false);
|
||||
};
|
||||
setTimeout(() => document.addEventListener('mousedown', outside, true), 0);
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
if (outside) {
|
||||
document.removeEventListener('mousedown', outside, true);
|
||||
outside = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the popover is currently visible. */
|
||||
function isOpen() {
|
||||
return !!(pop && pop.style.display && pop.style.display !== 'none');
|
||||
}
|
||||
|
||||
return { toggleShortcuts, isOpen };
|
||||
}
|
||||
191
static/js/editor/slider-ux.js
Normal file
191
static/js/editor/slider-ux.js
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Slider-UX wiring shared across the editor:
|
||||
*
|
||||
* 1. `is-using` class while a slider is being dragged (eraser-rows
|
||||
* expand to a wider track when in use). Cleared 0.5s after
|
||||
* pointerup so a quick click doesn't snap back instantly.
|
||||
* 2. Floating value bubble above the thumb during drag.
|
||||
* Desktop: only the layer-opacity slider gets a bubble (the
|
||||
* eraser-row sliders already show a value chip on the right).
|
||||
* Mobile: every slider in the editor gets a bubble.
|
||||
* 3. Click the value chip to type a number directly — replaces
|
||||
* the span with an inline input until blur/Enter.
|
||||
*
|
||||
* Wired ONCE on editor open; the listeners stay alive for the whole
|
||||
* session via state.container delegation.
|
||||
*
|
||||
* @param {{
|
||||
* registerDocClickAway: (handler: (e: Event) => void) => void,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
export function wireSliderUx({ registerDocClickAway }) {
|
||||
const container = state.container;
|
||||
if (!container) return;
|
||||
|
||||
// ── Floating bubble ──
|
||||
const sliderBubble = document.createElement('div');
|
||||
sliderBubble.className = 'ge-slider-bubble';
|
||||
sliderBubble.hidden = true;
|
||||
let sliderBubbleSlider = null;
|
||||
|
||||
// Find the container row for any slider — works for ge-eraser-row
|
||||
// sliders AND the layer-opacity slider on each layer item.
|
||||
function bubbleRowFor(slider) {
|
||||
return slider.closest('.ge-eraser-row, .ge-layer-item, .ge-control-row, .ge-adj-row');
|
||||
}
|
||||
function bubbleText(slider) {
|
||||
const row = bubbleRowFor(slider);
|
||||
// Pulled-out value chip (after the slider) wins; fall back to
|
||||
// the various `<label> <span>` styles used across the editor.
|
||||
const chip = row?.querySelector('.ge-slider-value')
|
||||
|| row?.querySelector('label > span[id$="-label"]')
|
||||
|| row?.querySelector('label > .ge-size-label')
|
||||
|| row?.querySelector('.ge-adj-value');
|
||||
if (chip) return chip.textContent;
|
||||
if (slider.classList.contains('ge-layer-opacity')) {
|
||||
return Math.round(parseFloat(slider.value)) + '%';
|
||||
}
|
||||
return slider.value;
|
||||
}
|
||||
function bubblePos(slider, cursorX) {
|
||||
// Bubble is fixed-positioned on document.body so it escapes any
|
||||
// overflow:hidden / overflow:auto on the row's ancestors. The
|
||||
// bubble's X is CLAMPED to the slider's track so it can't follow
|
||||
// a finger that drags way past either end.
|
||||
const sliderRect = slider.getBoundingClientRect();
|
||||
const minX = sliderRect.left + 8;
|
||||
const maxX = sliderRect.right - 8;
|
||||
const x = Math.max(minX, Math.min(maxX, cursorX));
|
||||
sliderBubble.style.left = x + 'px';
|
||||
sliderBubble.style.top = (sliderRect.top - 8) + 'px';
|
||||
}
|
||||
function showSliderBubble(slider, e) {
|
||||
if (sliderBubble.parentElement !== document.body) document.body.appendChild(sliderBubble);
|
||||
sliderBubble.textContent = bubbleText(slider);
|
||||
bubblePos(slider, e ? e.clientX : slider.getBoundingClientRect().left + slider.offsetWidth / 2);
|
||||
sliderBubble.hidden = false;
|
||||
sliderBubble.classList.add('visible');
|
||||
sliderBubbleSlider = slider;
|
||||
}
|
||||
function hideSliderBubble() {
|
||||
sliderBubble.classList.remove('visible');
|
||||
sliderBubble.hidden = true;
|
||||
sliderBubbleSlider = null;
|
||||
}
|
||||
|
||||
const slidingTimers = new WeakMap();
|
||||
// Desktop: only the layer-opacity slider gets the bubble (eraser-
|
||||
// rows have their own chip). Mobile: every slider gets one.
|
||||
const isMobileSliders = window.matchMedia('(max-width: 820px)').matches;
|
||||
const SLIDER_SEL = isMobileSliders
|
||||
? '.ge-layer-opacity, .ge-eraser-row input[type="range"], .ge-control-row input[type="range"], .ge-adj-row input[type="range"]'
|
||||
: '.ge-layer-opacity';
|
||||
|
||||
container.addEventListener('pointerdown', (e) => {
|
||||
const slider = e.target.closest(SLIDER_SEL);
|
||||
if (!slider) return;
|
||||
const t = slidingTimers.get(slider);
|
||||
if (t) { clearTimeout(t); slidingTimers.delete(slider); }
|
||||
slider.classList.add('is-using');
|
||||
showSliderBubble(slider, e);
|
||||
// Compensate for the leftward-expanding eraser sliders so the
|
||||
// thumb lands at the cursor's X on the new (wider) track. Layer-
|
||||
// opacity doesn't shift left when it grows, so it uses the
|
||||
// browser default.
|
||||
if (slider.matches('.ge-eraser-row input[type="range"]')) {
|
||||
const rect = slider.getBoundingClientRect();
|
||||
const valFrac = Math.max(0, Math.min(1, 1 - (rect.right - e.clientX) / 140));
|
||||
const min = parseFloat(slider.min) || 0;
|
||||
const max = parseFloat(slider.max) || 100;
|
||||
const step = parseFloat(slider.step) || 1;
|
||||
const raw = min + valFrac * (max - min);
|
||||
const stepped = Math.round(raw / step) * step;
|
||||
requestAnimationFrame(() => {
|
||||
slider.value = String(stepped);
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
sliderBubble.textContent = bubbleText(slider);
|
||||
});
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
sliderBubble.textContent = bubbleText(slider);
|
||||
});
|
||||
}
|
||||
}, true);
|
||||
document.addEventListener('pointermove', (e) => {
|
||||
if (!sliderBubbleSlider) return;
|
||||
bubblePos(sliderBubbleSlider, e.clientX);
|
||||
sliderBubble.textContent = bubbleText(sliderBubbleSlider);
|
||||
});
|
||||
const scheduleSliderRelease = (slider) => {
|
||||
if (!slider) return;
|
||||
const old = slidingTimers.get(slider);
|
||||
if (old) clearTimeout(old);
|
||||
const t = setTimeout(() => {
|
||||
slider.classList.remove('is-using');
|
||||
slidingTimers.delete(slider);
|
||||
}, 500);
|
||||
slidingTimers.set(slider, t);
|
||||
};
|
||||
document.addEventListener('pointerup', () => {
|
||||
container.querySelectorAll('input[type="range"].is-using').forEach(scheduleSliderRelease);
|
||||
hideSliderBubble();
|
||||
});
|
||||
|
||||
// ── Click value chip to type a number ──
|
||||
// Replaces the chip with a tiny inline input until blur/Enter,
|
||||
// then writes back to the slider and dispatches `input` so
|
||||
// previews react. Matches the legacy chip AND the pulled-out
|
||||
// `.ge-slider-value` chip so every slider row in the editor is
|
||||
// click-to-type editable.
|
||||
registerDocClickAway((e) => {
|
||||
const chip = e.target.closest(
|
||||
'.ge-eraser-row .ge-slider-value, ' +
|
||||
'.ge-eraser-row label > span[id$="-label"], ' +
|
||||
'.ge-eraser-row > span[id$="-label"], ' +
|
||||
'.ge-adj-row .ge-adj-value'
|
||||
);
|
||||
if (!chip) return;
|
||||
const row = chip.closest('.ge-eraser-row, .ge-adj-row');
|
||||
const slider = row?.querySelector('input[type="range"]');
|
||||
if (!slider) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const numeric = (slider.value ?? '').toString();
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'text';
|
||||
inp.value = numeric;
|
||||
inp.className = 'ge-slider-edit';
|
||||
chip.style.visibility = 'hidden';
|
||||
row.appendChild(inp);
|
||||
// Position the input over where the chip sits.
|
||||
const crect = chip.getBoundingClientRect();
|
||||
const rrect = row.getBoundingClientRect();
|
||||
inp.style.left = (crect.left - rrect.left) + 'px';
|
||||
inp.style.top = (crect.top - rrect.top - 1) + 'px';
|
||||
inp.style.width = Math.max(40, crect.width + 8) + 'px';
|
||||
inp.focus();
|
||||
inp.select();
|
||||
const commit = () => {
|
||||
const v = parseFloat(inp.value);
|
||||
if (!Number.isNaN(v)) {
|
||||
const min = parseFloat(slider.min) || 0;
|
||||
const max = parseFloat(slider.max) || 100;
|
||||
const clamped = Math.max(min, Math.min(max, v));
|
||||
slider.value = String(clamped);
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
cleanup();
|
||||
};
|
||||
const cleanup = () => {
|
||||
inp.remove();
|
||||
chip.style.visibility = '';
|
||||
};
|
||||
inp.addEventListener('blur', commit);
|
||||
inp.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter') { ev.preventDefault(); commit(); }
|
||||
if (ev.key === 'Escape') { ev.preventDefault(); cleanup(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
106
static/js/editor/snap.js
Normal file
106
static/js/editor/snap.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Snap-while-dragging: when the move tool drags a layer near another
|
||||
* layer's edge or the canvas centre/edges, gently lock the proposed
|
||||
* (nx, ny) to the nearest target within SNAP_PX.
|
||||
*
|
||||
* The implementation is pure — it takes the layer being moved + the
|
||||
* trial offset + a context describing zoom + the other layers, and
|
||||
* returns the snapped position plus any guides to draw.
|
||||
*
|
||||
* The legacy gallery editor's `_computeSnap` is a one-line wrapper
|
||||
* that builds the context from module state.
|
||||
*
|
||||
* @param {{canvas: HTMLCanvasElement, id: string}} layer
|
||||
* The layer currently being moved.
|
||||
* @param {number} nx / ny
|
||||
* Trial offset (top-left) in canvas pixels, before snapping.
|
||||
* @param {{
|
||||
* zoom: number,
|
||||
* canvasW: number,
|
||||
* canvasH: number,
|
||||
* otherLayers: Array<{visible: boolean, id: string, canvas: HTMLCanvasElement, offset: {x:number, y: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;
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
|
||||
const vTargets = [
|
||||
{ x: 0, label: 'canvas-l' },
|
||||
{ x: cw, label: 'canvas-r' },
|
||||
{ x: cw / 2, label: 'canvas-cx' },
|
||||
];
|
||||
const hTargets = [
|
||||
{ y: 0, label: 'canvas-t' },
|
||||
{ y: ch, label: 'canvas-b' },
|
||||
{ y: ch / 2, label: 'canvas-cy' },
|
||||
];
|
||||
for (const other of ctx.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;
|
||||
vTargets.push({ x: o.x, label: 'layer-l' });
|
||||
vTargets.push({ x: o.x + ow, label: 'layer-r' });
|
||||
vTargets.push({ x: o.x + ow / 2, label: 'layer-cx' });
|
||||
hTargets.push({ y: o.y, label: 'layer-t' });
|
||||
hTargets.push({ y: o.y + oh, label: 'layer-b' });
|
||||
hTargets.push({ y: o.y + oh / 2, label: 'layer-cy' });
|
||||
}
|
||||
|
||||
const myEdgesX = { l: nx, cx: nx + w / 2, r: nx + w };
|
||||
const myEdgesY = { t: ny, cy: ny + h / 2, b: ny + h };
|
||||
let bestX = null, bestDx = Infinity;
|
||||
let bestY = null, bestDy = Infinity;
|
||||
for (const [src, val] of Object.entries(myEdgesX)) {
|
||||
for (const t of vTargets) {
|
||||
const d = Math.abs(t.x - val);
|
||||
if (d < SNAP_PX && d < bestDx) {
|
||||
bestDx = d;
|
||||
bestX = { snapTo: t.x, src, target: t };
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [src, val] of Object.entries(myEdgesY)) {
|
||||
for (const t of hTargets) {
|
||||
const d = Math.abs(t.y - val);
|
||||
if (d < SNAP_PX && d < bestDy) {
|
||||
bestDy = d;
|
||||
bestY = { snapTo: t.y, src, target: t };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const guides = [];
|
||||
let snappedX = nx, snappedY = ny;
|
||||
if (bestX) {
|
||||
if (bestX.src === 'l') snappedX = bestX.snapTo;
|
||||
else if (bestX.src === 'cx') snappedX = bestX.snapTo - w / 2;
|
||||
else snappedX = bestX.snapTo - w;
|
||||
guides.push({ vertical: true, x: bestX.snapTo });
|
||||
}
|
||||
if (bestY) {
|
||||
if (bestY.src === 't') snappedY = bestY.snapTo;
|
||||
else if (bestY.src === 'cy') snappedY = bestY.snapTo - h / 2;
|
||||
else snappedY = bestY.snapTo - h;
|
||||
guides.push({ vertical: false, y: bestY.snapTo });
|
||||
}
|
||||
return { x: snappedX, y: snappedY, guides };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* CSS cursor name for each transform-tool handle.
|
||||
*
|
||||
* @param {'tl'|'tr'|'bl'|'br'|'rot'|string} id
|
||||
* @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';
|
||||
}
|
||||
}
|
||||
257
static/js/editor/state.js
Normal file
257
static/js/editor/state.js
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Editor state store — a single mutable object that the gallery editor
|
||||
* and its tool modules read and write directly.
|
||||
*
|
||||
* Migration: galleryEditor.js used to own ~110 module-scope `let`
|
||||
* declarations and capture them via closure. Tool modules can't import
|
||||
* a `let` binding's mutations across module boundaries, so we move the
|
||||
* state into a single exported OBJECT whose properties are freely
|
||||
* mutated by anyone holding a reference. Read/write `state.transformW`
|
||||
* exactly the way the old code wrote `_transformW`.
|
||||
*
|
||||
* Slices land here one tool at a time; this file grows as more state
|
||||
* migrates out of galleryEditor.js. Defaults match the legacy
|
||||
* module-scope initializers verbatim — every `state.foo = …` reset
|
||||
* site in galleryEditor.js still works unchanged.
|
||||
*/
|
||||
export const state = {
|
||||
// ── Transform tool ──
|
||||
// Drag-resize / rotate session state. While `transformActive` is
|
||||
// false every field below should be considered stale.
|
||||
transformActive: false,
|
||||
transformLayer: null,
|
||||
transformOrigW: 0,
|
||||
transformOrigH: 0,
|
||||
// Which corner/edge handle the user is currently dragging. One of
|
||||
// 'tl' | 'tr' | 'bl' | 'br' | 'rot' | null.
|
||||
transformHandle: null,
|
||||
// Which handle is currently under the cursor (no drag). Drives the
|
||||
// hover cursor lookup; lives next to `transformHandle` because both
|
||||
// come from `_getTransformHandle`.
|
||||
hoveredHandle: null,
|
||||
// Snapshot of the layer canvas + offset at transform start so Cancel
|
||||
// can restore exactly without re-fetching from the layer.
|
||||
transformOrigCanvas: null,
|
||||
transformOrigOffset: null,
|
||||
// In-progress dimensions / rotation / flips committed on Apply.
|
||||
transformPendingW: 0,
|
||||
transformPendingH: 0,
|
||||
transformPendingRot: 0,
|
||||
transformPendingFlipH: false,
|
||||
transformPendingFlipV: false,
|
||||
transformAspectLock: true,
|
||||
// Floating Transform popup element + drag-start offsets.
|
||||
transformPopup: null,
|
||||
transformStartX: 0,
|
||||
transformStartY: 0,
|
||||
transformStartOffX: 0,
|
||||
transformStartOffY: 0,
|
||||
// Transform overlay canvas — separate canvas positioned over the
|
||||
// main canvas with extra slack for handle rendering. Created by
|
||||
// _buildEditor; the move/transform tools draw their handle layer
|
||||
// onto its 2D context.
|
||||
transformOverlay: null,
|
||||
transformOverlayCtx: null,
|
||||
|
||||
// ── Magic Wand tool ──
|
||||
// Binary selection mask + the layer it was sampled from. `wandMask`
|
||||
// is a canvas the size of `wandLayer`'s pixels, white where selected,
|
||||
// transparent elsewhere. `wandLastSeed` remembers the last click so
|
||||
// tolerance retunes can re-run the flood-fill without re-prompting.
|
||||
wandMask: null,
|
||||
wandLayerId: null,
|
||||
wandTolerance: 24,
|
||||
wandMaskVisible: true,
|
||||
wandMode: 'replace',
|
||||
wandLiveRetune: false,
|
||||
wandLastSeed: null,
|
||||
// Cached layer pixel data (getImageData is O(pixels) — expensive for
|
||||
// 4K layers; invalidated when the active layer changes).
|
||||
wandSrcCache: null,
|
||||
|
||||
// ── Brush / Eraser / Clone tools ──
|
||||
// Shared paint color (brush picks up the swatch; eraser and clone
|
||||
// ignore color but reuse the same picker control).
|
||||
color: '#e06c75',
|
||||
// Brush diameter in canvas pixels. Persisted across tool switches;
|
||||
// bumped to a mask-friendly default on first inpaint entry.
|
||||
brushSize: 8,
|
||||
// Per-tool stroke modifiers — opacity + flow + softness. Each tool
|
||||
// owns its own row so users can dial them in independently.
|
||||
brushOpacity: 100,
|
||||
brushFlow: 100,
|
||||
brushSoftness: 100,
|
||||
eraserOpacity: 100,
|
||||
eraserFlow: 100,
|
||||
eraserSoftness: 100,
|
||||
cloneOpacity: 100,
|
||||
cloneFlow: 100,
|
||||
cloneSoftness: 100,
|
||||
// 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.
|
||||
cloneSourceX: null,
|
||||
cloneSourceY: null,
|
||||
// Stroke-start offsets so the source moves WITH the brush, keeping
|
||||
// the source→destination offset constant across the stroke.
|
||||
cloneStrokeStartX: null,
|
||||
cloneStrokeStartY: null,
|
||||
// Frozen snapshot of the source layer's pixels at stroke start so
|
||||
// moving the source over previously-painted pixels samples the
|
||||
// original, not the in-progress stamp ring.
|
||||
cloneSourceSnapshot: null,
|
||||
cloneSourceLayerId: null,
|
||||
// Mobile: double-tap detection for "set source" since Alt-click
|
||||
// isn't an option without a keyboard.
|
||||
cloneLastTapTime: 0,
|
||||
cloneLastTapX: 0,
|
||||
cloneLastTapY: 0,
|
||||
|
||||
// ── Inpaint + mask ──
|
||||
// Active mask canvas + its 2D context. Re-pointed to the active
|
||||
// mask sub-layer whenever the user picks a different mask in the
|
||||
// layer panel.
|
||||
maskCanvas: null,
|
||||
maskCtx: null,
|
||||
maskVisible: true,
|
||||
// Reused canvas for the union-of-masks tint pass (saves repeated
|
||||
// allocation on every composite).
|
||||
compositeMaskUnion: null,
|
||||
// Visual tint applied to mask pixels in the composite — purely
|
||||
// cosmetic; the AI model still sees a hard binary mask.
|
||||
maskTintColor: 'rgba(255, 110, 110, 1)',
|
||||
maskTintOpacity: 0.28,
|
||||
// Inpaint-tool paint vs erase modes (Ctrl+Alt flips for a single
|
||||
// stroke; UI buttons toggle the persistent setting).
|
||||
inpaintEraseMode: false,
|
||||
inpaintEraseStroke: false,
|
||||
// First-entry guard: bump brush size to the mask-friendly default
|
||||
// the first time the user opens inpaint per session.
|
||||
inpaintBrushInitialised: false,
|
||||
// Last successful inpaint result layer — drives the live edge
|
||||
// feather / stroke sliders (those only apply to the most recent
|
||||
// result).
|
||||
lastInpaintLayerId: null,
|
||||
// Captured handlers so we can detach them on close without leaking.
|
||||
inpaintDismissHandlers: null,
|
||||
// Background-remove tool state — pristine snapshot so the edge
|
||||
// cleanup sliders can live-rebuild alpha without re-running rembg.
|
||||
rembgLiveLayer: null,
|
||||
rembgLiveSnap: null,
|
||||
// Memoised "is rembg installed on the server?" probe.
|
||||
rembgInstalledCache: null,
|
||||
|
||||
// ── Stroke drag state ──
|
||||
// Generic in-progress-stroke flags shared by brush/eraser/clone/
|
||||
// inpaint. `lastX/Y` are the last mouse position used to interpolate
|
||||
// a continuous line through fast-moving cursor samples.
|
||||
drawing: false,
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
|
||||
// ── Move tool ──
|
||||
moving: false,
|
||||
moveStartX: 0,
|
||||
moveStartY: 0,
|
||||
// Layer offset at drag start so we can compute the new offset by
|
||||
// (mouse - startMouse) + startOffset rather than accumulating delta.
|
||||
moveLayerOffsetX: 0,
|
||||
moveLayerOffsetY: 0,
|
||||
// Snap guides drawn during a move-tool drag (Ctrl held). Each entry
|
||||
// is a vertical / horizontal line in canvas space.
|
||||
activeSnapGuides: null,
|
||||
|
||||
// ── Crop tool ──
|
||||
cropping: false,
|
||||
cropStart: null,
|
||||
cropEnd: null,
|
||||
cropRect: null,
|
||||
cropAspectLock: null,
|
||||
// True while the user drags the inside of an already-finished crop
|
||||
// rect to reposition it.
|
||||
cropMoving: false,
|
||||
cropMoveStart: null,
|
||||
|
||||
// ── Lasso tool ──
|
||||
// Freehand selection polygon in canvas pixels. Empty when no lasso
|
||||
// is in progress or staged.
|
||||
lassoPoints: [],
|
||||
lassoActive: false,
|
||||
|
||||
// In-editor copy/paste — separate from the OS clipboard so we can
|
||||
// round-trip layer alpha and metadata losslessly.
|
||||
internalClipboard: null,
|
||||
|
||||
// ── Editor DOM refs ──
|
||||
// Root container that openEditor mounts into.
|
||||
container: null,
|
||||
// Main image canvas + its 2D context. Re-created on every openEditor
|
||||
// so the editor can reopen with fresh dimensions.
|
||||
mainCanvas: null,
|
||||
mainCtx: null,
|
||||
|
||||
// ── Document + layers ──
|
||||
layers: [],
|
||||
activeLayerId: null,
|
||||
// Active tool ID — one of move/crop/transform/brush/eraser/clone/
|
||||
// lasso/wand/inpaint/rembg/harmonize/sharpen/upscale/style.
|
||||
tool: 'move',
|
||||
// Display zoom (1 = 100%). pan{X,Y} translate the canvas inside the
|
||||
// viewport.
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
// Document dimensions in canvas pixels.
|
||||
imgWidth: 0,
|
||||
imgHeight: 0,
|
||||
// Gallery image id this editor session is editing, or null for
|
||||
// blank-canvas drafts.
|
||||
imageId: null,
|
||||
// Original file extension so save-over-original re-encodes in the
|
||||
// same format (JPEG vs PNG matters: JPEG cuts upload size 5-10× for
|
||||
// camera photos over remote tunnels).
|
||||
originalExt: 'png',
|
||||
// True between openEditor / closeEditor — guards async callbacks
|
||||
// that fire after the user closes the editor (don't draw onto a
|
||||
// dead canvas, don't re-mount the spinner).
|
||||
editorOpen: false,
|
||||
// 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.
|
||||
editorDocClickHandlers: [],
|
||||
|
||||
// ── Undo / redo ──
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
|
||||
// ── Layer offsets + id allocation ──
|
||||
// Map<layerId, {x, y}> — kept in a Map so we can serialise it
|
||||
// separately from the layer's own canvas. Mutated in place.
|
||||
layerOffsets: new Map(),
|
||||
nextLayerId: 1,
|
||||
|
||||
// ── Popup / panel handles ──
|
||||
fxPopupEl: null,
|
||||
fxPopupLayerId: null,
|
||||
fxMenuEl: null,
|
||||
adjPopupEl: null,
|
||||
// rAF-throttled live preview while sliders are dragged in adj popups.
|
||||
adjRafPending: false,
|
||||
historyPanelEl: null,
|
||||
// Custom brush-cursor overlay element (circle following the mouse).
|
||||
cursorEl: null,
|
||||
// Hover-preview thumbnail floating element (singleton, repositioned).
|
||||
layerThumbEl: null,
|
||||
// Loading-overlay element (whirlpool + label).
|
||||
editorLoadingEl: null,
|
||||
|
||||
// ── Draft persistence ──
|
||||
draftId: null,
|
||||
draftName: '',
|
||||
persistTimer: null,
|
||||
// Current PUT/POST promise so concurrent saves can chain.
|
||||
persistInFlight: null,
|
||||
// True when an edit happened during an in-flight save — triggers a
|
||||
// follow-up persist after the current one finishes.
|
||||
persistDirty: false,
|
||||
};
|
||||
162
static/js/editor/stroke-pipeline.js
Normal file
162
static/js/editor/stroke-pipeline.js
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composite }) {
|
||||
function cloneStrokeTo(x, y, layer) {
|
||||
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 stamp = document.createElement('canvas');
|
||||
stamp.width = stampSize;
|
||||
stamp.height = stampSize;
|
||||
const stampCtx = stamp.getContext('2d');
|
||||
const softness = Math.max(0, Math.min(1, state.cloneSoftness / 300));
|
||||
const hardStop = stampRadius * (1 - softness);
|
||||
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.restore();
|
||||
state.lastX = x;
|
||||
state.lastY = y;
|
||||
composite();
|
||||
}
|
||||
|
||||
function strokeTo(x, y) {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
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;
|
||||
composite();
|
||||
}
|
||||
|
||||
return { strokeTo, cloneStrokeTo };
|
||||
}
|
||||
64
static/js/editor/stroke-tool-sliders.js
Normal file
64
static/js/editor/stroke-tool-sliders.js
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Per-tool stroke-modifier sliders (Opacity / Flow / Softness) for
|
||||
* Eraser, Brush, and Clone. The three sections share identical UX:
|
||||
*
|
||||
* - Opacity slider: writes to state, updates label, fades the
|
||||
* preview swatch opacity.
|
||||
* - Flow slider: writes to state, updates label, fades the swatch
|
||||
* opacity AND swaps its border style (dashed at low flow → dotted
|
||||
* at high flow) so the user sees the "denseness" change.
|
||||
* - Softness slider: writes to state, updates label, tweens the
|
||||
* radial-gradient inner stop on the swatch so it visually fades
|
||||
* from hard disk to soft falloff.
|
||||
*
|
||||
* The whole block was three near-identical 30-LOC copies before; now
|
||||
* it's one helper that takes the tool's prefix + a state-field bag.
|
||||
*
|
||||
* Usage: just call wireStrokeToolSliders() — the DOM IDs are wired
|
||||
* statically from #ge-{eraser,brush,clone}-{opacity,flow,softness}
|
||||
* + their labels + preview swatches.
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
/** Wire the three sliders for one stroke tool. */
|
||||
function wireToolSliders(prefix, fields) {
|
||||
const opPrev = document.getElementById(`ge-${prefix}-preview-opacity`);
|
||||
const flPrev = document.getElementById(`ge-${prefix}-preview-flow`);
|
||||
const softPrev = document.getElementById(`ge-${prefix}-preview-softness`);
|
||||
|
||||
document.getElementById(`ge-${prefix}-opacity`)?.addEventListener('input', (e) => {
|
||||
state[fields.opacity] = parseInt(e.target.value);
|
||||
document.getElementById(`ge-${prefix}-opacity-label`).textContent = state[fields.opacity] + '%';
|
||||
if (opPrev) opPrev.style.opacity = (state[fields.opacity] / 100).toFixed(2);
|
||||
});
|
||||
|
||||
document.getElementById(`ge-${prefix}-flow`)?.addEventListener('input', (e) => {
|
||||
state[fields.flow] = parseInt(e.target.value);
|
||||
document.getElementById(`ge-${prefix}-flow-label`).textContent = state[fields.flow] + '%';
|
||||
// Lower flow → fewer / sparser dots. Cycle dot densities by
|
||||
// swapping the dashed/dotted border style and fading opacity.
|
||||
if (flPrev) {
|
||||
const denseness = Math.max(1, Math.round(state[fields.flow] / 20));
|
||||
flPrev.style.borderStyle = denseness <= 2 ? 'dashed' : 'dotted';
|
||||
flPrev.style.opacity = (0.3 + (state[fields.flow] / 100) * 0.6).toFixed(2);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById(`ge-${prefix}-softness`)?.addEventListener('input', (e) => {
|
||||
state[fields.softness] = parseInt(e.target.value);
|
||||
document.getElementById(`ge-${prefix}-softness-label`).textContent = state[fields.softness] + '%';
|
||||
// Preview tweens from a hard disk into a soft radial gradient as
|
||||
// softness rises (the CSS already sets the radial gradient — we
|
||||
// just tween the inner solid radius to communicate the falloff).
|
||||
if (softPrev) {
|
||||
const innerStop = Math.max(0, 60 - state[fields.softness] * 0.55);
|
||||
softPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function wireStrokeToolSliders() {
|
||||
wireToolSliders('eraser', { opacity: 'eraserOpacity', flow: 'eraserFlow', softness: 'eraserSoftness' });
|
||||
wireToolSliders('brush', { opacity: 'brushOpacity', flow: 'brushFlow', softness: 'brushSoftness' });
|
||||
wireToolSliders('clone', { opacity: 'cloneOpacity', flow: 'cloneFlow', softness: 'cloneSoftness' });
|
||||
}
|
||||
81
static/js/editor/tools/clone.js
Normal file
81
static/js/editor/tools/clone.js
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Clone tool — Alt-click (desktop) or double-tap (mobile) sets the
|
||||
* sample source; a regular click+drag stamps from that source onto the
|
||||
* active layer. The source point moves WITH the brush so the offset
|
||||
* stays constant across the stroke.
|
||||
*
|
||||
* begin() handles the source-pick and stroke-start branches; the
|
||||
* actual per-sample stamping continues through the shared stroke
|
||||
* pipeline (`_strokeTo`) which knows about clone-mode internally.
|
||||
*
|
||||
* @param {{
|
||||
* activeLayer: () => object | null,
|
||||
* saveState: (label?: string) => void,
|
||||
* strokeTo: (x: number, y: number) => void,
|
||||
* showToast: (msg: string) => void,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
export function createCloneTool({ activeLayer, saveState, strokeTo, showToast }) {
|
||||
return {
|
||||
begin(e) {
|
||||
const layer = activeLayer();
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
// Mobile equivalent of Alt-click: double-tap in screen pixels.
|
||||
// Wider tolerances (500 ms, 40 px) than desktop because finger
|
||||
// taps drift more than mouse clicks.
|
||||
const isTouchEvt = e.type && e.type.startsWith('touch');
|
||||
let isDoubleTap = false;
|
||||
if (isTouchEvt) {
|
||||
const t = e.touches ? e.touches[0] : null;
|
||||
const cx = t ? t.clientX : 0;
|
||||
const cy = t ? t.clientY : 0;
|
||||
const now = Date.now();
|
||||
const dt = now - state.cloneLastTapTime;
|
||||
const dx = cx - state.cloneLastTapX;
|
||||
const dy = cy - state.cloneLastTapY;
|
||||
if (dt < 500 && Math.hypot(dx, dy) < 40) {
|
||||
isDoubleTap = true;
|
||||
state.cloneLastTapTime = 0; // consume the pair
|
||||
} else {
|
||||
state.cloneLastTapTime = now;
|
||||
state.cloneLastTapX = cx;
|
||||
state.cloneLastTapY = cy;
|
||||
}
|
||||
}
|
||||
if (e.altKey || isDoubleTap) {
|
||||
state.cloneSourceX = coords.x;
|
||||
state.cloneSourceY = coords.y;
|
||||
state.cloneSourceLayerId = (layer && layer.id) || state.activeLayerId;
|
||||
state.cloneSourceSnapshot = null; // captured at first stroke
|
||||
showToast('Clone source set');
|
||||
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');
|
||||
// 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 snap = document.createElement('canvas');
|
||||
snap.width = srcLayer.canvas.width;
|
||||
snap.height = srcLayer.canvas.height;
|
||||
snap.getContext('2d').drawImage(srcLayer.canvas, 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
137
static/js/editor/tools/crop.js
Normal file
137
static/js/editor/tools/crop.js
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Crop tool — drag-rect selection that lets the user cut down the
|
||||
* canvas to a smaller region. Supports Shift-lock aspect ratio and
|
||||
* click-inside-rect to reposition an existing crop without redrawing.
|
||||
*
|
||||
* Owns its own begin/drag/end handlers and reads/writes shared state.
|
||||
* The factory takes a small dependency bag for things still living in
|
||||
* galleryEditor.js — `composite` redraws the canvas, `showCropApply`
|
||||
* mounts the floating W×H + Apply panel after the user finishes
|
||||
* dragging.
|
||||
*
|
||||
* @param {{
|
||||
* composite: () => void,
|
||||
* showCropApply: () => void,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import { drawCheckerboard } from '../checkerboard.js';
|
||||
|
||||
export function createCropTool({ composite, showCropApply }) {
|
||||
return {
|
||||
begin(e) {
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
// Click inside an existing crop rect → switch to move-mode so
|
||||
// the user can reposition without redrawing.
|
||||
if (state.cropRect &&
|
||||
coords.x >= state.cropRect.x && coords.x <= state.cropRect.x + state.cropRect.w &&
|
||||
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 };
|
||||
return;
|
||||
}
|
||||
state.cropping = true;
|
||||
state.cropStart = coords;
|
||||
state.cropEnd = { ...state.cropStart };
|
||||
state.cropRect = null;
|
||||
state.cropAspectLock = null;
|
||||
// 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();
|
||||
},
|
||||
|
||||
drag(e) {
|
||||
// Move-mode: drag the existing rect around the canvas.
|
||||
if (state.cropMoving && state.cropRect && state.cropMoveStart) {
|
||||
e.preventDefault();
|
||||
const c = canvasCoords(e, state.mainCanvas);
|
||||
const dx = c.x - state.cropMoveStart.x;
|
||||
const dy = c.y - state.cropMoveStart.y;
|
||||
let nx = state.cropMoveStart.rx + dx;
|
||||
let ny = state.cropMoveStart.ry + dy;
|
||||
// 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));
|
||||
state.cropRect = { ...state.cropRect, x: nx, y: ny };
|
||||
composite();
|
||||
return;
|
||||
}
|
||||
if (!state.cropping) return;
|
||||
e.preventDefault();
|
||||
state.cropEnd = canvasCoords(e, state.mainCanvas);
|
||||
// Shift-held = lock aspect ratio. First Shift press during the
|
||||
// drag snapshots the current aspect; subsequent moves stay locked.
|
||||
// Releasing Shift resets so the user can re-lock at a new ratio.
|
||||
if (e.shiftKey) {
|
||||
const rawDx = state.cropEnd.x - state.cropStart.x;
|
||||
const rawDy = state.cropEnd.y - state.cropStart.y;
|
||||
if (state.cropAspectLock == null) {
|
||||
const rawW = Math.abs(rawDx) || 1;
|
||||
const rawH = Math.abs(rawDy) || 1;
|
||||
state.cropAspectLock = rawW / rawH;
|
||||
}
|
||||
const absDx = Math.abs(rawDx);
|
||||
const absDy = Math.abs(rawDy);
|
||||
// Whichever axis the user moved more (relative to the lock) is
|
||||
// the driver; scale the other to preserve aspect.
|
||||
let dx, dy;
|
||||
if (absDx >= absDy * state.cropAspectLock) {
|
||||
dx = rawDx;
|
||||
dy = Math.sign(rawDy || 1) * (absDx / state.cropAspectLock);
|
||||
} else {
|
||||
dy = rawDy;
|
||||
dx = Math.sign(rawDx || 1) * (absDy * state.cropAspectLock);
|
||||
}
|
||||
state.cropEnd = { x: state.cropStart.x + dx, y: state.cropStart.y + dy };
|
||||
} else {
|
||||
state.cropAspectLock = null;
|
||||
}
|
||||
composite();
|
||||
// Draw crop overlay.
|
||||
const x = Math.min(state.cropStart.x, state.cropEnd.x);
|
||||
const y = Math.min(state.cropStart.y, state.cropEnd.y);
|
||||
const w = Math.abs(state.cropEnd.x - state.cropStart.x);
|
||||
const h = Math.abs(state.cropEnd.y - state.cropStart.y);
|
||||
state.mainCtx.fillStyle = 'rgba(0,0,0,0.4)';
|
||||
state.mainCtx.fillRect(0, 0, state.mainCanvas.width, state.mainCanvas.height);
|
||||
state.mainCtx.clearRect(x, y, w, h);
|
||||
// Redraw layers inside the crop rect (dim everything outside).
|
||||
state.mainCtx.save();
|
||||
state.mainCtx.beginPath();
|
||||
state.mainCtx.rect(x, y, w, h);
|
||||
state.mainCtx.clip();
|
||||
drawCheckerboard(state.mainCtx, state.mainCanvas.width, state.mainCanvas.height);
|
||||
for (const layer of state.layers) {
|
||||
if (!layer.visible) continue;
|
||||
state.mainCtx.globalAlpha = layer.opacity;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
state.mainCtx.drawImage(layer.canvas, off.x, off.y);
|
||||
}
|
||||
state.mainCtx.globalAlpha = 1;
|
||||
state.mainCtx.restore();
|
||||
// Dashed border around the kept region.
|
||||
state.mainCtx.strokeStyle = '#fff';
|
||||
state.mainCtx.lineWidth = 1;
|
||||
state.mainCtx.setLineDash([4, 4]);
|
||||
state.mainCtx.strokeRect(x, y, w, h);
|
||||
state.mainCtx.setLineDash([]);
|
||||
state.cropRect = { x, y, w, h };
|
||||
},
|
||||
|
||||
end() {
|
||||
// Move-mode wrap-up: refresh the floating panel so Apply follows
|
||||
// the rect to its new spot.
|
||||
if (state.cropMoving) {
|
||||
state.cropMoving = false;
|
||||
state.cropMoveStart = null;
|
||||
if (state.cropRect) showCropApply();
|
||||
return;
|
||||
}
|
||||
state.cropping = false;
|
||||
if (state.cropRect && state.cropRect.w > 5 && state.cropRect.h > 5) {
|
||||
showCropApply();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
72
static/js/editor/tools/flood-fill.js
Normal file
72
static/js/editor/tools/flood-fill.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Iterative 4-connected flood fill on RGBA pixel data.
|
||||
*
|
||||
* Pure function — takes the source pixel array + seed + tolerance and
|
||||
* returns a mask canvas with white where the fill landed. The legacy
|
||||
* gallery editor's magic-wand tool delegates to this.
|
||||
*
|
||||
* @param {Uint8ClampedArray|Uint8Array} src RGBA bytes (length = w*h*4).
|
||||
* @param {number} w Pixel width.
|
||||
* @param {number} h Pixel height.
|
||||
* @param {number} seedX Floored seed X.
|
||||
* @param {number} seedY Floored seed Y.
|
||||
* @param {number} tolerance Tolerance 0..100. Internally
|
||||
* squared and scaled to RGB+A
|
||||
* space (max ≈ 195k at 100).
|
||||
* @returns {HTMLCanvasElement|null} A `w × h` mask canvas with
|
||||
* white-opaque pixels for
|
||||
* visited cells, or null if
|
||||
* the seed is out of bounds.
|
||||
*/
|
||||
export function floodFillMask(src, w, h, seedX, seedY, tolerance) {
|
||||
if (seedX < 0 || seedY < 0 || seedX >= w || seedY >= h) return null;
|
||||
|
||||
const seedIdx = (seedY * w + seedX) * 4;
|
||||
const sr = src[seedIdx], sg = src[seedIdx + 1];
|
||||
const sb = src[seedIdx + 2], sa = src[seedIdx + 3];
|
||||
|
||||
// 0..100 → squared RGB+A distance threshold. Max single-channel diff
|
||||
// is 255, so sqrt(4 * 255²) ≈ 510; squared cap ≈ 195k at tol = 100.
|
||||
const tol = Math.pow(tolerance * 4.42, 2);
|
||||
|
||||
const visited = new Uint8Array(w * h);
|
||||
const stack = [seedX, seedY];
|
||||
visited[seedY * w + seedX] = 1;
|
||||
while (stack.length) {
|
||||
const y = stack.pop();
|
||||
const x = stack.pop();
|
||||
const nbrs = [
|
||||
[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1],
|
||||
];
|
||||
for (const [nx, ny] of nbrs) {
|
||||
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
||||
const idx = ny * w + nx;
|
||||
if (visited[idx]) continue;
|
||||
const o = idx * 4;
|
||||
const dr = src[o] - sr, dg = src[o + 1] - sg;
|
||||
const db = src[o + 2] - sb, da = src[o + 3] - sa;
|
||||
// RGB + alpha-aware so a click on a transparent pixel selects
|
||||
// the transparent region cleanly.
|
||||
if (dr * dr + dg * dg + db * db + da * da <= tol) {
|
||||
visited[idx] = 1;
|
||||
stack.push(nx, ny);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mask = document.createElement('canvas');
|
||||
mask.width = w;
|
||||
mask.height = h;
|
||||
const mCtx = mask.getContext('2d');
|
||||
const mData = mCtx.createImageData(w, h);
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
if (visited[i]) {
|
||||
mData.data[i * 4] = 255;
|
||||
mData.data[i * 4 + 1] = 255;
|
||||
mData.data[i * 4 + 2] = 255;
|
||||
mData.data[i * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
mCtx.putImageData(mData, 0, 0);
|
||||
return mask;
|
||||
}
|
||||
171
static/js/editor/tools/lasso-mask.js
Normal file
171
static/js/editor/tools/lasso-mask.js
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Lasso-tool pixel & path helpers.
|
||||
*
|
||||
* All functions take the lasso polygon `points` as an explicit
|
||||
* argument so they can be tested in isolation. The legacy gallery
|
||||
* editor calls them with its module-level `_lassoPoints` array.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shift each polygon vertex along the outward normal by `grow` pixels.
|
||||
* Used by the lasso overlay (to draw the "feather" halo) and by
|
||||
* `buildLassoMask` (to bake the grown polygon into the mask).
|
||||
*
|
||||
* @param {{x: number, y: number}[]} points Polygon vertices in draw order.
|
||||
* @param {number} grow Positive = expand outward, negative = contract.
|
||||
* @returns {{x: number, y: number}[]} New array (same length, original is not mutated).
|
||||
*/
|
||||
export function lassoOffsetPoints(points, grow) {
|
||||
const n = points.length;
|
||||
if (n < 3 || !grow) return points;
|
||||
// Polygon winding (positive = CCW) — flip the normal so it points
|
||||
// away from the interior regardless of draw direction.
|
||||
let area = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = points[i], q = points[(i + 1) % n];
|
||||
area += (q.x - p.x) * (q.y + p.y);
|
||||
}
|
||||
const sign = area > 0 ? 1 : -1;
|
||||
const out = new Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = points[(i - 1 + n) % n], b = points[i], c = points[(i + 1) % n];
|
||||
const e1x = b.x - a.x, e1y = b.y - a.y;
|
||||
const e2x = c.x - b.x, e2y = c.y - b.y;
|
||||
const l1 = Math.hypot(e1x, e1y) || 1;
|
||||
const l2 = Math.hypot(e2x, e2y) || 1;
|
||||
// Perpendicular (dy, -dx); flip via `sign` for outward direction.
|
||||
const n1x = (e1y / l1) * sign, n1y = (-e1x / l1) * sign;
|
||||
const n2x = (e2y / l2) * sign, n2y = (-e2x / l2) * sign;
|
||||
const nx = (n1x + n2x) / 2;
|
||||
const ny = (n1y + n2y) / 2;
|
||||
const nl = Math.hypot(nx, ny) || 1;
|
||||
out[i] = { x: b.x + (nx / nl) * grow, y: b.y + (ny / nl) * grow };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Trace the lasso polygon on the given context (move-to + line-to,
|
||||
* closed). Caller is responsible for `stroke()` / `fill()` choice.
|
||||
*/
|
||||
export function getLassoPath(ctx, points) {
|
||||
if (!points || points.length < 1) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0].x, points[0].y);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
ctx.lineTo(points[i].x, points[i].y);
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build a (optionally feathered, optionally grown) selection mask
|
||||
* from a lasso polygon.
|
||||
*
|
||||
* @param {{x: number, y: number}[]} points Polygon vertices.
|
||||
* @param {number} w / h Output canvas dimensions.
|
||||
* @param {number} offX / offY Translate the polygon by (offX, offY) before rasterising.
|
||||
* @param {number} feather Feather width in pixels. 0 = hard edge.
|
||||
* @param {number} grow Positive = dilate the polygon, negative = erode.
|
||||
* @returns {HTMLCanvasElement} A `w × h` canvas with alpha = selection strength.
|
||||
*/
|
||||
export function buildLassoMask(points, w, h, offX, offY, feather, grow) {
|
||||
// Step 1: draw hard mask
|
||||
const hard = document.createElement('canvas');
|
||||
hard.width = w; hard.height = h;
|
||||
const hCtx = hard.getContext('2d');
|
||||
hCtx.beginPath();
|
||||
hCtx.moveTo(points[0].x - offX, points[0].y - offY);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
hCtx.lineTo(points[i].x - offX, points[i].y - offY);
|
||||
}
|
||||
hCtx.closePath();
|
||||
hCtx.fillStyle = '#fff';
|
||||
hCtx.fill();
|
||||
|
||||
// Step 1b: grow / shrink — blur the hard mask, threshold low for
|
||||
// grow and high for shrink. Same technique as the bg-remove edge
|
||||
// tuner. RGB is left alone, alpha is replaced.
|
||||
if (grow && grow !== 0) {
|
||||
const blurC = document.createElement('canvas');
|
||||
blurC.width = w; blurC.height = h;
|
||||
const bctx = blurC.getContext('2d');
|
||||
bctx.filter = `blur(${Math.abs(grow)}px)`;
|
||||
bctx.drawImage(hard, 0, 0);
|
||||
bctx.filter = 'none';
|
||||
const blurred = bctx.getImageData(0, 0, w, h).data;
|
||||
const hd = hCtx.getImageData(0, 0, w, h);
|
||||
const out = hd.data;
|
||||
const thr = grow > 0 ? 32 : 200;
|
||||
for (let i = 0; i < out.length; i += 4) {
|
||||
const a = blurred[i + 3] >= thr ? 255 : 0;
|
||||
out[i] = a; out[i + 1] = a; out[i + 2] = a; out[i + 3] = a;
|
||||
}
|
||||
hCtx.putImageData(hd, 0, 0);
|
||||
}
|
||||
|
||||
if (feather <= 0) return hard;
|
||||
|
||||
// Step 2: pixel data and distance-based feather.
|
||||
const hardData = hCtx.getImageData(0, 0, w, h);
|
||||
const d = hardData.data;
|
||||
|
||||
// Build inside/outside map.
|
||||
const inside = new Uint8Array(w * h);
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
inside[i] = d[i * 4] > 128 ? 1 : 0;
|
||||
}
|
||||
|
||||
// Distance from edge (for pixels inside the selection, distance to nearest outside pixel).
|
||||
const dist = new Float32Array(w * h);
|
||||
dist.fill(feather + 1);
|
||||
|
||||
// Seed: edge pixels (inside pixels adjacent to outside pixels).
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = y * w + x;
|
||||
if (!inside[i]) { dist[i] = 0; continue; }
|
||||
const hasOutside = (x > 0 && !inside[i-1]) || (x < w-1 && !inside[i+1]) ||
|
||||
(y > 0 && !inside[(y-1)*w+x]) || (y < h-1 && !inside[(y+1)*w+x]);
|
||||
if (hasOutside) dist[i] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Two-pass chamfer distance transform.
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = y * w + x;
|
||||
if (dist[i] === 0) continue;
|
||||
if (x > 0) dist[i] = Math.min(dist[i], dist[i-1] + 1);
|
||||
if (y > 0) dist[i] = Math.min(dist[i], dist[(y-1)*w+x] + 1);
|
||||
}
|
||||
}
|
||||
for (let y = h-1; y >= 0; y--) {
|
||||
for (let x = w-1; x >= 0; x--) {
|
||||
const i = y * w + x;
|
||||
if (dist[i] === 0) continue;
|
||||
if (x < w-1) dist[i] = Math.min(dist[i], dist[i+1] + 1);
|
||||
if (y < h-1) dist[i] = Math.min(dist[i], dist[(y+1)*w+x] + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Pixels near the edge get reduced alpha.
|
||||
const result = document.createElement('canvas');
|
||||
result.width = w; result.height = h;
|
||||
const rCtx = result.getContext('2d');
|
||||
const rData = rCtx.createImageData(w, h);
|
||||
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
if (!inside[i]) continue;
|
||||
const edgeDist = dist[i];
|
||||
const alpha = edgeDist >= feather ? 255 : Math.round((edgeDist / feather) * 255);
|
||||
rData.data[i*4] = alpha;
|
||||
rData.data[i*4+1] = alpha;
|
||||
rData.data[i*4+2] = alpha;
|
||||
rData.data[i*4+3] = 255;
|
||||
}
|
||||
rCtx.putImageData(rData, 0, 0);
|
||||
return result;
|
||||
}
|
||||
65
static/js/editor/tools/lasso.js
Normal file
65
static/js/editor/tools/lasso.js
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Lasso tool — freehand polygon selection. Mouse-down starts a fresh
|
||||
* polygon; every move appends a point and redraws the dashed outline;
|
||||
* mouse-up keeps the selection visible (the panel's action buttons
|
||||
* read `state.lassoPoints` to act on it).
|
||||
*
|
||||
* Owns its own begin/drag/end handlers and reads/writes shared state.
|
||||
*
|
||||
* @param {{
|
||||
* composite: () => void,
|
||||
* drawLassoOverlay: () => void,
|
||||
* syncToolClearIndicators: () => void,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
export function createLassoTool({ composite, drawLassoOverlay, syncToolClearIndicators }) {
|
||||
return {
|
||||
begin(e) {
|
||||
state.lassoPoints = [];
|
||||
state.lassoActive = true;
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
state.lassoPoints.push(coords);
|
||||
},
|
||||
|
||||
drag(e) {
|
||||
if (!state.lassoActive) return;
|
||||
e.preventDefault();
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
state.lassoPoints.push(coords);
|
||||
// Live overlay: dashed white outline + translucent red fill.
|
||||
composite();
|
||||
if (state.lassoPoints.length > 1) {
|
||||
state.mainCtx.beginPath();
|
||||
state.mainCtx.moveTo(state.lassoPoints[0].x, state.lassoPoints[0].y);
|
||||
for (let i = 1; i < state.lassoPoints.length; i++) {
|
||||
state.mainCtx.lineTo(state.lassoPoints[i].x, state.lassoPoints[i].y);
|
||||
}
|
||||
state.mainCtx.closePath();
|
||||
state.mainCtx.strokeStyle = '#fff';
|
||||
state.mainCtx.lineWidth = 1 / state.zoom;
|
||||
state.mainCtx.setLineDash([4 / state.zoom, 4 / state.zoom]);
|
||||
state.mainCtx.stroke();
|
||||
state.mainCtx.setLineDash([]);
|
||||
state.mainCtx.fillStyle = 'rgba(255, 80, 80, 0.15)';
|
||||
state.mainCtx.fill();
|
||||
}
|
||||
},
|
||||
|
||||
end() {
|
||||
state.lassoActive = false;
|
||||
if (state.lassoPoints.length < 3) {
|
||||
state.lassoPoints = [];
|
||||
composite();
|
||||
syncToolClearIndicators();
|
||||
return;
|
||||
}
|
||||
// Keep the selection drawn — the panel's action buttons use it.
|
||||
composite();
|
||||
drawLassoOverlay();
|
||||
syncToolClearIndicators();
|
||||
},
|
||||
};
|
||||
}
|
||||
79
static/js/editor/tools/move.js
Normal file
79
static/js/editor/tools/move.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Move tool — drag a layer around the canvas, with optional snap-on-Ctrl
|
||||
* to other layers' edges/centers and to canvas edges/center.
|
||||
*
|
||||
* Owns its own input handlers (begin/drag/end) and reads/writes the
|
||||
* shared `state` store directly. The factory takes a small dependency
|
||||
* bag for things that still live in galleryEditor.js — `activeLayer`,
|
||||
* `saveState`, `composite` — so this module doesn't have to know about
|
||||
* the orchestrator.
|
||||
*
|
||||
* @param {{
|
||||
* activeLayer: () => {id: string, canvas: HTMLCanvasElement, locked?: boolean} | null,
|
||||
* saveState: (label?: string) => void,
|
||||
* composite: () => 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';
|
||||
|
||||
export function createMoveTool({ activeLayer, saveState, composite }) {
|
||||
function computeSnap(layer, nx, ny) {
|
||||
return computeSnapImpl(layer, nx, ny, {
|
||||
zoom: state.zoom,
|
||||
canvasW: state.imgWidth,
|
||||
canvasH: state.imgHeight,
|
||||
otherLayers: state.layers.map(l => ({
|
||||
visible: l.visible,
|
||||
id: l.id,
|
||||
canvas: l.canvas,
|
||||
offset: state.layerOffsets.get(l.id) || { x: 0, y: 0 },
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
begin(e) {
|
||||
const layer = activeLayer();
|
||||
if (!layer || layer.locked) return;
|
||||
saveState();
|
||||
state.moving = true;
|
||||
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;
|
||||
},
|
||||
drag(e) {
|
||||
if (!state.moving) return;
|
||||
e.preventDefault();
|
||||
const layer = activeLayer();
|
||||
if (!layer) return;
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
const dx = coords.x - state.moveStartX;
|
||||
const dy = coords.y - state.moveStartY;
|
||||
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) {
|
||||
const snapped = computeSnap(layer, nx, ny);
|
||||
nx = snapped.x;
|
||||
ny = snapped.y;
|
||||
state.activeSnapGuides = snapped.guides;
|
||||
} else {
|
||||
state.activeSnapGuides = null;
|
||||
}
|
||||
state.layerOffsets.set(layer.id, { x: nx, y: ny });
|
||||
composite();
|
||||
},
|
||||
end() {
|
||||
state.moving = false;
|
||||
state.activeSnapGuides = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
123
static/js/editor/tools/stroke.js
Normal file
123
static/js/editor/tools/stroke.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Shared stroke pipeline for brush / eraser / inpaint.
|
||||
*
|
||||
* Per-sample stamping happens in `_strokeTo` (still in galleryEditor.js
|
||||
* because it touches a lot of pixel-pass internals). This module owns
|
||||
* the begin / continue / end orchestration around it:
|
||||
*
|
||||
* - begin: capture the inpaint-erase flag for the stroke, ensure a
|
||||
* mask sub-layer exists when inpaint runs against an empty
|
||||
* layer, push an undo entry with a tool-specific label, then
|
||||
* kick off the first stamp.
|
||||
* - continue: forward the new cursor position to `_strokeTo`.
|
||||
* - end: clear the drawing flag, composite, sync any tool indicators
|
||||
* that reflect mask state.
|
||||
*
|
||||
* Clone has its own begin (see tools/clone.js) but reuses `continue`
|
||||
* and `end` because once a clone stroke is in progress, the pipeline
|
||||
* is identical.
|
||||
*
|
||||
* @param {{
|
||||
* saveState: (label: string) => void,
|
||||
* strokeTo: (x: number, y: number) => void,
|
||||
* composite: () => void,
|
||||
* getActiveMaskLayer: () => object | null,
|
||||
* activeParentLayer: () => object | null,
|
||||
* ensureActiveMaskLayer: () => object | null,
|
||||
* createLayer: (name: string, w: number, h: number) => object,
|
||||
* renderLayerPanel: () => void,
|
||||
* syncToolClearIndicators: () => void,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
const STROKE_TOOLS = new Set(['brush', 'eraser', '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';
|
||||
return 'Stroke';
|
||||
}
|
||||
|
||||
export function createStrokeTool({
|
||||
saveState, strokeTo, composite,
|
||||
getActiveMaskLayer, activeParentLayer, ensureActiveMaskLayer, createLayer,
|
||||
renderLayerPanel, syncToolClearIndicators,
|
||||
}) {
|
||||
return {
|
||||
/**
|
||||
* Begin a stroke. Returns true if the dispatcher should consider
|
||||
* the event handled (i.e. tool is one of brush/eraser/inpaint).
|
||||
*/
|
||||
tryBegin(e) {
|
||||
if (!STROKE_TOOLS.has(state.tool)) return false;
|
||||
// Capture the inpaint-erase flag for this stroke. Ctrl+Alt
|
||||
// pressed at pointerdown flips the persistent toggle for one
|
||||
// stroke only.
|
||||
if (state.tool === 'inpaint') {
|
||||
const flip = e && e.ctrlKey && e.altKey;
|
||||
state.inpaintEraseStroke = flip ? !state.inpaintEraseMode : state.inpaintEraseMode;
|
||||
// Make sure we're painting onto an existing mask sub-layer. If
|
||||
// there's no parent layer at all, create one first so a totally
|
||||
// empty canvas can accept an inpaint stroke.
|
||||
if (!getActiveMaskLayer()) {
|
||||
let parent = activeParentLayer();
|
||||
if (!parent) {
|
||||
parent = createLayer('Layer 1', state.imgWidth, state.imgHeight);
|
||||
state.layers.push(parent);
|
||||
state.activeLayerId = parent.id;
|
||||
}
|
||||
if (parent.masks && parent.masks.length) {
|
||||
parent.activeMaskId = parent.masks[parent.masks.length - 1].id;
|
||||
const m = getActiveMaskLayer();
|
||||
if (m) {
|
||||
state.maskCanvas = m.canvas;
|
||||
state.maskCtx = m.ctx;
|
||||
renderLayerPanel();
|
||||
}
|
||||
} else {
|
||||
const mk = ensureActiveMaskLayer();
|
||||
if (mk) {
|
||||
state.maskCanvas = mk.canvas;
|
||||
state.maskCtx = mk.ctx;
|
||||
renderLayerPanel();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Forward an in-progress stroke. Returns true if a stroke is
|
||||
* actually in progress (dispatcher should short-circuit).
|
||||
*/
|
||||
tryContinue(e) {
|
||||
if (!state.drawing) return false;
|
||||
e.preventDefault();
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
strokeTo(coords.x, coords.y);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Wrap up an in-progress stroke. Returns true if there was one.
|
||||
*/
|
||||
tryEnd() {
|
||||
if (!state.drawing) return false;
|
||||
const wasDrawingInpaint = state.tool === 'inpaint';
|
||||
state.drawing = false;
|
||||
composite();
|
||||
if (wasDrawingInpaint) syncToolClearIndicators();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
174
static/js/editor/tools/transform-drag.js
Normal file
174
static/js/editor/tools/transform-drag.js
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Transform-drag tool — handle drag interactions for the Transform
|
||||
* tool (resize via corner/edge handles, rotation via the rot grip).
|
||||
*
|
||||
* The transform UI runs in TWO modes: the floating popup (W/H/rot
|
||||
* numeric inputs, lives elsewhere) AND direct drag on the canvas
|
||||
* handles. Both ultimately mutate `state.transformPendingW/H/Rot` and
|
||||
* call `reapplyTransform()` to redraw. This module owns the drag
|
||||
* branch.
|
||||
*
|
||||
* The dispatcher in galleryEditor.js calls `tryBegin/tryContinue/
|
||||
* tryEnd` which return `true` when the event was for the transform
|
||||
* 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,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
export function createTransformDragTool({
|
||||
beginMove, composite, drawTransformHandles, reapplyTransform,
|
||||
getTransformHandle, cursorForHandle,
|
||||
}) {
|
||||
return {
|
||||
/**
|
||||
* Called on pointerdown. Returns true if the transform tool handled
|
||||
* the event (the dispatcher should NOT fall through to other tools).
|
||||
*/
|
||||
tryBegin(e) {
|
||||
if (!state.transformActive) return false;
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
state.transformHandle = getTransformHandle(coords.x, coords.y);
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Called on pointermove. Returns true if handled.
|
||||
*
|
||||
* When transformActive but no handle is grabbed, updates the
|
||||
* hover cursor + pulse. When a handle is grabbed, drives the
|
||||
* resize / rotation pipeline.
|
||||
*/
|
||||
tryContinue(e) {
|
||||
if (!state.transformActive) return false;
|
||||
// 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';
|
||||
if (hovered !== state.hoveredHandle) {
|
||||
state.hoveredHandle = hovered;
|
||||
composite();
|
||||
}
|
||||
return false; // didn't fully consume the event
|
||||
}
|
||||
if (!state.transformHandle) return false;
|
||||
e.preventDefault();
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
// 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 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
|
||||
while (deg > 180) deg -= 360;
|
||||
while (deg <= -180) deg += 360;
|
||||
state.transformPendingRot = deg;
|
||||
reapplyTransform();
|
||||
if (state.transformPopup) {
|
||||
const rotIn = state.transformPopup.querySelector('#ge-transform-rot');
|
||||
if (rotIn) rotIn.value = String(deg);
|
||||
}
|
||||
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,
|
||||
};
|
||||
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);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Called on pointerup. Returns true if handled.
|
||||
*/
|
||||
tryEnd() {
|
||||
if (!(state.transformActive && state.transformHandle)) return false;
|
||||
state.transformHandle = null;
|
||||
state.transformOrigW = state.transformLayer?.canvas.width || 0;
|
||||
state.transformOrigH = state.transformLayer?.canvas.height || 0;
|
||||
composite();
|
||||
drawTransformHandles();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
271
static/js/editor/tools/transform-handles.js
Normal file
271
static/js/editor/tools/transform-handles.js
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Transform-tool handle rendering + hit-testing + overlay sync.
|
||||
*
|
||||
* Lives separately from `transform-drag.js` (which owns the drag
|
||||
* STATE MACHINE) because these three helpers are pure geometry that
|
||||
* happens to read shared state — they don't track in-progress drags,
|
||||
* they just paint and hit-test.
|
||||
*
|
||||
* - `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
|
||||
* hover / active visual states).
|
||||
* - `getHandleAt(x, y)` returns the handle id under (x, y), or
|
||||
* null. Geometry MUST mirror `drawHandles`
|
||||
* exactly or the user grabs phantom points.
|
||||
*
|
||||
* No event listeners attached here — the dispatcher in
|
||||
* editor/tools/transform-drag.js calls `getHandleAt` and routes
|
||||
* pointer events.
|
||||
*/
|
||||
import { state } from '../state.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).
|
||||
*/
|
||||
export function syncOverlay(margin) {
|
||||
if (!state.transformOverlay || !state.mainCanvas) return;
|
||||
if (!state.transformActive) {
|
||||
state.transformOverlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const W = state.mainCanvas.width + 2 * margin;
|
||||
const H = state.mainCanvas.height + 2 * margin;
|
||||
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
|
||||
// SAME on-screen size as the main canvas content. Without this, the
|
||||
// overlay renders at full bitmap size while main canvas shrinks
|
||||
// (zoomed-out), making handles look massive.
|
||||
state.transformOverlay.style.display = '';
|
||||
state.transformOverlay.style.position = 'absolute';
|
||||
state.transformOverlay.style.width = (W * state.zoom) + 'px';
|
||||
state.transformOverlay.style.height = (H * state.zoom) + 'px';
|
||||
state.transformOverlay.style.pointerEvents = 'none';
|
||||
state.transformOverlay.style.zIndex = '5';
|
||||
// Position the overlay at the main canvas's LAYOUT position
|
||||
// (offsetLeft/Top — unaffected by CSS transforms), shifted up-left by
|
||||
// the overlay's `margin` image-px of handle slack. Then SHARE the
|
||||
// canvas's transform (the pan handler writes the same translate3d to
|
||||
// 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.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.
|
||||
try {
|
||||
const area = state.container && state.container.querySelector('.ge-canvas-area');
|
||||
if (area && !rotInside) {
|
||||
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;
|
||||
}
|
||||
} catch {}
|
||||
const innerR = rotInside ? Math.max(4, baseInnerR - rotOffset) : baseInnerR;
|
||||
const rotR = rotInside ? innerR : baseInnerR + rotOffset;
|
||||
return {
|
||||
rotInside,
|
||||
innerR,
|
||||
rotX: cxh + Math.sin(rotRad) * rotR,
|
||||
rotY: cyh - Math.cos(rotRad) * rotR,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Draw the rotated bounding outline + 4 corner 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;
|
||||
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 ctx = state.transformOverlayCtx;
|
||||
// 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);
|
||||
// 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);
|
||||
|
||||
// Outline of the rotated rectangle — solid white inner line with a
|
||||
// thin black halo for contrast on light AND dark backgrounds.
|
||||
const drawRectOutline = () => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tl.x, tl.y);
|
||||
ctx.lineTo(tr.x, tr.y);
|
||||
ctx.lineTo(br.x, br.y);
|
||||
ctx.lineTo(bl.x, bl.y);
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
};
|
||||
ctx.lineWidth = 1 / state.zoom;
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.45)';
|
||||
ctx.setLineDash([6 / state.zoom, 4 / state.zoom]);
|
||||
ctx.lineDashOffset = 1 / state.zoom;
|
||||
drawRectOutline();
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineDashOffset = 0;
|
||||
drawRectOutline();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Corner 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) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(innerX, innerY);
|
||||
ctx.lineTo(knob.rotX, knob.rotY);
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
|
||||
ctx.lineWidth = 1 / state.zoom;
|
||||
ctx.stroke();
|
||||
}
|
||||
for (const c of corners) {
|
||||
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);
|
||||
ctx.beginPath();
|
||||
ctx.arc(c.x, c.y, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = active ? '#e06c75' : hovered ? '#ffd' : '#fff';
|
||||
ctx.fill();
|
||||
ctx.lineWidth = stroke;
|
||||
ctx.strokeStyle = active ? '#fff' : 'rgba(0, 0, 0, 0.5)';
|
||||
ctx.stroke();
|
||||
if (hovered) {
|
||||
// Subtle red ring around the hovered handle for visual feedback.
|
||||
ctx.beginPath();
|
||||
ctx.arc(c.x, c.y, radius + 2 / state.zoom, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = 'rgba(224, 108, 117, 0.7)';
|
||||
ctx.lineWidth = stroke;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Hit-test (x, y) against the transform handles. Returns the handle
|
||||
* id ('tl' | 'tr' | 'br' | 'bl' | '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);
|
||||
|
||||
// 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,
|
||||
});
|
||||
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;
|
||||
}
|
||||
381
static/js/editor/tools/transform-session.js
Normal file
381
static/js/editor/tools/transform-session.js
Normal file
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Transform-tool session lifecycle + floating popup wiring.
|
||||
*
|
||||
* _startTransform snapshot the active layer + open popup
|
||||
* _openTransformPopup build the W/H/rotation popup, wire inputs
|
||||
* _wireTransformDrag header drag, mobile + desktop position handling
|
||||
* _reapplyTransform live preview re-render from the snapshot
|
||||
* _confirmTransform commit + clear session state
|
||||
* _cancelTransform restore via undo() + clear session state
|
||||
*
|
||||
* Handle-drag interactions on the CANVAS (corner / rotation grip) live
|
||||
* in `editor/tools/transform-drag.js` — those mutate the same staged
|
||||
* `state.transformPending*` fields that the popup inputs do, so both
|
||||
* surfaces stay in sync via `_reapplyTransform()`.
|
||||
*
|
||||
* @param {{
|
||||
* activeLayer: () => object | null,
|
||||
* saveState: (label?: string) => void,
|
||||
* composite: () => void,
|
||||
* fitZoom: () => void,
|
||||
* drawTransformHandles: () => void,
|
||||
* showCanvasLoading: (label: string) => void,
|
||||
* hideCanvasLoading: () => void,
|
||||
* undo: () => void,
|
||||
* uiModule: object | null,
|
||||
* }} deps
|
||||
*
|
||||
* @returns {{
|
||||
* startTransform, openTransformPopup, closeTransformPopup,
|
||||
* reapplyTransform, confirmTransform, cancelTransform,
|
||||
* }}
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import {
|
||||
transformPopupHTML,
|
||||
attachSpinRepeat,
|
||||
} from '../build/transform-popup.js';
|
||||
|
||||
export function createTransformSession({
|
||||
activeLayer, saveState, composite, fitZoom, drawTransformHandles,
|
||||
showCanvasLoading, hideCanvasLoading, undo, uiModule,
|
||||
}) {
|
||||
function startTransform() {
|
||||
const layer = activeLayer();
|
||||
if (!layer || layer.locked) { uiModule.showToast('Select an unlocked layer'); return; }
|
||||
if (state.transformActive) { cancelTransform(); return; } // toggle off
|
||||
state.transformActive = true;
|
||||
state.transformLayer = layer;
|
||||
state.transformOrigW = layer.canvas.width;
|
||||
state.transformOrigH = layer.canvas.height;
|
||||
state.transformPendingW = state.transformOrigW;
|
||||
state.transformPendingH = state.transformOrigH;
|
||||
state.transformPendingRot = 0;
|
||||
state.transformPendingFlipH = false;
|
||||
state.transformPendingFlipV = false;
|
||||
// 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();
|
||||
// Fit canvas to viewport so the corner handles are visible —
|
||||
// without this, a layer larger than the viewport leaves the grab
|
||||
// markers off-screen.
|
||||
try { fitZoom(); } catch {}
|
||||
composite();
|
||||
drawTransformHandles();
|
||||
openTransformPopup();
|
||||
}
|
||||
|
||||
function closeTransformPopup() {
|
||||
if (state.transformPopup) {
|
||||
try { state.transformPopup.remove(); } catch {}
|
||||
state.transformPopup = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Floating Transform popup — horizontal layout, draggable via its
|
||||
// header, anchored over the right panel (layers area) by default
|
||||
// so it doesn't cover the canvas. Lets the user type exact W/H/Rot
|
||||
// and flip via negative values.
|
||||
function openTransformPopup() {
|
||||
closeTransformPopup();
|
||||
if (!state.container) return;
|
||||
const pop = document.createElement('div');
|
||||
pop.className = 'ge-transform-popup';
|
||||
pop.innerHTML = transformPopupHTML();
|
||||
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 aspectBtn = pop.querySelector('#ge-transform-aspect');
|
||||
wInput.value = String(state.transformOrigW);
|
||||
hInput.value = String(state.transformOrigH);
|
||||
rotInput.value = '0';
|
||||
aspectBtn.classList.toggle('active', state.transformAspectLock);
|
||||
aspectBtn.setAttribute('aria-pressed', state.transformAspectLock ? 'true' : 'false');
|
||||
|
||||
// Aspect-lock follower model: while the lock is engaged, ONE
|
||||
// field is the "driver" and the other is read-only + dimmed.
|
||||
// Driver = whichever field the user last typed in. Toggling the
|
||||
// chain releases the follower.
|
||||
let driver = null;
|
||||
const applyAspectVisuals = () => {
|
||||
if (!state.transformAspectLock || !driver) {
|
||||
wInput.readOnly = false;
|
||||
hInput.readOnly = false;
|
||||
wInput.classList.remove('ge-transform-input-locked');
|
||||
hInput.classList.remove('ge-transform-input-locked');
|
||||
return;
|
||||
}
|
||||
const followerW = driver === 'h';
|
||||
const followerH = driver === 'w';
|
||||
wInput.readOnly = followerW;
|
||||
hInput.readOnly = followerH;
|
||||
wInput.classList.toggle('ge-transform-input-locked', followerW);
|
||||
hInput.classList.toggle('ge-transform-input-locked', followerH);
|
||||
};
|
||||
const refresh = () => {
|
||||
let w = parseInt(wInput.value, 10);
|
||||
let h = parseInt(hInput.value, 10);
|
||||
const rot = parseInt(rotInput.value, 10) || 0;
|
||||
state.transformPendingFlipH = w < 0;
|
||||
state.transformPendingFlipV = h < 0;
|
||||
w = Math.abs(w || state.transformOrigW);
|
||||
h = Math.abs(h || state.transformOrigH);
|
||||
state.transformPendingW = Math.max(1, w);
|
||||
state.transformPendingH = Math.max(1, h);
|
||||
state.transformPendingRot = rot;
|
||||
reapplyTransform();
|
||||
};
|
||||
wInput.addEventListener('input', () => {
|
||||
if (state.transformAspectLock) {
|
||||
driver = 'w';
|
||||
const w = parseInt(wInput.value, 10);
|
||||
if (!Number.isNaN(w) && state.transformOrigW > 0) {
|
||||
const sign = (parseInt(hInput.value, 10) || 1) < 0 ? -1 : 1;
|
||||
const newH = Math.round((Math.abs(w) / state.transformOrigW) * state.transformOrigH) * sign;
|
||||
hInput.value = String(newH);
|
||||
}
|
||||
applyAspectVisuals();
|
||||
}
|
||||
refresh();
|
||||
});
|
||||
hInput.addEventListener('input', () => {
|
||||
if (state.transformAspectLock) {
|
||||
driver = 'h';
|
||||
const h = parseInt(hInput.value, 10);
|
||||
if (!Number.isNaN(h) && state.transformOrigH > 0) {
|
||||
const sign = (parseInt(wInput.value, 10) || 1) < 0 ? -1 : 1;
|
||||
const newW = Math.round((Math.abs(h) / state.transformOrigH) * state.transformOrigW) * sign;
|
||||
wInput.value = String(newW);
|
||||
}
|
||||
applyAspectVisuals();
|
||||
}
|
||||
refresh();
|
||||
});
|
||||
rotInput.addEventListener('input', refresh);
|
||||
aspectBtn.addEventListener('click', () => {
|
||||
state.transformAspectLock = !state.transformAspectLock;
|
||||
aspectBtn.classList.toggle('active', state.transformAspectLock);
|
||||
aspectBtn.setAttribute('aria-pressed', state.transformAspectLock ? 'true' : 'false');
|
||||
// Reset follower the moment the user breaks the lock so both
|
||||
// fields go editable; re-engaging means "next type sets the driver".
|
||||
driver = null;
|
||||
applyAspectVisuals();
|
||||
});
|
||||
pop.querySelector('#ge-transform-apply').addEventListener('click', () => confirmTransform());
|
||||
pop.querySelector('#ge-transform-cancel').addEventListener('click', () => cancelTransform());
|
||||
pop.querySelector('#ge-transform-cancel-btn')?.addEventListener('click', () => cancelTransform());
|
||||
// Minimise — collapses the body so only the header is visible.
|
||||
pop.querySelector('#ge-transform-min')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
pop.classList.toggle('ge-transform-popup-minimised');
|
||||
});
|
||||
// Quick actions: flip W/H via sign so the reapply pipeline picks
|
||||
// up the new orientation. Rotate-90 nudges rotation ±90°.
|
||||
pop.querySelector('#ge-transform-flip-h')?.addEventListener('click', () => {
|
||||
const wIn = pop.querySelector('#ge-transform-w');
|
||||
const cur = parseInt(wIn.value, 10) || state.transformOrigW;
|
||||
wIn.value = String(-cur);
|
||||
wIn.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
pop.querySelector('#ge-transform-flip-v')?.addEventListener('click', () => {
|
||||
const hIn = pop.querySelector('#ge-transform-h');
|
||||
const cur = parseInt(hIn.value, 10) || state.transformOrigH;
|
||||
hIn.value = String(-cur);
|
||||
hIn.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
pop.querySelector('#ge-transform-rot-90')?.addEventListener('click', (e) => {
|
||||
const rIn = pop.querySelector('#ge-transform-rot');
|
||||
const cur = parseInt(rIn.value, 10) || 0;
|
||||
const delta = e.shiftKey ? -90 : 90;
|
||||
let next = cur + delta;
|
||||
while (next > 180) next -= 360;
|
||||
while (next <= -180) next += 360;
|
||||
rIn.value = String(next);
|
||||
// Big images: rotation pass blocks UI ~0.5–2 s. Show a spinner
|
||||
// so the user sees something happen. rAF defers the heavy work
|
||||
// past the current frame so the overlay paints first.
|
||||
showCanvasLoading('Rotating…');
|
||||
requestAnimationFrame(() => {
|
||||
try { rIn.dispatchEvent(new Event('input', { bubbles: true })); }
|
||||
finally { hideCanvasLoading(); }
|
||||
});
|
||||
});
|
||||
attachSpinRepeat(pop);
|
||||
}
|
||||
|
||||
// Header-drag for the Transform popup. Default position: over the
|
||||
// right panel (layers area). Mobile pins via stylesheet so we use
|
||||
// setProperty 'important' to override during drag.
|
||||
function wireTransformDrag(pop) {
|
||||
const isMobile = window.matchMedia('(max-width: 820px)').matches;
|
||||
const defaultRight = 20;
|
||||
const defaultTop = 60;
|
||||
if (isMobile) {
|
||||
pop.style.setProperty('position', 'fixed', 'important');
|
||||
} else {
|
||||
pop.style.position = 'absolute';
|
||||
pop.style.right = defaultRight + 'px';
|
||||
pop.style.top = defaultTop + 'px';
|
||||
pop.style.left = 'auto';
|
||||
}
|
||||
const dragSource = pop.querySelector('[data-transform-drag]') || pop;
|
||||
let dragging = false;
|
||||
let startX = 0, startY = 0, originLeft = 0, originTop = 0;
|
||||
const NON_DRAG = 'input,button,select,textarea,a,[contenteditable]';
|
||||
|
||||
const setPos = (x, y) => {
|
||||
if (isMobile) {
|
||||
pop.style.setProperty('left', x + 'px', 'important');
|
||||
pop.style.setProperty('top', y + 'px', 'important');
|
||||
pop.style.setProperty('right', 'auto', 'important');
|
||||
pop.style.setProperty('bottom', 'auto', 'important');
|
||||
pop.style.setProperty('width', 'auto', 'important');
|
||||
pop.style.setProperty('max-width', 'calc(100vw - 16px)', 'important');
|
||||
} else {
|
||||
pop.style.left = x + 'px';
|
||||
pop.style.top = y + 'px';
|
||||
pop.style.right = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
const beginDrag = (clientX, clientY) => {
|
||||
dragging = true;
|
||||
const rect = pop.getBoundingClientRect();
|
||||
if (isMobile) {
|
||||
originLeft = rect.left;
|
||||
originTop = rect.top;
|
||||
} else {
|
||||
const parentRect = state.container.getBoundingClientRect();
|
||||
originLeft = rect.left - parentRect.left;
|
||||
originTop = rect.top - parentRect.top;
|
||||
}
|
||||
startX = clientX;
|
||||
startY = clientY;
|
||||
setPos(originLeft, originTop);
|
||||
pop.classList.add('ge-transform-popup-dragging');
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const moveDrag = (clientX, clientY) => {
|
||||
if (!dragging) return;
|
||||
const dx = clientX - startX;
|
||||
const dy = clientY - startY;
|
||||
let nx = originLeft + dx;
|
||||
let ny = originTop + dy;
|
||||
if (isMobile) {
|
||||
const rect = pop.getBoundingClientRect();
|
||||
nx = Math.max(0, Math.min(window.innerWidth - rect.width, nx));
|
||||
ny = Math.max(0, Math.min(window.innerHeight - rect.height, ny));
|
||||
}
|
||||
setPos(nx, ny);
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
document.body.style.userSelect = '';
|
||||
pop.classList.remove('ge-transform-popup-dragging');
|
||||
};
|
||||
|
||||
dragSource.addEventListener('mousedown', (e) => {
|
||||
if (e.target.closest(NON_DRAG)) return;
|
||||
e.preventDefault();
|
||||
beginDrag(e.clientX, e.clientY);
|
||||
});
|
||||
document.addEventListener('mousemove', (e) => moveDrag(e.clientX, e.clientY));
|
||||
document.addEventListener('mouseup', endDrag);
|
||||
|
||||
dragSource.addEventListener('touchstart', (e) => {
|
||||
if (e.target.closest(NON_DRAG)) return;
|
||||
if (!e.touches || e.touches.length !== 1) return;
|
||||
e.preventDefault();
|
||||
beginDrag(e.touches[0].clientX, e.touches[0].clientY);
|
||||
}, { passive: false });
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
if (!dragging) return;
|
||||
if (!e.touches || e.touches.length !== 1) return;
|
||||
e.preventDefault();
|
||||
moveDrag(e.touches[0].clientX, e.touches[0].clientY);
|
||||
}, { passive: false });
|
||||
document.addEventListener('touchend', endDrag);
|
||||
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 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),
|
||||
});
|
||||
composite();
|
||||
drawTransformHandles();
|
||||
}
|
||||
|
||||
function confirmTransform() {
|
||||
closeTransformPopup();
|
||||
state.transformOrigCanvas = null;
|
||||
state.transformOrigOffset = null;
|
||||
state.transformActive = false;
|
||||
state.transformLayer = null;
|
||||
state.transformHandle = null;
|
||||
composite();
|
||||
uiModule.showToast('Transform applied');
|
||||
}
|
||||
|
||||
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;
|
||||
composite();
|
||||
}
|
||||
|
||||
return {
|
||||
startTransform, openTransformPopup, closeTransformPopup,
|
||||
reapplyTransform, confirmTransform, cancelTransform,
|
||||
};
|
||||
}
|
||||
46
static/js/editor/tools/wand.js
Normal file
46
static/js/editor/tools/wand.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Magic-wand tool — single-click flood-fill selection on the active
|
||||
* layer's pixels. Shift/Alt modifiers override the persistent mode
|
||||
* toggle for the duration of the click (add / subtract).
|
||||
*
|
||||
* Clicking inside an existing selection with no modifier deselects.
|
||||
*
|
||||
* Wand is selection-only — it doesn't mutate the layer until the user
|
||||
* invokes an action (Erase / Copy / etc.) from the panel. That's why
|
||||
* it has just a `click` handler instead of begin/drag/end.
|
||||
*
|
||||
* @param {{
|
||||
* activeLayer: () => object | null,
|
||||
* saveState: () => void,
|
||||
* composite: () => void,
|
||||
* wandHits: (cx: number, cy: number) => boolean,
|
||||
* runMagicWand: (cx: number, cy: number, mode: 'replace'|'add'|'subtract') => void,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
export function createWandTool({ activeLayer, saveState, composite, wandHits, runMagicWand }) {
|
||||
return {
|
||||
click(e) {
|
||||
const layer = activeLayer();
|
||||
if (!layer) return;
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
// Persistent toggle sets the default mode; Shift forces add, Alt
|
||||
// forces subtract regardless of the toggle (modifiers always win).
|
||||
let mode = state.wandMode || 'replace';
|
||||
if (e.shiftKey) mode = 'add';
|
||||
else if (e.altKey) mode = 'subtract';
|
||||
// Click INSIDE the existing selection with no modifier → deselect.
|
||||
if (mode === 'replace' && wandHits(coords.x, coords.y)) {
|
||||
saveState();
|
||||
state.wandMask = null;
|
||||
state.wandLayerId = null;
|
||||
state.wandLastSeed = null;
|
||||
composite();
|
||||
return;
|
||||
}
|
||||
runMagicWand(coords.x, coords.y, mode);
|
||||
},
|
||||
};
|
||||
}
|
||||
148
static/js/editor/wire-import.js
Normal file
148
static/js/editor/wire-import.js
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Image-import wiring — covers all four entry points that drop an
|
||||
* image as a new layer:
|
||||
*
|
||||
* #ge-import-topbar topbar "+ Import" button
|
||||
* #ge-import-file File button in the Import section
|
||||
* #ge-import-paste Clipboard button (uses async clipboard API)
|
||||
* #ge-import-gallery Gallery picker — fetches /api/gallery/library
|
||||
* and shows a thumbnail grid overlay
|
||||
*
|
||||
* Plus the shared `handleImportedImage(img)` sink — scales to canvas,
|
||||
* centres, creates a new layer, switches to Move tool, hides the
|
||||
* import section, refreshes the panel. Returned so the drag-and-drop
|
||||
* + paste paths (wired in editor/clipboard-and-drop.js) can use the
|
||||
* same sink.
|
||||
*
|
||||
* @param {{
|
||||
* container: HTMLElement,
|
||||
* saveState: (label?: string) => void,
|
||||
* createLayer: (name, w, h) => object,
|
||||
* composite: () => void,
|
||||
* renderLayerPanel: () => void,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*
|
||||
* @returns {{ handleImportedImage: (img: HTMLImageElement) => void }}
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
export function wireImport({ container, saveState, createLayer, composite, renderLayerPanel, uiModule }) {
|
||||
// Hidden <input type="file"> the topbar + File buttons both click.
|
||||
const importFileInput = document.createElement('input');
|
||||
importFileInput.type = 'file';
|
||||
importFileInput.accept = 'image/*';
|
||||
importFileInput.style.display = 'none';
|
||||
container.appendChild(importFileInput);
|
||||
|
||||
function handleImportedImage(img) {
|
||||
if (!state.editorOpen) return;
|
||||
saveState('Import image');
|
||||
// Scale down if larger than canvas.
|
||||
let w = img.naturalWidth || img.width;
|
||||
let h = img.naturalHeight || img.height;
|
||||
if (w > state.imgWidth || h > state.imgHeight) {
|
||||
const scale = Math.min(state.imgWidth / w, state.imgHeight / h);
|
||||
w = Math.round(w * scale);
|
||||
h = Math.round(h * scale);
|
||||
}
|
||||
const layer = createLayer('Imported', state.imgWidth, state.imgHeight);
|
||||
// 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);
|
||||
state.layers.push(layer);
|
||||
state.activeLayerId = layer.id;
|
||||
// Switch to move tool so the imported layer is immediately
|
||||
// repositionable.
|
||||
state.tool = 'move';
|
||||
const tb = container.querySelector('.ge-toolbar');
|
||||
if (tb) tb.querySelectorAll('.ge-tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === 'move'));
|
||||
// Hide the import section now that the import is done.
|
||||
const importSec = document.getElementById('ge-import-section');
|
||||
if (importSec) importSec.style.display = 'none';
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
if (uiModule) uiModule.showToast('Image imported — drag to position');
|
||||
}
|
||||
|
||||
importFileInput.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const img = new Image();
|
||||
img.onload = () => handleImportedImage(img);
|
||||
img.src = ev.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
importFileInput.value = '';
|
||||
});
|
||||
|
||||
document.getElementById('ge-import-topbar')?.addEventListener('click', () => importFileInput.click());
|
||||
document.getElementById('ge-import-file')?.addEventListener('click', () => importFileInput.click());
|
||||
|
||||
document.getElementById('ge-import-paste')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const clipItems = await navigator.clipboard.read();
|
||||
let blob = null;
|
||||
for (const item of clipItems) {
|
||||
const imgType = item.types.find(t => t.startsWith('image/'));
|
||||
if (imgType) { blob = await item.getType(imgType); break; }
|
||||
}
|
||||
if (!blob) { if (uiModule) uiModule.showToast('No image found in clipboard'); return; }
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = () => { handleImportedImage(img); URL.revokeObjectURL(url); };
|
||||
img.onerror = () => { URL.revokeObjectURL(url); if (uiModule) uiModule.showToast('Failed to load clipboard image'); };
|
||||
img.src = url;
|
||||
} catch (e) {
|
||||
if (uiModule) uiModule.showToast('Clipboard access denied or no image available');
|
||||
}
|
||||
});
|
||||
|
||||
// Import from Gallery — fetch /api/gallery/library and show a
|
||||
// thumbnail-grid picker overlay.
|
||||
document.getElementById('ge-import-gallery')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await fetch('/api/gallery/library?limit=50', { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
if (!items.length) { if (uiModule) uiModule.showToast('No images in gallery'); return; }
|
||||
|
||||
// Picker overlay.
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;inset:0;z-index:10001;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;';
|
||||
const panel = document.createElement('div');
|
||||
panel.style.cssText = 'background:var(--panel,#1e1e1e);border-radius:12px;padding:16px;max-width:500px;max-height:70vh;overflow-y:auto;width:90%;';
|
||||
panel.innerHTML = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;"><span style="font-size:13px;font-weight:600;">Pick from Gallery</span><button id="ge-gallery-close" style="background:none;border:none;color:var(--fg);cursor:pointer;font-size:18px;">✕</button></div>';
|
||||
const grid = document.createElement('div');
|
||||
grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(80px,1fr));gap:6px;';
|
||||
for (const item of items) {
|
||||
const thumb = document.createElement('img');
|
||||
thumb.src = item.url;
|
||||
thumb.style.cssText = 'width:100%;aspect-ratio:1;object-fit:cover;border-radius:6px;cursor:pointer;border:2px solid transparent;transition:border-color 0.15s;';
|
||||
thumb.addEventListener('mouseenter', () => { thumb.style.borderColor = 'var(--accent,#61afef)'; });
|
||||
thumb.addEventListener('mouseleave', () => { thumb.style.borderColor = 'transparent'; });
|
||||
thumb.addEventListener('click', () => {
|
||||
overlay.remove();
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => handleImportedImage(img);
|
||||
img.onerror = () => { if (uiModule) uiModule.showToast('Failed to load gallery image'); };
|
||||
img.src = item.url;
|
||||
});
|
||||
grid.appendChild(thumb);
|
||||
}
|
||||
panel.appendChild(grid);
|
||||
overlay.appendChild(panel);
|
||||
document.body.appendChild(overlay);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||
panel.querySelector('#ge-gallery-close').addEventListener('click', () => overlay.remove());
|
||||
} catch (e) {
|
||||
if (uiModule) uiModule.showToast('Failed to load gallery: ' + e.message);
|
||||
}
|
||||
});
|
||||
|
||||
return { handleImportedImage };
|
||||
}
|
||||
164
static/js/editor/wire-inpaint-controls.js
Normal file
164
static/js/editor/wire-inpaint-controls.js
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Inpaint panel controls — the non-AI side-panel UI for the inpaint
|
||||
* tool (the AI Generate/Remove/Outpaint buttons live in
|
||||
* editor/ai-inpaint.js).
|
||||
*
|
||||
* Pre-gen sliders (Feather + Strength swatch previews):
|
||||
* #ge-strength-slider just-updates-the-label-and-swatch
|
||||
*
|
||||
* Post-gen live edge tuners — alpha-blur + dilate/erode on the most
|
||||
* recent Inpaint Result layer, rAF-throttled so dragging stays
|
||||
* smooth on big canvases:
|
||||
* #ge-feather-slider calls applyInpaintFeather + composite
|
||||
* #ge-edgestroke-slider same
|
||||
*
|
||||
* Mask controls:
|
||||
* #ge-mask-vis toggle red-overlay visibility
|
||||
* #ge-inpaint-invert invert the active mask sub-layer
|
||||
* #ge-inpaint-clear wipe the active mask
|
||||
* #ge-inpaint-mode-paint set persistent paint mode
|
||||
* #ge-inpaint-mode-erase set persistent erase mode
|
||||
*
|
||||
* Mask tint pickers (wired to keep both visually in sync):
|
||||
* .ge-inpaint-mask-color (inpaint section)
|
||||
* #ge-topbar-mask-color (topbar swatch — HSV picker attached)
|
||||
*
|
||||
* @param {{
|
||||
* composite: () => void,
|
||||
* applyInpaintFeather: (layer: object, featherPx: number, edgeShiftPx: number) => void,
|
||||
* syncToolClearIndicators: () => void,
|
||||
* attachColorPicker: (el: HTMLInputElement) => void,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.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,
|
||||
attachColorPicker, uiModule,
|
||||
}) {
|
||||
// ── Feather + Strength preview swatches ──
|
||||
const featherPrev = document.getElementById('ge-feather-preview');
|
||||
const strengthPrev = document.getElementById('ge-strength-preview');
|
||||
function syncFeatherPreview(v) {
|
||||
if (!featherPrev) return;
|
||||
const inner = Math.max(0, 50 - v * 1.25);
|
||||
featherPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${inner}%, transparent 75%)`;
|
||||
}
|
||||
function syncStrengthPreview(v) {
|
||||
if (!strengthPrev) return;
|
||||
strengthPrev.style.opacity = (v / 100).toFixed(2);
|
||||
}
|
||||
|
||||
// ── Post-inpaint live edge tuner ──
|
||||
// Alpha-blur (Feather) + dilate/erode (Edge Stroke) on the last
|
||||
// Inpaint Result layer. rAF-throttled so dragging stays smooth.
|
||||
let featherRafPending = false;
|
||||
function scheduleInpaintEdgeRefresh() {
|
||||
if (featherRafPending) return;
|
||||
featherRafPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
featherRafPending = false;
|
||||
const layer = state.layers.find(l => l.id === state.lastInpaintLayerId);
|
||||
if (!layer || !layer.inpaintSource) return;
|
||||
const feather = parseInt(document.getElementById('ge-feather-slider')?.value || '0', 10);
|
||||
const edge = parseInt(document.getElementById('ge-edgestroke-slider')?.value || '0', 10);
|
||||
applyInpaintFeather(layer, feather, edge);
|
||||
composite();
|
||||
});
|
||||
}
|
||||
document.getElementById('ge-feather-slider')?.addEventListener('input', (e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
document.getElementById('ge-feather-label').textContent = v + 'px';
|
||||
syncFeatherPreview(v);
|
||||
scheduleInpaintEdgeRefresh();
|
||||
});
|
||||
document.getElementById('ge-edgestroke-slider')?.addEventListener('input', (e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
const label = document.getElementById('ge-edgestroke-label');
|
||||
if (label) label.textContent = (v > 0 ? '+' : '') + v + 'px';
|
||||
const prev = document.getElementById('ge-edgestroke-preview');
|
||||
if (prev) {
|
||||
// Visualise direction: dilate (+) → green, erode (−) → red.
|
||||
const dir = v === 0 ? 'transparent' : (v > 0 ? 'rgba(120,200,120,0.5)' : 'rgba(200,120,120,0.5)');
|
||||
prev.style.background = dir;
|
||||
prev.style.opacity = Math.min(1, Math.abs(v) / 80).toFixed(2);
|
||||
}
|
||||
scheduleInpaintEdgeRefresh();
|
||||
});
|
||||
document.getElementById('ge-strength-slider')?.addEventListener('input', (e) => {
|
||||
document.getElementById('ge-strength-label').textContent = (e.target.value / 100).toFixed(2);
|
||||
syncStrengthPreview(parseInt(e.target.value, 10));
|
||||
});
|
||||
syncFeatherPreview(0);
|
||||
syncStrengthPreview(75);
|
||||
|
||||
// ── Mask vis / invert / clear ──
|
||||
document.getElementById('ge-mask-vis')?.addEventListener('click', () => {
|
||||
state.maskVisible = !state.maskVisible;
|
||||
const btn = document.getElementById('ge-mask-vis');
|
||||
if (!btn) { composite(); return; }
|
||||
btn.innerHTML = `${state.maskVisible ? EYE_OPEN_SM : EYE_OFF_SM}<span id="ge-mask-vis-label">${state.maskVisible ? 'Hide' : 'Show'}</span>`;
|
||||
btn.title = state.maskVisible ? 'Hide mask' : 'Show mask';
|
||||
btn.classList.toggle('visible', state.maskVisible);
|
||||
composite();
|
||||
});
|
||||
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);
|
||||
composite();
|
||||
syncToolClearIndicators();
|
||||
uiModule.showToast('Mask inverted');
|
||||
});
|
||||
document.getElementById('ge-inpaint-clear')?.addEventListener('click', () => {
|
||||
if (state.maskCtx) { state.maskCtx.clearRect(0, 0, state.maskCanvas.width, state.maskCanvas.height); composite(); }
|
||||
syncToolClearIndicators();
|
||||
});
|
||||
|
||||
// ── Paint / Erase segmented toggle ──
|
||||
function setInpaintMode(eraseMode) {
|
||||
state.inpaintEraseMode = !!eraseMode;
|
||||
const paintBtn = document.getElementById('ge-inpaint-mode-paint');
|
||||
const eraseBtn = document.getElementById('ge-inpaint-mode-erase');
|
||||
if (paintBtn) paintBtn.classList.toggle('active', !state.inpaintEraseMode);
|
||||
if (eraseBtn) eraseBtn.classList.toggle('active', state.inpaintEraseMode);
|
||||
}
|
||||
document.getElementById('ge-inpaint-mode-paint')?.addEventListener('click', () => setInpaintMode(false));
|
||||
document.getElementById('ge-inpaint-mode-erase')?.addEventListener('click', () => setInpaintMode(true));
|
||||
|
||||
// ── Mask color picker ──
|
||||
// Updates state.maskTintColor live so the user can pick a colour
|
||||
// that contrasts with their photo. Wire both the topbar picker AND
|
||||
// the inpaint-section picker so changing one syncs the other.
|
||||
function applyMaskTintFromHex(hex) {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
state.maskTintColor = `rgba(${r}, ${g}, ${b}, 1)`;
|
||||
const inpaintPicker = document.querySelector('.ge-inpaint-mask-color');
|
||||
const topbarPicker = document.getElementById('ge-topbar-mask-color');
|
||||
if (inpaintPicker && inpaintPicker.value !== hex) inpaintPicker.value = hex;
|
||||
if (topbarPicker && topbarPicker.value !== hex) topbarPicker.value = hex;
|
||||
composite();
|
||||
}
|
||||
document.querySelector('.ge-inpaint-mask-color')?.addEventListener('input', (e) => applyMaskTintFromHex(e.target.value));
|
||||
document.getElementById('ge-topbar-mask-color')?.addEventListener('input', (e) => applyMaskTintFromHex(e.target.value));
|
||||
// Use the in-house HSV picker for the topbar swatch.
|
||||
const topbarMaskColor = document.getElementById('ge-topbar-mask-color');
|
||||
if (topbarMaskColor) {
|
||||
try { attachColorPicker(topbarMaskColor); topbarMaskColor.value = topbarMaskColor.value; } catch {}
|
||||
}
|
||||
}
|
||||
103
static/js/editor/wire-merge-buttons.js
Normal file
103
static/js/editor/wire-merge-buttons.js
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Layer merge / flatten buttons in the layer-panel footer:
|
||||
*
|
||||
* #ge-flatten Flatten Copy — merge every visible layer into a
|
||||
* new "Flattened" layer, keep originals.
|
||||
* #ge-merge-all Merge All — flatten every VISIBLE layer into the
|
||||
* lowest visible one. Hidden layers dropped. Base
|
||||
* = lowest visible (not bottom of stack) so a
|
||||
* hidden base can't absorb the visible stack into
|
||||
* an invisible result.
|
||||
* #ge-merge-down Merge active layer into the one beneath it.
|
||||
*
|
||||
* @param {{
|
||||
* saveState: (label?: string) => void,
|
||||
* createLayer: (name, w, h) => object,
|
||||
* renderLayerPanel: () => void,
|
||||
* composite: () => void,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
export function mergeLayerDownAtIndex(idx) {
|
||||
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();
|
||||
state.layers.splice(idx, 1);
|
||||
state.layerOffsets.delete(upper.id);
|
||||
state.activeLayerId = lower.id;
|
||||
return lower;
|
||||
}
|
||||
|
||||
export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, composite, 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;
|
||||
}
|
||||
state.layers.push(merged);
|
||||
state.activeLayerId = merged.id;
|
||||
renderLayerPanel();
|
||||
composite();
|
||||
uiModule.showToast('Flattened copy created');
|
||||
});
|
||||
|
||||
// Merge All — drop hidden layers; base = lowest visible.
|
||||
document.getElementById('ge-merge-all')?.addEventListener('click', () => {
|
||||
const visibleLayers = state.layers.filter(l => l.visible);
|
||||
if (visibleLayers.length < 2) {
|
||||
if (uiModule) uiModule.showToast('Need at least two visible layers to merge');
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
// 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.activeLayerId = base.id;
|
||||
renderLayerPanel();
|
||||
composite();
|
||||
uiModule.showToast('Visible layers merged');
|
||||
});
|
||||
|
||||
// Merge Down.
|
||||
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
|
||||
saveState('Merge down');
|
||||
mergeLayerDownAtIndex(idx);
|
||||
renderLayerPanel();
|
||||
composite();
|
||||
uiModule.showToast('Layer merged down');
|
||||
});
|
||||
}
|
||||
170
static/js/editor/wire-selection-controls.js
Normal file
170
static/js/editor/wire-selection-controls.js
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Lasso + Magic Wand panel controls — sliders, mode toggles, and the
|
||||
* panel action buttons (Invert / Clear / Delete / Copy / To Mask /
|
||||
* Bg Remove). The actual selection algorithms live in their tool
|
||||
* modules (editor/tools/lasso.js, editor/tools/wand.js); this file
|
||||
* just wires the side-panel UI to them.
|
||||
*
|
||||
* Lasso section:
|
||||
* #ge-lasso-feather slider, updates label + preview, recomposites
|
||||
* #ge-lasso-grow slider, updates label + recomposites
|
||||
* #ge-lasso-invert → invertSelection
|
||||
* #ge-lasso-delete → lassoDeleteSelection
|
||||
* #ge-lasso-copy → lassoCopyToLayer
|
||||
* #ge-lasso-mask → lassoToMask
|
||||
*
|
||||
* Wand section:
|
||||
* #ge-wand-feather slider, updates label + recomposites
|
||||
* #ge-wand-grow slider, updates label + recomposites
|
||||
* #ge-wand-tolerance slider, updates future wand-click tolerance
|
||||
* #ge-wand-live opt-in rAF-coalesced live retune while dragging
|
||||
* .ge-wand-mode-btn segmented toggle (New / Add / Subtract)
|
||||
* #ge-wand-vis toggle the translucent red overlay
|
||||
* #ge-wand-clear / -invert / -delete / -copy / -mask / -rembg
|
||||
*
|
||||
* @param {{
|
||||
* composite: () => void,
|
||||
* invertSelection: () => boolean,
|
||||
* lassoDeleteSelection: () => void,
|
||||
* lassoCopyToLayer: () => void,
|
||||
* lassoToMask: () => void,
|
||||
* runMagicWand: (x: number, y: number, mode: string, opts?: object) => void,
|
||||
* wandClear: () => void,
|
||||
* wandDeleteSelection: () => void,
|
||||
* wandCopyToNewLayer: () => void,
|
||||
* wandToMask: () => void,
|
||||
* buildSelectionHintMask: () => string | null,
|
||||
* applyImageTool: (endpoint, payload, name, btn, opts?) => Promise<void>,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
const EYE_OPEN = '<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>';
|
||||
const EYE_OFF = '<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"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>';
|
||||
|
||||
export function wireSelectionControls({
|
||||
composite,
|
||||
invertSelection,
|
||||
lassoDeleteSelection, lassoCopyToLayer, lassoToMask,
|
||||
runMagicWand,
|
||||
wandClear, wandDeleteSelection, wandCopyToNewLayer, wandToMask,
|
||||
buildSelectionHintMask, applyImageTool,
|
||||
uiModule,
|
||||
}) {
|
||||
// ── Lasso section ──
|
||||
const lassoFPrev = document.getElementById('ge-lasso-feather-preview');
|
||||
function syncLassoFeather(v) {
|
||||
if (!lassoFPrev) return;
|
||||
const inner = Math.max(0, 50 - v * 1.0);
|
||||
lassoFPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${inner}%, transparent 75%)`;
|
||||
}
|
||||
syncLassoFeather(0);
|
||||
document.getElementById('ge-lasso-feather')?.addEventListener('input', (e) => {
|
||||
document.getElementById('ge-lasso-feather-label').textContent = e.target.value + 'px';
|
||||
syncLassoFeather(parseInt(e.target.value, 10));
|
||||
composite();
|
||||
});
|
||||
document.getElementById('ge-lasso-grow')?.addEventListener('input', (e) => {
|
||||
document.getElementById('ge-lasso-grow-label').textContent = e.target.value + 'px';
|
||||
composite();
|
||||
});
|
||||
document.getElementById('ge-lasso-delete')?.addEventListener('click', () => {
|
||||
if (state.lassoPoints.length >= 3 && !state.lassoActive) lassoDeleteSelection();
|
||||
else if (state.wandMask) wandDeleteSelection();
|
||||
});
|
||||
document.getElementById('ge-lasso-copy')?.addEventListener('click', () => {
|
||||
if (state.lassoPoints.length >= 3 && !state.lassoActive) lassoCopyToLayer();
|
||||
else if (state.wandMask) wandCopyToNewLayer();
|
||||
});
|
||||
document.getElementById('ge-lasso-mask')?.addEventListener('click', () => {
|
||||
if (state.lassoPoints.length >= 3 && !state.lassoActive) lassoToMask();
|
||||
else if (state.wandMask) wandToMask();
|
||||
});
|
||||
document.getElementById('ge-lasso-invert')?.addEventListener('click', invertSelection);
|
||||
|
||||
// ── Wand section ──
|
||||
document.getElementById('ge-wand-feather')?.addEventListener('input', (e) => {
|
||||
const v = parseInt(e.target.value, 10) || 0;
|
||||
document.getElementById('ge-wand-feather-label').textContent = v + 'px';
|
||||
const prev = document.getElementById('ge-wand-feather-preview');
|
||||
if (prev) prev.style.setProperty('--feather-blur', Math.min(v / 14, 8) + 'px');
|
||||
composite();
|
||||
});
|
||||
document.getElementById('ge-wand-grow')?.addEventListener('input', (e) => {
|
||||
document.getElementById('ge-wand-grow-label').textContent = e.target.value + 'px';
|
||||
composite();
|
||||
});
|
||||
|
||||
// Tolerance slider fires `input` rapidly — coalesce to one wand run
|
||||
// per frame with rAF. Label updates synchronously so the number
|
||||
// tracks the cursor even when the flood-fill runs at ~60fps.
|
||||
let wandRetuneRaf = null;
|
||||
const retuneWand = () => {
|
||||
if (!state.wandLastSeed || !state.wandMask) return;
|
||||
if (wandRetuneRaf) return;
|
||||
wandRetuneRaf = requestAnimationFrame(() => {
|
||||
wandRetuneRaf = null;
|
||||
runMagicWand(state.wandLastSeed.x, state.wandLastSeed.y, 'replace', { retune: true });
|
||||
});
|
||||
};
|
||||
const liveBtn = document.getElementById('ge-wand-live');
|
||||
liveBtn?.addEventListener('click', () => {
|
||||
state.wandLiveRetune = !state.wandLiveRetune;
|
||||
liveBtn.classList.toggle('active', state.wandLiveRetune);
|
||||
liveBtn.setAttribute('aria-pressed', state.wandLiveRetune ? 'true' : 'false');
|
||||
if (state.wandLiveRetune) retuneWand();
|
||||
});
|
||||
document.getElementById('ge-wand-tolerance')?.addEventListener('input', (e) => {
|
||||
state.wandTolerance = parseInt(e.target.value, 10);
|
||||
const lbl = document.getElementById('ge-wand-tol-label');
|
||||
if (lbl) lbl.textContent = state.wandTolerance;
|
||||
const wp = document.getElementById('ge-wand-tol-preview');
|
||||
if (wp) wp.style.opacity = (state.wandTolerance / 100).toFixed(2);
|
||||
if (state.wandLiveRetune) retuneWand();
|
||||
});
|
||||
|
||||
// Wand mode segmented toggle (New / Add / Subtract).
|
||||
document.querySelectorAll('.ge-wand-mode-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const mode = btn.dataset.wandMode;
|
||||
if (!mode) return;
|
||||
state.wandMode = mode;
|
||||
document.querySelectorAll('.ge-wand-mode-btn').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.wandMode === mode);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Toggle the translucent red overlay for the wand selection.
|
||||
document.getElementById('ge-wand-vis')?.addEventListener('click', () => {
|
||||
state.wandMaskVisible = !state.wandMaskVisible;
|
||||
const btn = document.getElementById('ge-wand-vis');
|
||||
if (btn) {
|
||||
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-clear')?.addEventListener('click', wandClear);
|
||||
document.getElementById('ge-wand-invert')?.addEventListener('click', invertSelection);
|
||||
document.getElementById('ge-wand-delete')?.addEventListener('click', wandDeleteSelection);
|
||||
document.getElementById('ge-wand-copy')?.addEventListener('click', wandCopyToNewLayer);
|
||||
document.getElementById('ge-wand-mask')?.addEventListener('click', wandToMask);
|
||||
// Selection-constrained Bg Remove — reuses the same path the toolbar
|
||||
// Bg Remove button does. buildSelectionHintMask picks the active
|
||||
// wand/lasso selection, so this just kicks off the existing flow.
|
||||
document.getElementById('ge-wand-rembg')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('ge-wand-rembg');
|
||||
const hint = buildSelectionHintMask();
|
||||
if (!hint) { if (uiModule) uiModule.showToast('Click to make a wand selection first'); return; }
|
||||
await applyImageTool('/api/image/remove-bg', { hint_mask: hint }, 'BG Removed', btn);
|
||||
wandClear();
|
||||
});
|
||||
|
||||
// Live tolerance preview (just opacity-tracking like sharpen).
|
||||
const wandTolPrev = document.getElementById('ge-wand-tol-preview');
|
||||
if (wandTolPrev) wandTolPrev.style.opacity = (state.wandTolerance / 100).toFixed(2);
|
||||
}
|
||||
174
static/js/editor/wire-topbar-menus.js
Normal file
174
static/js/editor/wire-topbar-menus.js
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Topbar dropdown menus — Image, Filter, and Resize.
|
||||
*
|
||||
* Image menu (#ge-image-menu-btn → #ge-image-menu):
|
||||
* resize, selection (edge feather/delete), fill, rotate 90/180,
|
||||
* flip horizontal/vertical.
|
||||
*
|
||||
* Filter menu (#ge-filter-menu-btn → #ge-filter-menu):
|
||||
* Blur sub-menu — Gaussian, Zoom.
|
||||
*
|
||||
* Resize menu (#ge-resize-menu-btn → #ge-resize-menu):
|
||||
* preset W×H items (data-resize-w/-h) apply immediately;
|
||||
* [data-resize-custom] opens a themed prompt for arbitrary sizes.
|
||||
*
|
||||
* Returns the resize helpers so the keyboard-shortcuts module can
|
||||
* call them too (Ctrl+Shift+T opens the custom prompt).
|
||||
*
|
||||
* @param {{
|
||||
* closeOtherTopbarMenus: (keepId: string) => void,
|
||||
* registerDocClickAway: (handler: (e: Event) => void) => void,
|
||||
* saveState: (label?: string) => void,
|
||||
* composite: () => void,
|
||||
* fitZoom: () => void,
|
||||
* promptCanvasSize: (opts: object) => Promise<{w, h} | null>,
|
||||
* doFillSelection: () => void,
|
||||
* rotateAllLayers: (deg: number) => void,
|
||||
* flipAllLayers: (axis: 'h' | 'v') => void,
|
||||
* applyGaussianBlur: () => void,
|
||||
* applyZoomBlur: () => void,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*
|
||||
* @returns {{
|
||||
* applyResize: (newW: number, newH: number) => void,
|
||||
* resizeCustomPrompt: () => Promise<void>,
|
||||
* }}
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
export function wireTopbarMenus({
|
||||
closeOtherTopbarMenus, registerDocClickAway,
|
||||
saveState, composite, fitZoom,
|
||||
promptCanvasSize, doFillSelection,
|
||||
rotateAllLayers, flipAllLayers,
|
||||
applyGaussianBlur, applyZoomBlur,
|
||||
uiModule,
|
||||
}) {
|
||||
// ── Resize canvas ──
|
||||
// Extracted so both the popup presets and the Ctrl+Shift+T shortcut
|
||||
// can call it.
|
||||
function applyResize(newW, newH) {
|
||||
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}`);
|
||||
}
|
||||
|
||||
async function resizeCustomPrompt() {
|
||||
const result = await promptCanvasSize({
|
||||
title: 'Canvas size',
|
||||
okLabel: 'Apply',
|
||||
initialW: state.imgWidth,
|
||||
initialH: state.imgHeight,
|
||||
});
|
||||
if (!result) return;
|
||||
applyResize(result.w, result.h);
|
||||
}
|
||||
|
||||
// ── Image menu ──
|
||||
{
|
||||
const btn = document.getElementById('ge-image-menu-btn');
|
||||
const menu = document.getElementById('ge-image-menu');
|
||||
if (btn && menu) {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const willOpen = menu.hidden;
|
||||
if (willOpen) closeOtherTopbarMenus('ge-image-menu');
|
||||
menu.hidden = !menu.hidden;
|
||||
});
|
||||
menu.addEventListener('click', (e) => {
|
||||
const item = e.target.closest('[data-image-action]');
|
||||
if (!item || item.disabled) return;
|
||||
menu.hidden = true;
|
||||
const action = item.dataset.imageAction;
|
||||
if (action === 'resize') resizeCustomPrompt();
|
||||
else if (action === 'selection') document.getElementById('ge-edge-menu-btn')?.click();
|
||||
else if (action === 'fill') doFillSelection();
|
||||
else if (action === 'rotate-90') rotateAllLayers(90);
|
||||
else if (action === 'rotate-180') rotateAllLayers(180);
|
||||
else if (action === 'flip-h') flipAllLayers('h');
|
||||
else if (action === 'flip-v') flipAllLayers('v');
|
||||
});
|
||||
registerDocClickAway((e) => {
|
||||
if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) menu.hidden = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filter menu (Blur sub-menu — Gaussian / Zoom) ──
|
||||
{
|
||||
const btn = document.getElementById('ge-filter-menu-btn');
|
||||
const menu = document.getElementById('ge-filter-menu');
|
||||
if (btn && menu) {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const willOpen = menu.hidden;
|
||||
if (willOpen) closeOtherTopbarMenus('ge-filter-menu');
|
||||
menu.hidden = !menu.hidden;
|
||||
});
|
||||
menu.addEventListener('click', (e) => {
|
||||
const item = e.target.closest('[data-filter-action]');
|
||||
if (!item) return;
|
||||
menu.hidden = true;
|
||||
const action = item.dataset.filterAction;
|
||||
if (action === 'blur-gaussian') applyGaussianBlur();
|
||||
else if (action === 'blur-zoom') applyZoomBlur();
|
||||
});
|
||||
registerDocClickAway((e) => {
|
||||
if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) menu.hidden = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resize popup (preset items + Custom… → resizeCustomPrompt) ──
|
||||
{
|
||||
const btn = document.getElementById('ge-resize-menu-btn');
|
||||
const menu = document.getElementById('ge-resize-menu');
|
||||
if (btn && menu) {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const willOpen = menu.hidden;
|
||||
if (willOpen) closeOtherTopbarMenus('ge-resize-menu');
|
||||
menu.hidden = !menu.hidden;
|
||||
});
|
||||
menu.querySelectorAll('[data-resize-w]').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
menu.hidden = true;
|
||||
applyResize(parseInt(item.dataset.resizeW, 10), parseInt(item.dataset.resizeH, 10));
|
||||
});
|
||||
});
|
||||
menu.querySelector('[data-resize-custom]')?.addEventListener('click', () => {
|
||||
menu.hidden = true;
|
||||
resizeCustomPrompt();
|
||||
});
|
||||
registerDocClickAway((e) => {
|
||||
if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) menu.hidden = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { applyResize, resizeCustomPrompt };
|
||||
}
|
||||
49
static/js/editor/wire-topbar-overflow.js
Normal file
49
static/js/editor/wire-topbar-overflow.js
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Topbar overflow handler — keeps lightweight labels updated and hides
|
||||
* only low-priority AI model controls when the editor window gets narrow.
|
||||
*
|
||||
* Plus the small canvas-size display label updater (since it sits in
|
||||
* the topbar too).
|
||||
*
|
||||
* Import and Canvas stay as real topbar buttons; there is intentionally
|
||||
* no "More" overflow menu here.
|
||||
*
|
||||
* @param {{
|
||||
* container: HTMLElement,
|
||||
* registerDocClickAway: (handler: (e: Event) => void) => void,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
export function wireTopbarOverflow({ container }) {
|
||||
// Canvas-size badge updater (kept simple — it lives in the topbar).
|
||||
const sizeLabel = document.getElementById('ge-canvas-size');
|
||||
function updateSizeLabel() {
|
||||
if (sizeLabel) sizeLabel.textContent = `${state.imgWidth}×${state.imgHeight}`;
|
||||
}
|
||||
updateSizeLabel();
|
||||
|
||||
const topbar = container.querySelector('.ge-topbar');
|
||||
// The Gen control + its "Gen" label span — collapse as a group when
|
||||
// narrow. The Inpaint model selector moved into the side panel.
|
||||
const aiGroup = [
|
||||
container.querySelector('#ge-ai-model'),
|
||||
...container.querySelectorAll('.ge-topbar span[style*="font-size:9px"]'),
|
||||
].filter(Boolean);
|
||||
|
||||
function syncOverflow() {
|
||||
if (!topbar) return;
|
||||
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 (topbar && window.ResizeObserver) {
|
||||
const ro = new ResizeObserver(() => syncOverflow());
|
||||
ro.observe(topbar);
|
||||
}
|
||||
// Initial pass after layout settles.
|
||||
requestAnimationFrame(syncOverflow);
|
||||
}
|
||||
188
static/js/editor/wire-topbar.js
Normal file
188
static/js/editor/wire-topbar.js
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Topbar wiring — undo/redo/history, Save dropdown, zoom buttons,
|
||||
* Save/Export/Download/Project, Edge popup, and the cross-dropdown
|
||||
* coordination (close-others + global outside-click).
|
||||
*
|
||||
* #ge-undo / #ge-redo / #ge-history-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
|
||||
* #ge-export-gallery / #ge-download
|
||||
* #ge-save-project / #ge-load-project
|
||||
* #ge-edge-menu-btn + #ge-edge-menu (Width input + Feather / Delete
|
||||
* action buttons)
|
||||
*
|
||||
* Dropdown coordination: every menu hides any sibling menu when it
|
||||
* opens (closeOtherTopbarMenus), and a global outside-click handler
|
||||
* closes every open menu if the user clicks anywhere outside.
|
||||
*
|
||||
* @param {{
|
||||
* undo: () => void,
|
||||
* redo: () => void,
|
||||
* toggleHistoryPanel: () => void,
|
||||
* fitZoom: () => void,
|
||||
* applyZoom: () => void,
|
||||
* exportToGallery: () => void,
|
||||
* downloadPNG: () => void,
|
||||
* saveProject: () => void,
|
||||
* loadProjectPrompt: () => void,
|
||||
* activeLayer: () => object | null,
|
||||
* saveState: (label?: string) => void,
|
||||
* applyEdgeFeather: (layer: object, width: number, hardDelete: boolean) => void,
|
||||
* composite: () => void,
|
||||
* registerDocClickAway: (handler: (e: Event) => void) => void,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.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'];
|
||||
|
||||
/**
|
||||
* Close every topbar dropdown except an optional "keep open" one.
|
||||
* Exported so the Image / Filter / Resize menus (wired elsewhere)
|
||||
* can call it from their own open handlers.
|
||||
*/
|
||||
export function closeOtherTopbarMenus(keepId) {
|
||||
for (const id of TOPBAR_MENU_IDS) {
|
||||
if (id === keepId) continue;
|
||||
const m = document.getElementById(id);
|
||||
if (m && !m.hidden) m.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function wireTopbar(deps) {
|
||||
const {
|
||||
undo, redo, toggleHistoryPanel,
|
||||
fitZoom, applyZoom,
|
||||
exportToGallery, downloadPNG, saveProject, loadProjectPrompt,
|
||||
activeLayer, saveState, applyEdgeFeather, composite,
|
||||
registerDocClickAway, uiModule,
|
||||
} = deps;
|
||||
|
||||
// Undo / Redo / History.
|
||||
document.getElementById('ge-undo')?.addEventListener('click', undo);
|
||||
document.getElementById('ge-redo')?.addEventListener('click', redo);
|
||||
document.getElementById('ge-history-btn')?.addEventListener('click', toggleHistoryPanel);
|
||||
|
||||
// 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');
|
||||
if (saveBtn && saveMenu) {
|
||||
const saveTopbar = saveBtn.closest('.ge-topbar');
|
||||
// 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
|
||||
// non-`none` transform on an ancestor makes that ancestor the
|
||||
// containing block for `position: fixed` descendants, even after
|
||||
// the animation lands on identity). The JS math below assumes
|
||||
// viewport-relative coords, so without the reparent the menu
|
||||
// ends up "way off" the button on desktop.
|
||||
if (saveMenu.parentNode !== document.body) {
|
||||
document.body.appendChild(saveMenu);
|
||||
}
|
||||
const setSaveMenuOpen = (open) => {
|
||||
saveMenu.hidden = !open;
|
||||
saveTopbar?.classList.toggle('ge-topbar-menu-open', !!open);
|
||||
};
|
||||
const positionSaveMenu = () => {
|
||||
const r = saveBtn.getBoundingClientRect();
|
||||
saveMenu.style.top = `${r.bottom + 2}px`;
|
||||
saveMenu.style.right = `${Math.max(8, window.innerWidth - r.right)}px`;
|
||||
saveMenu.style.left = 'auto';
|
||||
};
|
||||
saveBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const willOpen = saveMenu.hidden;
|
||||
setSaveMenuOpen(willOpen);
|
||||
if (willOpen) positionSaveMenu();
|
||||
});
|
||||
saveMenu.addEventListener('click', () => { setSaveMenuOpen(false); });
|
||||
window.addEventListener('resize', () => { if (!saveMenu.hidden) positionSaveMenu(); });
|
||||
registerDocClickAway((e) => {
|
||||
if (!saveMenu.hidden && !saveMenu.contains(e.target) && e.target !== saveBtn) {
|
||||
setSaveMenuOpen(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Zoom buttons.
|
||||
document.getElementById('ge-zoom-fit')?.addEventListener('click', fitZoom);
|
||||
document.getElementById('ge-zoom-100')?.addEventListener('click', () => { state.zoom = 1; applyZoom(); });
|
||||
document.getElementById('ge-zoom-in')?.addEventListener('click', () => { state.zoom = Math.min(5, state.zoom * 1.25); applyZoom(); });
|
||||
document.getElementById('ge-zoom-out')?.addEventListener('click', () => { state.zoom = Math.max(0.1, state.zoom / 1.25); applyZoom(); });
|
||||
|
||||
// Export / Download / Project Save / Project Load.
|
||||
document.getElementById('ge-export-gallery')?.addEventListener('click', exportToGallery);
|
||||
document.getElementById('ge-download')?.addEventListener('click', downloadPNG);
|
||||
document.getElementById('ge-save-project')?.addEventListener('click', saveProject);
|
||||
document.getElementById('ge-load-project')?.addEventListener('click', loadProjectPrompt);
|
||||
|
||||
// Global outside-click — closes EVERY editor dropdown when the
|
||||
// user clicks anywhere that isn't a menu or trigger button. Each
|
||||
// menu has its own click-away handler too; this is a defence-in-
|
||||
// depth net for cross-menu clicks / mobile touches that miss the
|
||||
// individual handlers.
|
||||
document.addEventListener('pointerdown', (e) => {
|
||||
for (const id of TOPBAR_MENU_IDS.concat(TOPBAR_TRIGGER_IDS)) {
|
||||
const el = document.getElementById(id);
|
||||
if (el && el.contains(e.target)) return;
|
||||
}
|
||||
for (const id of TOPBAR_MENU_IDS) {
|
||||
const m = document.getElementById(id);
|
||||
if (m && !m.hidden) m.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
// 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; }
|
||||
const widthInput = document.getElementById('ge-edge-width');
|
||||
const width = parseInt(widthInput?.value || '8');
|
||||
if (isNaN(width) || width < 1) { uiModule.showToast('Invalid width'); return; }
|
||||
saveState();
|
||||
applyEdgeFeather(layer, width, hardDelete);
|
||||
composite();
|
||||
uiModule.showToast(hardDelete ? `Edges deleted ${width}px` : `Edges feathered ${width}px`);
|
||||
}
|
||||
{
|
||||
const btn = document.getElementById('ge-edge-menu-btn');
|
||||
const menu = document.getElementById('ge-edge-menu');
|
||||
if (btn && menu) {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const willOpen = menu.hidden;
|
||||
if (willOpen) closeOtherTopbarMenus('ge-edge-menu');
|
||||
menu.hidden = !menu.hidden;
|
||||
if (!menu.hidden) {
|
||||
// Autofocus the width input so users can type immediately.
|
||||
setTimeout(() => document.getElementById('ge-edge-width')?.select(), 0);
|
||||
}
|
||||
});
|
||||
document.getElementById('ge-edge-feather')?.addEventListener('click', () => {
|
||||
menu.hidden = true;
|
||||
applyEdgeAction(false);
|
||||
});
|
||||
document.getElementById('ge-edge-delete')?.addEventListener('click', () => {
|
||||
menu.hidden = true;
|
||||
applyEdgeAction(true);
|
||||
});
|
||||
document.getElementById('ge-edge-width')?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
menu.hidden = true;
|
||||
applyEdgeAction(false);
|
||||
}
|
||||
});
|
||||
registerDocClickAway((e) => {
|
||||
if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) menu.hidden = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user