Compare commits

..
Author SHA1 Message Date
pewdiepie-archdaemonandClaude Opus 5.5 5fac693c1c feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).

Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:57:35 +00:00
290 changed files with 34358 additions and 766 deletions
File diff suppressed because it is too large Load Diff
+339
View File
@@ -978,3 +978,342 @@ body.doc-find-active mark.doc-find-mark.current {
color: var(--accent, var(--red));
vertical-align: -2px;
}
/* Colour chips: every pickable colour input renders as a circle, regardless of
the container it sits in (options bar .ge-text-field, Character panel,
gradient stops, export matte, etc.). Forced with !important so parent
input[type="color"] rules can't square them off. */
input.ge-color-picker,
input.ge-color-picker.cp-swatch-input,
.ge-text-field input.ge-color-picker,
.ge-swatch input.ge-color-picker,
.ge-filter-row input.ge-color-picker,
.ge-gradient-color-row input.ge-color-picker {
border-radius: 50% !important;
}
input.ge-color-picker::-webkit-color-swatch {
border: 0 !important;
border-radius: 50% !important;
}
input.ge-color-picker::-moz-color-swatch {
border: 0 !important;
border-radius: 50% !important;
}
/* ---------------------------------------------------------------------------
Editor theme alignment: map the editor's controls onto the app's design
tokens (static/css/00-tokens.css) so the editor matches the rest of the
product. No new colours — only the shared CSS variables.
--------------------------------------------------------------------------- */
/* Checkboxes + radios → the app's circular dot (see 17-research.css). */
body.ge-editor-open input[type="checkbox"],
body.ge-editor-open input[type="radio"] {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
margin: 0;
padding: 0;
border: 1.5px solid var(--border);
border-radius: 50%;
background: var(--bg);
cursor: pointer;
position: relative;
flex: 0 0 auto;
vertical-align: middle;
transition: background 0.12s ease, border-color 0.12s ease;
}
body.ge-editor-open input[type="checkbox"]:checked,
body.ge-editor-open input[type="radio"]:checked {
background: var(--fg);
border-color: var(--fg);
}
body.ge-editor-open input[type="checkbox"]:focus-visible,
body.ge-editor-open input[type="radio"]:focus-visible {
outline: 2px solid var(--red);
outline-offset: 1px;
}
/* Selects → the app's select tokens. */
body.ge-editor-open select {
background: var(--select-bg, var(--bg));
color: var(--select-fg, var(--fg));
border: 1px solid var(--border);
border-radius: 6px;
}
body.ge-editor-open select option {
background: var(--select-option-bg, var(--panel));
color: var(--select-option-fg, var(--fg));
}
body.ge-editor-open select option:checked,
body.ge-editor-open select option:hover {
background: var(--select-option-active-bg, var(--panel));
}
body.ge-editor-open select:focus-visible {
outline: 1px solid var(--red);
outline-offset: 1px;
}
/* Text / number inputs → token background, border and focus ring. */
body.ge-editor-open input[type="text"],
body.ge-editor-open input[type="number"],
body.ge-editor-open input[type="search"],
body.ge-editor-open textarea {
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 6px;
}
body.ge-editor-open input[type="text"]:focus-visible,
body.ge-editor-open input[type="number"]:focus-visible,
body.ge-editor-open input[type="search"]:focus-visible,
body.ge-editor-open textarea:focus-visible {
outline: 1px solid var(--red);
outline-offset: 1px;
}
/* Range sliders → the app's accent. */
body.ge-editor-open input[type="range"] {
accent-color: var(--accent, var(--red));
}
/* Scrollbars → the app's 8px red-thumb recipe (00-tokens.css). */
body.ge-editor-open * {
scrollbar-width: thin;
scrollbar-color: var(--red) var(--panel);
}
body.ge-editor-open *::-webkit-scrollbar {
width: 8px;
height: 8px;
}
body.ge-editor-open *::-webkit-scrollbar-track {
background: var(--panel);
}
body.ge-editor-open *::-webkit-scrollbar-thumb {
background-color: var(--red);
border-radius: 4px;
border: 2px solid var(--panel);
}
body.ge-editor-open *::-webkit-scrollbar-thumb:hover {
background-color: color-mix(in srgb, var(--red) 80%, white);
}
/* ---------------------------------------------------------------------------
Editor theme alignment (part 2): buttons, segmented toggles, tabs, list
rows, panel headers, badges, focus rings, number fields and colour-picker
chrome mapped onto the app's shared design tokens. Existing tokens only —
no new colours, and no geometry beyond colour / border / radius / focus.
--------------------------------------------------------------------------- */
/* The editor references two secondary / muted variables that the app token
layer never defines; map them onto the real tokens so every
var(--fg-muted) / var(--bg-secondary) editor rule resolves. */
body.ge-editor-open {
--fg-muted: var(--color-muted);
--bg-secondary: var(--panel);
}
/* Buttons → the app's `.cal-btn` recipe (5px radius; the primary button
uses the accent fill with a --bg foreground instead of a hard-coded #fff). */
body.ge-editor-open .ge-btn {
border-radius: 5px;
}
body.ge-editor-open .ge-btn-primary {
background: var(--accent, var(--red));
border-color: var(--accent, var(--red));
color: var(--bg);
}
body.ge-editor-open .ge-btn-primary:hover {
background: var(--accent, var(--red));
opacity: 0.9;
}
body.ge-editor-open .ge-win-close:hover {
background: var(--accent, var(--red));
color: var(--bg);
}
/* Segmented / option toggles, command palette and channel rows → the app's
accent token (--red) instead of the editor's blue #4c8bf5 fallback. */
body.ge-editor-open .ge-opt-mode.active,
body.ge-editor-open .ge-crop-preset.active,
body.ge-editor-open .ge-command-item.active,
body.ge-editor-open .ge-command-item:hover {
background: var(--accent, var(--red));
border-color: var(--accent, var(--red));
color: var(--bg);
}
body.ge-editor-open .ge-channel-row.active,
body.ge-editor-open .ge-dock-tabs-menu-item:hover,
body.ge-editor-open .ge-filter-menu .dropdown-item-compact:focus-visible,
body.ge-editor-open .ge-view-menu .dropdown-item-compact:focus-visible,
body.ge-editor-open .ge-window-menu .dropdown-item-compact:focus-visible,
body.ge-editor-open .ge-help-menu .dropdown-item-compact:focus-visible,
body.ge-editor-open .ge-3d-menu .dropdown-item-compact:focus-visible,
body.ge-editor-open .ge-plugins-menu .dropdown-item-compact:focus-visible {
background: color-mix(in srgb, var(--accent, var(--red)) 22%, transparent);
}
body.ge-editor-open .ge-sam-brush-add.active,
body.ge-editor-open .ge-sam-brush-subtract.active,
body.ge-editor-open .ge-actions-controls .ge-btn.active {
border-color: var(--accent, var(--red));
color: var(--accent, var(--red));
}
/* Tabs → the app tab convention: the active tab's underline is the accent. */
body.ge-editor-open .ge-dock-tab.active {
border-bottom-color: var(--accent, var(--red));
}
/* Colour-picker chrome → the token accent instead of the blue fallback. */
body.ge-editor-open .ge-swatch.active {
outline-color: var(--accent, var(--red));
}
/* Badges / status pills → the app's --bg foreground on an accent fill. */
body.ge-editor-open .ge-tool-clear {
color: var(--bg);
}
/* Panel headers → the app's muted section-title token. */
body.ge-editor-open .ge-panel-title,
body.ge-editor-open .ge-info-label {
color: var(--color-muted);
}
/* Focus rings → the app's 1px --red ring (matching the earlier input/select
block) on the controls that previously fell back to the UA outline. */
body.ge-editor-open .ge-btn:focus-visible,
body.ge-editor-open .ge-tool-btn:focus-visible,
body.ge-editor-open .ge-doc-tab:focus-visible,
body.ge-editor-open .ge-history-row:focus-visible,
body.ge-editor-open .ge-swatch:focus-visible,
body.ge-editor-open .ge-opt-mode:focus-visible,
body.ge-editor-open .ge-stepper button:focus-visible {
outline: 1px solid var(--red);
outline-offset: 1px;
}
/* ---------------------------------------------------------------------------
Editor theme alignment (part 3): the setup / preflight checklist and the
remaining checkbox list-row / badge chrome mapped onto the app's shared
design tokens. Existing tokens only — no new colours, and no geometry beyond
colour / border / radius / focus. Scoped to body.ge-editor-open.
--------------------------------------------------------------------------- */
/* Checklist card + rows → the app's panel / border / muted tokens. */
body.ge-editor-open .ge-preflight-card {
background: var(--panel);
color: var(--fg);
border-color: var(--border);
}
body.ge-editor-open .ge-preflight-head p,
body.ge-editor-open .ge-preflight-hint,
body.ge-editor-open .ge-preflight-detail,
body.ge-editor-open .ge-preflight-remember,
body.ge-editor-open .ge-preflight-close {
color: var(--color-muted);
}
body.ge-editor-open .ge-preflight-close:hover {
background: color-mix(in srgb, var(--fg) 8%, transparent);
color: var(--fg);
}
body.ge-editor-open .ge-preflight-row {
background: color-mix(in srgb, var(--panel) 92%, var(--fg) 4%);
border-color: var(--border);
}
body.ge-editor-open .ge-preflight-row.is-done {
border-color: color-mix(in srgb, var(--green) 45%, var(--border));
}
body.ge-editor-open .ge-preflight-row.is-todo {
border-color: var(--red);
}
body.ge-editor-open .ge-preflight-row.is-optional {
border-color: color-mix(in srgb, var(--accent-warm) 45%, var(--border));
}
/* Row badges → the semantic tokens (done = green, todo = red, optional = warm). */
body.ge-editor-open .ge-preflight-badge {
background: color-mix(in srgb, var(--fg) 10%, transparent);
color: var(--fg);
}
body.ge-editor-open .ge-preflight-row.is-done .ge-preflight-badge {
background: color-mix(in srgb, var(--green) 20%, transparent);
color: var(--green);
}
body.ge-editor-open .ge-preflight-row.is-todo .ge-preflight-badge {
background: color-mix(in srgb, var(--red) 18%, transparent);
color: var(--red);
}
body.ge-editor-open .ge-preflight-row.is-optional .ge-preflight-badge {
background: color-mix(in srgb, var(--accent-warm) 16%, transparent);
color: var(--accent-warm);
}
body.ge-editor-open .ge-preflight-opt {
background: color-mix(in srgb, var(--fg) 8%, transparent);
color: var(--color-muted);
}
/* Checklist actions / footer → token buttons and borders. */
body.ge-editor-open .ge-preflight-act {
background: color-mix(in srgb, var(--fg) 8%, transparent);
border-color: var(--border);
color: var(--fg);
}
body.ge-editor-open .ge-preflight-act:hover {
background: color-mix(in srgb, var(--fg) 16%, transparent);
}
body.ge-editor-open .ge-preflight-foot {
border-top-color: var(--border);
}
body.ge-editor-open .ge-preflight-summary {
color: var(--green);
}
body.ge-editor-open .ge-preflight-start {
background: var(--accent, var(--red));
color: var(--bg);
}
body.ge-editor-open .ge-preflight-start:hover {
background: var(--accent, var(--red));
opacity: 0.9;
}
body.ge-editor-open .ge-setup-btn {
border-color: var(--border);
color: var(--color-muted);
}
body.ge-editor-open .ge-setup-btn:hover {
background: color-mix(in srgb, var(--fg) 10%, transparent);
color: var(--fg);
}
/* The options-bar checkbox overrode the shared dot with a square + a drawn
::after tick; force it back to the app's 14px circular dot (part 1). */
body.ge-editor-open .ge-opt input[type="checkbox"],
body.ge-editor-open .ge-opt input[type="radio"] {
border-radius: 50%;
border-width: 1.5px;
}
body.ge-editor-open .ge-opt input[type="checkbox"]:checked,
body.ge-editor-open .ge-opt input[type="radio"]:checked {
background: var(--fg);
border-color: var(--fg);
}
body.ge-editor-open .ge-opt input[type="checkbox"]:checked::after {
content: none;
}
/* Remaining list-row / thumbnail chrome → panel + border + muted tokens. */
body.ge-editor-open .ge-history-row-dot {
background: var(--color-muted);
}
body.ge-editor-open .ge-layer-inline-thumb {
background: var(--panel);
border-color: var(--border);
}
body.ge-editor-open .ge-channel-row,
body.ge-editor-open .ge-channel-name {
color: var(--fg);
}
body.ge-editor-open .ge-channel-eye {
color: var(--color-muted);
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+239
View File
@@ -0,0 +1,239 @@
/**
* Actions panel (PhotoCraft parity, Phase 11) — a lightweight macro
* recorder/player. Every step maps to a known editor command by CSS
* selector; recording captures clicks on those selectors, and play
* replays them in order. Named actions persist to localStorage.
*
* The module owns no editor state: it only dispatches clicks on the
* selectors it is given.
*/
export const ACTION_COMMANDS = Object.freeze([
['flip-h', 'Flip Horizontal', '[data-image-action="flip-h"]'],
['flip-v', 'Flip Vertical', '[data-image-action="flip-v"]'],
['rotate-90', 'Rotate 90° CW', '[data-image-action="rotate-90"]'],
['rotate-180', 'Rotate 180°', '[data-image-action="rotate-180"]'],
['fill', 'Fill Foreground', '[data-image-action="fill"]'],
['merge-down', 'Merge Down', '#ge-merge-down'],
['flatten', 'Flatten', '#ge-flatten'],
['select-all', 'Select All', '[data-selection-action="all"]'],
['deselect', 'Deselect', '[data-selection-action="deselect"]'],
['invert-selection', 'Invert Selection', '[data-selection-action="invert"]'],
['blur-gaussian', 'Gaussian Blur', '[data-filter-action="blur-gaussian"]'],
['blur-motion', 'Motion Blur', '[data-filter-action="blur-motion"]'],
['noise-add', 'Add Noise', '[data-filter-action="noise-add"]'],
['pixelate-mosaic', 'Pixelate', '[data-filter-action="pixelate-mosaic"]'],
['sharpen', 'Sharpen', '[data-filter-action="sharpen"]'],
['stylize-emboss', 'Emboss', '[data-filter-action="stylize-emboss"]'],
['distort-twirl', 'Twirl', '[data-filter-action="distort-twirl"]'],
['rembg', 'Remove Background', '[data-tool-option="rembg"]'],
['inpaint', 'Inpaint', '[data-tool="inpaint"]'],
]);
const STORAGE_KEY = 'ge-actions';
function el(tag, className, html) {
const node = document.createElement(tag);
if (className) node.className = className;
if (html != null) node.innerHTML = html;
return node;
}
function commandById(id) {
return ACTION_COMMANDS.find(command => command[0] === id) || null;
}
export function createActionsPanel({ showToast = () => {}, getLayers = null, selectLayer = null } = {}) {
let steps = [];
let recording = false;
let saved = loadSaved();
const body = el('div', 'ge-panel-body ge-actions-body');
const nameRow = el('div', 'ge-actions-name-row');
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.placeholder = 'Action name';
nameInput.className = 'ge-actions-name';
const saveButton = el('button', 'ge-btn ge-btn-sm', 'Save');
saveButton.type = 'button';
nameRow.append(nameInput, saveButton);
const addRow = el('div', 'ge-actions-add-row');
const addSelect = document.createElement('select');
addSelect.className = 'ge-actions-command';
ACTION_COMMANDS.forEach(([id, label]) => addSelect.appendChild(new Option(label, id)));
const addButton = el('button', 'ge-btn ge-btn-sm', 'Add');
addButton.type = 'button';
addRow.append(addSelect, addButton);
const controls = el('div', 'ge-actions-controls');
const recordButton = el('button', 'ge-btn ge-btn-sm', 'Record');
recordButton.type = 'button';
const playButton = el('button', 'ge-btn ge-btn-sm', 'Play');
playButton.type = 'button';
const clearButton = el('button', 'ge-btn ge-btn-sm', 'Clear');
clearButton.type = 'button';
controls.append(recordButton, playButton, clearButton);
const batchRow = el('div', 'ge-actions-batch-row');
const batchSelect = document.createElement('select');
batchSelect.className = 'ge-actions-batch-select';
const batchButton = el('button', 'ge-btn ge-btn-sm', 'Batch');
batchButton.type = 'button';
batchButton.title = 'Apply the selected action to every layer';
const batchDocsButton = el('button', 'ge-btn ge-btn-sm', 'Batch docs');
batchDocsButton.type = 'button';
batchDocsButton.title = 'Apply the selected action to every open document';
batchRow.append(batchSelect, batchButton, batchDocsButton);
const list = el('div', 'ge-actions-list');
const savedList = el('div', 'ge-actions-saved');
body.append(nameRow, addRow, controls, batchRow, list, savedList);
function renderList() {
list.innerHTML = '';
if (!steps.length) {
list.appendChild(el('p', 'ge-panel-empty', 'No steps. Add commands or record.'));
return;
}
steps.forEach((step, index) => {
const command = commandById(step);
const row = el('div', 'ge-actions-step', `<span class="ge-actions-step-index">${index + 1}</span><span class="ge-actions-step-label">${command ? command[1] : step}</span>`);
const remove = el('button', 'ge-action-remove', '✕');
remove.type = 'button';
remove.addEventListener('click', () => { steps.splice(index, 1); renderList(); });
row.appendChild(remove);
list.appendChild(row);
});
}
function renderSaved() {
savedList.innerHTML = '';
syncBatchSelect();
const names = Object.keys(saved);
if (!names.length) return;
savedList.appendChild(el('div', 'ge-panel-title', 'SAVED'));
names.forEach(name => {
const row = el('div', 'ge-actions-saved-row');
const loadButton = el('button', 'ge-btn ge-btn-sm', name);
loadButton.type = 'button';
loadButton.addEventListener('click', () => { steps = saved[name].slice(); renderList(); });
const deleteButton = el('button', 'ge-action-remove', '✕');
deleteButton.type = 'button';
deleteButton.addEventListener('click', () => { delete saved[name]; persist(); renderSaved(); });
row.append(loadButton, deleteButton);
savedList.appendChild(row);
});
}
function persist() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); } catch { /* ignore */ }
}
function syncBatchSelect() {
const names = Object.keys(saved);
const previous = batchSelect.value;
batchSelect.innerHTML = '';
if (!names.length) {
batchSelect.appendChild(new Option('No saved actions', ''));
batchButton.disabled = true;
batchDocsButton.disabled = true;
return;
}
batchButton.disabled = false;
batchDocsButton.disabled = false;
names.forEach(name => batchSelect.appendChild(new Option(name, name)));
if (names.includes(previous)) batchSelect.value = previous;
}
function loadSaved() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch { return {}; }
}
function runStep(step) {
const command = commandById(step);
if (!command) return;
document.querySelector(command[2])?.click();
}
function play() {
steps.forEach((step, index) => setTimeout(() => runStep(step), index * 60));
showToast(`Playing ${steps.length} action${steps.length === 1 ? '' : 's'}`);
}
async function runOnAllLayers() {
const name = batchSelect.value;
const action = name && saved[name] ? saved[name].slice() : null;
if (!action || !action.length) { showToast('Select a saved action to batch'); return; }
if (!getLayers || !selectLayer) { showToast('Batch is unavailable'); return; }
const layers = getLayers() || [];
if (layers.length < 1) { showToast('No layers to batch'); return; }
showToast(`Batching "${name}" over ${layers.length} layer${layers.length === 1 ? '' : 's'}`);
for (const layer of layers) {
selectLayer(layer.id);
for (const step of action) {
runStep(step);
await new Promise(resolve => setTimeout(resolve, 80));
}
}
showToast('Batch complete');
}
function onDocumentClick(event) {
if (!recording) return;
const match = ACTION_COMMANDS.find(command => event.target.closest?.(command[2]));
if (match && !steps.includes(match[0])) {
steps.push(match[0]);
renderList();
}
}
function toggleRecord() {
recording = !recording;
recordButton.classList.toggle('active', recording);
recordButton.textContent = recording ? 'Stop' : 'Record';
showToast(recording ? 'Recording actions…' : 'Recording stopped');
}
addButton.addEventListener('click', () => { steps.push(addSelect.value); renderList(); });
recordButton.addEventListener('click', toggleRecord);
playButton.addEventListener('click', play);
batchButton.addEventListener('click', () => { runOnAllLayers(); });
batchDocsButton.addEventListener('click', async () => {
const name = batchSelect.value;
if (!name) { showToast('Select a saved action to batch'); return; }
try {
const response = await window.geEditorControl?.invoke?.('actions.batchDocuments', { name });
const results = (response && response.result) || [];
const ok = results.filter((entry) => entry.ok).length;
showToast(`Batch docs: ${ok}/${results.length} document${results.length === 1 ? '' : 's'}`);
} catch (error) {
showToast('Batch docs failed: ' + (error?.message || error));
}
});
clearButton.addEventListener('click', () => { steps = []; renderList(); });
saveButton.addEventListener('click', () => {
const name = (nameInput.value || '').trim();
if (!name) return;
saved[name] = steps.slice();
persist();
renderSaved();
showToast(`Saved action "${name}"`);
});
document.addEventListener('click', onDocumentClick, true);
const element = el('div', 'ge-panel');
element.id = 'ge-panel-actions';
element.appendChild(el('div', 'ge-panel-title', 'ACTIONS'));
element.appendChild(body);
renderList();
renderSaved();
return { element, play, run: runStep, runOnAllLayers, getSteps: () => steps.slice(), setSteps: (next) => { steps = next.slice(); renderList(); } };
}
+17 -10
View File
@@ -2,10 +2,12 @@
import { applyAdjustment } from './fx/pixel-pass.js';
import { defaultAdjParams } from './layer-helpers.js';
import { renderWithLayerMasks } from './composite-helpers.js';
import { blendDraw } from './blend-modes.js';
const TYPES = new Set([
'brightness-contrast',
'exposure',
'develop',
'white-balance',
'hue-saturation',
'vibrance',
@@ -16,6 +18,12 @@ const TYPES = new Set([
'color-balance',
'selective-color',
'gradient-map',
'invert',
'posterize',
'threshold',
'photo-filter',
'channel-mixer',
'color-lookup',
]);
function clone(value) {
@@ -52,6 +60,11 @@ export function normalizeAdjustmentData(value) {
params.ranges[name] = { ...clone(defaults.ranges[name]), ...(params.ranges[name] || {}) };
}
}
if (type === 'channel-mixer') {
for (const name of ['red', 'green', 'blue', 'constant']) {
params[name] = { ...clone(defaults[name]), ...(params[name] || {}) };
}
}
return { type, params };
}
@@ -109,11 +122,8 @@ export function drawAdjustmentLayer(target, editorState, layer, clippingBase, re
adjusted = clipped;
}
target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(adjusted, 0, 0);
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
const opacity = Number.isFinite(layer.opacity) ? layer.opacity : 1;
blendDraw(target, adjusted, layer.blendMode, opacity, 0, 0);
return true;
}
@@ -154,11 +164,8 @@ export async function drawAdjustmentLayerAsync(target, editorState, layer, clipp
clippedCtx.globalCompositeOperation = 'source-over';
adjusted = clipped;
}
target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(adjusted, 0, 0);
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
const opacity = Number.isFinite(layer.opacity) ? layer.opacity : 1;
blendDraw(target, adjusted, layer.blendMode, opacity, 0, 0);
return true;
}
+133 -1
View File
@@ -27,6 +27,12 @@
import { state } from './state.js';
import { sortModelIds } from '../modelSort.js';
// Last result of loadAIModels() — consumed by the editor setup checklist
// (static/js/editor/preflight.js) to decide whether an image provider is
// reachable and which image models are available.
let _aiModelsStatus = { ready: false, models: [], error: null, at: 0 };
export function getAIModelsStatus() { return _aiModelsStatus; }
export function resolveInpaintModel(select) {
const options = Array.from(select?.options || []).filter(option =>
!option.disabled && option.value && option.value !== '__serve_cookbook__');
@@ -34,6 +40,71 @@ export function resolveInpaintModel(select) {
return explicit || options.find(option => /inpaint|edit|fill/i.test(option.value)) || options[0] || null;
}
// Resolve the model the Quick Edit box will use. Prefers the explicit Quick
// Edit picker (#ge-ai-command-model); falls back to the inpaint picker / Auto.
export function resolveQuickEditModel() {
const sel = document.getElementById('ge-ai-command-model');
if (sel && sel.value && sel.value !== '__serve_cookbook__') {
const opt = sel.options[sel.selectedIndex];
const raw = sel.value || '';
const idx = raw.indexOf('::');
return {
endpoint: idx < 0 ? raw : raw.slice(0, idx),
model: idx < 0 ? '' : raw.slice(idx + 2),
label: ((opt && opt.textContent) || '').trim(),
};
}
const inp = document.getElementById('ge-ai-inpaint');
const opt = resolveInpaintModel(inp);
if (!opt) return { endpoint: '', model: '', label: '' };
const raw = opt.value || '';
const idx = raw.indexOf('::');
return {
endpoint: idx < 0 ? raw : raw.slice(0, idx),
model: idx < 0 ? '' : raw.slice(idx + 2),
label: ((opt.textContent || '').trim().replace(/^Auto\s*\((.*)\)$/, '$1')),
};
}
// Resolve the Quick Edit AGENT (planner) model — the vision-language model
// that reads the canvas screenshot and plans the edit. Default is the local
// Qwen3.5-9B VL server on seneca; "Server default" keeps the HARNESS planner.
export function resolveQuickEditAgentModel() {
const sel = document.getElementById('ge-ai-command-agent-model');
const raw = (sel && sel.value) || '';
if (!raw || raw === 'default') {
return { agent: '', endpoint: '', label: 'Server default' };
}
const idx = raw.indexOf('::');
const endpoint = idx < 0 ? '' : raw.slice(0, idx);
const agent = idx < 0 ? raw : raw.slice(idx + 2);
const opt = sel.options[sel.selectedIndex];
return { agent, endpoint, label: ((opt && opt.textContent) || '').trim() };
}
// Quick Edit agent (planner) model options. Kept small + explicit; the local
// Qwen3.5-9B VL is the default. `value` encodes "<endpoint>::<model>".
export const QUICK_EDIT_AGENT_OPTIONS = [
{ value: 'http://100.69.120.65:8097/v1::Qwen/Qwen3.5-9B', label: 'Qwen3.5-9B VL · local' },
{ value: 'default', label: 'Server default (hosted planner)' },
];
// can see which model Quick Edit / inpaint will call.
export function updateFooterEditModel() {
const el = document.getElementById('ge-status-model');
if (!el) return;
const info = resolveQuickEditModel();
if (!info.label && !info.model) {
el.textContent = 'Edit: none';
el.title = 'No AI edit model available';
return;
}
const label = info.label || info.model;
el.textContent = `Edit: ${label}`;
el.title = `AI edit model: ${label}`;
}
// Heuristic classifier on a model id + endpoint name. A model can be:
// - gen: text-to-image generation
// - inpaint: image+mask edit (inpaint / img2img)
@@ -92,11 +163,40 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
const aiGenSelect = document.getElementById('ge-ai-model');
const aiInpaintSelect = document.getElementById('ge-ai-inpaint');
const aiCommandSelect = document.getElementById('ge-ai-command-model');
const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model');
if (aiInpaintSelect) aiInpaintSelect.addEventListener('change', () => updateFooterEditModel());
if (aiCommandSelect) aiCommandSelect.addEventListener('change', () => updateFooterEditModel());
// The Quick Edit agent (planner) picker is static for now (local VL default,
// server default option); restore/save its choice from localStorage.
const populateAgentSelect = () => {
if (!aiCommandAgentSelect) return;
aiCommandAgentSelect.innerHTML = '';
for (const opt of QUICK_EDIT_AGENT_OPTIONS) {
const el = document.createElement('option');
el.value = opt.value;
el.textContent = opt.label;
aiCommandAgentSelect.appendChild(el);
}
try {
const saved = localStorage.getItem('ge-ai-command-agent-model');
if (saved && [...aiCommandAgentSelect.options].some(o => o.value === saved)) {
aiCommandAgentSelect.value = saved;
}
} catch {}
if (!aiCommandAgentSelect._agentWired) {
aiCommandAgentSelect._agentWired = true;
aiCommandAgentSelect.addEventListener('change', () => {
try { localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value); } catch {}
});
}
};
// 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 &&
if (!aiGenSelect && !aiInpaintSelect && !aiCommandSelect && !aiCommandAgentSelect &&
!document.querySelector('select.ge-tool-model')) return;
populateAgentSelect();
async function loadAIModels(opts = {}) {
try {
@@ -108,11 +208,13 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
const endpoints = await res.json();
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</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 selectedGen = null;
let selectedInpaint = null;
const loaded = [];
for (const ep of endpoints) {
if (!ep.is_enabled) continue;
const hasListedModels = Array.isArray(ep.models) && ep.models.length;
@@ -130,6 +232,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
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)'}`;
loaded.push({ value, label, gen: caps.gen, inpaint: caps.inpaint, usable: epUsable, endpoint: ep.base_url, model: modelId });
if (caps.gen && aiGenSelect) {
const opt = document.createElement('option');
opt.value = value;
@@ -158,6 +261,13 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
opt.disabled = !epUsable;
ts.appendChild(opt);
}
if (aiCommandSelect) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
aiCommandSelect.appendChild(opt);
}
}
}
}
@@ -173,6 +283,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
const auto = resolveInpaintModel({ options: aiInpaintSelect.options, value: '' });
aiInpaintSelect.options[0].textContent = auto ? `Auto (${auto.textContent})` : 'Auto (no available model)';
}
updateFooterEditModel();
// Append the "Serve a model in Cookbook…" sentinel at the
// bottom of every model dropdown.
const appendServeSentinel = (sel) => {
@@ -188,6 +299,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
for (const ts of perToolSelects) appendServeSentinel(ts);
if (aiGenSelect) appendServeSentinel(aiGenSelect);
if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect);
if (aiCommandSelect) appendServeSentinel(aiCommandSelect);
// Wire the sentinel on the Gen + Inpaint selects too.
const wireServeSentinel = (sel) => {
if (!sel) return;
@@ -203,6 +315,18 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
};
wireServeSentinel(aiGenSelect);
wireServeSentinel(aiInpaintSelect);
wireServeSentinel(aiCommandSelect);
if (aiCommandSelect) {
try {
const saved = localStorage.getItem('ge-ai-command-model');
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) aiCommandSelect.value = saved;
} catch {}
aiCommandSelect.addEventListener('change', () => {
if (aiCommandSelect.value === '__serve_cookbook__') return;
try { localStorage.setItem('ge-ai-command-model', aiCommandSelect.value); } catch {}
updateFooterEditModel();
});
}
// Restore each per-tool selection from localStorage.
for (const ts of perToolSelects) {
const key = 'ge-tool-model-' + ts.dataset.geToolModel;
@@ -223,6 +347,8 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
try { localStorage.setItem(key, ts.value); } catch {}
});
}
_aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() };
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
} catch (e) {
// Fetch failed — still give the user the affordance to set up
// a model. Otherwise the dropdown shows only "Auto" with no
@@ -230,6 +356,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
const fallback = '<option value="">Auto</option><option disabled>Could not load image models</option><option value="__serve_cookbook__">+ Serve a model in Cookbook…</option>';
if (aiGenSelect) aiGenSelect.innerHTML = fallback;
if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback;
if (aiCommandSelect) aiCommandSelect.innerHTML = fallback;
document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; });
const wireServe = (sel) => {
if (!sel) return;
@@ -245,7 +372,12 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
};
wireServe(aiGenSelect);
wireServe(aiInpaintSelect);
wireServe(aiCommandSelect);
document.querySelectorAll('select.ge-tool-model').forEach(wireServe);
populateAgentSelect();
updateFooterEditModel();
_aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() };
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
}
}
loadAIModels();
+85
View File
@@ -0,0 +1,85 @@
/**
* AI settings dialog.
*
* Holds the image-edit model, the AI agent (vision) model, and the opt-in
* "Double-check AI edits" toggle. Opened from the Quick Edit gear button and
* from the editor setup checklist; keeps the Quick Edit box itself uncluttered.
*/
export function createAiSettings({ container }) {
const el = document.createElement('div');
el.className = 'ge-ai-settings';
el.hidden = true;
el.setAttribute('role', 'dialog');
el.setAttribute('aria-modal', 'true');
el.setAttribute('aria-label', 'AI settings');
el.innerHTML = `
<div class="ge-ai-settings-card">
<div class="ge-ai-settings-head">
<h3>AI settings</h3>
<button type="button" class="ge-ai-settings-close" data-ai-settings-close aria-label="Close">×</button>
</div>
<div class="ge-ai-settings-body">
<label class="ge-ai-settings-label" for="ge-ai-command-model">Image edit model</label>
<select class="ge-ai-command-model" id="ge-ai-command-model" title="Image edit model" aria-label="Image edit model"></select>
<label class="ge-ai-settings-label" for="ge-ai-command-agent-model">AI agent (vision) model</label>
<select class="ge-ai-command-model" id="ge-ai-command-agent-model" title="Quick Edit agent model" aria-label="Quick Edit agent model"></select>
<p class="ge-ai-settings-note">Recommended, not required — the vision agent plans edits and can double-check results.</p>
<label class="ge-ai-settings-label" for="ge-look-strength">Look strength</label>
<div class="ge-ai-settings-strength">
<input type="range" id="ge-look-strength" min="0.5" max="2" step="0.1" value="1.4" aria-label="Look grade strength" />
<span class="ge-ai-settings-strength-val" id="ge-look-strength-val">1.4×</span>
</div>
<p class="ge-ai-settings-note">How strongly named looks (Kodachrome, teal and orange, …) are graded.</p>
<label class="ge-ai-settings-opt" title="Ask the vision model to review the result and retry on a clear failure">
<input type="checkbox" id="ge-ai-command-selfcheck" /> Double-check AI edits
</label>
</div>
<div class="ge-ai-settings-foot">
<button type="button" class="ge-ai-settings-done" data-ai-settings-close>Done</button>
</div>
</div>`;
function open(focusId) {
el.hidden = false;
if (focusId) {
const target = document.getElementById(focusId);
if (target) {
try { target.focus({ preventScroll: true }); } catch (_) { target.focus(); }
}
}
}
function close() { el.hidden = true; }
el.addEventListener('click', (e) => {
if (e.target === el || e.target.closest('[data-ai-settings-close]')) {
e.preventDefault();
close();
}
});
const onKey = (e) => { if (e.key === 'Escape' && !el.hidden) close(); };
document.addEventListener('keydown', onKey);
container.appendChild(el);
const strengthInput = el.querySelector('#ge-look-strength');
const strengthVal = el.querySelector('#ge-look-strength-val');
if (strengthInput) {
try {
const saved = parseFloat(localStorage.getItem('ge-look-strength'));
if (Number.isFinite(saved) && saved > 0) strengthInput.value = String(saved);
} catch (_) {}
if (strengthVal) strengthVal.textContent = Number(strengthInput.value).toFixed(1) + '×';
strengthInput.addEventListener('input', () => {
const v = Number(strengthInput.value);
if (strengthVal) strengthVal.textContent = v.toFixed(1) + '×';
try { localStorage.setItem('ge-look-strength', String(v)); } catch (_) {}
});
}
function destroy() {
document.removeEventListener('keydown', onKey);
el.remove();
}
return { element: el, open, close, destroy };
}
+28 -10
View File
@@ -220,16 +220,11 @@ export function wireAIToolsMisc({
renderLayerPanel();
composite();
}
const addButton = document.getElementById('ge-add-layer');
addButton?.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
const buildFxMenu = (anchor) => {
document.querySelector('.ge-add-layer-menu')?.remove();
const menu = document.createElement('div');
menu.className = 'ge-add-layer-menu ge-frosted';
menu.innerHTML = `
<button type="button" data-layer-kind="raster"><span class="ge-add-layer-symbol">+</span><span>Pixel Layer</span></button>
<span class="ge-add-layer-divider"></span>
<button type="button" data-adjustment-type="levels"><span class="ge-add-layer-symbol">▥</span><span>Levels</span></button>
<button type="button" data-adjustment-type="curves"><span class="ge-add-layer-symbol">⌁</span><span>Curves</span></button>
<button type="button" data-adjustment-type="exposure"><span class="ge-add-layer-symbol">☼</span><span>Exposure</span></button>
@@ -242,9 +237,16 @@ export function wireAIToolsMisc({
<button type="button" data-adjustment-type="color-balance"><span class="ge-add-layer-symbol">◒</span><span>Color Balance</span></button>
<button type="button" data-adjustment-type="selective-color"><span class="ge-add-layer-symbol">◎</span><span>Selective Color</span></button>
<button type="button" data-adjustment-type="gradient-map"><span class="ge-add-layer-symbol">▰</span><span>Gradient Map</span></button>
<span class="ge-add-layer-divider"></span>
<button type="button" data-adjustment-type="invert"><span class="ge-add-layer-symbol">◑</span><span>Invert</span></button>
<button type="button" data-adjustment-type="posterize"><span class="ge-add-layer-symbol">▤</span><span>Posterize</span></button>
<button type="button" data-adjustment-type="threshold"><span class="ge-add-layer-symbol">◧</span><span>Threshold</span></button>
<button type="button" data-adjustment-type="photo-filter"><span class="ge-add-layer-symbol">◍</span><span>Photo Filter</span></button>
<button type="button" data-adjustment-type="channel-mixer"><span class="ge-add-layer-symbol">⇄</span><span>Channel Mixer</span></button>
<button type="button" data-adjustment-type="color-lookup"><span class="ge-add-layer-symbol">▦</span><span>Color Lookup</span></button>
`;
document.body.appendChild(menu);
const rect = addButton.getBoundingClientRect();
const rect = anchor.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
menu.style.left = `${Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, rect.right - menuRect.width))}px`;
menu.style.top = `${Math.max(8, Math.min(window.innerHeight - menuRect.height - 8, rect.bottom + 5))}px`;
@@ -253,7 +255,7 @@ export function wireAIToolsMisc({
document.removeEventListener('pointerdown', away, true);
};
const away = pointerEvent => {
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== addButton) close();
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== anchor) close();
};
requestAnimationFrame(() => document.addEventListener('pointerdown', away, true));
menu.addEventListener('click', clickEvent => {
@@ -261,10 +263,26 @@ export function wireAIToolsMisc({
if (!button) return;
clickEvent.preventDefault();
clickEvent.stopPropagation();
if (button.dataset.layerKind === 'raster') addEmptyLayer();
else if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, addButton);
if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, anchor);
close();
});
};
// "+" adds a plain pixel layer directly.
const addButton = document.getElementById('ge-add-layer');
addButton?.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
document.querySelector('.ge-add-layer-menu')?.remove();
addEmptyLayer();
});
// "fx" opens the adjustment-layer chooser.
const addFxButton = document.getElementById('ge-add-fx');
addFxButton?.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
buildFxMenu(addFxButton);
});
return { addEmptyLayer };
+35
View File
@@ -0,0 +1,35 @@
// Pure aspect-fitting geometry for the editor. When a whole-image resize is
// asked for a target aspect that differs from the current image, content must
// be cropped to that aspect (centred on a focus point) and then scaled
// UNIFORMLY — never stretched.
//
// aspectFitCropRect returns the document-space rectangle to crop FIRST so a
// subsequent uniform scale-to-{targetW,targetH} preserves the subject aspect
// ratio. Returns the full image unchanged when the aspect already matches.
export function aspectFitCropRect({ width, height, focusX, focusY, targetAspect, tolerance = 0.005 } = {}) {
const w = Number(width);
const h = Number(height);
const a = Number(targetAspect);
if (!(w > 0) || !(h > 0) || !(a > 0)) return null;
const curAspect = w / h;
if (Math.abs(curAspect - a) / a <= tolerance) return { x: 0, y: 0, width: Math.round(w), height: Math.round(h) };
const fx = Number.isFinite(Number(focusX)) ? Number(focusX) : w / 2;
const fy = Number.isFinite(Number(focusY)) ? Number(focusY) : h / 2;
let cw;
let ch;
if (curAspect > a) {
ch = h;
cw = Math.max(1, Math.round(ch * a));
} else {
cw = w;
ch = Math.max(1, Math.round(cw / a));
}
let x = Math.round(fx - cw / 2);
let y = Math.round(fy - ch / 2);
x = Math.max(0, Math.min(w - cw, x));
y = Math.max(0, Math.min(h - ch, y));
return { x, y, width: cw, height: ch };
}
export default { aspectFitCropRect };
+35
View File
@@ -0,0 +1,35 @@
/**
* Batch runner — a small, dependency-free orchestrator that applies one action
* to every item in a list (open documents), isolating each item so a failure on
* one never aborts the batch. Pure async control flow: the caller supplies
* `runOne`, so the same loop is unit-testable without an editor.
*/
/**
* @param {object} options
* @param {Array<{id:string,name?:string}>} options.documents
* @param {(doc:object, index:number)=>Promise<any>} options.runOne
* @param {number} [options.delayMs]
* @param {(done:number,total:number,doc:object)=>void} [options.onProgress]
* @returns {Promise<Array<{id,name,ok,output?,error?}>>}
*/
export async function runBatchOverDocuments({ documents, runOne, delayMs = 0, onProgress = null } = {}) {
const list = Array.isArray(documents) ? documents.filter(Boolean) : [];
const results = [];
for (let index = 0; index < list.length; index += 1) {
const doc = list[index];
try {
const output = await runOne(doc, index);
results.push({ id: doc.id, name: doc.name, ok: true, output: output === undefined ? null : output });
} catch (error) {
results.push({ id: doc.id, name: doc.name, ok: false, error: String((error && error.message) || error) });
}
if (typeof onProgress === 'function') {
try { onProgress(index + 1, list.length, doc); } catch { /* progress is best-effort */ }
}
if (delayMs > 0) await new Promise(resolve => setTimeout(resolve, delayMs));
}
return results;
}
export default { runBatchOverDocuments };
+256
View File
@@ -0,0 +1,256 @@
/**
* Layer/graphic blend modes.
*
* Canvas2D can only composite a subset of the Photoshop blend modes natively.
* This module exposes that native set plus the "extra" modes PhotoCraft offers
* (Dissolve, Linear Burn, Darker Color, Linear Dodge/Add, Lighter Color,
* Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide) and a
* single `blendDraw` entry point that both paths go through.
*
* The non-native modes follow the W3C compositing spec (separable + the two
* non-separable luminance modes) and operate on ImageData in the overlap of
* the source and the destination so rendering stays reasonably cheap.
*/
export const NATIVE_BLEND_MODES = new Set([
'source-over',
'multiply',
'screen',
'overlay',
'darken',
'lighten',
'color-dodge',
'color-burn',
'hard-light',
'soft-light',
'difference',
'exclusion',
'hue',
'saturation',
'color',
'luminosity',
]);
const NATIVE_ALIASES = {
'linear-dodge': 'lighter',
add: 'lighter',
};
export const EXTRA_BLEND_MODES = [
{ value: 'linear-dodge', label: 'Linear Dodge (Add)' },
{ value: 'linear-burn', label: 'Linear Burn' },
{ value: 'darker-color', label: 'Darker Color' },
{ value: 'lighter-color', label: 'Lighter Color' },
{ value: 'vivid-light', label: 'Vivid Light' },
{ value: 'linear-light', label: 'Linear Light' },
{ value: 'pin-light', label: 'Pin Light' },
{ value: 'hard-mix', label: 'Hard Mix' },
{ value: 'subtract', label: 'Subtract' },
{ value: 'divide', label: 'Divide' },
{ value: 'dissolve', label: 'Dissolve' },
];
const NON_SEPARABLE = new Set(['darker-color', 'lighter-color']);
export function isNativeBlendMode(mode) {
if (!mode) return true;
return NATIVE_BLEND_MODES.has(mode) || Object.prototype.hasOwnProperty.call(NATIVE_ALIASES, mode);
}
export function isKnownBlendMode(mode) {
if (!mode) return true;
return isNativeBlendMode(mode) || EXTRA_BLEND_MODES.some(entry => entry.value === mode);
}
function clamp01(value) {
return value < 0 ? 0 : value > 1 ? 1 : value;
}
function lum(r, g, b) {
return 0.299 * r + 0.587 * g + 0.114 * b;
}
// Separable per-channel blend functions on normalized [0,1] values.
function blendChannel(mode, cb, cs) {
switch (mode) {
case 'normal':
return cs;
case 'multiply':
return cb * cs;
case 'screen':
return cb + cs - cb * cs;
case 'overlay':
return cb <= 0.5 ? 2 * cb * cs : 1 - 2 * (1 - cb) * (1 - cs);
case 'darken':
return Math.min(cb, cs);
case 'lighten':
return Math.max(cb, cs);
case 'color-dodge':
if (cb === 0) return 0;
if (cs === 1) return 1;
return Math.min(1, cb / (1 - cs));
case 'color-burn':
if (cb === 1) return 1;
if (cs === 0) return 0;
return 1 - Math.min(1, (1 - cb) / cs);
case 'hard-light':
return cs <= 0.5 ? 2 * cs * cb : 1 - 2 * (1 - cs) * (1 - cb);
case 'soft-light': {
if (cs <= 0.5) return cb - (1 - 2 * cs) * cb * (1 - cb);
const d = cb <= 0.25 ? ((16 * cb - 12) * cb + 4) * cb : Math.sqrt(cb);
return cb + (2 * cs - 1) * (d - cb);
}
case 'difference':
return Math.abs(cb - cs);
case 'exclusion':
return cb + cs - 2 * cb * cs;
case 'linear-burn':
return clamp01(cb + cs - 1);
case 'linear-dodge':
return clamp01(cb + cs);
case 'vivid-light':
return cs <= 0.5
? (cs === 0 ? 0 : 1 - Math.min(1, (1 - cb) / (2 * cs)))
: (cs === 1 ? 1 : Math.min(1, cb / (2 * (1 - cs))));
case 'linear-light':
return clamp01(cb + 2 * cs - 1);
case 'pin-light':
return cs <= 0.5 ? Math.min(cb, 2 * cs) : Math.max(cb, 2 * cs - 1);
case 'hard-mix': {
const vivid = cs <= 0.5
? (cs === 0 ? 0 : 1 - Math.min(1, (1 - cb) / (2 * cs)))
: (cs === 1 ? 1 : Math.min(1, cb / (2 * (1 - cs))));
return vivid < 0.5 ? 0 : 1;
}
case 'subtract':
return clamp01(cb - cs);
case 'divide':
return cs === 0 ? 1 : clamp01(cb / cs);
default:
return cs;
}
}
function dissolveRandom(i) {
let seed = (i * 2654435761) >>> 0;
seed ^= seed >>> 13;
seed = (seed * 1274126177) >>> 0;
return seed / 4294967296;
}
function ensureSourceData(source) {
const sw = source.width || source.naturalWidth || 0;
const sh = source.height || source.naturalHeight || 0;
if (!sw || !sh) return null;
const scratch = document.createElement('canvas');
scratch.width = sw;
scratch.height = sh;
const sctx = scratch.getContext('2d');
sctx.drawImage(source, 0, 0, sw, sh);
return { data: sctx.getImageData(0, 0, sw, sh).data, sw, sh };
}
function blendCustom(ctx, source, mode, alpha, dx, dy) {
if (alpha <= 0) return;
const src = ensureSourceData(source);
if (!src) return;
const tw = ctx.canvas.width;
const th = ctx.canvas.height;
const sw = src.sw;
const sh = src.sh;
const sx0 = Math.max(0, -dx);
const sy0 = Math.max(0, -dy);
const dx0 = Math.max(0, dx);
const dy0 = Math.max(0, dy);
const w = Math.min(sw - sx0, tw - dx0);
const h = Math.min(sh - sy0, th - dy0);
if (w <= 0 || h <= 0) return;
const dst = ctx.getImageData(dx0, dy0, w, h);
const dd = dst.data;
const sd = src.data;
const nonSeparable = NON_SEPARABLE.has(mode);
const dissolve = mode === 'dissolve';
for (let y = 0; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
const si = ((y + sy0) * sw + (x + sx0)) * 4;
const di = (y * w + x) * 4;
const sa = (sd[si + 3] / 255) * alpha;
const ba = dd[di + 3] / 255;
if (dissolve) {
const seed = (y + dy0) * 73856093 ^ (x + dx0) * 19349663;
if (dissolveRandom(seed) >= sa) continue;
dd[di] = sd[si];
dd[di + 1] = sd[si + 1];
dd[di + 2] = sd[si + 2];
dd[di + 3] = 255;
continue;
}
if (sa === 0) continue;
const sr = sd[si] / 255;
const sg = sd[si + 1] / 255;
const sb = sd[si + 2] / 255;
const br = dd[di] / 255;
const bg = dd[di + 1] / 255;
const bb = dd[di + 2] / 255;
let mergedR;
let mergedG;
let mergedB;
if (nonSeparable) {
const backdropLum = lum(br, bg, bb);
const sourceLum = lum(sr, sg, sb);
const useSource = mode === 'darker-color' ? sourceLum < backdropLum : sourceLum > backdropLum;
mergedR = useSource ? sr : br;
mergedG = useSource ? sg : bg;
mergedB = useSource ? sb : bb;
} else {
mergedR = blendChannel(mode, br, sr);
mergedG = blendChannel(mode, bg, sg);
mergedB = blendChannel(mode, bb, sb);
}
const srEff = (1 - ba) * sr + ba * mergedR;
const sgEff = (1 - ba) * sg + ba * mergedG;
const sbEff = (1 - ba) * sb + ba * mergedB;
const ao = sa + ba * (1 - sa);
if (ao <= 0) continue;
const cr = (sa * srEff + ba * br * (1 - sa)) / ao;
const cg = (sa * sgEff + ba * bg * (1 - sa)) / ao;
const cbOut = (sa * sbEff + ba * bb * (1 - sa)) / ao;
dd[di] = Math.round(clamp01(cr) * 255);
dd[di + 1] = Math.round(clamp01(cg) * 255);
dd[di + 2] = Math.round(clamp01(cbOut) * 255);
dd[di + 3] = Math.round(ao * 255);
}
}
ctx.putImageData(dst, dx0, dy0);
}
/**
* Composite `source` onto a 2D context (or canvas) using `mode` at `alpha`.
* Native modes use globalCompositeOperation; the rest fall back to pixel math.
*/
export function blendDraw(target, source, mode, alpha = 1, dx = 0, dy = 0) {
if (!target || !source) return;
const ctx = typeof target.getContext === 'function' ? target.getContext('2d') : target;
const a = Number.isFinite(alpha) ? clamp01(alpha) : 1;
const normalized = mode || 'source-over';
if (isNativeBlendMode(normalized)) {
const prevAlpha = ctx.globalAlpha;
const prevOp = ctx.globalCompositeOperation;
ctx.globalAlpha = a;
ctx.globalCompositeOperation = NATIVE_ALIASES[normalized] || normalized;
ctx.drawImage(source, dx, dy);
ctx.globalAlpha = prevAlpha;
ctx.globalCompositeOperation = prevOp;
return;
}
blendCustom(ctx, source, normalized, a, dx, dy);
}
+30 -20
View File
@@ -24,6 +24,32 @@ export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit
emit(emitted);
};
// Emit evenly spaced dabs from the last emitted sample toward `target`,
// carrying the sub-spacing remainder forward. Shared by drag updates and
// the final pointer-up so a stroke ends on the same spacing grid instead
// of stamping one detached dab (which reads as a dot where drawing stopped).
const emitSpaced = (target) => {
if (!emitted) return;
const start = { ...emitted };
const dx = target.x - start.x;
const dy = target.y - start.y;
const distance = Math.hypot(dx, dy);
if (!distance) return;
const diameter = Math.max(1, Number(getDiameter()) || 1);
const spacing = Math.max(0.5, diameter * Math.max(0.01, Number(getSpacing()) || 0.01));
let travelled = spacing - remainder;
while (travelled <= distance) {
const t = travelled / distance;
output({
x: start.x + dx * t,
y: start.y + dy * t,
pressure: start.pressure + (target.pressure - start.pressure) * t,
});
travelled += spacing;
}
remainder = Math.max(0, distance - (travelled - spacing));
};
const begin = (sample) => {
emitted = null;
filtered = { ...sample, pressure: normalisePressure(sample.pressure) };
@@ -41,30 +67,14 @@ export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit
y: filtered.y + (raw.y - filtered.y) * response,
pressure: filtered.pressure + (raw.pressure - filtered.pressure) * response,
};
const start = { ...emitted };
const dx = filtered.x - start.x;
const dy = filtered.y - start.y;
const distance = Math.hypot(dx, dy);
if (!distance) return;
const diameter = Math.max(1, Number(getDiameter()) || 1);
const spacing = Math.max(0.5, diameter * Math.max(0.01, Number(getSpacing()) || 0.01));
let travelled = spacing - remainder;
while (travelled <= distance) {
const t = travelled / distance;
output({
x: start.x + dx * t,
y: start.y + dy * t,
pressure: start.pressure + (filtered.pressure - start.pressure) * t,
});
travelled += spacing;
}
remainder = Math.max(0, distance - (travelled - spacing));
emitSpaced(filtered);
};
const end = (sample) => {
// Reach the true pointer-up point along the spacing grid rather than with
// a single far dab, so the stroke does not terminate in a detached dot.
if (sample && emitted) {
const finalSample = { ...sample, pressure: normalisePressure(sample.pressure) };
if (Math.hypot(finalSample.x - emitted.x, finalSample.y - emitted.y) > 0.25) output(finalSample);
emitSpaced({ ...sample, pressure: normalisePressure(sample.pressure) });
}
emitted = null;
filtered = null;
+4
View File
@@ -4,6 +4,7 @@ const BUILT_INS = [
{ id: 'soft-round', name: 'Soft Round', size: 80, opacity: 100, flow: 35, softness: 100, spacing: 12, smoothing: 25, blendMode: 'source-over' },
{ id: 'hard-round', name: 'Hard Round', size: 24, opacity: 100, flow: 100, softness: 0, spacing: 18, smoothing: 15, blendMode: 'source-over' },
{ id: 'detail', name: 'Detail', size: 6, opacity: 100, flow: 70, softness: 20, spacing: 10, smoothing: 55, blendMode: 'source-over' },
{ id: 'pixel-1px', name: 'Pixel', size: 1, opacity: 100, flow: 100, softness: 0, spacing: 1, smoothing: 0, blendMode: 'source-over', pixel: true },
];
export function loadBrushPresets(storage = globalThis.localStorage) {
@@ -31,6 +32,7 @@ export function captureBrushPreset(state, name) {
spacing: state.brushSpacing,
smoothing: state.brushSmoothing,
blendMode: state.brushBlendMode,
pixel: !!state.brushPixel,
};
}
@@ -43,4 +45,6 @@ export function applyBrushPreset(state, preset) {
state.brushSpacing = preset.spacing;
state.brushSmoothing = preset.smoothing;
state.brushBlendMode = preset.blendMode || 'source-over';
state.brushPixel = !!preset.pixel;
}
+80
View File
@@ -0,0 +1,80 @@
/** Command palette (Ctrl/Cmd+K): fuzzy list of tools, panels and actions. */
export function createCommandPalette({ commands = [], onRun = null } = {}) {
const overlay = document.createElement('div');
overlay.className = 'ge-command-palette';
overlay.hidden = true;
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Command palette');
overlay.innerHTML = `
<div class="ge-command-panel">
<input class="ge-command-input" type="text" placeholder="Type a command..." aria-label="Command" autocomplete="off" />
<div class="ge-command-list" role="listbox"></div>
</div>`;
document.body.appendChild(overlay);
const input = overlay.querySelector('.ge-command-input');
const list = overlay.querySelector('.ge-command-list');
let filtered = [];
let activeIndex = 0;
function render() {
const query = input.value.trim().toLowerCase();
filtered = commands.filter(cmd => !query || String(cmd.label).toLowerCase().includes(query));
list.innerHTML = '';
if (!filtered.length) {
const empty = document.createElement('p');
empty.className = 'ge-command-empty';
empty.textContent = 'No matching commands';
list.appendChild(empty);
return;
}
if (activeIndex >= filtered.length) activeIndex = 0;
filtered.forEach((cmd, index) => {
const row = document.createElement('button');
row.type = 'button';
row.className = `ge-command-item${index === activeIndex ? ' active' : ''}`;
row.setAttribute('role', 'option');
row.setAttribute('aria-selected', index === activeIndex ? 'true' : 'false');
row.textContent = cmd.label;
row.addEventListener('click', () => run(cmd));
list.appendChild(row);
});
}
function run(cmd) {
close();
if (typeof cmd.run === 'function') cmd.run();
else if (typeof onRun === 'function') onRun(cmd);
}
function open() {
overlay.hidden = false;
input.value = '';
activeIndex = 0;
render();
input.focus();
}
function close() {
overlay.hidden = true;
}
input.addEventListener('input', () => { activeIndex = 0; render(); });
input.addEventListener('keydown', (event) => {
if (event.key === 'Escape') { event.preventDefault(); close(); }
else if (event.key === 'ArrowDown') { event.preventDefault(); activeIndex = Math.min(activeIndex + 1, filtered.length - 1); render(); }
else if (event.key === 'ArrowUp') { event.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); render(); }
else if (event.key === 'Enter') { event.preventDefault(); if (filtered[activeIndex]) run(filtered[activeIndex]); }
});
overlay.addEventListener('pointerdown', (event) => { if (event.target === overlay) close(); });
return {
element: overlay,
open,
close,
toggle: () => (overlay.hidden ? open() : close()),
isOpen: () => !overlay.hidden,
};
}
+150 -29
View File
@@ -6,10 +6,10 @@
* 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
* @param {{ color: string, brushSize: number, wandTolerance: number, bucketTolerance: number, cropAspectPreset: string, eyedropperSampleSize: number }} ctx
* @returns {string}
*/
export function controlsHTML({ color, brushSize, wandTolerance }) {
export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance = 32, cropAspectPreset = 'free', eyedropperSampleSize = 1 }) {
const brushSliderValue = Math.round(Math.log(Math.max(1, brushSize)) / Math.log(800) * 1000);
return `
<div id="ge-pen-section" style="display:none;">
@@ -96,19 +96,39 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
</span>
</div>
<div class="ge-control-row"><label for="ge-eyedropper-sample">Sample</label><select id="ge-eyedropper-sample"><option value="composite">All layers</option><option value="layer">Active layer</option></select></div>
<div class="ge-control-row"><label for="ge-eyedropper-sample-size">Sample Size</label><select id="ge-eyedropper-sample-size" title="Point samples one pixel; the averages blur the reading over an N-by-N block.">${[1, 3, 5, 11, 31, 51].map((n) => `<option value="${n}"${Number(eyedropperSampleSize) === n ? ' selected' : ''}>${n === 1 ? 'Point Sample' : `${n} by ${n} Average`}</option>`).join('')}</select></div>
</div>
<div class="ge-bucket-section" id="ge-bucket-section" style="display:none;">
<div class="ge-section-title">Paint Bucket</div>
<div class="ge-control-row ge-eraser-row">
<label for="ge-bucket-tolerance">Tolerance <span id="ge-bucket-tolerance-label">${bucketTolerance}</span></label>
<input type="range" id="ge-bucket-tolerance" min="0" max="255" value="${bucketTolerance}" title="How different a neighbouring pixel can be and still be filled (0-255)." />
</div>
</div>
<div class="ge-crop-section" id="ge-crop-section" style="display:none;">
<div class="ge-section-title">Crop</div>
<div class="ge-control-row ge-crop-preset-row" id="ge-crop-presets" role="group" aria-label="Crop aspect ratio">
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === 'free' ? ' active' : ''}" data-crop-preset="free" title="Crop freely (no aspect lock)">Free</button>
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === 'original' ? ' active' : ''}" data-crop-preset="original" title="Keep the original image aspect ratio">Original</button>
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === '1:1' ? ' active' : ''}" data-crop-preset="1:1" title="Square (1:1)">1:1</button>
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === '4:5' ? ' active' : ''}" data-crop-preset="4:5" title="Portrait (4:5)">4:5</button>
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === '16:9' ? ' active' : ''}" data-crop-preset="16:9" title="Widescreen (16:9)">16:9</button>
</div>
<p class="ge-section-hint">Pick a ratio, then drag on the canvas. Press Enter to apply.</p>
</div>
<div class="ge-text-section" id="ge-text-section" style="display:none;">
<textarea id="ge-text-content" rows="3" placeholder="Type text..." aria-label="Text content"></textarea>
<div class="ge-text-grid">
<label class="ge-text-field ge-text-font-field"><span>Font</span>
<select id="ge-text-font">
<option value="Arial">Arial</option>
<option value="Verdana">Verdana</option>
<option value="Inter">Inter</option>
<option value="Roboto">Roboto</option>
<option value="Playfair Display">Playfair Display</option>
<option value="Lobster">Lobster</option>
<option value="Bebas Neue">Bebas Neue</option>
<option value="Roboto Mono">Roboto Mono</option>
<option value="Georgia">Georgia</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Courier New">Courier New</option>
<option value="Impact">Impact</option>
<option value="system-ui">System</option>
</select>
</label>
<label class="ge-text-field ge-text-size-field"><span>Size</span>
@@ -150,6 +170,36 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<input type="color" class="ge-color-picker" id="ge-text-stroke-color" value="#000000" title="Stroke color" aria-label="Stroke color" />
</div>
<label class="ge-text-auto-width-option"><span>Auto width</span><span class="toggle-switch"><input type="checkbox" id="ge-text-auto-width" /><span class="toggle-slider"></span></span></label>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Writing</span>
<select id="ge-text-direction"><option value="horizontal">Horizontal</option><option value="vertical">Vertical</option></select>
</label>
<label class="ge-text-field"><span>Warp</span>
<select id="ge-text-warp">
<option value="none">None</option>
<option value="arc">Arc</option>
<option value="arch">Arch</option>
<option value="bulge">Bulge</option>
<option value="flag">Flag</option>
<option value="wave">Wave</option>
<option value="rise">Rise</option>
<option value="fisheye">Fisheye</option>
<option value="inflate">Inflate</option>
<option value="squeeze">Squeeze</option>
<option value="twist">Twist</option>
</select>
</label>
</div>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Warp amt</span>
<input id="ge-text-warp-amount" type="range" min="-100" max="100" step="1" value="0" />
</label>
</div>
<div class="ge-text-grid ge-text-opentype-row">
<label class="ge-text-field ge-text-inline-check"><span>Small caps</span><input type="checkbox" id="ge-text-small-caps" /></label>
<label class="ge-text-field ge-text-inline-check"><span>Kerning</span><input type="checkbox" id="ge-text-kerning" checked /></label>
<label class="ge-text-field ge-text-inline-check"><span>Ligatures</span><input type="checkbox" id="ge-text-ligatures" checked /></label>
</div>
<button type="button" class="ge-btn ge-btn-sm" id="ge-text-rasterize">Rasterize</button>
</div>
<div class="ge-shape-section" id="ge-shape-section" style="display:none;">
@@ -186,6 +236,22 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<label class="ge-text-field ge-shape-sides-field" hidden><span>Sides</span><input id="ge-shape-sides" type="number" min="3" max="24" step="1" value="5" /></label>
<button type="button" class="ge-btn ge-btn-sm" id="ge-shape-rasterize">Rasterize</button>
</div>
<div class="ge-path-section" id="ge-path-section" style="display:none;">
<div class="ge-section-title">Path</div>
<p class="ge-section-hint">Click to add anchors; double-click or press Enter to finish.</p>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Fill</span><input type="color" class="ge-color-picker" id="ge-path-fill" value="${color}" /></label>
<label class="ge-text-field"><span>Stroke</span><input type="color" class="ge-color-picker" id="ge-path-stroke" value="#111111" /></label>
</div>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Width</span><input id="ge-path-stroke-width" type="number" min="0" max="500" step="1" value="2" /></label>
<label class="ge-text-field ge-path-closed-field"><span>Close path</span><input id="ge-path-closed" type="checkbox" checked /></label>
</div>
<div class="ge-control-row" style="display:flex;gap:4px;">
<button type="button" class="ge-btn ge-btn-sm" id="ge-path-finish">Finish</button>
<button type="button" class="ge-btn ge-btn-sm" id="ge-path-cancel">Cancel</button>
</div>
</div>
<div class="ge-marquee-section" id="ge-marquee-section" style="display:none;">
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;">
<button type="button" class="ge-btn ge-btn-sm ge-marquee-shape-btn active" data-marquee-shape="rectangle" title="Rectangular marquee" aria-pressed="true">
@@ -244,6 +310,17 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<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" style="margin-top:4px;">
<button type="button" class="ge-btn ge-btn-sm" id="ge-lasso-magnetic" aria-pressed="false" title="Snap the outline to image edges as you drag.">Magnetic</button>
</div>
<div class="ge-control-row ge-eraser-row" id="ge-lasso-mag-width-row" style="display:none;">
<label>Width <span id="ge-lasso-mag-width-label">12px</span></label>
<input type="range" id="ge-lasso-mag-width" min="2" max="60" value="12" title="Search radius for the strongest nearby edge." />
</div>
<div class="ge-control-row ge-eraser-row" id="ge-lasso-mag-contrast-row" style="display:none;">
<label>Contrast <span id="ge-lasso-mag-contrast-label">20%</span></label>
<input type="range" id="ge-lasso-mag-contrast" min="0" max="100" value="20" title="Minimum edge strength to snap to." />
</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+Shift+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>
@@ -347,6 +424,31 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
</div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click an object, or type a neutral object label. Shift adds, Alt subtracts.</p>
</div>
<div class="ge-quickselect-section" id="ge-quickselect-section" style="display:none;">
<div class="ge-section-title ge-section-title-with-help"><span>QUICK SELECT</span><span class="ge-section-help" tabindex="0" role="img" aria-label="Quick Select help" title="Brush over a region to grow the selection to similar nearby pixels. Shift adds, Alt subtracts, Shift+Alt intersects.">?</span></div>
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the brush combines with the current selection. Shift / Alt held during a stroke override this.">
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+ Add</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">− Subtract</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
</div>
<div class="ge-control-row" style="display:flex;gap:6px;align-items:center;min-width:0;">
<label style="font-size:10px;opacity:.7;white-space:nowrap;">Brush</label>
<input type="range" class="ge-quickselect-size" id="ge-quickselect-size" min="4" max="200" value="24" style="flex:1 1 auto;min-width:0;" />
<span class="ge-adj-value" id="ge-quickselect-size-value">24</span>
</div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Paint over a region; the brush grows the selection to similar nearby pixels.</p>
</div>
<div class="ge-objectselect-section" id="ge-objectselect-section" style="display:none;">
<div class="ge-section-title ge-section-title-with-help"><span>OBJECT SELECT</span><span class="ge-section-help" tabindex="0" role="img" aria-label="Object Select help" title="Draw over an object; the SAM model selects the whole object. Hold Alt to mark pixels to exclude.">?</span></div>
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the object selection combines with the current selection. Shift / Alt held during a stroke override this.">
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+ Add</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">− Subtract</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
</div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Draw a rough stroke over an object; SAM selects its boundary.</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>
@@ -619,27 +721,6 @@ export function layerPanelHTML() {
return `<div class="ge-layers-header">
<span class="ge-layers-grab"></span>
<span class="ge-layers-title">Layers</span>
<div class="ge-layer-header-tools" role="group" aria-label="Layer actions">
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-add-layer" title="New layer" aria-label="New layer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
</button>
<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 ge-icon-btn" id="ge-select-all-layers" title="Select all layers" aria-label="Select all layers">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="13" height="13" rx="1"/><path d="M4 16H3V3h13v1"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-group-selected" title="Group selected layers" aria-label="Group selected layers">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h7l2 2h9v10H3z"/></svg>
</button>
<button class="ge-btn ge-btn-sm" id="ge-layer-fill" title="Fill selected layer or selection with foreground color (Alt+Backspace)">Fill</button>
</div>
</div>
<div class="ge-layer-blend-row">
<label for="ge-layer-blend">Blend</label>
@@ -660,7 +741,21 @@ export function layerPanelHTML() {
<option value="saturation">Saturation</option>
<option value="color">Color</option>
<option value="luminosity">Luminosity</option>
<option value="dissolve">Dissolve</option>
<option value="linear-burn">Linear Burn</option>
<option value="linear-dodge">Linear Dodge (Add)</option>
<option value="darker-color">Darker Color</option>
<option value="lighter-color">Lighter Color</option>
<option value="vivid-light">Vivid Light</option>
<option value="linear-light">Linear Light</option>
<option value="pin-light">Pin Light</option>
<option value="hard-mix">Hard Mix</option>
<option value="subtract">Subtract</option>
<option value="divide">Divide</option>
</select>
<label class="ge-layer-opacity-head" for="ge-layer-header-opacity" title="Active layer opacity">Opacity
<input type="number" id="ge-layer-header-opacity" min="0" max="100" step="1" value="100" />
</label>
</div>
<div class="ge-layer-selection-bar" id="ge-layer-selection-bar" hidden>
<span id="ge-layer-selection-count">2 layers</span>
@@ -676,5 +771,31 @@ export function layerPanelHTML() {
<button class="ge-icon-btn danger" id="ge-selected-delete" title="Delete selected layers" aria-label="Delete selected layers">×</button>
</div>
<div class="ge-layers-list" id="ge-layers-list"></div>
<div class="ge-layer-tools" id="ge-layer-tools" aria-label="Selected layer tools"></div>`;
<div class="ge-layers-footer">
<div class="ge-layer-header-tools" role="group" aria-label="Layer actions">
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-add-layer" title="New layer" aria-label="New layer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
</button>
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn ge-add-fx" id="ge-add-fx" title="New adjustment (fx) layer" aria-label="New adjustment layer">
<span class="ge-add-fx-glyph" aria-hidden="true">fx</span>
</button>
<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 ge-icon-btn" id="ge-select-all-layers" title="Select all layers" aria-label="Select all layers">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="13" height="13" rx="1"/><path d="M4 16H3V3h13v1"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-group-selected" title="Group selected layers" aria-label="Group selected layers">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h7l2 2h9v10H3z"/></svg>
</button>
<button class="ge-btn ge-btn-sm" id="ge-layer-fill" title="Fill selected layer or selection with foreground color (Alt+Backspace)">Fill</button>
</div>
<div class="ge-layer-tools" id="ge-layer-tools" aria-label="Selected layer tools"></div>
</div>`;
}
+496
View File
@@ -0,0 +1,496 @@
/**
* Docked panel stack (PhotoCraft-parity phase 0/3).
*
* Wraps a list of existing panel elements into a Photoshop-style dock:
* several panel GROUPS are visible at once, stacked vertically. Each
* group has clickable tabs (one panel visible per group), a collapse
* control that shrinks it to its tab strip, and a drag divider so the
* user can resize the group above.
*
* Purely presentational: it never owns editor state. Each entry is an
* element that already exists elsewhere (so all existing ids, classes
* and event listeners keep working). Switching a tab only toggles the
* `hidden` attribute on the panel wrapper.
*
* A compact `<select>` variant (`compact: true`) is kept as a fallback
* for narrow columns where a multi-group stack will not fit.
*
* @param {{
* entries: Array<{id: string, label: string, element: HTMLElement, group?: string}>,
* persistKey?: string,
* defaultId?: string,
* compact?: boolean,
* }} opts
* @returns {{ dock: HTMLDivElement, show: (id: string) => void, readonly activeId: string|null }}
*/
export function buildDock({ entries = [], persistKey = 'ge-dock-active', defaultId = null, compact = false } = {}) {
const dock = document.createElement('div');
dock.className = 'ge-dock';
if (compact) dock.classList.add('ge-dock--compact');
// Collect entries into ordered groups (Photoshop panel groups).
const groups = [];
entries.forEach(entry => {
const name = entry.group || 'Panels';
let group = groups.find(g => g.name === name);
if (!group) {
group = { name, entries: [], size: entry.groupSize || null, collapsed: !!entry.groupCollapsed };
groups.push(group);
}
group.entries.push(entry);
});
const groupOfId = new Map();
groups.forEach(group => group.entries.forEach(e => groupOfId.set(e.id, group)));
let saved = {};
try { saved = JSON.parse(localStorage.getItem(persistKey) || '{}') || {}; } catch {}
const activeByGroup = new Map();
groups.forEach(group => {
const fallback = group.entries[0]?.id || null;
const savedId = saved[group.name];
const pick = (savedId && group.entries.some(e => e.id === savedId)) ? savedId
: (defaultId && group.entries.some(e => e.id === defaultId)) ? defaultId
: fallback;
activeByGroup.set(group.name, pick);
});
let activeId = null;
const persist = () => {
const out = {};
activeByGroup.forEach((v, k) => { if (v) out[k] = v; });
try { localStorage.setItem(persistKey, JSON.stringify(out)); } catch {}
};
const groupEls = new Map();
const detachPlacers = new Map();
const activateInGroup = (group, id) => {
activeByGroup.set(group.name, id);
activeId = id;
groupEls.get(group.name)?.querySelectorAll('.ge-dock-tab').forEach(tab => {
const on = tab.dataset.panel === id;
tab.classList.toggle('active', on);
tab.setAttribute('aria-selected', String(on));
});
group.entries.forEach(entry => {
const panel = groupEls.get(group.name)?.querySelector(`.ge-dock-panel[data-panel="${entry.id}"]`);
if (panel) panel.hidden = entry.id !== id;
});
// Keep the group's undock control anchored beside the active tab.
detachPlacers.get(group.name)?.();
persist();
};
function show(id) {
const group = groupOfId.get(id);
if (!group) return;
groupEls.get(group.name)?.classList.remove('is-collapsed');
activateInGroup(group, id);
}
// A dock tab plus a small "undock" control (Photoshop lets you tear a single
// panel tab out of its group). Used both for the initial build and when a
// floated panel is re-docked.
function makeTabItem(group, entry, groupEl) {
const item = document.createElement('div');
item.className = 'ge-dock-tab-item';
item.dataset.panel = entry.id;
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 'ge-dock-tab';
tab.dataset.panel = entry.id;
tab.textContent = entry.label;
tab.setAttribute('role', 'tab');
tab.setAttribute('aria-selected', 'false');
tab.title = entry.label;
tab.addEventListener('click', () => {
groupEl.classList.remove('is-collapsed');
activateInGroup(group, entry.id);
});
item.appendChild(tab);
return item;
}
// Float a single panel into its own draggable window (Photoshop undock-tab).
function floatPanel(group, entry) {
const groupEl = groupEls.get(group.name);
if (!groupEl) return;
const panelEl = groupEl.querySelector(`.ge-dock-panel[data-panel="${entry.id}"]`);
if (!panelEl || panelEl.closest('.ge-float-window')) return;
const win = document.createElement('div');
win.className = 'ge-float-window';
win.innerHTML = `
<div class="ge-float-head">
<span class="ge-float-title">${entry.label}</span>
<span class="ge-float-btns">
<button class="ge-float-dock" type="button" title="Dock panel" aria-label="Dock panel">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/></svg>
</button>
</span>
</div>
<div class="ge-float-body"></div>`;
win.querySelector('.ge-float-body').appendChild(panelEl);
panelEl.hidden = false;
document.body.appendChild(win);
const r = groupEl.getBoundingClientRect();
win.style.left = `${Math.max(8, Math.min(window.innerWidth - 240, r.left - 260))}px`;
win.style.top = `${Math.max(8, r.top)}px`;
makeFloatDraggable(win, win.querySelector('.ge-float-head'));
const tabsEl = groupEl.querySelector('.ge-dock-tabs');
const itemEl = tabsEl?.querySelector(`.ge-dock-tab-item[data-panel="${entry.id}"]`);
itemEl?.remove();
const wasActive = activeId === entry.id;
win.querySelector('.ge-float-dock').addEventListener('click', () => {
const bodyEl = groupEl.querySelector('.ge-dock-panels');
if (bodyEl) bodyEl.appendChild(panelEl); else groupEl.appendChild(panelEl);
panelEl.hidden = activeId !== entry.id;
win.remove();
if (tabsEl) tabsEl.appendChild(makeTabItem(group, entry, groupEl));
if (wasActive) activateInGroup(group, entry.id);
detachPlacers.get(group.name)?.();
syncTabsOverflow(groupEl);
});
if (wasActive) {
const next = group.entries.find(e => {
const p = groupEl.querySelector(`.ge-dock-panel[data-panel="${e.id}"]`);
return p && !p.closest('.ge-float-window');
});
if (next) activateInGroup(group, next.id);
}
syncTabsOverflow(groupEl);
}
if (compact) {
const tabbar = document.createElement('div');
tabbar.className = 'ge-dock-tabbar';
const select = document.createElement('select');
select.className = 'ge-dock-select';
select.setAttribute('aria-label', 'Editor panel');
const optgroups = new Map();
entries.forEach(({ id, label, group }) => {
const option = document.createElement('option');
option.value = id;
option.textContent = label;
if (group) {
let optgroup = optgroups.get(group);
if (!optgroup) {
optgroup = document.createElement('optgroup');
optgroup.label = group;
optgroups.set(group, optgroup);
select.appendChild(optgroup);
}
optgroup.appendChild(option);
} else {
select.appendChild(option);
}
});
tabbar.appendChild(select);
const panels = document.createElement('div');
panels.className = 'ge-dock-panels';
entries.forEach(({ id, element }) => {
const panel = document.createElement('div');
panel.className = 'ge-dock-panel';
panel.dataset.panel = id;
if (element) panel.appendChild(element);
panels.appendChild(panel);
});
dock.appendChild(tabbar);
dock.appendChild(panels);
const applyFlat = (id) => {
activeId = id;
panels.querySelectorAll('.ge-dock-panel').forEach(p => { p.hidden = p.dataset.panel !== id; });
const group = groupOfId.get(id);
if (group) activeByGroup.set(group.name, id);
persist();
};
select.addEventListener('change', () => applyFlat(select.value));
const initial = defaultId || entries[0]?.id || null;
if (initial) { select.value = initial; applyFlat(initial); }
return { dock, show, get activeId() { return activeId; } };
}
// Stacked mode: one `.ge-dock-group` per panel group, with a drag
// divider between groups (skip a leading divider).
groups.forEach((group, groupIndex) => {
if (groupIndex > 0) {
const divider = document.createElement('div');
divider.className = 'ge-dock-divider';
divider.setAttribute('role', 'separator');
divider.setAttribute('aria-orientation', 'horizontal');
divider.title = 'Drag to resize panel group';
dock.appendChild(divider);
wireDivider(divider, groupIndex);
}
const groupEl = document.createElement('div');
groupEl.className = 'ge-dock-group';
groupEl.dataset.group = group.name;
if (group.size) {
groupEl.style.flex = `0 0 ${group.size}px`;
groupEl.style.height = `${group.size}px`;
}
if (group.collapsed) groupEl.classList.add('is-collapsed');
const head = document.createElement('div');
head.className = 'ge-dock-group-head';
const tabs = document.createElement('div');
tabs.className = 'ge-dock-tabs';
tabs.setAttribute('role', 'tablist');
group.entries.forEach(entry => {
tabs.appendChild(makeTabItem(group, entry, groupEl));
});
const collapse = document.createElement('button');
collapse.type = 'button';
collapse.className = 'ge-dock-collapse';
collapse.title = 'Collapse panel group';
collapse.setAttribute('aria-label', 'Collapse panel group');
collapse.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
collapse.addEventListener('click', () => {
const collapsed = groupEl.classList.toggle('is-collapsed');
collapse.title = collapsed ? 'Expand panel group' : 'Collapse panel group';
collapse.setAttribute('aria-label', collapse.title);
});
// Overflow affordance: when a group's tabs do not fit the column,
// show a chevron that opens a menu of every tab so none become
// unreachable or unreadable (Photoshop-style tab overflow).
const tabsMore = document.createElement('button');
tabsMore.type = 'button';
tabsMore.className = 'ge-dock-tabs-more';
tabsMore.title = 'More panels';
tabsMore.setAttribute('aria-label', 'More panels');
tabsMore.hidden = true;
tabsMore.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="6" cy="12" r="1.9"/><circle cx="12" cy="12" r="1.9"/><circle cx="18" cy="12" r="1.9"/></svg>';
tabsMore.addEventListener('click', (e) => {
e.stopPropagation();
openTabsMenu(tabsMore, group, groupEl);
});
head.appendChild(tabs);
head.appendChild(tabsMore);
head.appendChild(collapse);
const body = document.createElement('div');
body.className = 'ge-dock-panels';
group.entries.forEach(entry => {
const panel = document.createElement('div');
panel.className = 'ge-dock-panel';
panel.dataset.panel = entry.id;
if (entry.element) panel.appendChild(entry.element);
body.appendChild(panel);
});
groupEl.appendChild(head);
groupEl.appendChild(body);
dock.appendChild(groupEl);
groupEls.set(group.name, groupEl);
syncTabsOverflow(groupEl);
});
// Re-evaluate tab overflow whenever the column width changes, and keep any
// torn-off (floating) panels on-screen after a viewport resize so they never
// drift off the edge or vanish.
window.addEventListener('resize', () => {
groupEls.forEach(el => syncTabsOverflow(el));
document.querySelectorAll('.ge-float-window').forEach((w) => {
const r = w.getBoundingClientRect();
const maxL = Math.max(8, window.innerWidth - r.width - 8);
const maxT = Math.max(8, window.innerHeight - 48);
w.style.left = Math.min(Math.max(8, r.left), maxL) + 'px';
w.style.top = Math.min(Math.max(8, r.top), maxT) + 'px';
});
});
// The dock is built before it is attached to the DOM (so its width is 0 at
// build time and the overflow check above cannot see the real column width).
// Observe size changes to recompute overflow once it is laid out and whenever
// the panel is resized, collapsed or expanded — otherwise a tab strip that
// overflows keeps the chevron hidden and clips its trailing labels.
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(() => groupEls.forEach(el => syncTabsOverflow(el)));
ro.observe(dock);
}
requestAnimationFrame(() => groupEls.forEach(el => syncTabsOverflow(el)));
function syncTabsOverflow(groupEl) {
const tabsEl = groupEl.querySelector('.ge-dock-tabs');
const moreEl = groupEl.querySelector('.ge-dock-tabs-more');
if (!tabsEl || !moreEl) return;
const items = Array.from(tabsEl.querySelectorAll('.ge-dock-tab-item'));
// Reset any previous hiding so widths are measured at their natural size.
items.forEach(item => { item.style.display = ''; });
const overflowing = tabsEl.scrollWidth - tabsEl.clientWidth > 1;
moreEl.hidden = !overflowing;
if (!overflowing) return;
// Photoshop-style tab overflow: rather than clipping a label mid-word
// ("PATTERNS" -> "P…", "CHARACTER" -> "CHARA"), keep only the tabs that
// fit whole and let the chevron expose the rest. The active tab is always
// kept visible so the current panel never loses its anchor.
const active = tabsEl.querySelector('.ge-dock-tab.active');
const activeItem = active ? active.closest('.ge-dock-tab-item') : null;
const limit = tabsEl.clientWidth;
let gap = 0;
try { gap = parseFloat(getComputedStyle(tabsEl).columnGap) || 0; } catch {}
// Keep the leading run of tabs that fits, then tuck the trailing tabs
// (and everything after the first that no longer fits) into the chevron.
// The active tab is always kept visible so the current panel stays anchored.
let used = 0;
let cutting = false;
items.forEach(item => {
const w = item.getBoundingClientRect().width;
if (item === activeItem || (!cutting && used + w <= limit + 0.5)) {
used += w + gap;
} else {
cutting = true;
item.style.display = 'none';
}
});
}
let tabsMenu = null;
function closeTabsMenu() {
if (!tabsMenu) return;
tabsMenu.remove();
tabsMenu = null;
document.removeEventListener('pointerdown', onTabsMenuDocDown, true);
}
function onTabsMenuDocDown(e) {
if (tabsMenu && !tabsMenu.contains(e.target) && !e.target.closest?.('.ge-dock-tabs-more')) closeTabsMenu();
}
function openTabsMenu(anchor, group, groupEl) {
closeTabsMenu();
tabsMenu = document.createElement('div');
tabsMenu.className = 'ge-dock-tabs-menu';
group.entries.forEach(entry => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'ge-dock-tabs-menu-item';
item.textContent = entry.label;
item.addEventListener('click', () => {
groupEl.classList.remove('is-collapsed');
activateInGroup(group, entry.id);
syncTabsOverflow(groupEl);
closeTabsMenu();
});
tabsMenu.appendChild(item);
});
document.body.appendChild(tabsMenu);
const r = anchor.getBoundingClientRect();
tabsMenu.style.left = `${Math.max(4, Math.min(window.innerWidth - 160, r.left))}px`;
tabsMenu.style.top = `${r.bottom + 2}px`;
document.addEventListener('pointerdown', onTabsMenuDocDown, true);
}
function wireDivider(divider, groupIndex) {
const groupEl = () => dock.querySelectorAll('.ge-dock-group')[groupIndex - 1];
let startY = 0;
let startH = 0;
let resizing = false;
divider.addEventListener('pointerdown', (event) => {
const target = groupEl();
if (!target) return;
resizing = true;
startY = event.clientY;
startH = target.getBoundingClientRect().height;
divider.setPointerCapture(event.pointerId);
document.body.style.cursor = 'ns-resize';
event.preventDefault();
});
divider.addEventListener('pointermove', (event) => {
if (!resizing) return;
const target = groupEl();
if (!target) return;
const next = Math.max(28, startH + (event.clientY - startY));
target.style.flex = `1 1 ${Math.round(next)}px`;
target.style.height = `${Math.round(next)}px`;
});
const end = (event) => {
if (!resizing) return;
resizing = false;
document.body.style.cursor = '';
try { divider.releasePointerCapture?.(event.pointerId); } catch {}
};
divider.addEventListener('pointerup', end);
divider.addEventListener('pointercancel', end);
}
// Move a panel group out of the dock into a floating, draggable
// window; the window offers a "Dock" control that returns the exact
// same `.ge-dock-group` node (DOM identity preserved) to its original
// position in the stack.
function detachGroup(group, groupEl, dockEl) {
if (groupEl.classList.contains('is-floating')) return;
const prevDivider = groupEl.previousElementSibling;
const hadDivider = !!prevDivider && prevDivider.classList.contains('ge-dock-divider');
if (hadDivider) prevDivider.hidden = true;
const dockIndex = Array.from(dockEl.querySelectorAll('.ge-dock-group')).indexOf(groupEl);
groupEl.classList.add('is-floating');
const win = document.createElement('div');
win.className = 'ge-float-window';
win.dataset.group = group.name;
win.innerHTML = `
<div class="ge-float-head">
<span class="ge-float-title">${group.name}</span>
<span class="ge-float-btns">
<button class="ge-float-dock" type="button" title="Dock panel group" aria-label="Dock panel group">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/></svg>
</button>
</span>
</div>
<div class="ge-float-body"></div>`;
win.querySelector('.ge-float-body').appendChild(groupEl);
document.body.appendChild(win);
const r = groupEl.getBoundingClientRect();
win.style.left = `${Math.max(8, Math.min(window.innerWidth - 260, r.left - 60))}px`;
win.style.top = `${Math.max(8, r.top)}px`;
makeFloatDraggable(win, win.querySelector('.ge-float-head'));
win.querySelector('.ge-float-dock').addEventListener('click', () => {
const groupsNow = Array.from(dockEl.querySelectorAll('.ge-dock-group'));
const anchor = groupsNow[dockIndex] || null;
if (anchor) dockEl.insertBefore(groupEl, anchor); else dockEl.appendChild(groupEl);
if (hadDivider) prevDivider.hidden = false;
groupEl.classList.remove('is-floating');
win.remove();
});
}
function makeFloatDraggable(win, handle) {
if (!handle) return;
handle.style.touchAction = 'none';
handle.addEventListener('pointerdown', (e) => {
if (e.target.closest('button')) return;
e.preventDefault();
const startX = e.clientX, startY = e.clientY;
const r0 = win.getBoundingClientRect();
try { handle.setPointerCapture(e.pointerId); } catch {}
handle.style.cursor = 'grabbing';
const onMove = (ev) => {
win.style.left = `${Math.max(0, Math.min(window.innerWidth - 80, r0.left + (ev.clientX - startX)))}px`;
win.style.top = `${Math.max(0, Math.min(window.innerHeight - 30, r0.top + (ev.clientY - startY)))}px`;
};
const onUp = () => {
try { handle.releasePointerCapture(e.pointerId); } catch {}
handle.style.cursor = '';
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
};
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
});
}
groupEls.forEach((groupEl, name) => {
const group = groups.find(g => g.name === name);
const id = activeByGroup.get(name) || group?.entries[0]?.id;
if (id) activateInGroup(group, id);
});
return {
dock,
show,
get activeId() { return activeId; },
};
}
+252
View File
@@ -0,0 +1,252 @@
/**
* Contextual tool options bar — a Photoshop-style strip that sits
* directly under the topbar and shows quick controls for the active
* tool.
*
* This is a *mirror* of the authoritative controls in the right-hand
* panel: it never owns state. Every value is delegated to the existing
* input element (by selector) so the two views can never disagree —
* editing here re-dispatches the same `input`/`change` event the real
* control listens for, and the real control pushes updates back into
* the bar.
*
* On phones (<= 820px) the bar is hidden by CSS and the right-panel
* bottom sheet remains the single surface, exactly as before.
*/
import { TOOLS } from './toolbar.js?v=20261008flyouts';
import { state } from '../state.js';
// Per-tool option sources. Each value is a CSS selector for an existing
// control inside the right panel; `null` means "no such control".
const BRUSH_TOOLS = {
brush: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
smudge: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
dodge: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
burn: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
eraser: { size: '.ge-size-slider', opacity: '#ge-eraser-opacity', flow: '#ge-eraser-flow', softness: '#ge-eraser-softness' },
clone: { size: '.ge-size-slider', opacity: '#ge-clone-opacity', flow: '#ge-clone-flow', softness: '#ge-clone-softness' },
heal: { size: '.ge-size-slider', opacity: '#ge-clone-opacity', flow: '#ge-clone-flow', softness: '#ge-clone-softness' },
};
const SELECTION_TOOLS = {
marquee: { modes: '#ge-marquee-section', feather: null },
lasso: { modes: '#ge-lasso-section', feather: '#ge-lasso-feather' },
wand: { modes: '#ge-wand-section', feather: '#ge-wand-feather' },
quickselect: { modes: '#ge-quickselect-section', feather: null },
objectselect: { modes: '#ge-objectselect-section', feather: null },
sam: { modes: '#ge-sam-section', feather: null },
};
const MODE_LABELS = [['replace', 'New'], ['add', '+'], ['subtract', '−'], ['intersect', '∩']];
function resolve(selector) {
if (!selector) return null;
return selector.startsWith('#')
? document.getElementById(selector.slice(1))
: document.querySelector(selector);
}
function makeRange(label, sourceSelector, { suffix = '' } = {}) {
const source = resolve(sourceSelector);
const value = source ? source.value : '0';
const min = source?.getAttribute('min') ?? '0';
const max = source?.getAttribute('max') ?? '100';
const step = source?.getAttribute('step') ?? '1';
return `
<div class="ge-opt" data-opt="range">
<label class="ge-opt-label">${label}<span class="ge-opt-value" data-opt-value>--</span></label>
<input type="range" data-opt-range data-src="${sourceSelector}" min="${min}" max="${max}" step="${step}" value="${value}" />
</div>`;
}
function makeSelect(label, sourceSelector) {
const source = resolve(sourceSelector);
const options = source
? [...source.options].map(o => `<option value="${o.value}">${o.textContent}</option>`).join('')
: '';
const value = source ? source.value : '';
return `
<div class="ge-opt" data-opt="select">
<label class="ge-opt-label">${label}</label>
<select data-opt-select data-src="${sourceSelector}">${options}</select>
</div>`;
}
function makeAutoSelect() {
return `
<label class="ge-opt ge-auto-select-option" data-opt="check">
<input type="checkbox" id="ge-auto-select-layer" />
<span class="ge-opt-label">Auto-Select</span>
</label>
<div class="ge-opt" data-opt="select">
<label class="ge-opt-label" for="ge-auto-select-target">Target</label>
<select id="ge-auto-select-target">
<option value="layer">Layer</option>
<option value="group">Group</option>
</select>
</div>`;
}
function makeModes(sectionSelector) {
const buttons = MODE_LABELS.map(([mode, text]) =>
`<button type="button" class="ge-opt-mode" data-opt-mode="${mode}" data-section="${sectionSelector}">${text}</button>`
).join('');
return `<div class="ge-opt" data-opt="modes"><span class="ge-opt-label">Mode</span><span class="ge-opt-modes">${buttons}</span></div>`;
}
function buildToolControls(toolId) {
const parts = [];
if (BRUSH_TOOLS[toolId]) {
const src = BRUSH_TOOLS[toolId];
parts.push(makeRange('Size', src.size, { suffix: 'px' }));
parts.push(makeRange('Opacity', src.opacity));
parts.push(makeRange('Flow', src.flow));
parts.push(makeRange('Softness', src.softness));
} else if (SELECTION_TOOLS[toolId]) {
const src = SELECTION_TOOLS[toolId];
parts.push(makeModes(src.modes));
if (src.feather) parts.push(makeRange('Feather', src.feather, { suffix: 'px' }));
} else if (toolId === 'gradient') {
parts.push(makeSelect('Type', '#ge-gradient-type'));
parts.push(makeRange('Opacity', '#ge-gradient-opacity'));
} else if (toolId === 'move') {
parts.push(makeAutoSelect());
}
if (!parts.length) {
return '';
}
return parts.join('');
}
function valueLabelOf(source, value) {
// Prefer the sibling label the right panel already maintains so the
// two surfaces always show the same formatted number.
const row = source?.closest?.('.ge-eraser-row, .ge-control-row');
const label = row?.querySelector('span[id$="-label"], span[id$="-opacity-label"], span[id$="-flow-label"], span[id$="-softness-label"]');
const text = label?.textContent?.trim();
return text || (typeof value === 'string' ? value : String(value));
}
/** Refresh every mirrored control inside `bar` from its source. */
export function syncOptionsBar(bar) {
if (!bar) return;
bar.querySelectorAll('[data-src]').forEach(el => {
const source = resolve(el.dataset.src);
if (!source) return;
if (el.tagName === 'SELECT') { el.value = source.value; return; }
el.value = source.value;
const field = el.closest('.ge-opt');
const out = field?.querySelector('[data-opt-value]');
if (out) out.textContent = valueLabelOf(source, source.value);
});
// Selection mode buttons.
bar.querySelectorAll('[data-opt-mode]').forEach(btn => {
const source = resolve(`${btn.dataset.section} [data-wand-mode="${btn.dataset.optMode}"]`);
btn.classList.toggle('active', !!source && source.classList.contains('active'));
});
// Move-tool auto-select (owned here rather than in the right panel).
const autoSelect = bar.querySelector('#ge-auto-select-layer');
if (autoSelect) autoSelect.checked = !!state.autoSelectLayer;
const target = bar.querySelector('#ge-auto-select-target');
if (target) target.value = state.autoSelectTarget === 'group' ? 'group' : 'layer';
}
/** Render the controls for `toolId` and wire delegation + mirroring. */
export function renderOptionsBar(bar, toolId) {
if (!bar) return;
bar.dataset.optionsTool = toolId;
const hasControls = !!(BRUSH_TOOLS[toolId] || SELECTION_TOOLS[toolId] || toolId === 'gradient' || toolId === 'move');
// Keep the strip mounted at a stable height for every tool. Hiding it
// for tools without options reflows the centered canvas-area and makes
// the canvas jump vertically when switching tools; an empty strip is
// visually harmless.
bar.classList.toggle('ge-options-empty', !hasControls);
const icon = bar.querySelector('.ge-options-tool-icon');
const name = bar.querySelector('.ge-options-tool-name');
const meta = TOOLS[toolId];
if (name) name.textContent = meta?.label || toolId || '';
if (icon) icon.innerHTML = meta?.icon || '';
const body = bar.querySelector('.ge-options-body');
if (body) body.innerHTML = buildToolControls(toolId);
// Mirror right-panel changes back into the bar.
bar.querySelectorAll('[data-src]').forEach(el => {
const source = resolve(el.dataset.src);
if (!source || source.dataset.geOptMirror) return;
source.dataset.geOptMirror = '1';
source.addEventListener('input', () => syncOptionsBar(bar));
source.addEventListener('change', () => syncOptionsBar(bar));
});
syncOptionsBar(bar);
}
/**
* Build the (empty) options-bar shell. Call `renderOptionsBar` after
* the right-panel controls exist so delegation can resolve them.
*/
export function buildOptionsBar() {
const bar = document.createElement('div');
bar.className = 'ge-options-bar';
bar.id = 'ge-options-bar';
bar.innerHTML = `
<div class="ge-options-tool">
<span class="ge-options-tool-icon" aria-hidden="true"></span>
<span class="ge-options-tool-name"></span>
</div>
<div class="ge-options-sep" aria-hidden="true"></div>
<div class="ge-options-body"></div>
<button type="button" class="ge-options-more" id="ge-options-more" title="More options" aria-label="More options" aria-haspopup="true" aria-expanded="false" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>
</button>`;
const more = bar.querySelector('#ge-options-more');
more.addEventListener('click', (event) => {
event.stopPropagation();
const open = bar.classList.toggle('ge-more-open');
more.setAttribute('aria-expanded', open ? 'true' : 'false');
});
// Delegation: ranges + selects write through to their real control.
bar.addEventListener('input', event => {
const el = event.target.closest('[data-opt-range]');
if (!el) return;
const source = resolve(el.dataset.src);
if (!source) return;
source.value = el.value;
source.dispatchEvent(new Event('input', { bubbles: true }));
const out = el.closest('.ge-opt')?.querySelector('[data-opt-value]');
if (out) out.textContent = valueLabelOf(source, source.value);
});
bar.addEventListener('change', event => {
if (event.target.id === 'ge-auto-select-layer') {
state.autoSelectLayer = event.target.checked;
bar.dispatchEvent(new CustomEvent('ge-editor-prefs-change', { bubbles: true }));
return;
}
if (event.target.id === 'ge-auto-select-target') {
state.autoSelectTarget = event.target.value === 'group' ? 'group' : 'layer';
bar.dispatchEvent(new CustomEvent('ge-editor-prefs-change', { bubbles: true }));
return;
}
const el = event.target.closest('[data-opt-select]');
if (!el) return;
const source = resolve(el.dataset.src);
if (!source) return;
source.value = el.value;
source.dispatchEvent(new Event('change', { bubbles: true }));
});
bar.addEventListener('click', event => {
const btn = event.target.closest('[data-opt-mode]');
if (!btn) return;
const source = resolve(`${btn.dataset.section} [data-wand-mode="${btn.dataset.optMode}"]`);
source?.click();
syncOptionsBar(bar);
});
return bar;
}
+13 -1
View File
@@ -34,6 +34,8 @@ export function shortcutsPopupHTML() {
<div><kbd>W</kbd> Wand</div>
<div><kbd>${TOOL_SHORTCUTS.marquee}</kbd> Marquee</div>
<div><kbd>C</kbd> Crop</div>
<div><kbd>${TOOL_SHORTCUTS.gradient}</kbd> Gradient</div>
<div><kbd>${TOOL_SHORTCUTS.zoom}</kbd> Zoom <span style="opacity:0.5">(Alt-click = zoom out)</span></div>
</div>
<div class="ge-shortcuts-col">
<h5>Edit</h5>
@@ -43,7 +45,7 @@ export function shortcutsPopupHTML() {
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> Save to Gallery</div>
<div><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>J</kbd> New Layer</div>
<div><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>G</kbd> Clipping Mask</div>
<div><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>T</kbd> Free Transform</div>
<div><kbd>Ctrl</kbd>+<kbd>T</kbd> Free Transform</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>T</kbd> Canvas size…</div>
</div>
<div class="ge-shortcuts-col">
@@ -62,6 +64,13 @@ export function shortcutsPopupHTML() {
<div><kbd>]</kbd> Brush size +</div>
<div>Drag tolerance slider → live wand retune</div>
</div>
<div class="ge-shortcuts-col">
<h5>Touch</h5>
<div>Pinch → Zoom</div>
<div>Two-finger drag → Pan</div>
<div>Two-finger tap → Undo</div>
<div>Three-finger tap → Redo</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>
`;
@@ -81,6 +90,7 @@ export function historyPanelHTML(historyIcon) {
<button class="ge-adj-min" type="button" title="Minimise">&minus;</button>
</span>
</div>
<div class="ge-history-snapshots" id="ge-history-snapshots"></div>
<div class="ge-history-list" id="ge-history-list"></div>
`;
}
@@ -95,6 +105,7 @@ export function canvasSizePromptHTML() {
<div class="modal-content ge-canvas-prompt">
<div class="modal-header"><h4 id="ge-canvas-prompt-title">New project</h4></div>
<div class="modal-body">
<div class="ge-canvas-preview" hidden><img id="ge-canvas-prompt-preview" alt="Image preview" /></div>
<div class="ge-canvas-prompt-section-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M3 9h18M9 3v18"></path></svg><span>Resolution templates</span></div>
<div class="ge-canvas-preset-grid" role="group" aria-label="Resolution templates">
<button type="button" class="ge-canvas-preset" data-canvas-preset="1024x1024"><svg class="ge-canvas-preset-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="1"></rect></svg><span>Square</span><strong>1024 × 1024</strong></button>
@@ -126,6 +137,7 @@ export function canvasSizePromptHTML() {
<div class="ge-canvas-resize-options">
<label class="ge-canvas-lock-option"><input type="checkbox" id="ge-canvas-prompt-lock" /> <span>Keep proportions</span></label>
<label class="ge-canvas-interpolation-field"><span class="ge-canvas-control-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18 9 13l3 3 8-8"></path><path d="M15 8h5v5"></path></svg><span>Interpolation</span></span><select id="ge-canvas-prompt-interpolation"><option value="high">Smooth</option><option value="medium">Balanced</option><option value="low">Crisp</option></select></label>
<label class="ge-canvas-resolution-field" hidden><span class="ge-canvas-control-label"><span>Resolution</span></span><input type="number" id="ge-canvas-prompt-resolution" min="1" max="1000" value="72" /><span class="ge-canvas-resolution-unit">px/inch</span></label>
</div>
</div>
<div class="modal-footer ge-canvas-prompt-footer">
+85 -5
View File
@@ -13,7 +13,7 @@
* values).
*
* @param {{
* controlsHTML: (ctx: {color, brushSize, wandTolerance}) => string,
* controlsHTML: (ctx: {color, brushSize, wandTolerance, bucketTolerance, cropAspectPreset}) => string,
* layerPanelHTML: () => string,
* }} build
*
@@ -22,13 +22,19 @@
* controls: HTMLDivElement,
* layerPanel: HTMLDivElement,
* panelResize: HTMLDivElement,
* dock: {dock: HTMLDivElement, show: (id: string) => void}|null,
* }}
*/
import { state } from '../state.js';
import { buildDock } from './dock.js';
export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = [] }) {
const rightPanel = document.createElement('div');
rightPanel.className = 'ge-right-panel';
// Desktop: present Tool Options + Layers as tabs in a dock. Mobile
// (≤ 700 px) keeps the existing bottom-sheet model, where the two
// panels are independent sheets, so the dock is bypassed entirely.
const useDock = window.innerWidth > 700;
// Controls section.
const controls = document.createElement('div');
@@ -66,8 +72,11 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
color: state.color,
brushSize: state.brushSize,
wandTolerance: state.wandTolerance,
bucketTolerance: state.bucketTolerance,
cropAspectPreset: state.cropAspectPreset,
eyedropperSampleSize: state.eyedropperSampleSize,
});
rightPanel.appendChild(controls);
if (!useDock) 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
@@ -99,7 +108,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
const layerPanel = document.createElement('div');
layerPanel.className = 'ge-layers';
layerPanel.innerHTML = layerPanelHTML();
rightPanel.appendChild(layerPanel);
if (!useDock) 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.
@@ -164,6 +173,77 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
}
}
// Mobile panel access (≤ 700 px). The desktop dock is bypassed on
// phones, which left the whole studio panel set (Swatches, Gradients,
// Patterns, Properties, Adjustments, Histogram, Info, Channels, Paths,
// Character, Navigator, History, Actions) unreachable — there was no way
// to add an adjustment or pick a colour from the sheet. Mount the same
// entries in a compact `<select>` overlay reachable from a "Panels"
// button in the layers header, without touching the peek/expand model.
let mobilePanels = null;
if (!useDock && Array.isArray(extraPanels) && extraPanels.length) {
const mDock = buildDock({
entries: extraPanels,
persistKey: 'ge-dock-active',
defaultId: null,
compact: true,
});
const overlay = document.createElement('div');
overlay.className = 'ge-mobile-panels';
overlay.hidden = true;
const head = document.createElement('div');
head.className = 'ge-mobile-panels-head';
const title = document.createElement('span');
title.className = 'ge-mobile-panels-title';
title.textContent = 'Panels';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'ge-mobile-panels-close';
closeBtn.setAttribute('aria-label', 'Close panels');
closeBtn.textContent = '✕';
head.append(title, closeBtn);
overlay.append(head, mDock.dock);
rightPanel.appendChild(overlay);
closeBtn.addEventListener('click', () => { overlay.hidden = true; });
const hdr = layerPanel.querySelector('.ge-layers-header');
if (hdr) {
const panelsBtn = document.createElement('button');
panelsBtn.type = 'button';
panelsBtn.className = 'ge-layers-panels-btn';
panelsBtn.textContent = 'Panels';
panelsBtn.title = 'Show panels';
panelsBtn.addEventListener('click', (e) => {
e.stopPropagation();
overlay.hidden = !overlay.hidden;
});
hdr.appendChild(panelsBtn);
}
mobilePanels = overlay;
}
// Desktop dock: Tool Options + Layers as tabs (PhotoCraft-parity).
// The panels are only re-parented, so every existing selector and
// listener keeps working; switching tabs just toggles `hidden`.
let dock = null;
if (useDock) { rightPanel.classList.add('ge-right-panel--dock');
dock = buildDock({
entries: [
...(Array.isArray(extraPanels) ? extraPanels : []),
],
defaultId: (Array.isArray(extraPanels) && extraPanels[0]?.id) || null,
persistKey: 'ge-dock-active',
// Narrow desktop columns get the compact selector fallback; the
// multi-group stack needs a bit more room to be usable.
compact: window.innerWidth <= 820,
});
rightPanel.appendChild(dock.dock);
// Layers stays pinned below the dock so the layer list is always
// visible while editing (Photoshop-style), regardless of the active
// dock tab. Only the tool options + studio panels live in tabs.
rightPanel.appendChild(layerPanel);
}
// 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
@@ -205,5 +285,5 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
} catch {}
});
return { rightPanel, controls, layerPanel, panelResize };
return { rightPanel, controls, layerPanel, panelResize, dock };
}
+242 -63
View File
@@ -6,85 +6,264 @@
* mask plumbing, etc.) stays in the caller and arrives here as the
* `onSelectTool` callback.
*
* Layout mirrors PhotoCraft's rail: ordered sections, with related tools
* collapsed into a single flyout slot (face = current member, caret opens
* the family). Selecting a flyout member promotes it to the slot face.
*
* @param {{
* currentTool: string,
* onSelectTool: (toolId: string, btn: HTMLButtonElement, toolbar: HTMLDivElement) => void,
* onClearSelection: (which: 'marquee'|'lasso'|'wand') => void,
* onClearSelection: (which: 'marquee'|'lasso'|'wand'|'sam') => void,
* }} ctx
* @returns {{ toolbar: HTMLDivElement, toolKeyMap: Record<string,string> }}
* @returns {{
* toolbar: HTMLDivElement,
* toolKeyMap: Record<string,string>,
* syncActive: (toolId: string) => void,
* }}
*/
import { TOOL_SHORTCUTS } from '../tool-shortcuts.js';
import { loadBrushPresets } from '../brush-presets.js';
const CLEAR_X = '<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>';
const CARET = '<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>';
export const TOOLS = {
move: { label: 'Move', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>' },
hand: { label: 'Hand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-4 0v4"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 10V5a2 2 0 0 0-4 0v9"/><path d="M6 13.5 4.5 12A2.1 2.1 0 0 0 2 15l4.5 5A6 6 0 0 0 11 22h2a7 7 0 0 0 7-7v-4a2 2 0 0 0-4 0v1"/></svg>' },
zoom: { label: 'Zoom', 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="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>' },
crop: { label: 'Crop', 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="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M18 22V8a2 2 0 0 0-2-2H2"/></svg>' },
transform: { label: 'Transform', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>' },
text: { label: 'Text', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>' },
shape: { label: 'Shape', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="12" height="12" rx="1"/><circle cx="17" cy="15" r="4"/></svg>' },
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>' },
gradient: { label: 'Gradient', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20 20 4"/><path d="M6 18 18 6" opacity=".45"/><path d="M8 16 16 8" opacity=".2"/></svg>' },
bucket: { label: 'Paint Bucket', 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 11-8-8-8.6 8.6a2 2 0 0 0 0 2.8l5.2 5.2c.8.8 2 .8 2.8 0L19 11Z"/><path d="m5 2 5 5"/><path d="M2 13h15"/><path d="M22 20a2 2 0 1 1-4 0c0-1.6 1.7-2.4 2-4 .3 1.6 2 2.4 2 4Z"/></svg>' },
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>' },
eyedropper: { label: 'Eyedropper', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 22 1-1h3l9-9"/><path d="M3 21v-3l9-9"/><path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.9.9a2.1 2.1 0 1 1-3 3l-2.1-2.1a2.1 2.1 0 0 1 0-3l-.9-.9Z"/></svg>' },
smudge: { label: 'Smudge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19c2-3 4-5 6-6 2-1 4-3 4-6 0-2-1-4-3-4s-3 2-3 4v5"/><path d="M9 13c-2 0-4 1-5 3-.7 1.2-.1 3 1.4 3H18c1.7 0 3-1.3 3-3 0-1.1-.9-2-2-2h-4"/></svg>' },
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>' },
heal: { label: 'Healing', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m5 19 14-14"/><path d="M7 5h4M9 3v4M13 17h4M15 15v4"/></svg>' },
patch: { label: 'Patch', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="10" height="10" rx="1" stroke-dasharray="3 2"/><path d="M13 13h8v8h-8z"/></svg>' },
dodge: { label: 'Dodge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="10" cy="10" r="6"/><path d="m14.5 14.5 6 6"/></svg>' },
burn: { label: 'Burn', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 22c4 0 7-3 7-7 0-5-4-8-7-13-3 5-7 8-7 13 0 4 3 7 7 7Z"/><path d="M9 16c1.5 1 4.5 1 6 0"/></svg>' },
marquee: { label: 'Marquee', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg>' },
lasso: { label: 'Lasso', 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="M7 22a5 5 0 0 1-2-4"/><path d="M3.3 13.9A6.9 6.9 0 0 1 2 9c0-4 4-7 10-7s10 3 10 7-4 7-10 7"/><path d="M5 18a4.6 4.6 0 0 1-1.5-3.5"/><circle cx="5" cy="19" r="2"/></svg>' },
pen: { label: 'Pen selection', 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="m12 19 7-7 3 3-7 7-3-3zM18 13l-1.5-7.5L2 2l3.5 14.5L13 18M2 2l7.5 7.5"/><circle cx="10" cy="10" r="2"/></svg>' },
path: { label: 'Pen Path', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20 20 4"/><path d="M4 20h4M20 4v4"/><circle cx="4" cy="20" r="2"/><circle cx="20" cy="4" r="2"/></svg>' },
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>' },
quickselect: { label: 'Quick Select', 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="M12 3v4M12 17v4M3 12h4M17 12h4"/><circle cx="12" cy="12" r="4"/><path d="M12 8a4 4 0 0 1 0 8"/></svg>' },
objectselect: { label: 'Object Select', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2" stroke-dasharray="3 3"/><path d="M9 9h6v6H9z"/></svg>' },
sam: { label: 'SAM', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>' },
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>' },
rembg: { label: 'Bg Remove', 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"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>' },
sharpen: { label: 'Sharpen', 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="M12 2l2.4 6.6L21 11l-6.6 2.4L12 20l-2.4-6.6L3 11l6.6-2.4z"/></svg>' },
};
// Ordered sections. A string is a single-tool slot; an array is a flyout
// slot whose first member is the default face.
const SECTIONS = [
['move'],
[['marquee', 'lasso'], ['wand', 'quickselect', 'objectselect', 'sam']],
[['crop', 'transform'], ['brush', 'eraser'], ['clone', 'heal', 'patch'], ['smudge', 'dodge', 'burn'], ['gradient', 'bucket'], 'eyedropper'],
[['pen', 'path'], ['text', 'shape'], ['inpaint', 'rembg', 'sharpen']],
[['hand', 'zoom']],
];
export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
const toolbar = document.createElement('div');
toolbar.className = 'ge-toolbar';
const tools = [
{ id: 'move', label: 'Move', icon: '✥' },
{ id: 'hand', label: 'Hand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-4 0v4"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 10V5a2 2 0 0 0-4 0v9"/><path d="M6 13.5 4.5 12A2.1 2.1 0 0 0 2 15l4.5 5A6 6 0 0 0 11 22h2a7 7 0 0 0 7-7v-4a2 2 0 0 0-4 0v1"/></svg>' },
{ id: 'crop', label: 'Crop', icon: '✂' },
{ id: 'transform', label: 'Transform', icon: '⤢' },
{ id: 'text', label: 'Text', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>' },
{ id: 'shape', label: 'Shape', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="12" height="12" rx="1"/><circle cx="17" cy="15" r="4"/></svg>' },
{ 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>' },
{ id: 'gradient', label: 'Gradient', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20 20 4"/><path d="M6 18 18 6" opacity=".45"/><path d="M8 16 16 8" opacity=".2"/></svg>' },
{ 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>' },
{ id: 'eyedropper', label: 'Eyedropper', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 3 2 2-9.5 9.5-3-3Z"/><path d="m8.5 11.5-5 5V21h4.5l5-5"/></svg>' },
{ id: 'smudge', label: 'Smudge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19c2-3 4-5 6-6 2-1 4-3 4-6 0-2-1-4-3-4s-3 2-3 4v5"/><path d="M9 13c-2 0-4 1-5 3-.7 1.2-.1 3 1.4 3H18c1.7 0 3-1.3 3-3 0-1.1-.9-2-2-2h-4"/></svg>' },
{ 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>' },
{ id: 'heal', label: 'Healing', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m5 19 14-14"/><path d="M7 5h4M9 3v4M13 17h4M15 15v4"/></svg>' },
{ id: 'dodge', label: 'Dodge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="10" cy="10" r="6"/><path d="m14.5 14.5 6 6"/></svg>' },
{ id: 'burn', label: 'Burn', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 22c4 0 7-3 7-7 0-5-4-8-7-13-3 5-7 8-7 13 0 4 3 7 7 7Z"/><path d="M9 16c1.5 1 4.5 1 6 0"/></svg>' },
{ id: 'marquee', label: 'Marquee', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg>' },
{ id: 'lasso', label: 'Lasso', icon: '⟡' },
{ id: 'pen', label: 'Pen selection', 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="m12 19 7-7 3 3-7 7-3-3zM18 13l-1.5-7.5L2 2l3.5 14.5L13 18M2 2l7.5 7.5"/><circle cx="10" cy="10" r="2"/></svg>' },
{ 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>' },
{ id: 'sam', label: 'SAM', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>' },
{ sep: true },
{ id: 'inpaint', label: 'Inpaint', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>' },
{ id: 'rembg', ai: true, label: 'Bg Remove', icon: '✄' },
{ id: 'sharpen', ai: true, label: 'Sharpen', icon: '◈' },
];
const toolKeyMap = {};
for (const t of tools) {
t.key = TOOL_SHORTCUTS[t.id];
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})` : '');
if (t.id === 'pen') btn.title += ' - Click anchors; drag for curves; close path or press Enter';
// 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>' : '';
const slots = [];
for (const id of Object.keys(TOOLS)) {
const key = TOOL_SHORTCUTS[id];
if (key) toolKeyMap[key.toLowerCase()] = id;
}
const setInner = (btn, toolId) => {
const t = TOOLS[toolId];
btn.dataset.tool = toolId;
btn.title = t.label + (TOOL_SHORTCUTS[toolId] ? ` (${TOOL_SHORTCUTS[toolId]})` : '');
if (toolId === 'pen') btn.title += ' - Click anchors; drag for curves; close path or press Enter';
if (toolId === 'path') btn.title += ' - Click to add anchors; double-click or press Enter to finish';
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 clearTitle = t.id === 'sam' ? 'Open SAM prompt' : 'Clear selection';
const clearBadge = (t.id === 'marquee' || t.id === 'lasso' || t.id === 'wand' || t.id === 'sam')
? '<span class="ge-tool-clear" title="' + clearTitle + '" data-clear-tool="' + t.id + '">' +
'<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>' +
'</span>'
const aiStar = t.ai ? '<span class="ge-tool-ai" title="AI">✦</span>' : '';
const clearTitle = toolId === 'sam' ? 'Open SAM prompt' : 'Clear selection';
const clearBadge = ['marquee', 'lasso', 'quickselect', 'objectselect', 'wand', 'sam'].includes(toolId)
? '<span class="ge-tool-clear" title="' + clearTitle + '" data-clear-tool="' + toolId + '">' + CLEAR_X + '</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.innerHTML = `${aiStar}<span class="ge-tool-icon"${t.small ? ' style="font-size:14px"' : ''}>${t.icon}</span>${clearBadge}`;
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);
};
const closeAllFlyouts = () => {
toolbar.querySelectorAll('.ge-tool-slot.open').forEach(s => s.classList.remove('open'));
};
const openFlyout = (slotEl, face) => {
const isOpen = slotEl.classList.contains('open');
closeAllFlyouts();
if (isOpen) return;
slotEl.classList.add('open');
const flyout = slotEl.querySelector('.ge-tool-flyout');
if (!flyout) return;
const r = face.getBoundingClientRect();
// The flyout is position:fixed, but an ancestor with a transform (the
// gallery modal sets an identity matrix) becomes its containing block, so
// viewport coords must be offset by that block's origin or the popup lands
// far from the button.
const cb = flyout.offsetParent;
const cbRect = cb ? cb.getBoundingClientRect() : { left: 0, top: 0 };
const cbX = cbRect.left + (cb ? cb.clientLeft : 0);
const cbY = cbRect.top + (cb ? cb.clientTop : 0);
if (window.innerWidth <= 820) {
flyout.style.left = (Math.max(r.left, 4) - cbX) + 'px';
flyout.style.top = (r.bottom + 4 - cbY) + 'px';
} else {
flyout.style.left = (r.right + 4 - cbX) + 'px';
flyout.style.top = (Math.max(r.top, 4) - cbY) + 'px';
}
};
document.addEventListener('click', (ev) => {
if (!toolbar.contains(ev.target)) closeAllFlyouts();
});
let first = true;
for (const section of SECTIONS) {
if (!first) {
const sep = document.createElement('div');
sep.className = 'ge-tool-sep';
toolbar.appendChild(sep);
}
first = false;
for (const entry of section) {
const members = Array.isArray(entry) ? entry : [entry];
const slotEl = document.createElement('div');
slotEl.className = 'ge-tool-slot';
const face = document.createElement('button');
face.type = 'button';
face.className = 'ge-tool-btn ge-tool-slot-face';
const faceId = members.includes(currentTool) ? currentTool : members[0];
setInner(face, faceId);
face.addEventListener('click', () => {
closeAllFlyouts();
onSelectTool(face.dataset.tool, face, toolbar);
});
slotEl.appendChild(face);
const slot = { el: slotEl, face, members, current: faceId, items: [] };
if (members.length > 1) {
// Photoshop-style corner triangle marks a slot whose family has
// more tools behind it.
face.insertAdjacentHTML('beforeend', '<span class="ge-tool-flyout-tri" aria-hidden="true"></span>');
const tri = face.querySelector('.ge-tool-flyout-tri');
tri?.addEventListener('click', (ev) => {
ev.stopPropagation();
openFlyout(slotEl, face);
});
const flyout = document.createElement('div');
flyout.className = 'ge-tool-flyout';
for (const id of members) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'ge-tool-btn ge-tool-flyout-item';
setInner(item, id);
// Keep `data-tool` unique to the slot face so `[data-tool="X"]`
// selectors are unambiguous; flyout members expose the option id.
item.dataset.toolOption = id;
delete item.dataset.tool;
item.addEventListener('click', (ev) => {
ev.stopPropagation();
setInner(face, id);
slot.current = id;
closeAllFlyouts();
onSelectTool(id, face, toolbar);
});
item.insertAdjacentHTML('beforeend', `<span class="ge-tool-flyout-label">${TOOLS[id].label}</span>`);
flyout.appendChild(item);
slot.items.push(item);
}
// Photoshop-style: the Brush slot's flyout also lists the brush
// presets, so the Pixel brush (and any saved presets) are reachable
// straight from the tool button. Picking one switches to the Brush
// and applies the preset through the options-bar picker so the size
// and stroke controls stay in sync.
if (members.includes('brush')) {
const brushPresets = loadBrushPresets();
if (brushPresets.length) {
const sep = document.createElement('div');
sep.className = 'ge-tool-flyout-sep';
flyout.appendChild(sep);
const heading = document.createElement('div');
heading.className = 'ge-tool-flyout-heading';
heading.textContent = 'Brushes';
flyout.appendChild(heading);
for (const preset of brushPresets) {
const presetItem = document.createElement('button');
presetItem.type = 'button';
presetItem.className = 'ge-tool-btn ge-tool-flyout-item ge-tool-flyout-preset';
presetItem.dataset.brushPreset = preset.id;
presetItem.insertAdjacentHTML('beforeend', `<span class="ge-tool-flyout-label">${preset.name}</span>`);
presetItem.addEventListener('click', (ev) => {
ev.stopPropagation();
setInner(face, 'brush');
slot.current = 'brush';
onSelectTool('brush', face, toolbar);
const picker = document.getElementById('ge-brush-preset');
if (picker) {
picker.value = preset.id;
picker.dispatchEvent(new Event('change', { bubbles: true }));
}
closeAllFlyouts();
});
flyout.appendChild(presetItem);
}
}
}
slotEl.appendChild(flyout);
face.addEventListener('contextmenu', (ev) => {
ev.preventDefault();
openFlyout(slotEl, face);
});
}
slots.push(slot);
toolbar.appendChild(slotEl);
}
}
return { toolbar, toolKeyMap };
// Foreground / background color swatches (Photoshop-style) pinned to
// the bottom of the tool rail, with a swap control.
const swatches = document.createElement('div');
swatches.className = 'ge-tool-swatches';
swatches.innerHTML = `
<label class="ge-swatch ge-swatch-bg" title="Background color">
<input type="color" id="ge-bg-color" value="#ffffff" aria-label="Background color" />
</label>
<label class="ge-swatch ge-swatch-fg" title="Foreground color">
<input type="color" id="ge-fg-color" value="#000000" aria-label="Foreground color" />
</label>
<button type="button" class="ge-swatch-swap" id="ge-swap-colors" title="Swap foreground and background colors" aria-label="Swap colors">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4 3 8l4 4"/><path d="M3 8h13a4 4 0 0 1 0 8h-2"/><path d="M17 20l4-4-4-4"/></svg>
</button>`;
toolbar.appendChild(swatches);
const syncActive = (toolId) => {
for (const slot of slots) {
if (!slot.members.includes(toolId)) continue;
if (slot.current !== toolId) setInner(slot.face, toolId);
slot.current = toolId;
slot.face.classList.add('active');
slot.items.forEach(it => it.classList.toggle('active', (it.dataset.toolOption || it.dataset.tool) === toolId));
}
};
// Initial active state.
syncActive(currentTool);
return { toolbar, toolKeyMap, syncActive };
}
+447 -74
View File
@@ -13,47 +13,82 @@ export function buildTopbar() {
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>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-compare-btn" title="Show the document before editing" aria-label="Show the document before editing" aria-pressed="false">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"></path><path d="M5 7h14M5 17h14"></path></svg></span>
<span class="ge-stacked-label">BEFORE</span>
</button>
</div>
<div class="ge-topbar-right">
<div class="ge-view-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-view-menu-btn" title="Canvas view" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">VIEW</span>
<div class="ge-file-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-file-menu-btn" title="File actions" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">File</span>
</button>
<div class="ge-view-menu dropdown" id="ge-view-menu" hidden>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="rulers"><span class="dropdown-icon ge-view-check">✓</span><span>Rulers</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="grid"><span class="dropdown-icon ge-view-check">✓</span><span>Grid</span></button>
<label class="ge-view-grid-size"><span>Grid size</span><input id="ge-grid-size" type="number" min="2" max="1000" step="1" value="16"><span>px</span></label>
<div class="dropdown-section-divider"></div>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap"><span class="dropdown-icon ge-view-check">✓</span><span>Snap</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap-grid"><span class="dropdown-icon ge-view-check">✓</span><span>Snap to grid</span></button>
<button class="dropdown-item-compact" data-view-action="clear-guides"><span class="dropdown-icon">×</span><span>Clear guides</span></button>
<div class="ge-file-menu dropdown" id="ge-file-menu" hidden>
<div class="ge-menu-label">Open</div>
<button class="dropdown-item-compact" data-file-action="import"><span>Import Image…</span></button>
<button class="dropdown-item-compact" data-file-action="load-project"><span>Load Project…</span></button>
<div class="ge-menu-label">Save</div>
<button class="dropdown-item-compact" data-file-action="save"><span>Save Over Original</span><span class="dropdown-shortcut">Ctrl+S</span></button>
<button class="dropdown-item-compact" data-file-action="save-copy"><span>Save As Copy</span><span class="dropdown-shortcut">Ctrl+Shift+S</span></button>
<button class="dropdown-item-compact" data-file-action="export"><span>Export Image…</span></button>
<button class="dropdown-item-compact" data-file-action="export-psd"><span>Export Layers as PSD…</span></button>
<div class="ge-menu-label">Project</div>
<button class="dropdown-item-compact" data-file-action="save-project"><span>Save Project (.json)</span></button>
</div>
</div>
<div class="ge-edit-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-edit-menu-btn" title="Edit actions" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">Edit</span>
</button>
<div class="ge-edit-menu dropdown" id="ge-edit-menu" hidden>
<div class="ge-menu-label">History</div>
<button class="dropdown-item-compact" data-edit-action="undo"><span>Undo</span><span class="dropdown-shortcut">Ctrl+Z</span></button>
<button class="dropdown-item-compact" data-edit-action="redo"><span>Redo</span><span class="dropdown-shortcut">Ctrl+Shift+Z</span></button>
<button class="dropdown-item-compact" data-edit-action="history"><span>History…</span></button>
<div class="ge-menu-label">Clipboard</div>
<button class="dropdown-item-compact" data-edit-action="copy-merged" title="Copy the flattened visible image to the clipboard"><span>Copy Merged</span><span class="dropdown-shortcut">Ctrl+Shift+C</span></button>
<div class="ge-menu-label">Transform (active layer)</div>
<button class="dropdown-item-compact" data-edit-action="rotate-90" title="Rotate the active layer 90° CW"><span>Rotate 90° CW</span></button>
<button class="dropdown-item-compact" data-edit-action="rotate-180" title="Rotate the active layer 180°"><span>Rotate 180°</span></button>
<button class="dropdown-item-compact" data-edit-action="flip-h" title="Flip the active layer horizontally"><span>Flip Horizontal</span></button>
<button class="dropdown-item-compact" data-edit-action="flip-v" title="Flip the active layer vertically"><span>Flip Vertical</span></button>
<div class="ge-menu-label">Content-Aware</div>
<button class="dropdown-item-compact" data-edit-action="content-aware-move" title="Move the selection content and fill the hole it leaves behind"><span>Content-Aware Move…</span></button>
<div class="ge-menu-label">Stroke</div>
<button class="dropdown-item-compact" data-edit-action="stroke" title="Stroke the active layer or selection"><span>Stroke…</span></button>
<div class="ge-menu-label">Presets</div>
<button class="dropdown-item-compact" data-edit-action="define-brush" title="Save the current brush settings as a reusable preset"><span>Define Brush Preset…</span></button>
</div>
</div>
<div class="ge-image-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-image-menu-btn" title="Image actions" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">IMAGE</span>
<span class="ge-stacked-label">Image</span>
</button>
<div class="ge-image-menu dropdown" id="ge-image-menu" hidden>
<button class="dropdown-item-compact" data-image-action="fill" title="Fill with foreground color (Alt+Backspace)"><span>Fill</span></button>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="image-adjustments" aria-haspopup="true" aria-expanded="false"><span>Adjustments</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="image-adjustments">
<button class="dropdown-item-compact" data-image-action="desaturate" title="Desaturate"><span>Desaturate</span></button>
<button class="dropdown-item-compact" data-image-action="auto-tone" title="Auto Tone"><span>Auto Tone</span></button>
<button class="dropdown-item-compact" data-image-action="auto-contrast" title="Auto Contrast"><span>Auto Contrast</span></button>
<button class="dropdown-item-compact" data-image-action="auto-color" title="Auto Color"><span>Auto Color</span></button>
<button class="dropdown-item-compact" data-image-action="equalize" title="Equalize the histogram of the active layer"><span>Equalize</span></button>
<button class="dropdown-item-compact" data-image-action="replace-color" title="Recolour pixels near a target colour"><span>Replace Color…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-brightness-contrast" title="Brightness/Contrast"><span>Brightness/Contrast…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-hue-saturation" title="Hue/Saturation"><span>Hue/Saturation…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-selective-color" title="Selective Color"><span>Selective Color…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-levels" title="Levels"><span>Levels…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-curves" title="Curves"><span>Curves…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-exposure" title="Exposure"><span>Exposure…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-develop" title="Develop (Camera Raw)"><span>Develop…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-vibrance" title="Vibrance"><span>Vibrance…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-color-balance" title="Color Balance"><span>Color Balance…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-black-white" title="Black &amp; White"><span>Black &amp; White…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-photo-filter" title="Photo Filter"><span>Photo Filter…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-channel-mixer" title="Channel Mixer"><span>Channel Mixer…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-invert" title="Invert"><span>Invert</span></button>
<button class="dropdown-item-compact" data-image-action="adj-posterize" title="Posterize"><span>Posterize…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-threshold" title="Threshold"><span>Threshold…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-gradient-map" title="Gradient Map"><span>Gradient Map…</span></button>
<button class="dropdown-item-compact" data-image-action="adj-shadows-highlights" title="Shadows/Highlights"><span>Shadows/Highlights…</span></button>
</div>
<button class="dropdown-item-compact" data-image-action="canvas-size">
<span class="dropdown-icon">⤢</span>
<span>Canvas Size...</span>
@@ -62,37 +97,131 @@ export function buildTopbar() {
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg></span>
<span>Image Size...</span>
</button>
<div class="ge-filter-submenu-label">Transform</div>
<div class="ge-filter-submenu-label">Trim</div>
<button class="dropdown-item-compact" data-image-action="trim" title="Trim transparent edges from the active layer"><span>Trim…</span></button>
<div class="ge-filter-submenu-label">Image Rotation (whole image)</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-ccw">
<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="M3 12a9 9 0 1 0 3-6.7"/><polyline points="3 3 3 9 9 9"/></svg></span>
<span>Rotate 90° CCW</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="rotate-arbitrary">
<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="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></span>
<span>Arbitrary…</span>
</button>
<button class="dropdown-item-compact" data-image-action="perspective-crop">
<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="M3 6l18-2-2 14-16-2z"/><circle cx="3" cy="6" r="1.6" fill="currentColor" stroke="none"/><circle cx="21" cy="4" r="1.6" fill="currentColor" stroke="none"/><circle cx="19" cy="18" r="1.6" fill="currentColor" stroke="none"/><circle cx="3" cy="16" r="1.6" fill="currentColor" stroke="none"/></svg></span>
<span>Perspective Crop…</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>
<span>Flip Canvas 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>
<span>Flip Canvas Vertical</span>
</button>
<div class="ge-filter-submenu-label">Mode</div>
<button class="dropdown-item-compact" data-image-action="mode-rgb"><span>RGB Color</span></button>
<button class="dropdown-item-compact" data-image-action="mode-grayscale"><span>Grayscale</span></button>
<button class="dropdown-item-compact" data-image-action="mode-cmyk" disabled title="CMYK needs a color-managed pipeline"><span>CMYK Color</span></button>
<button class="dropdown-item-compact" data-image-action="mode-lab" disabled title="Lab needs a color-managed pipeline"><span>Lab Color</span></button>
<div class="ge-filter-submenu-label">Bits/Channel</div>
<button class="dropdown-item-compact" data-image-action="bits-8"><span>8 Bits/Channel</span></button>
<button class="dropdown-item-compact" data-image-action="bits-16" disabled title="16-bit needs a 16-bit working canvas"><span>16 Bits/Channel</span></button>
<button class="dropdown-item-compact" data-image-action="bits-32" disabled title="32-bit needs a float working canvas"><span>32 Bits/Channel</span></button>
</div>
</div>
<div class="ge-layer-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-layer-menu-btn" title="Layer actions" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 3 8l9 5 9-5-9-5z"/><path d="m3 13 9 5 9-5"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">Layer</span>
</button>
<div class="ge-layer-menu dropdown" id="ge-layer-menu" hidden>
<div class="ge-menu-label">New</div>
<button class="dropdown-item-compact" data-layer-action="new"><span>New Layer…</span></button>
<button class="dropdown-item-compact" data-layer-action="new-fill"><span>New Fill Layer…</span></button>
<button class="dropdown-item-compact" data-layer-action="duplicate"><span>Duplicate Layer</span><span class="dropdown-shortcut">Ctrl+J</span></button>
<button class="dropdown-item-compact" data-layer-action="group"><span>Group Selected</span></button>
<button class="dropdown-item-compact" data-layer-action="rasterize"><span>Rasterize Layer</span></button>
<div class="ge-menu-label">Modify</div>
<button class="dropdown-item-compact" data-layer-action="delete"><span>Delete Layer</span><span class="dropdown-shortcut">Del</span></button>
<div class="ge-menu-label">Arrange</div>
<button class="dropdown-item-compact" data-layer-action="arrange-front"><span>Bring to Front</span></button>
<button class="dropdown-item-compact" data-layer-action="arrange-forward"><span>Bring Forward</span></button>
<button class="dropdown-item-compact" data-layer-action="arrange-backward"><span>Send Backward</span></button>
<button class="dropdown-item-compact" data-layer-action="arrange-back"><span>Send to Back</span></button>
<div class="ge-menu-label">Merge</div>
<button class="dropdown-item-compact" data-layer-action="merge-down"><span>Merge Down</span><span class="dropdown-shortcut">Ctrl+E</span></button>
<button class="dropdown-item-compact" data-layer-action="merge-layers"><span>Merge Layers</span></button>
<button class="dropdown-item-compact" data-layer-action="merge-all"><span>Merge All</span></button>
<button class="dropdown-item-compact" data-layer-action="merge-visible"><span>Merge Visible</span></button>
<button class="dropdown-item-compact" data-layer-action="flatten"><span>Flatten Image</span></button>
<div class="ge-menu-label">Select</div>
<button class="dropdown-item-compact" data-layer-action="select-all"><span>Select All Layers</span></button>
<div class="ge-menu-label">Vector Mask</div>
<button class="dropdown-item-compact" data-layer-action="vector-mask-reveal" title="Add a mask that reveals the whole layer"><span>Reveal All</span></button>
<button class="dropdown-item-compact" data-layer-action="vector-mask-hide" title="Add a mask that hides the whole layer"><span>Hide All</span></button>
<div class="ge-menu-label">Combine Shapes</div>
<button class="dropdown-item-compact" data-layer-action="combine-unite"><span>Unite Shapes</span></button>
<button class="dropdown-item-compact" data-layer-action="combine-subtract"><span>Subtract Shapes</span></button>
<button class="dropdown-item-compact" data-layer-action="combine-intersect"><span>Intersect Shapes</span></button>
<button class="dropdown-item-compact" data-layer-action="combine-exclude"><span>Exclude Shapes</span></button>
<div class="ge-menu-label">Fill</div>
<button class="dropdown-item-compact" data-layer-action="fill"><span>Fill</span></button>
</div>
</div>
<div class="ge-type-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-type-menu-btn" title="Type actions" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">Type</span>
</button>
<div class="ge-type-menu dropdown" id="ge-type-menu" hidden>
<div class="ge-menu-label">Create</div>
<button class="dropdown-item-compact" data-type-action="add"><span>Add Text Layer</span></button>
<div class="ge-menu-label">Panels</div>
<button class="dropdown-item-compact" data-type-action="character"><span>Character…</span></button>
<button class="dropdown-item-compact" data-type-action="paragraph"><span>Paragraph…</span></button>
<div class="ge-menu-label">Warp</div>
<button class="dropdown-item-compact" data-type-action="warp"><span>Warp Text…</span></button>
<div class="ge-menu-label">Path</div>
<button class="dropdown-item-compact" data-type-action="text-on-path" title="Lay the active text layer along the selected path layer"><span>Text on a Path</span></button>
<button class="dropdown-item-compact" data-type-action="text-on-circle" title="Wrap the active text layer around a circle"><span>Text on a Circle</span></button>
<button class="dropdown-item-compact" data-type-action="text-off-path" title="Return the text layer to a straight baseline"><span>Remove from Path</span></button>
</div>
</div>
<div class="ge-selection-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-selection-menu-btn" title="Selection actions" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">SELECT</span>
<span class="ge-stacked-label">Select</span>
</button>
<div class="ge-selection-menu dropdown" id="ge-selection-menu" hidden>
<button class="dropdown-item-compact" data-selection-action="all"><span>Select All</span><span class="dropdown-shortcut">Ctrl+A</span></button>
<button class="dropdown-item-compact" data-selection-action="deselect"><span>Deselect</span><span class="dropdown-shortcut">Ctrl+D</span></button>
<button class="dropdown-item-compact" data-selection-action="reselect"><span>Reselect</span></button>
<button class="dropdown-item-compact" data-selection-action="invert"><span>Invert</span><span class="dropdown-shortcut">Ctrl+Alt+Shift+I</span></button>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="selection-modify" aria-haspopup="true" aria-expanded="false"><span>Modify</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="selection-modify">
<button class="dropdown-item-compact" data-selection-action="modifyBorder"><span>Border…</span></button>
<button class="dropdown-item-compact" data-selection-action="modifySmooth"><span>Smooth…</span></button>
<button class="dropdown-item-compact" data-selection-action="modifyExpand"><span>Expand…</span></button>
<button class="dropdown-item-compact" data-selection-action="modifyContract"><span>Contract…</span></button>
<button class="dropdown-item-compact" data-selection-action="modifyFeather"><span>Feather…</span></button>
</div>
<button class="dropdown-item-compact" data-selection-action="grow"><span>Grow</span></button>
<button class="dropdown-item-compact" data-selection-action="similar"><span>Similar</span></button>
<button class="dropdown-item-compact" data-selection-action="subject"><span>Subject</span></button>
<button class="dropdown-item-compact" data-selection-action="transform"><span>Transform Selection</span></button>
<button class="dropdown-item-compact" data-selection-action="refine"><span>Refine Selection…</span></button>
<button class="dropdown-item-compact" data-selection-action="colorRange"><span>Color Range…</span></button>
<button class="dropdown-item-compact" data-selection-action="selectAndMask"><span>Select and Mask…</span></button>
<div class="dropdown-section-divider"></div>
<div class="ge-selection-save-row">
<input id="ge-selection-name" type="text" maxlength="100" placeholder="Selection name" aria-label="Saved selection name" />
@@ -107,50 +236,281 @@ export function buildTopbar() {
<div class="ge-filter-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-filter-menu-btn" title="Filters" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16M7 12h10M10 19h4"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">FILTER</span>
<span class="ge-stacked-label">Filter</span>
</button>
<div class="ge-filter-menu dropdown" id="ge-filter-menu" hidden>
<div class="ge-filter-submenu-label">Retained effects</div>
<button class="dropdown-item-compact" data-filter-action="effect-blur-gaussian">
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
<span>Gaussian Blur (retained)…</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-sharpen">
<span class="dropdown-icon" aria-hidden="true">◈</span>
<span>Sharpen (retained)…</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-color-overlay">
<span class="dropdown-icon" aria-hidden="true">◐</span>
<span>Color Overlay (retained)…</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-drop-shadow">
<span class="dropdown-icon" aria-hidden="true">◒</span>
<span>Drop Shadow (retained)…</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-stroke">
<span class="dropdown-icon" aria-hidden="true">□</span>
<span>Stroke (retained)…</span>
</button>
<div class="ge-filter-submenu-label">Presets</div>
<button class="dropdown-item-compact" data-filter-action="effect-preset-soft-blur"><span>Soft Blur</span></button>
<button class="dropdown-item-compact" data-filter-action="effect-preset-crisp-detail"><span>Crisp Detail</span></button>
<button class="dropdown-item-compact" data-filter-action="effect-preset-soft-shadow"><span>Soft Shadow</span></button>
<button class="dropdown-item-compact" data-filter-action="effect-preset-white-outline"><span>White Outline</span></button>
<div class="ge-filter-submenu-label">Blur</div>
<button class="dropdown-item-compact" data-filter-action="blur-gaussian">
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
<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 class="ge-menu-label">Enhance</div>
<button class="dropdown-item-compact" data-filter-action="dehaze"><span class="dropdown-icon" aria-hidden="true">≈</span><span>Haze Removal…</span></button>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-retained" aria-haspopup="true" aria-expanded="false"><span>Retained effects</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-retained">
<button class="dropdown-item-compact" data-filter-action="effect-blur-gaussian">
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
<span>Gaussian Blur (retained)…</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-sharpen">
<span class="dropdown-icon" aria-hidden="true">◈</span>
<span>Sharpen (retained)…</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-color-overlay">
<span class="dropdown-icon" aria-hidden="true">◐</span>
<span>Color Overlay (retained)…</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-drop-shadow">
<span class="dropdown-icon" aria-hidden="true">◒</span>
<span>Drop Shadow (retained)…</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-stroke">
<span class="dropdown-icon" aria-hidden="true">□</span>
<span>Stroke (retained)…</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-motion-blur">
<span class="dropdown-icon" aria-hidden="true">↔</span>
<span>Motion Blur (retained)…</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-zoom-blur">
<span class="dropdown-icon" aria-hidden="true">✳</span>
<span>Zoom Blur (retained)…</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-pixelate">
<span class="dropdown-icon" aria-hidden="true">▦</span>
<span>Pixelate (retained)…</span>
</button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-presets" aria-haspopup="true" aria-expanded="false"><span>Presets</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-presets">
<button class="dropdown-item-compact" data-filter-action="effect-preset-soft-blur"><span>Soft Blur</span></button>
<button class="dropdown-item-compact" data-filter-action="effect-preset-crisp-detail"><span>Crisp Detail</span></button>
<button class="dropdown-item-compact" data-filter-action="effect-preset-soft-shadow"><span>Soft Shadow</span></button>
<button class="dropdown-item-compact" data-filter-action="effect-preset-white-outline"><span>White Outline</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-blur" aria-haspopup="true" aria-expanded="false"><span>Blur</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-blur">
<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>
<button class="dropdown-item-compact" data-filter-action="blur-motion"><span>Motion Blur…</span></button>
<button class="dropdown-item-compact" data-filter-action="radial-blur"><span>Radial Blur…</span></button>
<button class="dropdown-item-compact" data-filter-action="blur-box"><span>Box Blur…</span></button>
<button class="dropdown-item-compact" data-filter-action="blur-surface"><span>Surface Blur…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-sharpen" aria-haspopup="true" aria-expanded="false"><span>Sharpen</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-sharpen">
<button class="dropdown-item-compact" data-filter-action="sharpen"><span>Sharpen…</span></button>
<button class="dropdown-item-compact" data-filter-action="sharpen-edges"><span>Sharpen Edges…</span></button>
<button class="dropdown-item-compact" data-filter-action="sharpen-more"><span>Sharpen More…</span></button>
<button class="dropdown-item-compact" data-filter-action="unsharp-mask"><span>Unsharp Mask…</span></button>
<button class="dropdown-item-compact" data-filter-action="smart-sharpen"><span>Smart Sharpen…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-noise" aria-haspopup="true" aria-expanded="false"><span>Noise</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-noise">
<button class="dropdown-item-compact" data-filter-action="noise-add"><span>Add Noise…</span></button>
<button class="dropdown-item-compact" data-filter-action="noise-despeckle"><span>Despeckle…</span></button>
<button class="dropdown-item-compact" data-filter-action="reduce-noise"><span>Reduce Noise…</span></button>
<button class="dropdown-item-compact" data-filter-action="dust-scratches"><span>Dust &amp; Scratches…</span></button>
<button class="dropdown-item-compact" data-filter-action="noise-median"><span>Median…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-pixelate" aria-haspopup="true" aria-expanded="false"><span>Pixelate</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-pixelate">
<button class="dropdown-item-compact" data-filter-action="pixelate-mosaic"><span>Mosaic…</span></button>
<button class="dropdown-item-compact" data-filter-action="pixelate-crystallize"><span>Crystallize…</span></button>
<button class="dropdown-item-compact" data-filter-action="pixelate-mosaic-tiles"><span>Mosaic Tiles…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-stylize" aria-haspopup="true" aria-expanded="false"><span>Stylize</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-stylize">
<button class="dropdown-item-compact" data-filter-action="stylize-emboss"><span>Emboss…</span></button>
<button class="dropdown-item-compact" data-filter-action="stylize-find-edges"><span>Find Edges…</span></button>
<button class="dropdown-item-compact" data-filter-action="stylize-glowing-edges"><span>Glowing Edges…</span></button>
<button class="dropdown-item-compact" data-filter-action="stylize-solarize"><span>Solarize…</span></button>
<button class="dropdown-item-compact" data-filter-action="stylize-oil-paint"><span>Oil Paint…</span></button>
<button class="dropdown-item-compact" data-filter-action="stylize-diffuse"><span>Diffuse…</span></button>
<button class="dropdown-item-compact" data-filter-action="stylize-wind"><span>Wind…</span></button>
<button class="dropdown-item-compact" data-filter-action="stylize-trace-contour"><span>Trace Contour…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-sketch" aria-haspopup="true" aria-expanded="false"><span>Sketch</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-sketch">
<button class="dropdown-item-compact" data-filter-action="sketch-halftone-pattern"><span>Halftone Pattern…</span></button>
<button class="dropdown-item-compact" data-filter-action="sketch-reticulation"><span>Reticulation…</span></button>
<button class="dropdown-item-compact" data-filter-action="sketch-note-paper"><span>Note Paper…</span></button>
<button class="dropdown-item-compact" data-filter-action="sketch-charcoal"><span>Charcoal…</span></button>
<button class="dropdown-item-compact" data-filter-action="sketch-chalk-charcoal"><span>Chalk &amp; Charcoal…</span></button>
<button class="dropdown-item-compact" data-filter-action="sketch-graphic-pen"><span>Graphic Pen…</span></button>
<button class="dropdown-item-compact" data-filter-action="sketch-photocopy"><span>Photocopy…</span></button>
<button class="dropdown-item-compact" data-filter-action="sketch-water-paper"><span>Water Paper…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-artistic" aria-haspopup="true" aria-expanded="false"><span>Artistic</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-artistic">
<button class="dropdown-item-compact" data-filter-action="artistic-poster-edges"><span>Poster Edges…</span></button>
<button class="dropdown-item-compact" data-filter-action="artistic-cutout"><span>Cutout…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-brush" aria-haspopup="true" aria-expanded="false"><span>Brush Strokes</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-brush">
<button class="dropdown-item-compact" data-filter-action="brush-ink-outlines"><span>Ink Outlines…</span></button>
<button class="dropdown-item-compact" data-filter-action="brush-crosshatch"><span>Crosshatch…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-texture" aria-haspopup="true" aria-expanded="false"><span>Texture</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-texture">
<button class="dropdown-item-compact" data-filter-action="texture-stained-glass"><span>Stained Glass…</span></button>
<button class="dropdown-item-compact" data-filter-action="texture-grain"><span>Grain…</span></button>
<button class="dropdown-item-compact" data-filter-action="texture-texturizer"><span>Texturizer…</span></button>
<button class="dropdown-item-compact" data-filter-action="texture-patchwork"><span>Patchwork…</span></button>
<button class="dropdown-item-compact" data-filter-action="texture-craquelure"><span>Craquelure…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-distort" aria-haspopup="true" aria-expanded="false"><span>Distort</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-distort">
<button class="dropdown-item-compact" data-filter-action="distort-twirl"><span>Twirl…</span></button>
<button class="dropdown-item-compact" data-filter-action="distort-pinch"><span>Pinch…</span></button>
<button class="dropdown-item-compact" data-filter-action="distort-spherize"><span>Spherize…</span></button>
<button class="dropdown-item-compact" data-filter-action="distort-ripple"><span>Ripple…</span></button>
<button class="dropdown-item-compact" data-filter-action="distort-polar"><span>Polar Coordinates…</span></button>
<button class="dropdown-item-compact" data-filter-action="distort-lens-correction"><span>Lens Correction…</span></button>
<button class="dropdown-item-compact" data-filter-action="distort-ocean-ripple"><span>Ocean Ripple…</span></button>
<button class="dropdown-item-compact" data-filter-action="distort-glass"><span>Glass…</span></button>
<button class="dropdown-item-compact" data-filter-action="distort-shear"><span>Shear…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-render" aria-haspopup="true" aria-expanded="false"><span>Render</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-render">
<button class="dropdown-item-compact" data-filter-action="render-clouds"><span>Clouds…</span></button>
<button class="dropdown-item-compact" data-filter-action="render-difference-clouds"><span>Difference Clouds…</span></button>
<button class="dropdown-item-compact" data-filter-action="render-lens-flare"><span>Lens Flare…</span></button>
<button class="dropdown-item-compact" data-filter-action="render-fibers"><span>Fibers…</span></button>
</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="filter-other" aria-haspopup="true" aria-expanded="false"><span>Other</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="filter-other">
<button class="dropdown-item-compact" data-filter-action="other-offset"><span>Offset…</span></button>
<button class="dropdown-item-compact" data-filter-action="other-custom"><span>Custom…</span></button>
<button class="dropdown-item-compact" data-filter-action="other-maximum"><span>Maximum…</span></button>
<button class="dropdown-item-compact" data-filter-action="other-minimum"><span>Minimum…</span></button>
</div>
</div>
</div>
<span class="ge-topbar-sep"></span>
<div class="ge-3d-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-3d-menu-btn" title="3D" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 3 7v10l9 5 9-5V7z"/><path d="M3 7l9 5 9-5"/><path d="M12 12v10"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">3D</span>
</button>
<div class="ge-3d-menu dropdown" id="ge-3d-menu" hidden>
<div class="ge-menu-label">New 3D</div>
<button class="dropdown-item-compact" data-3d-action="from-file" disabled title="3D is not available in this build"><span>New 3D Layer from File…</span></button>
<button class="dropdown-item-compact" data-3d-action="from-layer" disabled title="3D is not available in this build"><span>New Mesh from Layer…</span></button>
<div class="ge-menu-label">Render</div>
<button class="dropdown-item-compact" data-3d-action="render" disabled title="3D is not available in this build"><span>Render 3D Layer</span></button>
<div class="ge-menu-label">3D Printing</div>
<button class="dropdown-item-compact" data-3d-action="print" disabled title="3D is not available in this build"><span>Export as 3D…</span></button>
<div class="dropdown-section-divider"></div>
<button class="dropdown-item-compact" data-3d-action="about" disabled title="3D is not available in this build"><span>About 3D support</span></button>
</div>
</div>
<div class="ge-view-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-view-menu-btn" title="Canvas view" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">View</span>
</button>
<div class="ge-view-menu dropdown" id="ge-view-menu" hidden>
<div class="ge-menu-label">Overlays</div>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="rulers"><span class="dropdown-icon ge-view-check">✓</span><span>Rulers</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="grid"><span class="dropdown-icon ge-view-check">✓</span><span>Grid</span></button>
<label class="ge-view-grid-size"><span>Grid size</span><input id="ge-grid-size" type="number" min="2" max="1000" step="1" value="16"><span>px</span></label>
<div class="ge-menu-label">Snapping</div>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap"><span class="dropdown-icon ge-view-check">✓</span><span>Snap</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap-grid"><span class="dropdown-icon ge-view-check">✓</span><span>Snap to grid</span></button>
<button class="dropdown-item-compact" data-view-action="new-h-guide" title="Add a horizontal guide across the middle"><span class="dropdown-icon">⊢</span><span>New horizontal guide</span></button>
<button class="dropdown-item-compact" data-view-action="new-v-guide" title="Add a vertical guide down the middle"><span class="dropdown-icon">⊣</span><span>New vertical guide</span></button>
<button class="dropdown-item-compact" data-view-action="clear-guides"><span class="dropdown-icon">×</span><span>Clear guides</span></button>
<div class="ge-menu-label">Panels</div>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-options"><span class="dropdown-icon ge-view-check">✓</span><span>Tool Options</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-layers"><span class="dropdown-icon ge-view-check">✓</span><span>Layers</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-swatches"><span class="dropdown-icon ge-view-check">✓</span><span>Swatches</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-gradients"><span class="dropdown-icon ge-view-check">✓</span><span>Gradients</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-patterns"><span class="dropdown-icon ge-view-check">✓</span><span>Patterns</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-properties"><span class="dropdown-icon ge-view-check">✓</span><span>Properties</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-adjustments"><span class="dropdown-icon ge-view-check">✓</span><span>Adjustments</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-histogram"><span class="dropdown-icon ge-view-check">✓</span><span>Histogram</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-info"><span class="dropdown-icon ge-view-check">✓</span><span>Info</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-channels"><span class="dropdown-icon ge-view-check">✓</span><span>Channels</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-paths"><span class="dropdown-icon ge-view-check">✓</span><span>Paths</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-character"><span class="dropdown-icon ge-view-check">✓</span><span>Character</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-navigator"><span class="dropdown-icon ge-view-check">✓</span><span>Navigator</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-history"><span class="dropdown-icon ge-view-check">✓</span><span>History</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="panel-actions"><span class="dropdown-icon ge-view-check">✓</span><span>Actions</span></button>
<div class="ge-menu-label">Panel layout</div>
<button class="dropdown-item-compact" data-view-action="panel-show-all"><span class="dropdown-icon">▣</span><span>Show all</span></button>
<button class="dropdown-item-compact" data-view-action="panel-hide-all"><span class="dropdown-icon">▢</span><span>Hide all</span></button>
<button class="dropdown-item-compact" data-view-action="panel-reset"><span class="dropdown-icon">↺</span><span>Reset to default</span></button>
<div class="ge-menu-label">Screen</div>
<button class="dropdown-item-compact" data-view-action="screen-mode" title="Toggle a distraction-free, maximized screen mode"><span class="dropdown-icon">⛶</span><span>Screen Mode</span></button>
</div>
</div>
<div class="ge-plugins-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-plugins-menu-btn" title="Plugins" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h4a2 2 0 1 1 4 0h4v4a2 2 0 0 0 0 4v4H4v-4a2 2 0 0 0 0-4z"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">Plugins</span>
</button>
<div class="ge-plugins-menu dropdown" id="ge-plugins-menu" hidden>
<div class="ge-menu-label">Plugins</div>
<button class="dropdown-item-compact" data-plugins-action="browse" disabled title="Plugins are not available in this build"><span>Browse Plugins…</span></button>
<button class="dropdown-item-compact" data-plugins-action="manage" disabled title="Plugins are not available in this build"><span>Manage Plugins…</span></button>
<div class="dropdown-section-divider"></div>
<button class="dropdown-item-compact" data-plugins-action="script" disabled title="Plugins are not available in this build"><span>Run Editor Script…</span></button>
</div>
</div>
<div class="ge-window-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-window-menu-btn" title="Window panels" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">Window</span>
</button>
<div class="ge-window-menu dropdown" id="ge-window-menu" hidden>
<div class="ge-menu-label">Panels</div>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="options"><span class="dropdown-icon ge-window-check">●</span><span>Tool Options</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="layers"><span class="dropdown-icon ge-window-check">●</span><span>Layers</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="properties"><span class="dropdown-icon ge-window-check">●</span><span>Properties</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="character"><span class="dropdown-icon ge-window-check">●</span><span>Character</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="adjustments"><span class="dropdown-icon ge-window-check">●</span><span>Adjustments</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="swatches"><span class="dropdown-icon ge-window-check">●</span><span>Swatches</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="gradients"><span class="dropdown-icon ge-window-check">●</span><span>Gradients</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="channels"><span class="dropdown-icon ge-window-check">●</span><span>Channels</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="paths"><span class="dropdown-icon ge-window-check">●</span><span>Paths</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="patterns"><span class="dropdown-icon ge-window-check">●</span><span>Patterns</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="histogram"><span class="dropdown-icon ge-window-check">●</span><span>Histogram</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="info"><span class="dropdown-icon ge-window-check">●</span><span>Info</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="navigator"><span class="dropdown-icon ge-window-check">●</span><span>Navigator</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="history"><span class="dropdown-icon ge-window-check">●</span><span>History</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="actions"><span class="dropdown-icon ge-window-check">●</span><span>Actions</span></button>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="window-workspace" aria-haspopup="true" aria-expanded="false"><span>Workspace</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="window-workspace">
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-essentials"><span class="dropdown-icon ge-window-check">●</span><span>Essentials</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-photography"><span class="dropdown-icon ge-window-check">●</span><span>Photography</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-painting"><span class="dropdown-icon ge-window-check">●</span><span>Painting</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-graphic"><span class="dropdown-icon ge-window-check">●</span><span>Graphic &amp; Web</span></button>
</div>
<div class="ge-menu-label">Help</div>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="shortcuts"><span class="dropdown-icon ge-window-check">●</span><span>Keyboard Shortcuts</span></button>
</div>
</div>
<div class="ge-help-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-help-menu-btn" title="Help" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg><span class="ge-stacked-caret">▾</span></span>
<span class="ge-stacked-label">Help</span>
</button>
<div class="ge-help-menu dropdown" id="ge-help-menu" hidden>
<div class="ge-menu-label">Help</div>
<button class="dropdown-item-compact" data-help-action="shortcuts"><span>Keyboard Shortcuts</span></button>
<button class="dropdown-item-compact" data-help-action="about"><span>About Ajax Image Editor</span></button>
</div>
</div>
</div>
<div class="ge-topbar-right">
<span class="ge-alpha-badge" title="This editor is in active development — expect rough edges">ALPHA</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-maximize-toggle" title="Maximize editor" aria-label="Maximize editor" aria-pressed="false">
<svg class="ge-max-icon" 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;"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-import-topbar" title="Import image as layer">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><polyline points="7 8 12 3 17 8"/><path d="M5 14v5h14v-5"/></svg></span>
<span class="ge-stacked-label">IMPORT</span>
@@ -161,6 +521,7 @@ export function buildTopbar() {
<svg hidden class="ge-save-state-icon ge-save-state-dirty" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h12l4 4v14H4z"/><path d="M8 3v6h8V3"/><path d="m16 3 6 6m0-6-6 6" stroke="var(--fg)" stroke-width="3"/></svg>
<svg class="ge-save-state-icon ge-save-state-saved" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
</span>
<span class="ge-stacked-label ge-save-action-label">SAVE</span>
<span class="ge-stacked-label ge-save-label" id="ge-draft-status" role="status" aria-live="polite">Saved</span>
</button>
<div class="ge-save-menu dropdown" id="ge-save-menu" hidden>
@@ -179,6 +540,10 @@ export function buildTopbar() {
<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>Export image...</span>
</button>
<button class="dropdown-item-compact" id="ge-export-png" title="Download the flattened image as a PNG 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"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 15l4-4 5 5"/><circle cx="9" cy="8" r="1.3"/></svg></span>
<span>Save as 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">
@@ -200,7 +565,15 @@ export function buildZoomFooter() {
const footer = document.createElement('div');
footer.className = 'ge-editor-footer';
footer.innerHTML = `
<span class="ge-canvas-size" id="ge-canvas-size" title="Canvas size" hidden></span>
<div class="ge-footer-history" role="group" aria-label="History">
<button class="ge-btn ge-btn-sm" id="ge-undo" title="Undo" aria-label="Undo">↩</button>
<button class="ge-btn ge-btn-sm" id="ge-redo" title="Redo" aria-label="Redo">↪</button>
<button class="ge-btn ge-btn-sm" id="ge-history-btn" title="History — click an entry to jump to that state" aria-label="History"><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></button>
<button class="ge-btn ge-btn-sm" id="ge-compare-btn" title="Show the document before editing" aria-label="Show the document before editing" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M5 7h14M5 17h14"/></svg></button>
</div>
<span class="ge-status-tool" id="ge-status-tool" aria-live="polite"></span>
<span class="ge-canvas-size" id="ge-canvas-size" title="Document size"></span>
<span class="ge-status-model" id="ge-status-model" title="AI edit model"></span>
<div class="ge-footer-zoom" role="group" aria-label="Canvas zoom">
<button class="ge-btn ge-btn-sm" id="ge-zoom-out" title="Zoom out" aria-label="Zoom out">&minus;</button>
<span class="ge-zoom-stack" aria-live="off">
+32
View File
@@ -65,6 +65,38 @@ export function transformPopupHTML() {
<button type="button" data-spin="up" tabindex="-1" aria-label="Rotate +1°">+</button>
</span>
</div>
<div class="ge-transform-field">
<label>⇱</label>
<input type="number" class="ge-transform-popup-input" id="ge-transform-skew-x" step="1" value="0" aria-label="Horizontal skew" />
<span class="ge-transform-spin" data-spin-for="ge-transform-skew-x">
<button type="button" data-spin="down" tabindex="-1" aria-label="Skew X -1°">−</button>
<button type="button" data-spin="up" tabindex="-1" aria-label="Skew X +1°">+</button>
</span>
</div>
<div class="ge-transform-field">
<label>⇲</label>
<input type="number" class="ge-transform-popup-input" id="ge-transform-skew-y" step="1" value="0" aria-label="Vertical skew" />
<span class="ge-transform-spin" data-spin-for="ge-transform-skew-y">
<button type="button" data-spin="down" tabindex="-1" aria-label="Skew Y -1°">−</button>
<button type="button" data-spin="up" tabindex="-1" aria-label="Skew Y +1°">+</button>
</span>
</div>
<div class="ge-transform-field">
<label>⤒</label>
<input type="number" class="ge-transform-popup-input" id="ge-transform-persp-x" step="1" value="0" aria-label="Horizontal perspective" />
<span class="ge-transform-spin" data-spin-for="ge-transform-persp-x">
<button type="button" data-spin="down" tabindex="-1" aria-label="Perspective X -1">−</button>
<button type="button" data-spin="up" tabindex="-1" aria-label="Perspective X +1">+</button>
</span>
</div>
<div class="ge-transform-field">
<label>⤓</label>
<input type="number" class="ge-transform-popup-input" id="ge-transform-persp-y" step="1" value="0" aria-label="Vertical perspective" />
<span class="ge-transform-spin" data-spin-for="ge-transform-persp-y">
<button type="button" data-spin="down" tabindex="-1" aria-label="Perspective Y -1">−</button>
<button type="button" data-spin="up" tabindex="-1" aria-label="Perspective Y +1">+</button>
</span>
</div>
<span class="ge-transform-quick" aria-label="Transform actions">
<button type="button" class="ge-transform-quick-btn" id="ge-transform-flip-h" title="Flip horizontally" aria-label="Flip horizontally">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="m8 7-4 5 4 5"/><path d="m16 7 4 5-4 5"/></svg>
+313
View File
@@ -0,0 +1,313 @@
/**
* Tool-aware canvas context menu (Photoshop-style right-click).
*
* A right-click on the image opens a menu whose items depend on the
* active tool: paint tools get brush size/presets, selection tools get
* select/feather/expand actions, the crop tool gets Apply/Cancel, the
* transform tool gets commit/cancel, and so on. Every action re-uses the
* existing control ops (window.geEditorControl.invoke) or clicks the
* existing topbar menu items, so there is a single source of truth.
*
* Wire it with wireCanvasContextMenu(canvasArea) from canvas-events.js.
*/
import { state } from './state.js';
const PAINT = ['brush', 'eraser', 'clone', 'heal', 'dodge', 'burn', 'smudge', 'inpaint', 'rembg', 'sharpen'];
const SELECT = ['marquee', 'lasso', 'wand', 'quickselect', 'objectselect', 'pen', 'path', 'sam'];
const TRANSFORM = ['move', 'transform'];
let rootMenu = null;
let subMenu = null;
let cleanups = [];
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const hasSelection = () => !!(state.wandMask || (state.lassoPoints && state.lassoPoints.length >= 3));
function invoke(op, params) {
try { return window.geEditorControl?.invoke(op, params || {}); } catch (err) { console.warn('[ge-canvas-menu]', op, err); }
}
function clickEl(selector) { document.querySelector(selector)?.click(); }
function clickMenu(selector) { document.querySelector(selector)?.click(); }
function dispatchEscape() {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
}
function stepBrushSize(factor) {
const slider = document.querySelector('.ge-size-slider');
if (!slider) return;
const next = Math.max(1, Math.min(800, Math.round((state.brushSize || 1) * factor)));
slider.value = String(Math.round(Math.log(next) / Math.log(800) * 1000));
slider.dispatchEvent(new Event('input', { bubbles: true }));
}
function applyPresetId(id) {
const sel = document.getElementById('ge-brush-preset');
if (!sel) return;
sel.value = id;
sel.dispatchEvent(new Event('change', { bubbles: true }));
}
function presetItems() {
const sel = document.getElementById('ge-brush-preset');
if (!sel) return [];
return [...sel.options].map((opt) => ({
label: opt.textContent,
checked: opt.value === sel.value,
onSelect: () => applyPresetId(opt.value),
}));
}
function promptNumber(message, fallback, run) {
const raw = window.prompt(message, String(fallback));
if (raw == null) return;
const value = Number(raw);
if (Number.isFinite(value)) run(value);
}
/** Build the menu item descriptors for a tool. Exported for tests. */
export function buildCanvasMenuItems(tool) {
const items = [];
const push = (...x) => items.push(...x);
const sep = () => items.push({ type: 'sep' });
const sel = hasSelection();
if (PAINT.includes(tool)) {
push(
{ label: 'Increase Brush Size', hint: ']', onSelect: () => stepBrushSize(1.25) },
{ label: 'Decrease Brush Size', hint: '[', onSelect: () => stepBrushSize(0.8) },
{ label: 'Brush Preset', submenu: () => presetItems() },
{
label: 'Pixel Brush',
checked: !!state.brushPixel,
onSelect: () => applyPresetId(state.brushPixel ? 'hard-round' : 'pixel-1px'),
},
);
sep();
} else if (SELECT.includes(tool)) {
push(
{ label: 'Select All', hint: 'Ctrl+A', onSelect: () => invoke('selection.selectAll') },
{ label: 'Deselect', hint: 'Ctrl+D', disabled: !sel, onSelect: () => invoke('selection.deselect') },
{ label: 'Inverse Selection', disabled: !sel, onSelect: () => invoke('selection.invert') },
{ label: 'Transform Selection', disabled: !sel, onSelect: () => clickMenu('[data-selection-action="transform"]') },
{ label: 'Refine Edge…', disabled: !sel, onSelect: () => clickMenu('[data-selection-action="refine"]') },
);
sep();
push(
{ label: 'Feather…', disabled: !sel, onSelect: () => promptNumber('Feather radius (px)', 8, (v) => invoke('selection.feather', { radius: v })) },
{ label: 'Expand', disabled: !sel, onSelect: () => invoke('selection.expand', { amount: 4 }) },
{ label: 'Contract', disabled: !sel, onSelect: () => invoke('selection.contract', { amount: 4 }) },
);
sep();
} else if (tool === 'crop' || state.cropRect) {
const cropping = !!(state.cropRect || state.cropping || state.cropMoving);
push(
{ label: 'Apply Crop', disabled: !cropping, onSelect: () => clickEl('.ge-crop-apply-btn') },
{ label: 'Cancel Crop', hint: 'Esc', disabled: !cropping, onSelect: () => dispatchEscape() },
);
sep();
} else if (TRANSFORM.includes(tool) || state.transformActive) {
push(
{ label: 'Commit Transform', hint: 'Enter', disabled: !state.transformActive, onSelect: () => invoke('transform.apply') },
{ label: 'Cancel Transform', hint: 'Esc', disabled: !state.transformActive, onSelect: () => invoke('transform.cancel') },
{ label: 'Free Transform', hint: 'Ctrl+T', disabled: state.transformActive, onSelect: () => invoke('transform.start') },
);
sep();
} else if (tool === 'text') {
push(
{ label: 'Add Text Layer', onSelect: () => invoke('text.add', { text: 'Text' }) },
{ label: 'Rasterize Text', onSelect: () => invoke('layers.rasterize') },
);
sep();
} else if (tool === 'eyedropper') {
push(
{ label: 'Copy Color Hex', onSelect: () => { try { navigator.clipboard?.writeText(state.color || ''); } catch (_) {} } },
{ label: 'Swap Foreground / Background', onSelect: () => clickEl('#ge-swap-colors') },
);
sep();
} else if (tool === 'hand') {
push(
{ label: 'Zoom In', hint: 'Ctrl++', onSelect: () => clickEl('#ge-zoom-in') },
{ label: 'Zoom Out', hint: 'Ctrl+-', onSelect: () => clickEl('#ge-zoom-out') },
{ label: 'Fit on Screen', hint: 'Ctrl+0', onSelect: () => clickEl('#ge-zoom-100') },
);
sep();
} else if (tool === 'gradient' || tool === 'shape') {
push(
{ label: 'Fill Selection with Foreground', disabled: !sel, onSelect: () => invoke('selection.fill', { color: state.color }) },
);
sep();
}
if (!SELECT.includes(tool) && sel) {
push(
{ label: 'Deselect', hint: 'Ctrl+D', onSelect: () => invoke('selection.deselect') },
{ label: 'Inverse Selection', onSelect: () => invoke('selection.invert') },
{ label: 'Fill Selection', onSelect: () => invoke('selection.fill', { color: state.color }) },
{ label: 'Delete Selection', hint: 'Del', onSelect: () => invoke('selection.delete') },
);
sep();
}
push(
{ label: 'Cut', hint: 'Ctrl+X', disabled: !sel, onSelect: () => invoke('selection.copy', { cut: true }) },
{ label: 'Copy', hint: 'Ctrl+C', disabled: !sel, onSelect: () => invoke('selection.copy') },
{ label: 'Paste', hint: 'Ctrl+V', onSelect: () => invoke('selection.paste') },
sep(),
{ label: 'Select All', hint: 'Ctrl+A', onSelect: () => invoke('selection.selectAll') },
{ label: 'Deselect', hint: 'Ctrl+D', disabled: !sel, onSelect: () => invoke('selection.deselect') },
sep(),
{ label: 'Rotate 90° CW', onSelect: () => invoke('layer.rotate', { degrees: 90 }) },
{ label: 'Rotate 180°', onSelect: () => invoke('layer.rotate', { degrees: 180 }) },
{ label: 'Flip Horizontal', onSelect: () => invoke('layer.flip', { axis: 'h' }) },
{ label: 'Flip Vertical', onSelect: () => invoke('layer.flip', { axis: 'v' }) },
sep(),
{ label: 'Undo', hint: 'Ctrl+Z', onSelect: () => invoke('history.undo') },
{ label: 'Redo', hint: 'Ctrl+Shift+Z', onSelect: () => invoke('history.redo') },
);
return items;
}
function buildItemEl(item, close) {
if (item.type === 'sep') {
const hr = document.createElement('div');
hr.className = 'ge-canvas-menu-sep';
return hr;
}
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ge-canvas-menu-item';
btn.setAttribute('role', 'menuitem');
btn.disabled = !!item.disabled;
if (item.checked) btn.classList.add('is-checked');
const lead = document.createElement('span');
lead.className = 'ge-canvas-menu-lead';
lead.textContent = item.checked ? '✓' : '';
const label = document.createElement('span');
label.className = 'ge-canvas-menu-label';
label.textContent = item.label;
btn.append(lead, label);
if (item.submenu) {
btn.classList.add('has-submenu');
const caret = document.createElement('span');
caret.className = 'ge-canvas-menu-caret';
caret.textContent = '›';
btn.appendChild(caret);
btn.addEventListener('pointerenter', () => openSubmenu(btn, item.submenu));
} else if (item.hint) {
const hint = document.createElement('span');
hint.className = 'ge-canvas-menu-hint';
hint.textContent = item.hint;
btn.appendChild(hint);
}
btn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (item.disabled) return;
if (item.submenu) return;
try { item.onSelect?.(); } finally { close(); }
});
btn.addEventListener('pointerenter', () => { closeSubmenu(); });
return btn;
}
function buildMenuEl(items, close) {
const menu = document.createElement('div');
menu.className = 'ge-canvas-menu';
menu.setAttribute('role', 'menu');
for (const item of items) menu.appendChild(buildItemEl(item, close));
return menu;
}
function placeMenu(menu, x, y) {
menu.style.left = '0px';
menu.style.top = '0px';
document.body.appendChild(menu);
const rect = menu.getBoundingClientRect();
const left = clamp(x, 4, Math.max(4, window.innerWidth - rect.width - 4));
const top = clamp(y, 4, Math.max(4, window.innerHeight - rect.height - 4));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
}
function closeSubmenu() {
subMenu?.remove();
subMenu = null;
}
function openSubmenu(anchor, spec) {
closeSubmenu();
const items = typeof spec === 'function' ? spec() : spec;
if (!items || !items.length) return;
subMenu = buildMenuEl(items, closeAll);
subMenu.classList.add('ge-canvas-menu-sub');
document.body.appendChild(subMenu);
const a = anchor.getBoundingClientRect();
const r = subMenu.getBoundingClientRect();
let left = a.right - 4;
if (left + r.width > window.innerWidth - 4) left = a.left - r.width + 4;
const top = clamp(a.top - 4, 4, Math.max(4, window.innerHeight - r.height - 4));
subMenu.style.left = `${left}px`;
subMenu.style.top = `${top}px`;
}
function moveFocus(menu, delta) {
const focusables = [...menu.querySelectorAll('.ge-canvas-menu-item:not([disabled])')];
if (!focusables.length) return;
const current = focusables.indexOf(document.activeElement);
const next = clamp((current < 0 ? (delta > 0 ? -1 : 0) : current) + delta, 0, focusables.length - 1);
focusables[next]?.focus();
}
export function closeCanvasContextMenu() {
for (const fn of cleanups) { try { fn(); } catch (_) {} }
cleanups = [];
closeSubmenu();
rootMenu?.remove();
rootMenu = null;
}
/** Open the tool-aware menu at a pointer event (or {clientX, clientY}). */
export function openCanvasContextMenu(event) {
if (!state.editorOpen || !event) return;
closeCanvasContextMenu();
const menu = buildMenuEl(buildCanvasMenuItems(state.tool), closeCanvasContextMenu);
placeMenu(menu, event.clientX, event.clientY);
rootMenu = menu;
menu.querySelector('.ge-canvas-menu-item:not([disabled])')?.focus();
const onDown = (ev) => {
if (menu.contains(ev.target)) return;
if (subMenu && subMenu.contains(ev.target)) return;
closeCanvasContextMenu();
};
const onKey = (ev) => {
if (ev.key === 'Escape') { ev.preventDefault(); closeCanvasContextMenu(); }
else if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') { ev.preventDefault(); moveFocus(menu, ev.key === 'ArrowDown' ? 1 : -1); }
else if (ev.key === 'Enter') {
const active = document.activeElement;
if (active?.classList?.contains('ge-canvas-menu-item')) { ev.preventDefault(); active.click(); }
}
};
const onAway = () => closeCanvasContextMenu();
document.addEventListener('pointerdown', onDown, true);
document.addEventListener('keydown', onKey, true);
window.addEventListener('resize', onAway);
window.addEventListener('blur', onAway);
cleanups.push(() => document.removeEventListener('pointerdown', onDown, true));
cleanups.push(() => document.removeEventListener('keydown', onKey, true));
cleanups.push(() => window.removeEventListener('resize', onAway));
cleanups.push(() => window.removeEventListener('blur', onAway));
}
/** Wire right-click on the image (and the surrounding canvas area). */
export function wireCanvasContextMenu(canvasArea, { signal } = {}) {
const canvas = state.mainCanvas;
if (!canvas) return;
const opts = signal ? { signal } : undefined;
canvas.addEventListener('contextmenu', (event) => {
event.preventDefault();
openCanvasContextMenu(event);
}, opts);
canvasArea?.addEventListener('contextmenu', (event) => {
if (event.target === canvas) return;
if (event.target.closest?.('input, textarea, .ge-canvas-menu')) return;
event.preventDefault();
openCanvasContextMenu(event);
}, opts);
}
+102 -3
View File
@@ -16,6 +16,12 @@
* touchmove 1 finger → continueDraw
* touchend → endDraw; touchcancel → cancelDraw
* touchstart 2 fingers → pinch-zoom + 2-finger pan
* 2-finger tap → undo (onTwoFingerTap)
* 3-finger tap → redo (onThreeFingerTap)
*
* A multi-finger gesture reverts the accidental single-finger stroke
* begun by the first finger to land (cancelDraw), so a pinch or a
* tap never leaves a stray dab behind.
*
* Pan:
* Hand tool / held Space / middle mouse can drag directly over the
@@ -33,6 +39,8 @@
* cancelDraw?: () => void,
* updateBrushCursor: (e: Event) => void,
* syncZoomControls?: () => void,
* onTwoFingerTap?: () => void,
* onThreeFingerTap?: () => void,
* }} ctx
*/
import { state } from './state.js';
@@ -42,14 +50,35 @@ import {
nextPanOffset,
syncPanCursor,
} from './canvas-navigation.js';
import { wireCanvasContextMenu } from './canvas-context-menu.js';
let canvasWindowBindings;
export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, cancelDraw, updateBrushCursor, updateEyedropperPreview, syncZoomControls, onViewportChange }) {
export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, cancelDraw, updateBrushCursor, updateEyedropperPreview, syncZoomControls, onViewportChange, onTwoFingerTap, onThreeFingerTap }) {
canvasWindowBindings?.abort();
canvasWindowBindings = new AbortController();
const { signal } = canvasWindowBindings;
let suppressMouseUntil = 0;
// Tool-aware right-click menu on the image and the surrounding area.
wireCanvasContextMenu(canvasArea, { signal });
// Window-level drag handlers exist so a gesture can continue past the
// canvas edge. But the direct-manipulation selection/crop rects are
// built from raw pointer coords, so pointer motion over editor chrome
// (topbar, toolbars, panels) must not feed the rect. Brush/transform/
// move/resize gestures DO keep tracking over chrome — overshooting the
// canvas edge is normal for those and was the reason for the window
// binding.
const UI_CHROME_SELECTOR = '.ge-topbar, .ge-toolbar, .ge-controls, .ge-right-panel, '
+ '.ge-dock, .ge-options-bar, .ge-canvas-toolbar, button, input, select, textarea';
const isUiChrome = e => !!e.target?.closest?.(UI_CHROME_SELECTOR);
// Only in-progress selection/crop drags are affected by the chrome
// guard. A committed cropRect/selection is a resting state — clicking
// its Apply button must not be treated as "strayed onto chrome".
const directGestureActive = () => !!(
state.selectionMoving || state.marqueeActive ||
state.lassoActive || (state.lassoPoints && state.lassoPoints.length) ||
state.cropping || state.cropMoving
);
// 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.
@@ -60,12 +89,25 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
});
window.addEventListener('mousemove', (e) => {
if (Date.now() < suppressMouseUntil) return;
// Only shield the raw-coordinate selection/crop rects from chrome.
if (isUiChrome(e) && directGestureActive()) return;
continueDraw(e);
}, { signal });
window.addEventListener('mouseup', (e) => {
if (Date.now() < suppressMouseUntil) return;
// Releasing over chrome commits the gesture; the marquee/crop end
// paths clamp the rect to the document, so an overshoot onto a panel
// is kept (Photoshop behaviour), not discarded.
endDraw(e);
}, { signal });
// A tool switch happens on the tool button, before the release. Cancel
// any in-progress direct gesture first so its move is reverted rather
// than committed by the subsequent chrome mouseup. Explicit tool
// switches are the only place we abandon a selection/crop gesture.
window.addEventListener('mousedown', (e) => {
if (!directGestureActive()) return;
if (e.target?.closest?.('.ge-tool-btn')) cancelDraw?.();
}, { capture: true, signal });
// Preserve pressure and browser-coalesced samples for pen input.
// Compatibility mouse events are briefly suppressed to avoid a
// duplicate stroke after pointerup.
@@ -121,11 +163,22 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
});
// Touch — single finger draws; two fingers pan + pinch-zoom.
// A quick two-finger tap = undo, a quick three-finger tap = redo.
let multiActive = false;
let multiStartDist = 0;
let multiStartZoom = 1;
let multiStartCenter = { x: 0, y: 0 };
let multiStartPan = { x: 0, y: 0 };
// Tap-gesture tracking (mobile undo/redo). A gesture starts when the
// first finger lands and is decided when the last finger lifts: at
// most TAP_MAX_MS elapsed, the centre never moved more than
// TAP_MAX_MOVE px, and the peak finger count was 2 (undo) or 3 (redo).
const TAP_MAX_MS = 300;
const TAP_MAX_MOVE = 12;
let gesturePeakTouches = 0;
let gestureStartAt = 0;
let gestureStartCenter = null;
let gestureMoved = false;
const touchInfo = (e) => {
const t1 = e.touches[0], t2 = e.touches[1];
const cx = (t1.clientX + t2.clientX) / 2;
@@ -134,12 +187,30 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
const dy = t2.clientY - t1.clientY;
return { cx, cy, dist: Math.hypot(dx, dy) };
};
const touchCenter = (e) => {
if (!e.touches || !e.touches.length) return null;
let x = 0, y = 0;
for (const t of e.touches) { x += t.clientX; y += t.clientY; }
return { x: x / e.touches.length, y: y / e.touches.length };
};
const applyCanvasOffset = (x, y) => applyCanvasPan(state, canvasArea, x, y);
state.mainCanvas.addEventListener('touchstart', (e) => {
e.preventDefault();
if (gesturePeakTouches === 0) {
// First contact of a fresh gesture (finger counts are read from
// e.touches so simultaneous touches are handled too).
gesturePeakTouches = Math.max(1, e.touches.length);
gestureStartAt = Date.now();
gestureStartCenter = touchCenter(e);
gestureMoved = false;
} else if (e.touches.length > gesturePeakTouches) {
gesturePeakTouches = e.touches.length;
}
if (e.touches.length >= 2) {
// End any in-progress single-finger draw before switching modes.
if (!multiActive) endDraw();
// Revert the accidental single-finger stroke the first finger
// began, then switch to pinch/pan so multi-finger input never
// leaves a stray dab behind.
if (!multiActive) cancelDraw?.();
multiActive = true;
const info = touchInfo(e);
multiStartDist = info.dist;
@@ -154,6 +225,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
}, { passive: false });
state.mainCanvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (!gestureMoved && gestureStartCenter) {
const c = touchCenter(e);
if (c && Math.hypot(c.x - gestureStartCenter.x, c.y - gestureStartCenter.y) > TAP_MAX_MOVE) {
gestureMoved = true;
}
}
if (multiActive && e.touches.length >= 2) {
const info = touchInfo(e);
const ratio = info.dist / Math.max(1, multiStartDist);
@@ -177,14 +254,36 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
continueDraw(e);
}, { passive: false });
state.mainCanvas.addEventListener('touchend', (e) => {
// Decide the gesture only once every finger has lifted.
if (e.touches.length === 0 && gesturePeakTouches >= 2) {
const quick = !gestureMoved && (Date.now() - gestureStartAt) <= TAP_MAX_MS;
const peak = gesturePeakTouches;
gesturePeakTouches = 0;
gestureStartCenter = null;
gestureMoved = false;
multiActive = false;
if (quick) {
if (peak === 2) onTwoFingerTap?.();
else if (peak >= 3) onThreeFingerTap?.();
}
return;
}
if (multiActive) {
if (e.touches.length < 2) multiActive = false;
return;
}
endDraw(e);
if (e.touches.length === 0) {
gesturePeakTouches = 0;
gestureStartCenter = null;
gestureMoved = false;
}
});
state.mainCanvas.addEventListener('touchcancel', () => {
multiActive = false;
gesturePeakTouches = 0;
gestureStartCenter = null;
gestureMoved = false;
cancelDraw?.();
});
+44 -1
View File
@@ -18,7 +18,7 @@
* }} deps
*/
import { state } from './state.js';
import { flipDocument, rotateDocument } from './document-geometry.js';
import { flipDocument, rotateDocument, rotateDocumentArbitrary, flipLayer as flipLayerGeometry, rotateLayer as rotateLayerGeometry } from './document-geometry.js';
export function createCanvasTransforms({
saveState, composite, fitZoom, showCanvasLoading, hideCanvasLoading, renderLayerPanel,
@@ -52,6 +52,29 @@ export function createCanvasTransforms({
});
},
/**
* Rotate the entire document by an arbitrary angle in degrees
* (clockwise; negative for counter-clockwise), expanding the canvas
* so nothing is clipped. Right angles use the exact integer path.
*/
rotateAllArbitrary(deg) {
if (!state.layers.length) return;
saveState(`Rotate ${deg}°`);
showCanvasLoading('Rotating…');
requestAnimationFrame(() => {
try {
const result = rotateDocumentArbitrary(state, deg);
const sizeLabel = document.getElementById('ge-canvas-size');
if (sizeLabel && result) sizeLabel.textContent = `${result.width}×${result.height}`;
fitZoom();
composite();
renderLayerPanel?.();
} finally {
hideCanvasLoading();
}
});
},
/**
* Mirror every layer horizontally ('h') or vertically ('v').
* Canvas dimensions don't change. Each layer offset is reflected
@@ -64,5 +87,25 @@ export function createCanvasTransforms({
composite();
renderLayerPanel?.();
},
/** Flip only the active layer, in place (Edit ▸ Transform ▸ Flip). */
flipLayer(axis) {
const layer = state.layers.find((item) => item.id === state.activeLayerId);
if (!layer) return;
saveState(axis === 'h' ? 'Flip layer horizontal' : 'Flip layer vertical');
flipLayerGeometry(state, layer, axis);
composite();
renderLayerPanel?.();
},
/** Rotate only the active layer by 90/180/270°, centre preserved. */
rotateLayer(deg) {
const layer = state.layers.find((item) => item.id === state.activeLayerId);
if (!layer) return;
saveState(`Rotate layer ${deg}°`);
rotateLayerGeometry(state, layer, deg);
composite();
renderLayerPanel?.();
},
};
}
+56 -26
View File
@@ -38,35 +38,45 @@ export function wireClipboardAndDrop({
clipboardBindings?.abort();
clipboardBindings = new AbortController();
const { signal } = clipboardBindings;
const pasteAsLayer = (imgSource, label, offset = { x: 0, y: 0 }) => {
if (!state.editorOpen) return; // user closed mid-paste
saveState();
const layer = createLayer(label || 'Pasted', imgSource.width, imgSource.height);
// Selection clipboard data is already a complete layer-sized surface.
// Keep it source-backed with an identity matrix so future transforms do
// not repeatedly resample the pasted pixels.
layer.kind = 'placed';
layer.placed = createPlacedData(imgSource, [1, 0, 0, 1, offset.x, offset.y], label || 'Pasted');
const rendered = renderPlacedLayer(layer);
state.layerOffsets.set(layer.id, rendered.offset);
state.layers.push(layer);
state.activeLayerId = layer.id;
state.selectedLayerIds = [layer.id];
state.activeGroupId = null;
const tb = state.container?.querySelector('.ge-toolbar');
tb?.querySelector('[data-tool="move"]')?.click();
renderLayerPanel();
composite();
uiModule.showToast('Pasted as new layer');
};
// ── Paste ──
window.addEventListener('paste', (e) => {
if (!state.editorOpen || state.container !== container) return;
// Editable fields keep native paste. The Clipboard button focuses the
// canvas container before asking for a keyboard paste when read() is
// unavailable on an insecure origin.
if (e.target?.isContentEditable || e.target?.closest?.('input, textarea, select, [role="dialog"]')) return;
function pasteAsLayer(imgSource, label, offset = { x: 0, y: 0 }) {
if (!state.editorOpen) return; // user closed mid-paste
saveState();
const layer = createLayer(label || 'Pasted', imgSource.width, imgSource.height);
// Selection clipboard data is already a complete layer-sized surface.
// Keep it source-backed with an identity matrix so future transforms do
// not repeatedly resample the pasted pixels.
layer.kind = 'placed';
layer.placed = createPlacedData(imgSource, [1, 0, 0, 1, offset.x, offset.y], label || 'Pasted');
const rendered = renderPlacedLayer(layer);
state.layerOffsets.set(layer.id, rendered.offset);
state.layers.push(layer);
state.activeLayerId = layer.id;
state.selectedLayerIds = [layer.id];
state.activeGroupId = null;
const tb = state.container?.querySelector('.ge-toolbar');
tb?.querySelector('[data-tool="move"]')?.click();
renderLayerPanel();
composite();
uiModule.showToast('Pasted as new layer');
}
// Editable fields INSIDE the editor keep native paste (the Clipboard button
// focuses the canvas container before asking for a keyboard paste when
// read() is unavailable on an insecure origin). Fields OUTSIDE the editor —
// notably the chat composer sitting behind the editor modal — must NOT
// swallow an image paste: while the editor is open it owns clipboard images,
// so we intercept here (capture phase) before the global chat attach
// listener in app.js can stage the file into the chatbox.
// NOTE: do NOT add `[role="dialog"]` to this guard — the editor itself lives
// inside the gallery modal's role="dialog" modal-content, so that would
// swallow every paste (including the internal Clipboard + Ctrl+V).
const target = e.target;
const insideEditor = target && container && container.contains(target);
if (insideEditor && (target.isContentEditable || target.closest?.('input, textarea, select'))) return;
const imageItem = Array.from(e.clipboardData?.items || []).find(item => item.type.startsWith('image/'));
if (imageItem) {
@@ -91,6 +101,26 @@ export function wireClipboardAndDrop({
}
}, { capture: true, signal });
// ── Ctrl/Cmd+V ──
// Paste the internal selection clipboard directly. On an insecure origin
// (the editor is served over http on a LAN IP) `navigator.clipboard` is
// unavailable, so copying a selection never reaches the OS clipboard and the
// native `paste` event above either carries stale `text/plain` or nothing —
// leaving Ctrl+V a silent no-op. Handling the keystroke here makes
// copy→paste work regardless of the platform clipboard (the toolbar buttons
// already did, which is why they worked).
window.addEventListener('keydown', (e) => {
if (!(state.editorOpen && state.container === container)) return;
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return;
if (String(e.key || '').toLowerCase() !== 'v') return;
if (!state.internalClipboard) return;
const target = e.target;
if (target && (target.isContentEditable || target.closest?.('input, textarea, select'))) return;
e.preventDefault();
e.stopImmediatePropagation();
pasteAsLayer(state.internalClipboard, 'Pasted Selection', state.internalClipboardOffset || { x: 0, y: 0 });
}, { capture: true, signal });
// ── Drag-and-drop ──
// Visual drop-zone overlay appears mid-drag; routes via
// handleImportedImage so the import respects canvas resizing rules
+56
View File
@@ -0,0 +1,56 @@
/**
* Color-mode support (PhotoCraft parity, Phase 10 slice).
*
* The editor works in an RGBA8 canvas, so only RGB and Grayscale are
* convertible destructively; CMYK/Lab/Indexed remain metadata-only menu
* entries (disabled) because they need a color-managed pipeline we do not
* have. Bit depth is tracked as document metadata only.
*/
export const COLOR_MODES = Object.freeze(['rgb', 'grayscale', 'cmyk', 'lab', 'indexed']);
export const BIT_DEPTHS = Object.freeze([8, 16, 32]);
/** True for modes we can actually convert layer pixels into/out of. */
export function isConvertibleMode(mode) {
return mode === 'rgb' || mode === 'grayscale';
}
/** Convert an ImageData-like buffer to grayscale in place (Rec. 709 luma). */
export function rgbaToGrayscale(data) {
for (let i = 0; i < data.length; i += 4) {
const luma = Math.round(0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]);
data[i] = luma;
data[i + 1] = luma;
data[i + 2] = luma;
}
return data;
}
/** Convert a single canvas to the requested mode in place. Returns true on success. */
export function convertCanvasToMode(canvas, mode) {
if (!canvas || !canvas.getContext) return false;
if (!isConvertibleMode(mode)) return false;
if (mode === 'grayscale') {
const ctx = canvas.getContext('2d');
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
rgbaToGrayscale(image.data);
ctx.putImageData(image, 0, 0);
}
// RGB is the native working space; nothing to do.
return true;
}
/** Apply a mode to every layer canvas. Returns the number of layers converted. */
export function applyColorMode(layers, mode) {
if (!isConvertibleMode(mode)) return 0;
let converted = 0;
for (const layer of layers || []) {
if (layer && layer.canvas && convertCanvasToMode(layer.canvas, mode)) converted += 1;
}
return converted;
}
/** Only 8 bits/channel is supported by the RGBA8 working canvas. */
export function supportsBitDepth(bits) {
return bits === 8;
}
+201
View File
@@ -0,0 +1,201 @@
/*
* Editor control kit — shared theming for the range/number/select/checkbox
* controls scattered across the editor. It only ADDS classes and small helper
* affordances (range fill, custom steppers, scrubby labels); it never changes
* the value semantics of a control. It is strictly SCOPED to editor surfaces
* (the editor shell and its dialogs) so the rest of the app is untouched, and
* it skips controls that already have a bespoke implementation (e.g. the
* transform popup's own spinner).
*
* Styling lives in static/css/13-image-editor.css (section "Editor control kit").
*/
export const CONTROL_KIT_VERSION = '1.0';
const RANGE_SELECTOR = 'input[type="range"]';
const NUMBER_SELECTOR = 'input[type="number"]';
const CHECK_SELECTOR = 'input[type="checkbox"]';
const SELECT_SELECTOR = 'select';
// Only controls inside one of these surfaces are themed. Dialogs are appended
// to <body> outside #gallery-editor-container, so they are listed explicitly.
const EDITOR_ROOT_SELECTOR = [
'#gallery-editor-container',
'.ge-adj-popup',
'.ge-filter-overlay',
'.ge-canvas-size-overlay',
'#ge-canvas-size-overlay',
'.ge-float-window',
'.ge-transform-popup',
].join(', ');
function inEditor(el) {
return !!(el && el.closest && el.closest(EDITOR_ROOT_SELECTOR));
}
function isSkipped(el) {
if (!el || el.__geKit) return true;
if (!inEditor(el)) return true;
if (el.closest('[data-ge-nokit]')) return true;
// The transform popup renders its own themed spinner for its inputs.
if (el.closest('.ge-transform-field')) return true;
return false;
}
function numberStep(el) {
const raw = Number(el.getAttribute('step'));
if (Number.isFinite(raw) && raw > 0) return raw;
return 1;
}
function clampToRange(el, value) {
const min = el.hasAttribute('min') ? Number(el.min) : -Infinity;
const max = el.hasAttribute('max') ? Number(el.max) : Infinity;
let v = value;
if (Number.isFinite(min)) v = Math.max(min, v);
if (Number.isFinite(max)) v = Math.min(max, v);
return v;
}
function attachScrub(handle, el) {
handle.classList.add('ge-scrub');
handle.addEventListener('pointerdown', (e) => {
if (el.disabled || el.readOnly) return;
e.preventDefault();
handle.classList.add('ge-scrubbing');
const startX = e.clientX;
const startVal = Number(el.value) || 0;
const step = numberStep(el);
let moved = false;
const move = (ev) => {
const dx = ev.clientX - startX;
if (Math.abs(dx) < 2) return;
moved = true;
const factor = ev.shiftKey ? 10 : 1;
const next = clampToRange(el, startVal + Math.round(dx) * step * factor * 0.1);
if (String(next) !== el.value) {
el.value = String(next);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
};
const up = () => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
handle.classList.remove('ge-scrubbing');
if (moved) el.dispatchEvent(new Event('change', { bubbles: true }));
};
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
});
}
function decorateNumber(el) {
if (isSkipped(el)) return;
el.classList.add('ge-ctl-num');
el.__geKit = true;
if (el.disabled || el.readOnly) return;
// Photoshop-style scrub-on-label: drag a sibling <span> label to change the
// value; the field also keeps native arrow-key stepping. We do NOT inject a
// separate +/- stepper — most editor number fields live in display:grid
// labels, where an injected element wraps onto its own grid row and shows as
// an empty pill.
const label = el.closest('label');
if (label) {
const handle = label.querySelector(':scope > span');
if (handle && !handle.classList.contains('ge-scrub')) attachScrub(handle, el);
}
}
function updateRangeFill(el) {
const min = Number(el.min);
const max = Number(el.max);
const value = Number(el.value);
if (Number.isFinite(min) && Number.isFinite(max) && max > min) {
const pct = ((value - min) / (max - min)) * 100;
el.style.setProperty('--ge-pct', `${Math.max(0, Math.min(100, pct))}%`);
} else {
el.style.setProperty('--ge-pct', '50%');
}
}
function decorateRange(el) {
if (isSkipped(el)) return;
el.classList.add('ge-ctl-range');
el.__geKit = true;
updateRangeFill(el);
}
function decorateSelect(el) {
if (isSkipped(el)) return;
el.classList.add('ge-ctl-select');
el.__geKit = true;
}
function decorateCheck(el) {
if (isSkipped(el)) return;
el.classList.add('ge-ctl-check');
el.__geKit = true;
}
export function initControlKit(root = document) {
if (!root || !root.querySelectorAll) return 0;
// If the root itself is a control, decorate just it; otherwise its subtree.
let count = 0;
const decorate = (el) => {
if (el.matches(RANGE_SELECTOR)) { if (!isSkipped(el)) { decorateRange(el); count += 1; } }
else if (el.matches(NUMBER_SELECTOR)) { if (!isSkipped(el)) { decorateNumber(el); count += 1; } }
else if (el.matches(SELECT_SELECTOR)) { if (!isSkipped(el)) { decorateSelect(el); count += 1; } }
else if (el.matches(CHECK_SELECTOR)) { if (!isSkipped(el)) { decorateCheck(el); count += 1; } }
};
if (root.nodeType === 1) decorate(root);
root.querySelectorAll(RANGE_SELECTOR).forEach((el) => { if (!isSkipped(el)) { decorateRange(el); count += 1; } });
root.querySelectorAll(NUMBER_SELECTOR).forEach((el) => { if (!isSkipped(el)) { decorateNumber(el); count += 1; } });
root.querySelectorAll(SELECT_SELECTOR).forEach((el) => { if (!isSkipped(el)) { decorateSelect(el); count += 1; } });
root.querySelectorAll(CHECK_SELECTOR).forEach((el) => { if (!isSkipped(el)) { decorateCheck(el); count += 1; } });
return count;
}
let installed = false;
/**
* Install once: decorates the current editor DOM, keeps range fills in sync,
* and watches for controls that appear later (dialogs, popups, dock panels).
* Only nodes that are (or contain) an editor surface are processed.
*/
export function installControlKit() {
if (installed) return;
installed = true;
const boot = () => {
document.querySelectorAll(EDITOR_ROOT_SELECTOR).forEach((root) => initControlKit(root));
};
boot();
document.addEventListener(
'input',
(e) => {
const t = e.target;
if (t && t.matches && t.matches(RANGE_SELECTOR)) updateRangeFill(t);
},
true
);
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
m.addedNodes.forEach((node) => {
if (node.nodeType !== 1) return;
const isRoot = node.matches && node.matches(EDITOR_ROOT_SELECTOR);
const containsRoot = node.querySelector && node.querySelector(EDITOR_ROOT_SELECTOR);
if (isRoot) {
initControlKit(node);
} else if (containsRoot) {
node.querySelectorAll(EDITOR_ROOT_SELECTOR).forEach((root) => initControlKit(root));
} else if (inEditor(node)) {
initControlKit(node);
}
});
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
export default { CONTROL_KIT_VERSION, initControlKit, installControlKit };
+10 -2
View File
@@ -24,6 +24,8 @@ const SUPPORTED_BLEND_MODES = new Set([
'source-over', 'multiply', 'screen', 'overlay', 'soft-light', 'hard-light',
'darken', 'lighten', 'color-dodge', 'color-burn', 'difference', 'exclusion',
'hue', 'saturation', 'color', 'luminosity',
'dissolve', 'linear-burn', 'linear-dodge', 'darker-color', 'lighter-color',
'vivid-light', 'linear-light', 'pin-light', 'hard-mix', 'subtract', 'divide',
]);
function normalizeGuideValues(values, fallback = []) {
@@ -51,6 +53,8 @@ export function normalizeEditorView(view, fallback = {}) {
gridSize: Math.max(2, Math.min(1000, Math.round(Number.isFinite(rawGridSize) ? rawGridSize : 16))),
snapEnabled: Boolean(source.snapEnabled ?? fallback.snapEnabled ?? false),
snapToGrid: Boolean(source.snapToGrid ?? fallback.snapToGrid ?? false),
autoSelectLayer: Boolean(source.autoSelectLayer ?? fallback.autoSelectLayer ?? false),
autoSelectTarget: (source.autoSelectTarget ?? fallback.autoSelectTarget) === 'group' ? 'group' : 'layer',
guides: {
vertical: normalizeGuideValues(guides.vertical, fallbackGuides.vertical),
horizontal: normalizeGuideValues(guides.horizontal, fallbackGuides.horizontal),
@@ -590,8 +594,10 @@ export function prepareEditorDocument(raw) {
document: {
...migrated,
v: EDITOR_DOCUMENT_VERSION,
imgWidth: width,
imgHeight: height,
imgWidth: width,
imgHeight: height,
colorMode: migrated.colorMode === 'grayscale' ? 'grayscale' : 'rgb',
bitDepth: 8,
activeLayerId,
view: normalizeEditorView(migrated.view),
groups,
@@ -634,6 +640,8 @@ export function serializeEditorDocument(state, extras = {}) {
imageId: state.imageId || null,
imgWidth: state.imgWidth,
imgHeight: state.imgHeight,
colorMode: state.colorMode || 'rgb',
bitDepth: state.bitDepth || 8,
activeLayerId: state.activeLayerId || null,
nextLayerId: state.nextLayerId,
view: normalizeEditorView(state),
+272 -6
View File
@@ -9,18 +9,22 @@
import { flipTextLayer, rotateTextLayer, scaleTextLayer } from './text-layer.js';
import { flipShapeLayer, rotateShapeLayer, scaleShapeLayer } from './shape-layer.js';
import { renderPlacedLayer, transformPlacedData } from './placed-layer.js';
import { affineBounds, renderPlacedLayer, transformPlacedData } from './placed-layer.js';
function makeCanvas(width, height) {
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width));
canvas.height = Math.max(1, Math.round(height));
canvas.width = Math.max(1, Math.round(Number.isFinite(width) ? width : 1));
canvas.height = Math.max(1, Math.round(Number.isFinite(height) ? height : 1));
return canvas;
}
function renderWindow(source, x, y, width, height) {
const out = makeCanvas(width, height);
out.getContext('2d').drawImage(source, -x, -y);
// A freshly-created / empty layer can legitimately have a 0-sized canvas;
// drawImage() throws on those, which would abort cropping the whole document.
if (source && source.width > 0 && source.height > 0) {
out.getContext('2d').drawImage(source, -x, -y);
}
return out;
}
@@ -50,6 +54,25 @@ function renderRotated(source, degrees) {
return out;
}
function renderRotatedArbitrary(source, degrees) {
const radians = (degrees * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const width = Math.abs(source.width * cos) + Math.abs(source.height * sin);
const height = Math.abs(source.width * sin) + Math.abs(source.height * cos);
const out = makeCanvas(Math.ceil(width), Math.ceil(height));
// Empty layers can have a 0-sized canvas; drawImage() throws on those.
if (source && source.width > 0 && source.height > 0) {
const ctx = out.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.translate(out.width / 2, out.height / 2);
ctx.rotate(radians);
ctx.drawImage(source, -source.width / 2, -source.height / 2);
}
return out;
}
function renderFlipped(source, axis) {
const out = makeCanvas(source.width, source.height);
const ctx = out.getContext('2d');
@@ -172,10 +195,13 @@ export function clipPolygonToRect(points, width, height) {
}
function normalizeCropRect(editorState, rect) {
// Accept either {w,h} (internal) or {width,height} (control-op params).
const rawW = Number.isFinite(rect.w) ? rect.w : rect.width;
const rawH = Number.isFinite(rect.h) ? rect.h : rect.height;
const x = Math.max(0, Math.min(editorState.imgWidth - 1, Math.round(rect.x)));
const y = Math.max(0, Math.min(editorState.imgHeight - 1, Math.round(rect.y)));
const width = Math.max(1, Math.min(editorState.imgWidth - x, Math.round(rect.w)));
const height = Math.max(1, Math.min(editorState.imgHeight - y, Math.round(rect.h)));
const width = Math.max(1, Math.min(editorState.imgWidth - x, Math.round(Number.isFinite(rawW) ? rawW : editorState.imgWidth)));
const height = Math.max(1, Math.min(editorState.imgHeight - y, Math.round(Number.isFinite(rawH) ? rawH : editorState.imgHeight)));
return { x, y, width, height };
}
@@ -501,6 +527,121 @@ export function rotateDocument(editorState, degrees) {
return { width: newWidth, height: newHeight, degrees: normalized };
}
/**
* Rotate the whole document by an arbitrary angle (degrees, clockwise),
* expanding the canvas so nothing is clipped. Right angles delegate to the
* exact integer path; other angles resample every layer, mask, and selection
* about the old image centre and re-place each layer's centre in the new
* frame. Guides are dropped because arbitrary rotation leaves no
* axis-aligned reference.
*/
export function rotateDocumentArbitrary(editorState, degrees) {
const raw = Number(degrees);
if (!Number.isFinite(raw)) return null;
const normalized = ((raw % 360) + 360) % 360;
if (normalized === 0) {
return { width: editorState.imgWidth, height: editorState.imgHeight, degrees: 0 };
}
if ([90, 180, 270].includes(normalized)) return rotateDocument(editorState, normalized);
const radians = (normalized * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const oldWidth = editorState.imgWidth;
const oldHeight = editorState.imgHeight;
const newWidth = Math.max(1, Math.ceil(Math.abs(oldWidth * cos) + Math.abs(oldHeight * sin)));
const newHeight = Math.max(1, Math.ceil(Math.abs(oldWidth * sin) + Math.abs(oldHeight * cos)));
const mapPoint = (x, y) => ({
x: cos * (x - oldWidth / 2) - sin * (y - oldHeight / 2) + newWidth / 2,
y: sin * (x - oldWidth / 2) + cos * (y - oldHeight / 2) + newHeight / 2,
});
for (const layer of editorState.layers) {
const before = layerBounds(editorState, layer);
const mapped = mapPoint(before.centerX, before.centerY);
let placedOffset = null;
if (layer.kind === 'text' && layer.text) {
rotateTextLayer(layer, normalized);
} else if (layer.kind === 'shape' && layer.shape) {
rotateShapeLayer(layer, normalized);
} else if (layer.kind === 'placed' && layer.placed) {
// Rotate the placed matrix about its centre, then translate that centre
// to where the document rotation maps it.
const matrix = [
cos, sin, -sin, cos,
before.centerX - cos * before.centerX + sin * before.centerY + (mapped.x - before.centerX),
before.centerY - sin * before.centerX - cos * before.centerY + (mapped.y - before.centerY),
];
layer.placed = transformPlacedData(layer.placed, matrix);
placedOffset = renderPlacedLayer(layer)?.offset || null;
} else {
replaceCanvas(layer, renderRotatedArbitrary(layer.canvas, normalized));
}
const layerWidth = layer.canvas.width;
const layerHeight = layer.canvas.height;
for (const mask of layer.masks || []) {
const oldMaskWidth = mask.canvas.width;
const oldMaskHeight = mask.canvas.height;
const maskOffset = mask.offset || { x: 0, y: 0 };
replaceCanvas(mask, renderRotatedArbitrary(mask.canvas, normalized));
const cx = (Number(maskOffset.x) || 0) + oldMaskWidth / 2;
const cy = (Number(maskOffset.y) || 0) + oldMaskHeight / 2;
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
// Layer-space masks rotate with their layer's canvas.
const nx = cos * (cx - before.width / 2) - sin * (cy - before.height / 2) + layerWidth / 2;
const ny = sin * (cx - before.width / 2) + cos * (cy - before.height / 2) + layerHeight / 2;
mask.offset = {
x: Math.round(nx - mask.canvas.width / 2),
y: Math.round(ny - mask.canvas.height / 2),
};
} else {
const mappedCenter = mapPoint(cx, cy);
mask.offset = {
x: Math.round(mappedCenter.x - mask.canvas.width / 2),
y: Math.round(mappedCenter.y - mask.canvas.height / 2),
};
}
}
editorState.layerOffsets.set(layer.id, placedOffset || {
x: Math.round(mapped.x - layerWidth / 2),
y: Math.round(mapped.y - layerHeight / 2),
});
invalidateLayerCaches(layer);
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) {
const oldMaskWidth = mask.canvas.width;
const oldMaskHeight = mask.canvas.height;
const maskOffset = mask.offset || { x: 0, y: 0 };
replaceCanvas(mask, renderRotatedArbitrary(mask.canvas, normalized));
const mappedCenter = mapPoint(
(Number(maskOffset.x) || 0) + oldMaskWidth / 2,
(Number(maskOffset.y) || 0) + oldMaskHeight / 2,
);
mask.offset = {
x: Math.round(mappedCenter.x - mask.canvas.width / 2),
y: Math.round(mappedCenter.y - mask.canvas.height / 2),
};
}
}
if (editorState.wandMask) {
replaceStandaloneCanvas(editorState.wandMask, renderRotatedArbitrary(editorState.wandMask, normalized));
}
forEachStoredSelection(editorState, canvas => {
replaceStandaloneCanvas(canvas, renderRotatedArbitrary(canvas, normalized));
});
if (editorState.wandLastSeed) {
const mappedSeed = mapPoint(editorState.wandLastSeed.x, editorState.wandLastSeed.y);
editorState.wandLastSeed = { ...editorState.wandLastSeed, x: mappedSeed.x, y: mappedSeed.y };
}
editorState.lassoPoints = (editorState.lassoPoints || []).map(point => mapPoint(point.x, point.y));
// Arbitrary rotation leaves no axis-aligned reference, so guides are dropped.
editorState.guides = { vertical: [], horizontal: [] };
syncDocumentCanvas(editorState, newWidth, newHeight);
return { width: newWidth, height: newHeight, degrees: normalized };
}
/** Flip every layer, mask, offset, and active selection across the document. */
export function flipDocument(editorState, axis) {
if (axis !== 'h' && axis !== 'v') return null;
@@ -573,3 +714,128 @@ export function flipDocument(editorState, axis) {
syncDocumentCanvas(editorState, width, height);
return { width, height, axis };
}
// ── Single-layer transforms (Edit ▸ Transform semantics) ──
// Unlike the document-wide helpers above, these mutate ONE layer in place:
// the document bounds, other layers, and the active selection are untouched.
// The layer's visual centre is preserved so a rotation does not fling it
// across the canvas.
function layerBounds(editorState, layer) {
if (layer.kind === 'placed' && layer.placed?.sourceCanvas) {
const sw = layer.placed.sourceWidth || layer.placed.sourceCanvas.width;
const sh = layer.placed.sourceHeight || layer.placed.sourceCanvas.height;
return affineBounds(layer.placed.matrix, sw, sh);
}
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const width = layer.canvas.width;
const height = layer.canvas.height;
return {
x: offset.x,
y: offset.y,
width,
height,
centerX: offset.x + width / 2,
centerY: offset.y + height / 2,
};
}
function recenterLayer(editorState, layer, before) {
const width = layer.canvas.width;
const height = layer.canvas.height;
editorState.layerOffsets.set(layer.id, {
x: Math.round(before.centerX - width / 2),
y: Math.round(before.centerY - height / 2),
});
}
function flipMatrixAbout(centerX, centerY, axis) {
return axis === 'h'
? [-1, 0, 0, 1, 2 * centerX, 0]
: [1, 0, 0, -1, 0, 2 * centerY];
}
function rotateMatrixAbout(centerX, centerY, degrees) {
const radians = (degrees * Math.PI) / 180;
const cos = Math.round(Math.cos(radians));
const sin = Math.round(Math.sin(radians));
// T(center) · R · T(-center)
return [
cos, sin, -sin, cos,
centerX - cos * centerX + sin * centerY,
centerY - sin * centerX - cos * centerY,
];
}
function transformLayerMasks(layer, transform) {
for (const mask of layer.masks || []) {
transform(mask);
}
}
/** Flip a single layer horizontally ('h') or vertically ('v'), in place. */
export function flipLayer(editorState, layer, axis) {
if (!layer || (axis !== 'h' && axis !== 'v')) return null;
const before = layerBounds(editorState, layer);
if (layer.kind === 'text' && layer.text) flipTextLayer(layer, axis);
else if (layer.kind === 'shape' && layer.shape) flipShapeLayer(layer, axis);
else if (layer.kind === 'placed' && layer.placed) {
layer.placed = transformPlacedData(layer.placed, flipMatrixAbout(before.centerX, before.centerY, axis));
const rendered = renderPlacedLayer(layer);
if (rendered) editorState.layerOffsets.set(layer.id, rendered.offset);
} else {
replaceCanvas(layer, renderFlipped(layer.canvas, axis));
recenterLayer(editorState, layer, before);
}
transformLayerMasks(layer, (mask) => {
const maskWidth = mask.canvas.width;
const maskHeight = mask.canvas.height;
const maskOffset = mask.offset || { x: 0, y: 0 };
replaceCanvas(mask, renderFlipped(mask.canvas, axis));
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
const ox = Number(maskOffset.x) || 0;
const oy = Number(maskOffset.y) || 0;
mask.offset = axis === 'h'
? { x: before.width - ox - maskWidth, y: oy }
: { x: ox, y: before.height - oy - maskHeight };
}
});
invalidateLayerCaches(layer);
return { axis };
}
/** Rotate a single layer by 90/180/270°, keeping its centre fixed. */
export function rotateLayer(editorState, layer, degrees) {
const normalized = ((degrees % 360) + 360) % 360;
if (!layer || ![90, 180, 270].includes(normalized)) return null;
const before = layerBounds(editorState, layer);
if (layer.kind === 'text' && layer.text) rotateTextLayer(layer, normalized);
else if (layer.kind === 'shape' && layer.shape) rotateShapeLayer(layer, normalized);
else if (layer.kind === 'placed' && layer.placed) {
layer.placed = transformPlacedData(layer.placed, rotateMatrixAbout(before.centerX, before.centerY, normalized));
const rendered = renderPlacedLayer(layer);
if (rendered) editorState.layerOffsets.set(layer.id, rendered.offset);
} else {
replaceCanvas(layer, renderRotated(layer.canvas, normalized));
recenterLayer(editorState, layer, before);
}
transformLayerMasks(layer, (mask) => {
const oldMaskWidth = mask.canvas.width;
const oldMaskHeight = mask.canvas.height;
const maskOffset = mask.offset || { x: 0, y: 0 };
replaceCanvas(mask, renderRotated(mask.canvas, normalized));
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
const ox = Number(maskOffset.x) || 0;
const oy = Number(maskOffset.y) || 0;
if (normalized === 90) {
mask.offset = { x: before.height - oy - oldMaskHeight, y: ox };
} else if (normalized === 180) {
mask.offset = { x: before.width - ox - oldMaskWidth, y: before.height - oy - oldMaskHeight };
} else {
mask.offset = { x: oy, y: before.width - ox - oldMaskWidth };
}
}
});
invalidateLayerCaches(layer);
return { degrees: normalized };
}
+72
View File
@@ -0,0 +1,72 @@
// Makes a modal dialog draggable by its header, Photoshop-style.
//
// On the first grab the dialog is switched to `position:fixed` anchored at
// its current on-screen box, so it follows the pointer independently of the
// host modal's centering/transform layout. Dragging is clamped to the
// viewport and ignores presses that start on interactive controls (buttons,
// inputs, selects) so clicking Cancel/Create never becomes a drag.
export function makeDraggable(dialog, handle) {
if (!dialog || !handle || dialog.__geDraggable) return;
dialog.__geDraggable = true;
handle.classList.add('ge-draggable-handle');
let startX = 0;
let startY = 0;
let originLeft = 0;
let originTop = 0;
let dragging = false;
const onDown = (event) => {
if (event.button != null && event.button !== 0) return;
if (event.target?.closest?.('button, input, select, textarea, a, [role="button"]')) return;
const rect = dialog.getBoundingClientRect();
dialog.style.position = 'fixed';
dialog.style.margin = '0';
dialog.style.left = `${rect.left}px`;
dialog.style.top = `${rect.top}px`;
dialog.style.right = 'auto';
dialog.style.bottom = 'auto';
dialog.style.transform = 'none';
startX = event.clientX;
startY = event.clientY;
originLeft = rect.left;
originTop = rect.top;
dragging = true;
handle.setPointerCapture?.(event.pointerId);
event.preventDefault();
};
const onMove = (event) => {
if (!dragging) return;
const width = dialog.offsetWidth;
const height = dialog.offsetHeight;
const left = Math.max(0, Math.min(window.innerWidth - width, originLeft + (event.clientX - startX)));
const top = Math.max(0, Math.min(window.innerHeight - height, originTop + (event.clientY - startY)));
dialog.style.left = `${left}px`;
dialog.style.top = `${top}px`;
};
const onUp = (event) => {
if (!dragging) return;
dragging = false;
handle.releasePointerCapture?.(event.pointerId);
};
handle.addEventListener('pointerdown', onDown);
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onUp);
}
// Clears the inline positioning set by makeDraggable so the next open
// re-centers the dialog inside its host modal.
export function resetDraggable(dialog) {
if (!dialog) return;
dialog.style.position = '';
dialog.style.margin = '';
dialog.style.left = '';
dialog.style.top = '';
dialog.style.right = '';
dialog.style.bottom = '';
dialog.style.transform = '';
}
+374
View File
@@ -0,0 +1,374 @@
// Client side of the scoped "photo_editor" agent path.
//
// The editor Quick Edit popup sends {prompt, session, state, screenshot} to the
// app's editor-agent endpoint. That agent turn is offered ONLY the editor tool
// family (plan_edit / run_editor_command / get_editor_screenshot / list_layers)
// plus ask_user; the model's run_editor_command calls are executed in-process by
// the owner-scoped bridge (/api/editor/bridge/*), which delivers each command to
// this browser subscriber. We stream those executed steps into the popup and
// fold the whole run into a single undo group.
//
// The endpoint is HARNESS-owned; if it is not present yet (404/405) `run()`
// resolves {unavailable:true} so the caller can fall back to the local planner.
//
// Payload: { prompt, session, editor_session, state, screenshot }
// - session / editor_session : the editor bridge session id (scopes commands)
// - state : document.info + layers.list (+ imageId/name)
// - screenshot : data:image/png;base64,... of the canvas
// Response: { ok?, reply?, tools_offered?[], steps?[], trace?[] } (any subset).
export const EDITOR_AGENT_VERSION = '1.0';
export const DEFAULT_AGENT_ENDPOINT = '/api/editor/agent';
import { formatCropRect, isGenericIntent } from './nl-edit.js';
export function createEditorAgent({
control,
endpoint = DEFAULT_AGENT_ENDPOINT,
fetchImpl = null,
getSession = null,
getModel = null,
getAgentModel = null,
onStep = null,
} = {}) {
if (!control || typeof control.invoke !== 'function') {
throw new Error('createEditorAgent requires a control with invoke()');
}
const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
let activeStep = null;
let running = false;
let executed = null;
// Called by the editor bridge for every queued command it executes. While an
// agent run is in flight this streams the step into the popup.
function reportStep(payload, cmd) {
const op = (cmd && cmd.op) || (payload && payload.op) || '';
const entry = {
op,
ok: !!(payload && payload.ok),
error: payload && payload.error ? payload.error : null,
ms: payload && payload.ms != null ? payload.ms : null,
params: cmd && cmd.params ? JSON.stringify(cmd.params).slice(0, 400) : null,
screenshot: payload && payload.screenshot ? payload.screenshot : null,
};
if (executed) executed.push(entry);
if (activeStep) { try { activeStep(entry); } catch (_) {} }
return entry;
}
function isRunning() { return running; }
async function collectState() {
const state = {};
try { Object.assign(state, (await control.invoke('document.info')).result || {}); } catch (_) {}
try {
const ls = await control.invoke('layers.list');
if (ls && ls.ok) state.layers = ls.result;
} catch (_) {}
return state;
}
// Mean absolute pixel delta (0..1) between two data-URL images, computed on a
// 64px thumbnail. Browser-only; resolves null when it cannot measure.
async function dataUrlDelta(a, b) {
try {
if (typeof document === 'undefined' || !a || !b) return null;
const load = (url) => new Promise((res, rej) => { const im = new Image(); im.onload = () => res(im); im.onerror = rej; im.src = url; });
const [ia, ib] = await Promise.all([load(a), load(b)]);
const w = 64, h = 64;
const ca = document.createElement('canvas'); ca.width = w; ca.height = h;
const cb = document.createElement('canvas'); cb.width = w; cb.height = h;
ca.getContext('2d').drawImage(ia, 0, 0, w, h);
cb.getContext('2d').drawImage(ib, 0, 0, w, h);
const da = ca.getContext('2d').getImageData(0, 0, w, h).data;
const db = cb.getContext('2d').getImageData(0, 0, w, h).data;
let sum = 0;
for (let i = 0; i < da.length; i += 4) sum += Math.abs(da[i] - db[i]) + Math.abs(da[i + 1] - db[i + 1]) + Math.abs(da[i + 2] - db[i + 2]);
return sum / ((da.length / 4) * 3 * 255);
} catch (_) { return null; }
}
// Fraction of near-white (clipped-highlight) pixels in a data-URL image, on a
// 96px thumbnail. Browser-only; resolves null when it cannot measure.
async function dataUrlClipFraction(url) {
try {
if (typeof document === 'undefined' || !url) return null;
const load = (u) => new Promise((res, rej) => { const im = new Image(); im.onload = () => res(im); im.onerror = rej; im.src = u; });
const im = await load(url);
const w = 96, h = 96;
const c = document.createElement('canvas'); c.width = w; c.height = h;
c.getContext('2d').drawImage(im, 0, 0, w, h);
const d = c.getContext('2d').getImageData(0, 0, w, h).data;
let clipped = 0, total = 0;
for (let i = 0; i < d.length; i += 4) {
total++;
const lum = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
if (lum > 250) clipped++;
}
return total ? clipped / total : null;
} catch (_) { return null; }
}
// Exposure summary of a data-URL image on a 96px thumbnail: fractions of dark
// (shadow) and bright (highlight) pixels plus the mean luminance. Used to decide
// whether a whole-image Auto Tone is actually warranted for a portrait.
async function dataUrlExposure(url) {
try {
if (typeof document === 'undefined' || !url) return null;
const load = (u) => new Promise((res, rej) => { const im = new Image(); im.onload = () => res(im); im.onerror = rej; im.src = u; });
const im = await load(url);
const w = 96, h = 96;
const c = document.createElement('canvas'); c.width = w; c.height = h;
c.getContext('2d').drawImage(im, 0, 0, w, h);
const d = c.getContext('2d').getImageData(0, 0, w, h).data;
let dark = 0, bright = 0, sum = 0, total = 0;
for (let i = 0; i < d.length; i += 4) {
total++;
const lum = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
sum += lum;
if (lum < 42) dark++;
if (lum > 214) bright++;
}
return total ? { dark: dark / total, bright: bright / total, mean: sum / total / 255 } : null;
} catch (_) { return null; }
}
async function run(prompt, { onStep: stepCb = null, signal = null } = {}) {
if (!prompt || !String(prompt).trim()) throw new Error('empty prompt');
if (!doFetch || !endpoint) return { unavailable: true, ok: false };
const session = getSession ? getSession() : 'default';
let modelInfo = null;
try { modelInfo = getModel ? getModel() : null; } catch (_) { modelInfo = null; }
let agentInfo = null;
try { agentInfo = getAgentModel ? getAgentModel() : null; } catch (_) { agentInfo = null; }
const report = stepCb || onStep || null;
activeStep = report;
executed = [];
running = true;
// Honest before-image (captured before any client pre-step) for the
// imperceptible-edit check, then the history mark scoping the undo group.
let beforeShot = null;
try { const s = await control.invoke('screenshot'); beforeShot = s && s.result ? s.result.dataUrl : null; } catch (_) {}
let mark = 0;
try { mark = (await control.invoke('history.mark')).result; } catch (_) {}
// Generic intents may get an Auto Tone/Color strengthen below IF the agent's
// own plan turned out imperceptible (avoids double-processing a dark image).
const generic = isGenericIntent(prompt);
// Portrait/teeth intents: the planner sometimes leads with a whole-image
// Auto Tone, which lightens and whitens the face ("pale/airbrushed"). Suppress
// photo.autoTone during the run unless the photo is genuinely under- or
// over-exposed (checked from the before-image histogram).
const lp = String(prompt).toLowerCase();
const portraitIntent = /skin|complexion|blemish|acne|pores|smoot|retouch|look (better|good|amazing)|flawless|face|portrait/.test(lp);
const teethIntent = /teeth|smile/.test(lp);
let origInvoke = null;
const restoreInvoke = () => { if (origInvoke) { control.invoke = origInvoke; origInvoke = null; } };
if (portraitIntent || teethIntent) {
const exposure = await dataUrlExposure(beforeShot);
const needsTone = !!(exposure && (exposure.dark > 0.32 || exposure.bright > 0.32));
if (!needsTone) {
origInvoke = control.invoke;
const prev = origInvoke;
control.invoke = function (op, params) {
if (op === 'photo.autoTone') return Promise.resolve({ ok: true, op, result: { skipped: true, reason: 'portrait-no-auto-tone' } });
return prev.call(this, op, params);
};
}
}
const state = await collectState();
let screenshot = null;
try {
const shot = await control.invoke('screenshot');
screenshot = shot && shot.result ? shot.result.dataUrl : null;
} catch (_) {}
let res = null;
try {
res = await doFetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: signal || undefined,
body: JSON.stringify({
prompt, session, editor_session: session, state, screenshot,
model: (modelInfo && modelInfo.model) || '',
endpoint: (modelInfo && modelInfo.endpoint) || '',
model_label: (modelInfo && modelInfo.label) || '',
agent_model: (agentInfo && agentInfo.agent) || '',
agent_endpoint: (agentInfo && agentInfo.endpoint) || '',
agent_label: (agentInfo && agentInfo.label) || '',
}),
});
} catch (e) {
activeStep = null; running = false; restoreInvoke();
return { unavailable: true, ok: false, error: String(e && e.message ? e.message : e) };
}
// No agent endpoint yet -> let the caller fall back to the local planner.
if (!res || res.status === 404 || res.status === 405) {
activeStep = null; running = false; restoreInvoke();
return { unavailable: true, ok: false };
}
let data = null;
try { data = await res.json(); } catch (_) {}
const trace = executed;
// How many history entries did the agent's bridge commands add?
let applied = 0;
try {
const nowMark = (await control.invoke('history.mark')).result;
applied = Math.max(0, Number(nowMark || 0) - Number(mark || 0));
} catch (_) {}
const needsProvider = !!(data && (data.needs_provider || data.requires_provider || data.needsProvider));
const blocked = !res.ok || !data || data.ok === false || needsProvider || !!(data && data.blocked);
// A clarification: the planner returned no steps but asked a question. Do NOT
// run any post-guard (especially the imperceptible-edit tone strengthen),
// which would silently tweak the image for a prompt the model did not understand.
const planSteps = Array.isArray(data && data.steps) ? data.steps : [];
const asked = !blocked && planSteps.length === 0 && !!((data && (data.reply || data.message)));
activeStep = null; running = false;
// Safety net: if the agent could not fulfil the request, roll back every
// partial/substitute edit it applied instead of leaving a wrong edit behind.
if (blocked && applied > 0) {
for (let i = 0; i < applied; i++) {
try { await control.invoke('history.undo'); } catch (_) { break; }
}
} else if (!blocked && !asked) {
// Format/platform requests: guarantee the target aspect crop even when the
// agent's own plan omitted document.crop (e.g. "make it instagram ready").
// Do NOT crop when the request is to EXTEND the canvas (that adds area,
// it does not crop the image).
const extendIntent = /\b(extend|expand)\b|\badd\b[^.]*\b(canvas|border|space|margin)\b|\b(wider|taller|letterbox)\b/i.test(prompt);
const docOps = (trace || []).map((t) => t && t.op);
const alreadyFormatted = extendIntent || docOps.includes('document.crop') || docOps.includes('document.resizeCanvas') || docOps.includes('document.extend');
try {
const info = (await control.invoke('document.info')).result || {};
const rect = alreadyFormatted ? null : formatCropRect(prompt, info.width, info.height);
if (rect && (Math.abs((info.width || 0) - rect.width) > 2 || Math.abs((info.height || 0) - rect.height) > 2)) {
await control.invoke('document.crop', rect);
}
} catch (_) {}
// Skin/teeth retouch routing guard: rather than let a portrait request
// degrade to a destructive global blur, apply the specialised masked,
// texture-preserving retouch ops when the agent did not.
try {
const traceOps = (trace || []).map((t) => t && t.op);
const lp = String(prompt).toLowerCase();
const skinReq = /skin|complexion|blemish|acne|pores|smoot|retouch|look (better|good|amazing)|flawless/.test(lp);
// If the agent softened the WHOLE image as a stand-in for skin retouch,
// revert that destructive global filter before applying the real one.
if (skinReq && !traceOps.includes('retouch.skin')) {
const last = trace[trace.length - 1];
if (last && last.op === 'filters.apply' && last.ok) {
try {
await control.invoke('history.undo');
executed.push({ op: 'history.undo', ok: true, error: null, source: 'guard:global-blur' });
} catch (_) {}
}
}
if (skinReq && !traceOps.includes('retouch.skin')) {
await control.invoke('retouch.skin', {});
executed.push({ op: 'retouch.skin', ok: true, error: null, source: 'guard' });
}
// Portrait chain must not brighten: if a global tone pass runs after the
// skin retouch, revert it so the face keeps its original luminance.
if (skinReq) {
const skinIdx = traceOps.lastIndexOf('retouch.skin');
const autoIdx = traceOps.lastIndexOf('photo.autoTone');
const lastOp = traceOps[traceOps.length - 1];
if (skinIdx >= 0 && autoIdx > skinIdx && lastOp === 'photo.autoTone') {
try {
await control.invoke('history.undo');
executed.push({ op: 'history.undo', ok: true, error: null, source: 'guard:portrait-brighten' });
} catch (_) {}
}
}
if (/teeth|smile/.test(lp) && !traceOps.includes('retouch.teeth')) {
await control.invoke('retouch.teeth', {});
executed.push({ op: 'retouch.teeth', ok: true, error: null, source: 'guard' });
}
} catch (_) {}
// Title/text placement guard: place titles RELATIVE to the image layer
// bounds (top-centre with a margin) at ~10% of the image height, so they
// sit inside the photo instead of the empty canvas margin.
try {
const titleIntent = /\btitle\b|\bcaption\b|\bheading\b|\bwatermark\b|\bwordmark\b|\badd\s+(?:a\s+)?(?:text|label)\b|\bwrite\b/i.test(String(prompt));
const traceOps2 = (trace || []).map((t) => t && t.op);
if (titleIntent && traceOps2.includes('text.add')) {
await control.invoke('text.layout', { anchor: 'top-center', sizePct: 0.13, marginPct: 0.05 });
executed.push({ op: 'text.layout', ok: true, error: null, source: 'guard' });
}
} catch (_) {}
// Imperceptible-edit guard: when a GENERIC look/brighten request barely
// changed the image, strengthen it so generic requests are visible but
// still natural. Never run this for structural/action prompts (add a
// layer, resize, add a title, align…) — a tone tweak there is wrong.
try {
if (beforeShot && generic) {
const after = (await control.invoke('screenshot')).result;
const delta = await dataUrlDelta(beforeShot, after && after.dataUrl);
if (delta != null && delta < 0.02) {
try { await control.invoke('photo.autoTone', {}); } catch (_) {}
await control.invoke('adjustments.apply', { type: 'vibrance', params: { vibrance: 22 } });
await control.invoke('adjustments.apply', { type: 'brightness-contrast', params: { brightness: 5, contrast: 8 } });
}
}
} catch (_) {}
// Blown-highlight guard: agents sometimes over-lift exposure/shadows and
// clip large areas to white; step back up to two ops if that happened.
try {
if (beforeShot) {
const beforeClip = await dataUrlClipFraction(beforeShot);
let after = (await control.invoke('screenshot')).result;
let afterClip = await dataUrlClipFraction(after && after.dataUrl);
for (let guard = 0; guard < 2 && beforeClip != null && afterClip != null && (afterClip - beforeClip) > 0.12; guard++) {
await control.invoke('history.undo');
executed.push({ op: 'history.undo', ok: true, error: null, source: 'guard:clipped-highlights' });
after = (await control.invoke('screenshot')).result;
afterClip = await dataUrlClipFraction(after && after.dataUrl);
}
}
} catch (_) {}
// Fold every op the agent ran (via the bridge) into one undo group.
try { await control.invoke('history.collapse', { mark, label: `AI edit: ${String(prompt).slice(0, 60)}` }); } catch (_) {}
}
if (blocked || asked) {
const message = (asked ? (data && (data.reply || data.message)) : null)
|| (data && (data.message || data.reply)) || (needsProvider
? 'Removing or adding objects needs an image generation provider. Enable one in Settings > Models.'
: ((data && data.error) || `Agent error (${res ? res.status : 'no response'})`));
restoreInvoke();
return {
unavailable: false, ok: false, blocked: true, needsProvider, message,
asked,
error: (data && data.error) || null,
reply: (data && (data.reply || data.message)) || '',
toolsOffered: (data && (data.tools_offered || data.tools)) || null,
steps: [], trace, session, applied, rolledBack: applied,
};
}
restoreInvoke();
return {
unavailable: false,
ok: true,
reply: data.reply || data.message || '',
toolsOffered: data.tools_offered || data.tools || null,
steps: Array.isArray(data.steps) ? data.steps : [],
trace,
session,
applied,
};
}
return { version: EDITOR_AGENT_VERSION, endpoint, run, reportStep, isRunning };
}
export default createEditorAgent;
+104
View File
@@ -0,0 +1,104 @@
// Browser side of the editor agent bridge. When the editor is open it
// subscribes to the app's SSE command stream and executes queued ops through
// the editor control API, then posts the result (+ a screenshot) back.
//
// Protocol (see routes/editor_agent_routes.py):
// GET /api/editor/bridge/events?session=<id> -> SSE `data: {id,op,params}`
// POST /api/editor/bridge/result {id,ok,result,error,screenshot,ms}
//
// The agent enqueues with POST /api/editor/bridge/command {session,steps},
// optionally with wait:true to block until this subscriber posts the result.
export const BRIDGE_VERSION = '1.0';
export function startEditorBridge({
control,
session = 'default',
baseUrl = '',
EventSourceImpl = null,
fetchImpl = null,
onCommand = null,
} = {}) {
if (!control || typeof control.invoke !== 'function') {
throw new Error('startEditorBridge requires a control with invoke()');
}
const ES = EventSourceImpl || (typeof EventSource !== 'undefined' ? EventSource : null);
const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
const eventUrl = `${baseUrl}/api/editor/bridge/events?session=${encodeURIComponent(session)}`;
const resultUrl = `${baseUrl}/api/editor/bridge/result`;
let source = null;
let timer = null;
let retry = 0;
let stopped = false;
// Execute one queued command and report the outcome (with an after screenshot).
async function runAndReport(cmd) {
const id = cmd && cmd.id;
const started = Date.now();
let ok = false;
let result = null;
let error = null;
let screenshot = null;
try {
const out = await control.invoke(cmd.op, cmd.params || {});
ok = !!(out && out.ok);
result = out ? (out.result ?? null) : null;
if (!ok) error = (out && out.error) || 'op failed';
} catch (e) {
error = String(e && e.message ? e.message : e);
}
try {
const shot = await control.invoke('screenshot');
screenshot = shot && shot.result ? shot.result.dataUrl : null;
} catch (_) { /* screenshot is best-effort */ }
const payload = { id, ok, result, error, screenshot, ms: Date.now() - started };
if (onCommand) { try { onCommand(payload, cmd); } catch (_) {} }
if (doFetch) {
try {
await doFetch(resultUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} catch (_) { /* the agent may have timed out; ignore */ }
}
return payload;
}
function connect() {
if (stopped || !ES) return;
try { source = new ES(eventUrl); } catch (_) { return; }
source.onopen = () => { retry = 0; };
source.onmessage = (ev) => {
retry = 0;
let cmd = null;
try { cmd = JSON.parse(ev.data); } catch (_) { return; }
if (!cmd || !cmd.op) return;
// Fire-and-forget; results go back over /result, not the SSE stream.
runAndReport(cmd);
};
source.onerror = () => {
try { source && source.close(); } catch (_) {}
if (stopped) return;
retry += 1;
timer = setTimeout(connect, Math.min(5000, 500 * retry));
};
}
connect();
return {
version: BRIDGE_VERSION,
session,
eventUrl,
resultUrl,
runAndReport,
stop() {
stopped = true;
try { source && source.close(); } catch (_) {}
if (timer) clearTimeout(timer);
},
};
}
export default startEditorBridge;
+97
View File
@@ -0,0 +1,97 @@
// Programmatic control surface for the PhotoCraft editor.
//
// This module turns the editor's internal operations into a stable,
// JSON-friendly command API so an MCP server (or the in-editor natural
// language entrypoint) can drive the editor without poking at private
// closures. The heavy lifting lives in galleryEditor.js, which builds the
// handler map from its in-scope functions and installs it on
// window.geEditorControl.
//
// Contract:
// control.invoke(op, params) -> Promise<{ ok, result } | { ok:false, error }>
// control.run(steps) -> Promise<Array<stepResult>> (sequential)
// control.list() -> [{ op, description }]
// control.describe(op) -> { op, description } | null
//
// Every op name is namespaced: document.*, layers.*, selection.*,
// adjustments.*, filters.*, transform.*, text.*, history.*, export.*.
export const EDITOR_CONTROL_VERSION = '1.0';
function normalizeError(err) {
if (!err) return 'Unknown error';
if (typeof err === 'string') return err;
return err.message || String(err);
}
export function createEditorControl({ handlers = {}, getState = null, version = EDITOR_CONTROL_VERSION } = {}) {
const registry = new Map();
for (const [op, entry] of Object.entries(handlers)) {
if (!entry) continue;
if (typeof entry === 'function') {
registry.set(op, { run: entry, description: '' });
} else if (typeof entry.run === 'function') {
registry.set(op, { run: entry.run, description: entry.description || '' });
}
}
function describe(op) {
const entry = registry.get(op);
if (!entry) return null;
return { op, description: entry.description };
}
function list() {
return Array.from(registry.keys())
.sort()
.map((op) => ({ op, description: registry.get(op).description }));
}
function snapshot() {
if (typeof getState !== 'function') return {};
try {
return getState() || {};
} catch (_err) {
return {};
}
}
async function invoke(op, params = {}) {
const entry = registry.get(op);
if (!entry) {
return { ok: false, error: `Unknown editor op: ${op}`, op };
}
try {
const result = await entry.run(params || {}, { state: snapshot(), control });
return { ok: true, op, result: result === undefined ? null : result };
} catch (err) {
return { ok: false, op, error: normalizeError(err) };
}
}
async function run(steps) {
const list = Array.isArray(steps) ? steps : [];
const results = [];
for (const step of list) {
const op = step && step.op;
const params = (step && step.params) || {};
// eslint-disable-next-line no-await-in-loop
const res = await invoke(op, params);
results.push(res);
if (!res.ok) break;
}
return results;
}
const control = {
version,
installed: true,
list,
describe,
invoke,
run,
};
return control;
}
export default createEditorControl;
+36
View File
@@ -65,6 +65,42 @@ function renderEffects(source, effects, masks) {
ctx.filter = 'none';
} else if (effect.type === 'sharpen') {
ctx.drawImage(sharpenCanvas(current, effect.params.amount), 0, 0);
} else if (effect.type === 'motion-blur') {
const dist = effect.params.distance;
const rad = (effect.params.angle || 0) * Math.PI / 180;
const dx = Math.cos(rad);
const dy = Math.sin(rad);
const samples = Math.max(1, Math.min(64, Math.round(dist) || 1));
ctx.globalAlpha = 1 / samples;
for (let s = 0; s < samples; s += 1) {
const t = (samples > 1 ? (s / (samples - 1) - 0.5) : 0) * dist;
ctx.drawImage(current, dx * t, dy * t);
}
ctx.globalAlpha = 1;
} else if (effect.type === 'zoom-blur') {
const amt = effect.params.amount / 100;
const samples = Math.max(1, Math.min(48, Math.round(amt * 48) || 1));
const cx = next.width / 2;
const cy = next.height / 2;
ctx.globalAlpha = 1 / samples;
for (let s = 0; s < samples; s += 1) {
const scale = 1 + (s / samples) * amt * 0.5;
const dw = next.width * scale;
const dh = next.height * scale;
ctx.drawImage(current, cx - dw / 2, cy - dh / 2, dw, dh);
}
ctx.globalAlpha = 1;
} else if (effect.type === 'pixelate') {
const size = Math.max(1, effect.params.size);
const sw = Math.max(1, Math.round(next.width / size));
const sh = Math.max(1, Math.round(next.height / size));
const tiny = new OffscreenCanvas(sw, sh);
const tctx = tiny.getContext('2d');
tctx.imageSmoothingEnabled = false;
tctx.drawImage(current, 0, 0, sw, sh);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(tiny, 0, 0, next.width, next.height);
ctx.imageSmoothingEnabled = true;
} else if (effect.type === 'color-overlay') {
ctx.drawImage(current, 0, 0);
ctx.globalAlpha = effect.params.opacity;
+51 -1
View File
@@ -9,7 +9,7 @@ export function effectsWithPreview(owner) {
return index < 0 ? [...effects, preview] : effects.map((effect, i) => i === index ? preview : effect);
}
const EFFECT_TYPES = new Set(['gaussian-blur', 'sharpen', 'color-overlay', 'drop-shadow', 'stroke', 'linear-gradient', 'radial-gradient']);
const EFFECT_TYPES = new Set(['gaussian-blur', 'sharpen', 'motion-blur', 'zoom-blur', 'pixelate', 'color-overlay', 'drop-shadow', 'stroke', 'linear-gradient', 'radial-gradient']);
for (const type of Object.keys(LAYER_STYLES)) EFFECT_TYPES.add(type);
export const EFFECT_PRESETS = {
@@ -41,6 +41,9 @@ export function defaultEffectParams(type) {
if (LAYER_STYLES[type]) return styleParams(type);
if (type === 'gaussian-blur') return { radius: 6 };
if (type === 'sharpen') return { amount: 0.5 };
if (type === 'motion-blur') return { distance: 20, angle: 0 };
if (type === 'zoom-blur') return { amount: 40 };
if (type === 'pixelate') return { size: 8 };
if (type === 'color-overlay') return { color: '#ffffff', opacity: 0.2, blendMode: 'source-atop' };
if (type === 'drop-shadow') return { color: '#000000', opacity: 0.45, blur: 12, x: 4, y: 6 };
if (type === 'stroke') return { color: '#ffffff', opacity: 1, width: 3 };
@@ -66,6 +69,12 @@ export function normalizeEffect(effect) {
if (LAYER_STYLES[type]) Object.assign(params, styleParams(type, params));
if (type === 'gaussian-blur') params.radius = Math.max(0, Math.min(200, Number(params.radius) || 0));
if (type === 'sharpen') params.amount = Math.max(0, Math.min(1, Number(params.amount) || 0));
if (type === 'motion-blur') {
params.distance = Math.max(0, Math.min(200, Number(params.distance) || 0));
params.angle = Math.max(-180, Math.min(180, Number(params.angle) || 0));
}
if (type === 'zoom-blur') params.amount = Math.max(0, Math.min(100, Number(params.amount) || 0));
if (type === 'pixelate') params.size = Math.max(1, Math.min(200, Math.round(Number(params.size) || 1)));
if (type === 'color-overlay') {
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
params.opacity = Math.max(0, Math.min(1, Number(params.opacity) || 0));
@@ -114,6 +123,9 @@ export function effectLabel(type) {
return {
'gaussian-blur': 'Gaussian Blur',
sharpen: 'Sharpen',
'motion-blur': 'Motion Blur',
'zoom-blur': 'Zoom Blur',
pixelate: 'Pixelate',
'color-overlay': 'Color Overlay',
'drop-shadow': 'Drop Shadow',
'stroke': 'Stroke',
@@ -212,6 +224,44 @@ export function renderEffects(source, effects = [], shouldContinue = () => true)
const sharpened = sharpenCanvas(current, effect.params.amount, shouldContinue);
if (!sharpened) return current;
ctx.drawImage(sharpened, 0, 0);
} else if (effect.type === 'motion-blur') {
const dist = effect.params.distance;
const rad = (effect.params.angle || 0) * Math.PI / 180;
const dx = Math.cos(rad);
const dy = Math.sin(rad);
const samples = Math.max(1, Math.min(64, Math.round(dist) || 1));
ctx.globalAlpha = 1 / samples;
for (let s = 0; s < samples; s += 1) {
const t = (samples > 1 ? (s / (samples - 1) - 0.5) : 0) * dist;
ctx.drawImage(current, dx * t, dy * t);
}
ctx.globalAlpha = 1;
} else if (effect.type === 'zoom-blur') {
const amt = effect.params.amount / 100;
const samples = Math.max(1, Math.min(48, Math.round(amt * 48) || 1));
const cx = next.width / 2;
const cy = next.height / 2;
ctx.globalAlpha = 1 / samples;
for (let s = 0; s < samples; s += 1) {
const scale = 1 + (s / samples) * amt * 0.5;
const dw = next.width * scale;
const dh = next.height * scale;
ctx.drawImage(current, cx - dw / 2, cy - dh / 2, dw, dh);
}
ctx.globalAlpha = 1;
} else if (effect.type === 'pixelate') {
const size = Math.max(1, effect.params.size);
const sw = Math.max(1, Math.round(next.width / size));
const sh = Math.max(1, Math.round(next.height / size));
const tiny = document.createElement('canvas');
tiny.width = sw;
tiny.height = sh;
const tctx = tiny.getContext('2d');
tctx.imageSmoothingEnabled = false;
tctx.drawImage(current, 0, 0, sw, sh);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(tiny, 0, 0, next.width, next.height);
ctx.imageSmoothingEnabled = true;
} else if (effect.type === 'color-overlay') {
ctx.drawImage(current, 0, 0);
ctx.globalAlpha = effect.params.opacity;
+12
View File
@@ -1,9 +1,12 @@
/** Controlled image export dialog and canvas encoding helpers. */
import { exportPsd } from './psd.js';
const FORMAT_META = {
png: { mime: 'image/png', extension: 'png', quality: undefined },
jpeg: { mime: 'image/jpeg', extension: 'jpg', quality: 0.9 },
webp: { mime: 'image/webp', extension: 'webp', quality: 0.9 },
psd: { mime: 'image/vnd.adobe.photoshop', extension: 'psd', quality: undefined },
};
function clampNumber(value, min, max, fallback) {
@@ -43,6 +46,14 @@ export function prepareExportCanvas(sourceCanvas, settings) {
export function encodeExportCanvas(sourceCanvas, settings) {
const normalized = normalizeExportSettings(settings, sourceCanvas.width, sourceCanvas.height);
const output = prepareExportCanvas(sourceCanvas, normalized);
if (normalized.format === 'psd') {
const bytes = exportPsd(output);
return Promise.resolve({
blob: new Blob([bytes], { type: FORMAT_META.psd.mime }),
settings: normalized,
extension: 'psd',
});
}
const meta = FORMAT_META[normalized.format];
return new Promise((resolve, reject) => {
output.toBlob(
@@ -86,6 +97,7 @@ export function openExportDialog({
<button type="button" class="active" data-format="png">PNG</button>
<button type="button" data-format="jpeg">JPEG</button>
<button type="button" data-format="webp">WebP</button>
<button type="button" data-format="psd">PSD</button>
</div></div>
<div class="ge-export-dimensions">
<label class="ge-export-field"><span>Width</span><input id="ge-export-width" type="number" min="1" max="16384" value="${sourceCanvas.width}" /></label>
+238
View File
@@ -0,0 +1,238 @@
/**
* Haze removal (dehaze) — a compact, offline dark-channel-prior implementation
* with a guided-filter refined transmission map.
*
* The dark channel of a hazy photo is dominated by the atmospheric light, so the
* haze can be estimated and removed from a single image:
* 1. dark channel D(x) = min over {R,G,B} of a small local window (min of the
* channels, then a separable minimum filter). The window is kept small
* (patch of ~7-15px) so it does not smear a rectangular halo around hard
* edges.
* 2. atmospheric light A = the mean colour of the brightest 0.1% of the
* dark channel (a robust estimate; a single brightest cell is noisy).
* 3. transmission t(x) = 1 - omega * D(x) / max(A), clamped to >= t0 (0.1).
* 4. refine t with a guided filter (He et al. 2010) using the luminance as the
* guide, so the map follows real edges instead of the min-filter blocks.
* 5. recover J = (I - A) / max(t, t0) + A.
*
* The dark channel, atmospheric light and guided filter run on a bounded working
* copy (<= maxDim) and the refined transmission is up-sampled smoothly, so the
* pass stays interactive on large documents. No network or AI; deterministic.
*/
function clamp(value, lo, hi) {
return value < lo ? lo : value > hi ? hi : value;
}
/* Separable minimum filter (horizontal then vertical) over a Float array. */
function minFilter2D(values, width, height, radius) {
if (radius < 1) return values.slice();
const tmp = new Float32Array(width * height);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
let min = Infinity;
for (let d = -radius; d <= radius; d += 1) {
const xx = x + d;
if (xx < 0 || xx >= width) continue;
const v = values[y * width + xx];
if (v < min) min = v;
}
tmp[y * width + x] = min;
}
}
const out = new Float32Array(width * height);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
let min = Infinity;
for (let d = -radius; d <= radius; d += 1) {
const yy = y + d;
if (yy < 0 || yy >= height) continue;
const v = tmp[yy * width + x];
if (v < min) min = v;
}
out[y * width + x] = min;
}
}
return out;
}
/* O(N) mean (box) filter via a summed-area table. */
function boxFilter(src, width, height, radius) {
const stride = width + 1;
const integral = new Float64Array(stride * (height + 1));
for (let y = 0; y < height; y += 1) {
let rowSum = 0;
for (let x = 0; x < width; x += 1) {
rowSum += src[y * width + x];
integral[(y + 1) * stride + (x + 1)] = integral[y * stride + (x + 1)] + rowSum;
}
}
const out = new Float32Array(width * height);
for (let y = 0; y < height; y += 1) {
const y0 = Math.max(0, y - radius);
const y1 = Math.min(height - 1, y + radius);
for (let x = 0; x < width; x += 1) {
const x0 = Math.max(0, x - radius);
const x1 = Math.min(width - 1, x + radius);
const area = (x1 - x0 + 1) * (y1 - y0 + 1);
const sum = integral[(y1 + 1) * stride + (x1 + 1)]
- integral[y0 * stride + (x1 + 1)]
- integral[(y1 + 1) * stride + x0]
+ integral[y0 * stride + x0];
out[y * width + x] = sum / area;
}
}
return out;
}
/*
* Guided filter (He, Sun, Tang 2010) — edge-preserving smoothing of `p` guided
* by `guide`. Both arrays are 0..1; `eps` is the regularisation (1e-3 typical).
*/
function guidedFilter(guide, p, width, height, radius, eps) {
const n = width * height;
const meanGuide = boxFilter(guide, width, height, radius);
const meanP = boxFilter(p, width, height, radius);
const gg = new Float32Array(n);
const gp = new Float32Array(n);
for (let i = 0; i < n; i += 1) {
gg[i] = guide[i] * guide[i];
gp[i] = guide[i] * p[i];
}
const corrGG = boxFilter(gg, width, height, radius);
const corrGP = boxFilter(gp, width, height, radius);
const a = new Float32Array(n);
const b = new Float32Array(n);
for (let i = 0; i < n; i += 1) {
const varG = corrGG[i] - meanGuide[i] * meanGuide[i];
const covGP = corrGP[i] - meanGuide[i] * meanP[i];
a[i] = covGP / (varG + eps);
b[i] = meanP[i] - a[i] * meanGuide[i];
}
const meanA = boxFilter(a, width, height, radius);
const meanB = boxFilter(b, width, height, radius);
const q = new Float32Array(n);
for (let i = 0; i < n; i += 1) q[i] = meanA[i] * guide[i] + meanB[i];
return q;
}
export function dehazeImageData(src, width, height, options = {}) {
const strength = clamp(Number(options.strength ?? 0.8), 0, 1);
if (strength <= 0 || width < 2 || height < 2) return src;
const omega = clamp(Number(options.omega ?? 0.95), 0, 1) * strength;
// Small dark-channel patch (Photoshop-style 7-15px) keeps the halo tight.
const patch = Math.max(3, Math.min(31, Math.round(Number(options.patch) || 15)));
const guideRadius = Math.max(1, Math.round(Number(options.guideRadius) || 40));
const eps = Math.max(1e-6, Number(options.eps) || 1e-3);
const t0 = clamp(Number(options.t0 ?? 0.1), 0.02, 1);
const maxDim = Math.max(16, Number(options.maxDim) || 1024);
const scale = Math.min(1, maxDim / Math.max(width, height));
const sw = Math.max(2, Math.round(width * scale));
const sh = Math.max(2, Math.round(height * scale));
// Radius that yields a ~patch-pixel window at the working resolution.
const radius = Math.max(1, Math.round((patch - 1) / 2 * scale));
// Block down-sample: the darkest per-pixel min(R,G,B) in each cell, its colour
// (for the atmospheric light) and the mean luminance (the guided-filter guide).
const dark = new Float32Array(sw * sh);
const darkColor = new Float32Array(sw * sh * 3);
const lum = new Float32Array(sw * sh);
const bw = width / sw;
const bh = height / sh;
for (let sy = 0; sy < sh; sy += 1) {
const y0 = Math.floor(sy * bh);
const y1 = Math.min(height, Math.max(y0 + 1, Math.ceil((sy + 1) * bh)));
for (let sx = 0; sx < sw; sx += 1) {
const x0 = Math.floor(sx * bw);
const x1 = Math.min(width, Math.max(x0 + 1, Math.ceil((sx + 1) * bw)));
let best = 256;
let br = 0;
let bg = 0;
let bb = 0;
let lumSum = 0;
let count = 0;
for (let y = y0; y < y1; y += 1) {
for (let x = x0; x < x1; x += 1) {
const i = (y * width + x) * 4;
const r = src[i];
const g = src[i + 1];
const b = src[i + 2];
const mn = Math.min(r, g, b);
if (mn < best) { best = mn; br = r; bg = g; bb = b; }
lumSum += (r * 0.299 + g * 0.587 + b * 0.114) / 255;
count += 1;
}
}
const k = sy * sw + sx;
dark[k] = best;
darkColor[k * 3] = br;
darkColor[k * 3 + 1] = bg;
darkColor[k * 3 + 2] = bb;
lum[k] = count ? lumSum / count : 0;
}
}
const filtered = minFilter2D(dark, sw, sh, radius);
// Atmospheric light: mean colour of the brightest 0.1% of the dark channel.
const total = sw * sh;
const topCount = Math.max(1, Math.round(total * 0.001));
const order = new Int32Array(total);
for (let k = 0; k < total; k += 1) order[k] = k;
const ordered = Array.from(order).sort((a, b) => filtered[b] - filtered[a]);
let ar = 0;
let ag = 0;
let ab = 0;
for (let n = 0; n < topCount; n += 1) {
const idx = ordered[n];
ar += darkColor[idx * 3];
ag += darkColor[idx * 3 + 1];
ab += darkColor[idx * 3 + 2];
}
ar /= topCount;
ag /= topCount;
ab /= topCount;
const aLum = Math.max(1, Math.max(ar, ag, ab));
// Raw per-cell transmission (clamped to >= t0), then guided-filter refined.
const rawT = new Float32Array(total);
for (let k = 0; k < total; k += 1) {
rawT[k] = clamp(1 - omega * (filtered[k] / aLum), t0, 1);
}
const refined = guidedFilter(lum, rawT, sw, sh, guideRadius, eps);
const small = new Uint8ClampedArray(sw * sh * 4);
for (let k = 0; k < sw * sh; k += 1) {
const v = Math.round(clamp(refined[k], t0, 1) * 255);
small[k * 4] = v;
small[k * 4 + 1] = v;
small[k * 4 + 2] = v;
small[k * 4 + 3] = 255;
}
let tFull = null;
if (typeof document !== 'undefined') {
const sm = document.createElement('canvas');
sm.width = sw;
sm.height = sh;
sm.getContext('2d').putImageData(new ImageData(small, sw, sh), 0, 0);
const big = document.createElement('canvas');
big.width = width;
big.height = height;
const bctx = big.getContext('2d');
bctx.imageSmoothingEnabled = true;
bctx.imageSmoothingQuality = 'high';
bctx.drawImage(sm, 0, 0, width, height);
tFull = bctx.getImageData(0, 0, width, height).data;
}
const out = new Uint8ClampedArray(src.length);
for (let i = 0, p = 0; i < src.length; i += 4, p += 1) {
const t = tFull ? Math.max(t0, tFull[p * 4] / 255) : 1;
out[i] = clamp((src[i] - ar) / t + ar, 0, 255);
out[i + 1] = clamp((src[i + 1] - ag) / t + ag, 0, 255);
out[i + 2] = clamp((src[i + 2] - ab) / t + ab, 0, 255);
out[i + 3] = src[i + 3];
}
return out;
}
File diff suppressed because it is too large Load Diff
+183 -7
View File
@@ -48,6 +48,7 @@ import {
defaultAdjParams,
} from '../layer-helpers.js';
import { drawHistogram } from './histogram.js';
import { parseCubeLut, generateCubeLut } from './cube-lut.js';
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null }) {
function suppressLayerGhostTap() {
@@ -152,6 +153,12 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
{ type: 'color-balance', label: 'Color Balance' },
{ type: 'selective-color', label: 'Selective Color' },
{ type: 'gradient-map', label: 'Gradient Map' },
{ type: 'invert', label: 'Invert' },
{ type: 'posterize', label: 'Posterize' },
{ type: 'threshold', label: 'Threshold' },
{ type: 'photo-filter', label: 'Photo Filter' },
{ type: 'channel-mixer', label: 'Channel Mixer' },
{ type: 'color-lookup', label: 'Color Lookup' },
];
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>`
@@ -332,7 +339,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
<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">&minus;</button>
<button class="ge-adj-close" type="button" title="Close" aria-label="Close">&times;</button>
</span>
</div>
<label class="ge-adj-preset-field">Preset
@@ -382,7 +389,6 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
}
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]');
@@ -530,8 +536,8 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
return p.channels[channel];
};
const revertIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
const sliderRow = (key, label, min, max, value, suffix) => `
<div class="ge-adj-row" data-adj-key="${key}">
const sliderRow = (key, label, min, max, value, suffix, defaultValue) => `
<div class="ge-adj-row" data-adj-key="${key}"${defaultValue != null ? ` data-default="${defaultValue}"` : ''}>
<label>${label}</label>
<input type="range" min="${min}" max="${max}" value="${value}" data-key="${key}" />
<span class="ge-adj-value">${value}${suffix || ''}</span>
@@ -551,6 +557,25 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
${sliderRow('offset', 'Offset', -50, 50, Math.round(p.offset * 100), '')}
${sliderRow('gamma', 'Gamma', 10, 300, Math.round(p.gamma * 100), 'γ')}
`;
} else if (type === 'develop') {
body.innerHTML = `
<div class="ge-adj-develop">
<div class="ge-adj-group-label">Basic</div>
${sliderRow('temperature', 'Temperature', -100, 100, Math.round(p.temperature || 0), '', 0)}
${sliderRow('tint', 'Tint', -100, 100, Math.round(p.tint || 0), '', 0)}
${sliderRow('exposure', 'Exposure', -500, 500, Math.round((p.exposure || 0) * 100), ' EV', 0)}
${sliderRow('contrast', 'Contrast', -100, 100, Math.round(p.contrast || 0), '', 0)}
${sliderRow('highlights', 'Highlights', -100, 100, Math.round(p.highlights || 0), '', 0)}
${sliderRow('shadows', 'Shadows', -100, 100, Math.round(p.shadows || 0), '', 0)}
${sliderRow('whites', 'Whites', -100, 100, Math.round(p.whites || 0), '', 0)}
${sliderRow('blacks', 'Blacks', -100, 100, Math.round(p.blacks || 0), '', 0)}
<div class="ge-adj-group-label">Presence</div>
${sliderRow('clarity', 'Clarity', -100, 100, Math.round(p.clarity || 0), '', 0)}
${sliderRow('vibrance', 'Vibrance', -100, 100, Math.round(p.vibrance || 0), '', 0)}
${sliderRow('saturation', 'Saturation', -100, 100, Math.round(p.saturation || 0), '', 0)}
<p class="ge-adj-help">Camera-Raw-style Basic panel: white balance, exposure and tone, then clarity, vibrance and saturation.</p>
</div>
`;
} else if (type === 'white-balance') {
body.innerHTML = `
${sliderRow('temperature', 'Temperature', -100, 100, Math.round(p.temperature), '')}
@@ -746,6 +771,134 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
p.reverse = event.target.checked;
scheduleAdjRefresh(layer);
});
} else if (type === 'invert') {
body.innerHTML = `
<p class="ge-adj-help">Inverts every color channel in the source layer.</p>
`;
} else if (type === 'posterize') {
body.innerHTML = `
${sliderRow('levels', 'Levels', 2, 255, Math.round(p.levels ?? 4), '')}
<p class="ge-adj-help">Reduces each channel to a limited number of tonal steps.</p>
`;
} else if (type === 'threshold') {
body.innerHTML = `
${sliderRow('level', 'Threshold', 0, 255, Math.round(p.level ?? 128), '')}
<p class="ge-adj-help">Pixels brighter than the threshold become white, the rest black.</p>
`;
} else if (type === 'photo-filter') {
body.innerHTML = `
<div class="ge-adj-row">
<label>Color</label>
<input type="color" data-photo-key="color" value="${p.color || '#ec8a00'}">
</div>
${sliderRow('density', 'Density', 0, 100, Math.round(p.density ?? 25), '%')}
<label class="ge-adj-toggle-row"><span>Preserve luminosity</span><input type="checkbox" data-photo-preserve${p.preserve_luminosity ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
`;
body.querySelector('[data-photo-key="color"]')?.addEventListener('input', event => {
p.color = event.target.value;
scheduleAdjRefresh(layer);
});
body.querySelector('[data-photo-preserve]')?.addEventListener('change', event => {
p.preserve_luminosity = event.target.checked;
scheduleAdjRefresh(layer);
});
} else if (type === 'channel-mixer') {
const channels = ['red', 'green', 'blue'];
const ch = channels.includes(popEl._cmMixChannel) ? popEl._cmMixChannel : 'red';
popEl._cmMixChannel = ch;
if (!p[ch]) p[ch] = defaultAdjParams(type)[ch];
if (!p.constant) p.constant = defaultAdjParams(type).constant;
const values = p[ch];
body.innerHTML = `
<label class="ge-adj-channel-field">Output channel
<select class="ge-adj-cm-channel-select">
<option value="red"${ch === 'red' ? ' selected' : ''}>Red</option>
<option value="green"${ch === 'green' ? ' selected' : ''}>Green</option>
<option value="blue"${ch === 'blue' ? ' selected' : ''}>Blue</option>
</select>
</label>
${sliderRow(`${ch}-r`, 'Red', -200, 200, Math.round(values.r ?? 0), '%')}
${sliderRow(`${ch}-g`, 'Green', -200, 200, Math.round(values.g ?? 0), '%')}
${sliderRow(`${ch}-b`, 'Blue', -200, 200, Math.round(values.b ?? 0), '%')}
${sliderRow(`${ch}-c`, 'Constant', -100, 100, Math.round(p.constant[ch] ?? 0), '')}
<label class="ge-adj-toggle-row"><span>Monochrome</span><input type="checkbox" data-cm-monochrome${p.monochrome ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
<p class="ge-adj-help">Mixes source channels into the selected output channel.</p>
`;
body.querySelector('.ge-adj-cm-channel-select')?.addEventListener('change', event => {
popEl._cmMixChannel = event.target.value;
body.innerHTML = '';
buildAdjBody(layer, type, body, popEl);
});
body.querySelector('[data-cm-monochrome]')?.addEventListener('change', event => {
p.monochrome = event.target.checked;
scheduleAdjRefresh(layer);
});
} else if (type === 'color-lookup') {
const presets = ['none', 'teal_orange', 'cold', 'warm', 'high_contrast', 'sepia'];
const presetLabels = { none: 'None', teal_orange: 'Teal & Orange', cold: 'Cold', warm: 'Warm', high_contrast: 'High Contrast', sepia: 'Sepia' };
const strength = p.strength == null ? 100 : p.strength;
const hasCube = !!(p.cube && p.cube.size && p.cube.data);
const lutName = hasCube ? `Loaded LUT · ${p.cube.size}³` : 'No .cube loaded';
body.innerHTML = `
<label class="ge-adj-channel-field">Lookup preset
<select class="ge-adj-lut-select">
${presets.map(name => `<option value="${name}"${(p.preset || 'none') === name ? ' selected' : ''}>${presetLabels[name]}</option>`).join('')}
</select>
</label>
<div class="ge-adj-lut-actions">
<label class="ge-btn ge-btn-sm ge-adj-lut-load">Load .cube…
<input type="file" accept=".cube,text/plain" hidden />
</label>
<button type="button" class="ge-btn ge-btn-sm ge-adj-lut-export"${hasCube ? '' : ' disabled'}>Export .cube</button>
</div>
<div class="ge-adj-lut-name">${lutName}</div>
${sliderRow('strength', 'Strength', 0, 100, strength, '%', 100)}
<p class="ge-adj-help">Import an Adobe .cube 3D LUT (or pick a built-in look). Strength blends the LUT with the original pixels.</p>
`;
body.querySelector('.ge-adj-lut-select')?.addEventListener('change', event => {
p.preset = event.target.value;
scheduleAdjRefresh(layer);
});
const fileInput = body.querySelector('.ge-adj-lut-load input[type="file"]');
fileInput?.addEventListener('change', async () => {
const file = fileInput.files && fileInput.files[0];
if (!file) return;
try {
const text = await file.text();
const lut = parseCubeLut(text);
p.cube = {
size: lut.size,
domainMin: Array.from(lut.domainMin),
domainMax: Array.from(lut.domainMax),
data: Array.from(lut.data),
};
if (p.strength == null) p.strength = 100;
const nameEl = body.querySelector('.ge-adj-lut-name');
if (nameEl) nameEl.textContent = `Loaded ${file.name} · ${lut.size}³`;
const exp = body.querySelector('.ge-adj-lut-export');
if (exp) exp.disabled = false;
scheduleAdjRefresh(layer);
} catch (err) {
const nameEl = body.querySelector('.ge-adj-lut-name');
if (nameEl) nameEl.textContent = 'Could not read .cube: ' + (err?.message || err);
}
});
body.querySelector('.ge-adj-lut-export')?.addEventListener('click', () => {
const cube = p.cube && p.cube.size ? p.cube : null;
if (!cube) return;
const text = generateCubeLut({
size: cube.size,
domainMin: cube.domainMin || [0, 0, 0],
domainMax: cube.domainMax || [1, 1, 1],
data: cube.data,
});
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'look.cube';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
}
// Wire all sliders.
body.querySelectorAll('input[type="range"]').forEach(sl => {
@@ -769,7 +922,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
function revertAdjKey(layer, type, key) {
const defaults = defaultAdjParams(type);
const p = layer._stagedAdj.params;
if (['brightness-contrast', 'exposure', 'white-balance', 'hue-saturation', 'vibrance', 'black-white', 'shadows-highlights', 'gradient-map'].includes(type)) {
if (['brightness-contrast', 'exposure', 'white-balance', 'hue-saturation', 'vibrance', 'black-white', 'shadows-highlights', 'gradient-map', 'posterize', 'threshold', 'photo-filter', 'color-lookup'].includes(type)) {
p[key] = defaults[key];
} else if (type === 'levels') {
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
@@ -779,6 +932,10 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
} else if (type === 'color-balance') {
const [tone, ch] = key.split('-');
p[tone][ch] = defaults[tone][ch];
} else if (type === 'channel-mixer') {
const [ch, comp] = key.split('-');
if (comp === 'c') p.constant[ch] = defaults.constant[ch];
else p[ch][comp] = defaults[ch][comp];
} else if (type === 'selective-color') {
p.ranges[p.range][key] = defaults.ranges[p.range][key];
}
@@ -791,6 +948,10 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
const raw = parseInt(sl.value, 10);
const valEl = sl.parentElement.querySelector('.ge-adj-value');
const p = layer._stagedAdj.params;
const row = sl.closest('.ge-adj-row');
if (row && row.hasAttribute('data-default')) {
row.classList.toggle('is-modified', Number(sl.value) !== Number(row.getAttribute('data-default')));
}
let display = String(raw);
if (type === 'brightness-contrast' || type === 'hue-saturation') {
if (key === 'brightness' || key === 'contrast' || key === 'saturation') {
@@ -804,10 +965,13 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
if (key === 'exposure') { p.exposure = raw / 100; display = (raw / 100).toFixed(2) + ' EV'; }
else if (key === 'offset') { p.offset = raw / 100; display = (raw / 100).toFixed(2); }
else if (key === 'gamma') { p.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ'; }
} else if (type === 'develop') {
if (key === 'exposure') { p.exposure = raw / 100; display = (raw / 100).toFixed(2) + ' EV'; }
else { p[key] = raw; }
} else if (type === 'color-lookup') {
p.strength = raw; display = raw + '%';
} else if (type === 'white-balance') {
p[key] = raw;
} else if (type === 'vibrance') {
p[key] = raw;
} else if (type === 'black-white') {
p[key] = raw;
} else if (type === 'shadows-highlights') {
@@ -828,6 +992,18 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
} else if (type === 'gradient-map') {
p.midpoint = raw;
display = raw + '%';
} else if (type === 'posterize') {
p.levels = raw;
} else if (type === 'threshold') {
p.level = raw;
} else if (type === 'photo-filter') {
p.density = raw;
display = raw + '%';
} else if (type === 'channel-mixer') {
const [ch, comp] = key.split('-');
if (comp === 'c') p.constant[ch] = raw;
else p[ch][comp] = raw;
display = raw + (comp === 'c' ? '' : '%');
}
if (valEl) valEl.textContent = display;
scheduleAdjRefresh(layer);
+146
View File
@@ -0,0 +1,146 @@
/**
* Adobe .cube 3D LUT support — parse, apply (trilinear), and generate.
*
* Pure functions (no DOM, no network, no module state) so they run identically
* on the main thread, inside the adjustment worker, and under node in tests.
*
* Data layout: `data` is a flat RGB array with the RED component varying
* fastest, then green, then blue — the ordering the .cube spec uses for its
* row list. Sample index = (ri + gi*size + bi*size*size) * 3.
*/
function clamp01(v) {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
function sampleChannel(data, size, ri, gi, bi, ch) {
return data[(ri + gi * size + bi * size * size) * 3 + ch];
}
/** Parse an Adobe .cube text file into { size, domainMin, domainMax, data }. */
export function parseCubeLut(text) {
if (typeof text !== 'string' || !text.trim()) throw new Error('empty .cube data');
const lines = text.split(/\r?\n/);
let size = 0;
let domainMin = [0, 0, 0];
let domainMax = [1, 1, 1];
const values = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split(/\s+/);
const keyword = parts[0].toUpperCase();
if (keyword === 'TITLE') continue;
if (keyword === 'LUT_3D_SIZE') { size = Math.max(2, Math.round(Number(parts[1]) || 0)); continue; }
if (keyword === 'LUT_1D_SIZE') throw new Error('1D .cube LUTs are not supported');
if (keyword === 'DOMAIN_MIN') { domainMin = [Number(parts[1]) || 0, Number(parts[2]) || 0, Number(parts[3]) || 0]; continue; }
if (keyword === 'DOMAIN_MAX') { domainMax = [Number(parts[1]) || 1, Number(parts[2]) || 1, Number(parts[3]) || 1]; continue; }
const r = Number(parts[0]);
const g = Number(parts[1]);
const b = Number(parts[2]);
if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) values.push(r, g, b);
}
if (!size) throw new Error('missing LUT_3D_SIZE');
const expected = size * size * size * 3;
if (values.length < expected) throw new Error(`incomplete .cube data (${values.length / 3} of ${size ** 3} rows)`);
for (let i = 0; i < size; i += 1) {
if (domainMax[i] <= domainMin[i]) domainMax[i] = domainMin[i] + 1;
}
return { size, domainMin, domainMax, data: new Float64Array(values.slice(0, expected)) };
}
/** Trilinear lookup of an already domain-normalized [0,1] rgb triple. */
function trilinear(lut, r, g, b) {
const n = lut.size;
const max = n - 1;
const fr = clamp01(r) * max;
const fg = clamp01(g) * max;
const fb = clamp01(b) * max;
const r0 = Math.floor(fr); const r1 = Math.min(max, r0 + 1); const tr = fr - r0;
const g0 = Math.floor(fg); const g1 = Math.min(max, g0 + 1); const tg = fg - g0;
const b0 = Math.floor(fb); const b1 = Math.min(max, b0 + 1); const tb = fb - b0;
const out = [0, 0, 0];
for (let ch = 0; ch < 3; ch += 1) {
const c000 = sampleChannel(lut.data, n, r0, g0, b0, ch);
const c100 = sampleChannel(lut.data, n, r1, g0, b0, ch);
const c010 = sampleChannel(lut.data, n, r0, g1, b0, ch);
const c110 = sampleChannel(lut.data, n, r1, g1, b0, ch);
const c001 = sampleChannel(lut.data, n, r0, g0, b1, ch);
const c101 = sampleChannel(lut.data, n, r1, g0, b1, ch);
const c011 = sampleChannel(lut.data, n, r0, g1, b1, ch);
const c111 = sampleChannel(lut.data, n, r1, g1, b1, ch);
const c00 = c000 + (c100 - c000) * tr;
const c10 = c010 + (c110 - c010) * tr;
const c01 = c001 + (c101 - c001) * tr;
const c11 = c011 + (c111 - c011) * tr;
const c0 = c00 + (c10 - c00) * tg;
const c1 = c01 + (c11 - c01) * tg;
out[ch] = c0 + (c1 - c0) * tb;
}
return out;
}
/**
* Apply a parsed LUT in place to an RGBA byte array.
* @param {Uint8ClampedArray|Uint8Array} data RGBA bytes
* @param {number} strength 0..1 blend (1 = full LUT)
*/
export function applyCubeLut(data, width, height, lut, { strength = 1 } = {}) {
if (!lut || !lut.data || !lut.size) return data;
const s = clamp01(Number.isFinite(strength) ? strength : 1);
if (s <= 0) return data;
const { domainMin, domainMax } = lut;
const n = data.length;
const out = [0, 0, 0];
for (let i = 0; i < n; i += 4) {
const r = (data[i] / 255 - domainMin[0]) / (domainMax[0] - domainMin[0]);
const g = (data[i + 1] / 255 - domainMin[1]) / (domainMax[1] - domainMin[1]);
const b = (data[i + 2] / 255 - domainMin[2]) / (domainMax[2] - domainMin[2]);
const mapped = trilinear(lut, r, g, b);
data[i] = Math.round((data[i] / 255 + (clamp01(mapped[0]) - data[i] / 255) * s) * 255);
data[i + 1] = Math.round((data[i + 1] / 255 + (clamp01(mapped[1]) - data[i + 1] / 255) * s) * 255);
data[i + 2] = Math.round((data[i + 2] / 255 + (clamp01(mapped[2]) - data[i + 2] / 255) * s) * 255);
}
return data;
}
/** Serialize a { size, data } LUT to .cube text (r fastest). */
export function generateCubeLut(lut) {
const size = lut?.size;
if (!size || !lut.data) throw new Error('lut with size + data required');
const domainMin = lut.domainMin || [0, 0, 0];
const domainMax = lut.domainMax || [1, 1, 1];
const lines = [
'TITLE "odysseus"',
`LUT_3D_SIZE ${size}`,
`DOMAIN_MIN ${domainMin[0].toFixed(6)} ${domainMin[1].toFixed(6)} ${domainMin[2].toFixed(6)}`,
`DOMAIN_MAX ${domainMax[0].toFixed(6)} ${domainMax[1].toFixed(6)} ${domainMax[2].toFixed(6)}`,
];
for (let b = 0; b < size; b += 1) {
for (let g = 0; g < size; g += 1) {
for (let r = 0; r < size; r += 1) {
const o = (r + g * size + b * size * size) * 3;
lines.push(`${lut.data[o].toFixed(6)} ${lut.data[o + 1].toFixed(6)} ${lut.data[o + 2].toFixed(6)}`);
}
}
}
return lines.join('\n') + '\n';
}
/** Identity LUT of the given grid size. */
export function identityLut(size = 2) {
const n = Math.max(2, Math.round(size));
const data = new Float64Array(n * n * n * 3);
const max = n - 1;
for (let b = 0; b < n; b += 1) {
for (let g = 0; g < n; g += 1) {
for (let r = 0; r < n; r += 1) {
const o = (r + g * n + b * n * n) * 3;
data[o] = r / max; data[o + 1] = g / max; data[o + 2] = b / max;
}
}
}
return { size: n, domainMin: [0, 0, 0], domainMax: [1, 1, 1], data };
}
export default { parseCubeLut, applyCubeLut, generateCubeLut, identityLut };
+161
View File
@@ -0,0 +1,161 @@
/**
* Camera-Raw-style "Develop" tonal pipeline — a single deterministic pass that
* mirrors the Basic slider set (white balance, exposure, highlights, shadows,
* whites, blacks, contrast, clarity, vibrance, saturation).
*
* Pure pixel math: no DOM, no network, no module state, so it runs identically
* on the main thread and inside the adjustment worker. Work is done in
* [0,1] floats and hue is preserved by scaling the RGB triplet by the ratio
* between the shaped luma and the original luma, so a tonal move never drifts
* the colour.
*/
function clamp01(value) {
return value < 0 ? 0 : value > 1 ? 1 : value;
}
function finiteOr(value, fallback) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
export const DEVELOP_DEFAULTS = Object.freeze({
exposure: 0,
contrast: 0,
highlights: 0,
shadows: 0,
whites: 0,
blacks: 0,
temperature: 0,
tint: 0,
clarity: 0,
vibrance: 0,
saturation: 0,
});
/** Separable box blur of a scalar field — used for the clarity local contrast. */
function boxBlurField(field, width, height, radius) {
if (radius < 1) return field;
const tmp = new Float32Array(field.length);
const out = new Float32Array(field.length);
const window = radius * 2 + 1;
for (let y = 0; y < height; y += 1) {
const row = y * width;
let sum = 0;
for (let x = -radius; x <= radius; x += 1) sum += field[row + Math.max(0, Math.min(width - 1, x))];
for (let x = 0; x < width; x += 1) {
tmp[row + x] = sum / window;
const add = field[row + Math.min(width - 1, x + radius + 1)];
const sub = field[row + Math.max(0, x - radius)];
sum += add - sub;
}
}
for (let x = 0; x < width; x += 1) {
let sum = 0;
for (let y = -radius; y <= radius; y += 1) sum += tmp[Math.max(0, Math.min(height - 1, y)) * width + x];
for (let y = 0; y < height; y += 1) {
out[y * width + x] = sum / window;
const add = tmp[Math.min(height - 1, y + radius + 1) * width + x];
const sub = tmp[Math.max(0, y - radius) * width + x];
sum += add - sub;
}
}
return out;
}
/**
* Apply the Develop pipeline in place on an RGBA byte array.
* @returns {Uint8ClampedArray} the same array (mutated) for chaining.
*/
export function developImageData(data, width, height, params = {}) {
const p = {
exposure: finiteOr(params.exposure, 0),
contrast: finiteOr(params.contrast, 0),
highlights: finiteOr(params.highlights, 0),
shadows: finiteOr(params.shadows, 0),
whites: finiteOr(params.whites, 0),
blacks: finiteOr(params.blacks, 0),
temperature: finiteOr(params.temperature, 0),
tint: finiteOr(params.tint, 0),
clarity: finiteOr(params.clarity, 0),
vibrance: finiteOr(params.vibrance, 0),
saturation: finiteOr(params.saturation, 0),
};
const active = Object.keys(p).some(key => Math.abs(p[key]) > 1e-6);
if (!active || width <= 0 || height <= 0) return data;
const n = width * height;
const exp = Math.pow(2, p.exposure);
const t = p.temperature / 100;
const ti = p.tint / 100;
// Temperature: +warms (more red, less blue). Tint: +magenta (less green).
const rMul = exp * (1 + 0.30 * t) * (1 + 0.10 * ti);
const gMul = exp * (1 - 0.20 * ti);
const bMul = exp * (1 - 0.30 * t) * (1 + 0.10 * ti);
const hi = p.highlights / 100;
const sh = p.shadows / 100;
const wh = p.whites / 100;
const bl = p.blacks / 100;
const con = p.contrast / 100;
const vib = p.vibrance / 100;
const sat = p.saturation / 100;
const clarity = p.clarity / 100;
const lum = new Float32Array(n);
for (let i = 0; i < n; i += 1) {
const o = i * 4;
let r = clamp01((data[o] / 255) * rMul);
let g = clamp01((data[o + 1] / 255) * gMul);
let b = clamp01((data[o + 2] / 255) * bMul);
const base = 0.2126 * r + 0.7152 * g + 0.0722 * b;
let y = base;
if (sh) y += sh * 0.5 * (1 - y) * (1 - y); // + brightens shadows
if (hi) y += hi * 0.5 * y * y; // + brightens / - recovers highlights
if (wh) y += wh * 0.30 * y * y; // whites: endpoint at the top
if (bl) y -= bl * 0.30 * (1 - y) * (1 - y); // blacks: endpoint at the bottom
y = clamp01(y);
if (con) y = clamp01(0.5 + (y - 0.5) * (1 + con * 0.9)); // S-curve about mid-grey
if (base > 1e-5) {
const k = y / base;
r = clamp01(r * k);
g = clamp01(g * k);
b = clamp01(b * k);
} else {
r = g = b = y;
}
lum[i] = 0.2126 * r + 0.7152 * g + 0.0722 * b;
if (vib || sat) {
const avg = (r + g + b) / 3;
const mx = Math.max(r, g, b);
const mn = Math.min(r, g, b);
const chroma = mx > 0 ? (mx - mn) / mx : 0;
const amount = sat + vib * (1 - chroma);
if (amount) {
r = clamp01(r + (r - avg) * amount);
g = clamp01(g + (g - avg) * amount);
b = clamp01(b + (b - avg) * amount);
}
}
data[o] = Math.round(r * 255);
data[o + 1] = Math.round(g * 255);
data[o + 2] = Math.round(b * 255);
}
if (clarity) {
const radius = Math.max(2, Math.round(Math.min(width, height) * 0.01));
const blurred = boxBlurField(lum, width, height, radius);
for (let i = 0; i < n; i += 1) {
const detail = (lum[i] - blurred[i]) * clarity * 1.2;
const o = i * 4;
data[o] = Math.max(0, Math.min(255, data[o] + detail * 255));
data[o + 1] = Math.max(0, Math.min(255, data[o + 1] + detail * 255));
data[o + 2] = Math.max(0, Math.min(255, data[o + 2] + detail * 255));
}
}
return data;
}
export default { developImageData, DEVELOP_DEFAULTS };
+159 -16
View File
@@ -14,6 +14,9 @@
* { type: 'curves', params: { points: {rgb, red, green, blue} } }
* { type: 'color-balance', params: { shadows, midtones, highlights } }
*/
import { developImageData } from './develop.js';
import { applyCubeLut } from './cube-lut.js';
function clampByte(value) {
return value < 0 ? 0 : value > 255 ? 255 : Math.round(value);
}
@@ -136,6 +139,12 @@ export function applyAdjustment(srcCanvas, adj) {
const img = octx.getImageData(0, 0, w, h);
const d = img.data;
if (adj.type === 'develop') {
const p = adj.params || {};
developImageData(d, w, h, p);
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'exposure') {
const p = adj.params || {};
const multiplier = Math.pow(2, finiteOr(p.exposure, 0));
@@ -182,13 +191,20 @@ export function applyAdjustment(srcCanvas, adj) {
if (adj.type === 'vibrance') {
const amount = Math.max(-1, Math.min(1, finiteOr(adj.params?.vibrance, 0) / 100));
// Scale chroma around luminance by a factor that leans on low-saturation
// pixels. Doing it directly in RGB (no HSL round-trip) keeps near-gray
// pixels near-gray instead of pinning them to an absolute saturation floor,
// which previously turned faint hue noise (e.g. lifted shadow haze) into
// false-colour banding.
for (let i = 0; i < d.length; i += 4) {
const [hue, saturation, lightness] = rgbToHsl(d[i], d[i + 1], d[i + 2]);
const adjustedSaturation = amount >= 0
? saturation + (1 - saturation) * amount
: saturation * (1 + amount);
const rgb = hslToRgb(hue, Math.max(0, Math.min(1, adjustedSaturation)), lightness);
d[i] = clampByte(rgb[0]); d[i + 1] = clampByte(rgb[1]); d[i + 2] = clampByte(rgb[2]);
const r = d[i], g = d[i + 1], b = d[i + 2];
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const avg = (r + g + b) / 3;
const sat = max <= 0 ? 0 : (max - min) / max;
const factor = 1 + (1 - sat) * amount;
d[i] = clampByte(avg + (r - avg) * factor);
d[i + 1] = clampByte(avg + (g - avg) * factor);
d[i + 2] = clampByte(avg + (b - avg) * factor);
}
octx.putImageData(img, 0, 0);
return out;
@@ -213,16 +229,23 @@ export function applyAdjustment(srcCanvas, adj) {
if (adj.type === 'shadows-highlights') {
const shadows = Math.max(-1, Math.min(1, finiteOr(adj.params?.shadows, 0) / 100));
const highlights = Math.max(-1, Math.min(1, finiteOr(adj.params?.highlights, 0) / 100));
const toneAdjust = (value, amount, weight) => amount >= 0
? value + (255 - value) * amount * weight
: value + value * amount * weight;
// Endpoint-preserving tonal lift. A linear "add toward white" lift turns
// every near-black pixel grey (flattening the whole image under a veil);
// instead scale luminance with a gamma curve so 0 stays 0 and 255 stays 255
// while shadows/midtones open up. Colour is preserved via the luminance ratio.
for (let i = 0; i < d.length; i += 4) {
const luminance = (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]) / 255;
const shadowWeight = (1 - luminance) ** 2;
const highlightWeight = luminance ** 2;
d[i] = clampByte(toneAdjust(toneAdjust(d[i], shadows, shadowWeight), highlights, highlightWeight));
d[i + 1] = clampByte(toneAdjust(toneAdjust(d[i + 1], shadows, shadowWeight), highlights, highlightWeight));
d[i + 2] = clampByte(toneAdjust(toneAdjust(d[i + 2], shadows, shadowWeight), highlights, highlightWeight));
const r = d[i], g = d[i + 1], b = d[i + 2];
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
if (luminance <= 1 / 255) continue;
const shadowWeight = (1 - luminance) * (1 - luminance);
const highlightWeight = luminance * luminance;
let exponent = 1;
if (shadows) exponent /= (1 + shadows * shadowWeight * 0.85);
if (highlights) exponent /= (1 + highlights * highlightWeight * 0.85);
const ratio = Math.pow(luminance, exponent - 1);
d[i] = clampByte(r * ratio);
d[i + 1] = clampByte(g * ratio);
d[i + 2] = clampByte(b * ratio);
}
octx.putImageData(img, 0, 0);
return out;
@@ -261,7 +284,17 @@ export function applyAdjustment(srcCanvas, adj) {
if (adj.type === 'color-balance') {
const cb = adj.params;
const scale = 0.6;
const s = cb.shadows, m = cb.midtones, hi = cb.highlights;
// Be robust to malformed planner params: a tone may arrive as a bare
// number instead of {r,g,b}, which would otherwise produce NaN pixels
// (a blank white canvas). Coerce every tone to finite {r,g,b}.
const toTone = (v) => {
if (v && typeof v === 'object') {
return { r: Number.isFinite(v.r) ? v.r : 0, g: Number.isFinite(v.g) ? v.g : 0, b: Number.isFinite(v.b) ? v.b : 0 };
}
const n = Number.isFinite(v) ? v : 0;
return { r: n, g: n, b: n };
};
const s = toTone(cb.shadows), m = toTone(cb.midtones), hi = toTone(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;
@@ -327,6 +360,116 @@ export function applyAdjustment(srcCanvas, adj) {
return out;
}
if (adj.type === 'invert') {
for (let i = 0; i < d.length; i += 4) {
d[i] = 255 - d[i];
d[i + 1] = 255 - d[i + 1];
d[i + 2] = 255 - d[i + 2];
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'posterize') {
const levels = Math.max(2, Math.min(255, Math.round(finiteOr(adj.params?.levels, 4))));
const step = 255 / (levels - 1);
for (let i = 0; i < d.length; i += 4) {
d[i] = clampByte(Math.round(d[i] / step) * step);
d[i + 1] = clampByte(Math.round(d[i + 1] / step) * step);
d[i + 2] = clampByte(Math.round(d[i + 2] / step) * step);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'threshold') {
const level = Math.max(0, Math.min(255, Math.round(finiteOr(adj.params?.level, 128))));
for (let i = 0; i < d.length; i += 4) {
const luminance = 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2];
const value = luminance >= level ? 255 : 0;
d[i] = value; d[i + 1] = value; d[i + 2] = value;
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'photo-filter') {
const p = adj.params || {};
const color = parseHexColor(p.color, 'ec8a00');
const density = Math.max(0, Math.min(1, finiteOr(p.density, 25) / 100));
const preserve = p.preserve_luminosity !== false;
for (let i = 0; i < d.length; i += 4) {
const sourceLum = 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2];
let r = d[i] * (1 - density) + color[0] * density;
let g = d[i + 1] * (1 - density) + color[1] * density;
let b = d[i + 2] * (1 - density) + color[2] * density;
if (preserve) {
const filteredLum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
if (filteredLum > 0.001) { const scale = sourceLum / filteredLum; r *= scale; g *= scale; b *= scale; }
}
d[i] = clampByte(r); d[i + 1] = clampByte(g); d[i + 2] = clampByte(b);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'channel-mixer') {
const p = adj.params || {};
const red = p.red || { r: 100, g: 0, b: 0 };
const green = p.green || { r: 0, g: 100, b: 0 };
const blue = p.blue || { r: 0, g: 0, b: 100 };
const constant = p.constant || { r: 0, g: 0, b: 0 };
const mono = !!p.monochrome;
for (let i = 0; i < d.length; i += 4) {
const r = d[i], g = d[i + 1], b = d[i + 2];
const nr = (r * finiteOr(red.r, 100) + g * finiteOr(red.g, 0) + b * finiteOr(red.b, 0)) / 100 + finiteOr(constant.r, 0) * 2.55;
const ng = (r * finiteOr(green.r, 0) + g * finiteOr(green.g, 100) + b * finiteOr(green.b, 0)) / 100 + finiteOr(constant.g, 0) * 2.55;
const nb = (r * finiteOr(blue.r, 0) + g * finiteOr(blue.g, 0) + b * finiteOr(blue.b, 100)) / 100 + finiteOr(constant.b, 0) * 2.55;
if (mono) { d[i] = d[i + 1] = d[i + 2] = clampByte(nr); continue; }
d[i] = clampByte(nr); d[i + 1] = clampByte(ng); d[i + 2] = clampByte(nb);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'color-lookup') {
const preset = String(adj.params?.preset || 'none');
// An imported .cube LUT (Adobe Cube spec) takes precedence over the small
// built-in preset matrices. `params.cube` is stored serializable:
// { size, domainMin:[3], domainMax:[3], data:[size^3*3] } (red fastest)
const cube = adj.params?.cube;
if (cube && cube.size && cube.data) {
const lut = {
size: Number(cube.size),
domainMin: Array.isArray(cube.domainMin) ? cube.domainMin.slice(0, 3) : [0, 0, 0],
domainMax: Array.isArray(cube.domainMax) ? cube.domainMax.slice(0, 3) : [1, 1, 1],
data: cube.data instanceof Float64Array ? cube.data : new Float64Array(cube.data),
};
const strength = adj.params?.strength == null ? 1 : Math.max(0, Math.min(1, Number(adj.params.strength) / 100));
applyCubeLut(d, w, h, lut, { strength });
octx.putImageData(img, 0, 0);
return out;
}
const matrices = {
teal_orange: [[1.05, -0.05, 0.0], [0.0, 1.0, 0.0], [0.0, -0.02, 1.05]],
cold: [[0.95, 0.0, 0.05], [0.0, 1.0, 0.03], [0.02, 0.05, 1.12]],
warm: [[1.12, 0.02, 0.0], [0.03, 1.0, 0.0], [0.0, 0.02, 0.92]],
high_contrast: [[1.35, -0.18, -0.17], [-0.17, 1.35, -0.18], [-0.18, -0.17, 1.35]],
sepia: [[0.393, 0.769, 0.189], [0.349, 0.686, 0.168], [0.272, 0.534, 0.131]],
};
const m = matrices[preset];
if (m) {
for (let i = 0; i < d.length; i += 4) {
const r = d[i], g = d[i + 1], b = d[i + 2];
d[i] = clampByte(r * m[0][0] + g * m[0][1] + b * m[0][2]);
d[i + 1] = clampByte(r * m[1][0] + g * m[1][1] + b * m[1][2]);
d[i + 2] = clampByte(r * m[2][0] + g * m[2][1] + b * m[2][2]);
}
}
octx.putImageData(img, 0, 0);
return out;
}
return out;
}
+108 -12
View File
@@ -9,6 +9,10 @@
* @param {{
* undo: () => void,
* redo: () => void,
* takeSnapshot?: (label?: string) => ({ id: string, label: string } | void),
* restoreSnapshot?: (id: string) => void,
* deleteSnapshot?: (id: string) => void,
* listSnapshots?: () => ({ id: string, label: string, ts: number }[]),
* }} deps
*
* @returns {{
@@ -22,7 +26,7 @@ import modalManager from '../modalManager.js';
import { HISTORY_ICON, relTime } from './layer-helpers.js';
import { historyPanelHTML } from './build/popups.js';
export function createHistoryPanel({ undo, redo }) {
export function createHistoryPanel({ undo, redo, takeSnapshot, restoreSnapshot, deleteSnapshot, listSnapshots }) {
function jumpToHistory(offset) {
if (offset === 0) return;
if (offset < 0) {
@@ -81,11 +85,27 @@ export function createHistoryPanel({ undo, redo }) {
panel.innerHTML = historyPanelHTML(HISTORY_ICON);
document.body.appendChild(panel);
state.historyPanelEl = panel;
// Fill in the snapshot + timeline content FIRST so the panel has its real
// height before we measure it for on-screen clamping below.
refreshHistoryPanelIfOpen();
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.style.top = (r.bottom + 6) + 'px';
// Anchor under the button, but if the panel would run off the bottom of
// the viewport (the History button lives in the footer) flip it above the
// button and clamp both edges so it is always fully visible.
const ph = panel.offsetHeight || 200;
const pw = panel.offsetWidth || 240;
let top = r.bottom + 6;
let left = Math.max(8, Math.min(r.left, window.innerWidth - pw - 8));
if (top + ph > window.innerHeight - 8) {
top = Math.max(8, r.top - ph - 6);
if (top < 8) top = Math.max(8, window.innerHeight - ph - 8);
}
panel.style.top = top + 'px';
panel.style.left = left + 'px';
}
panel.querySelector('.ge-adj-min').addEventListener('click', minimiseHistoryPanel);
// Click anywhere outside the panel (or trigger button) closes it.
@@ -94,6 +114,10 @@ export function createHistoryPanel({ undo, redo }) {
if (!state.historyPanelEl) return;
if (state.historyPanelEl.contains(ev.target)) return;
if (btn && (ev.target === btn || btn.contains(ev.target))) return;
// A click inside a topbar dropdown (Window/Edit > History) is an
// explicit toggle, not an "away" click — don't close on its
// pointerdown or the row's click would immediately reopen it.
if (ev.target.closest && ev.target.closest('.dropdown')) return;
closeHistoryPanel();
document.removeEventListener('pointerdown', onAway, true);
};
@@ -134,16 +158,13 @@ export function createHistoryPanel({ undo, redo }) {
};
document.addEventListener('keydown', onKey, true);
panel._escHandler = onKey;
refreshHistoryPanelIfOpen();
}
function refreshHistoryPanelIfOpen() {
if (!state.historyPanelEl) return;
const list = state.historyPanelEl.querySelector('#ge-history-list');
// Render the chronological timeline (oldest/past -> Current -> future) into
// any list element. Shared by the floating panel and the docked History
// panel, matching Photoshop's docked History timeline.
function renderHistoryList(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];
@@ -167,10 +188,85 @@ export function createHistoryPanel({ undo, redo }) {
jumpToHistory(off);
});
});
// Scroll the current marker into view.
// Reveal the current marker without disturbing the surrounding chrome.
// Element.scrollIntoView() bubbles up and scrolls EVERY scrollable
// ancestor, which yanked the whole right-hand dock down to the History
// group on load (hiding the Color/Properties tab strips). Instead scroll
// only the list itself, or the dock group's own panel when the list is
// not the scroller — so the docked History auto-reveals the current step
// while the tab strips above stay put.
const cur = list.querySelector('.current');
if (cur) cur.scrollIntoView({ block: 'center' });
if (cur) {
const scroller = list.scrollHeight > list.clientHeight + 1
? list
: list.closest('.ge-dock-panels');
if (scroller) {
const c = cur.getBoundingClientRect();
const s = scroller.getBoundingClientRect();
if (c.bottom > s.bottom) scroller.scrollTop += (c.bottom - s.bottom) + 8;
else if (c.top < s.top) scroller.scrollTop -= (s.top - c.top) + 8;
}
}
}
return { toggleHistoryPanel, refreshHistoryPanelIfOpen, jumpToHistory };
// Named snapshots (Photoshop-style restore points) section — a camera
// button plus one row per snapshot. Rendered above the timeline in both
// the floating panel and the docked History panel.
const SNAPSHOT_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path><circle cx="12" cy="13" r="4"></circle></svg>';
function renderSnapshots(container) {
if (!container) return;
const snaps = (typeof listSnapshots === 'function') ? (listSnapshots() || []) : [];
const rows = snaps.map(s => `
<div class="ge-history-snap-row" data-snap-id="${s.id}">
<button class="ge-history-snap-restore" type="button" data-snap-id="${s.id}" title="Restore this snapshot">
<span class="ge-history-snap-dot"></span>
<span class="ge-history-row-label">${(s.label || 'Snapshot').replace(/[<>&]/g, '')}</span>
</button>
<span class="ge-history-row-time">${relTime(s.ts)}</span>
<button class="ge-history-snap-del" type="button" data-snap-id="${s.id}" title="Delete snapshot" aria-label="Delete snapshot">&times;</button>
</div>`).join('');
container.innerHTML = `
<div class="ge-history-snap-head">
<span class="ge-history-snap-title">Snapshots</span>
<button class="ge-history-snap-new" type="button" title="Create a new snapshot">${SNAPSHOT_ICON}</button>
</div>
<div class="ge-history-snap-rows">${rows || '<div class="ge-history-snap-empty">No snapshots yet</div>'}</div>
`;
container.querySelector('.ge-history-snap-new')?.addEventListener('click', () => {
if (typeof takeSnapshot !== 'function') return;
const n = ((typeof listSnapshots === 'function' && listSnapshots()) || []).length + 1;
const label = window.prompt('Snapshot name', 'Snapshot ' + n);
if (label == null) return;
takeSnapshot(label.trim() || ('Snapshot ' + n));
});
container.querySelectorAll('.ge-history-snap-restore').forEach(btn => {
btn.addEventListener('click', () => { if (typeof restoreSnapshot === 'function') restoreSnapshot(btn.dataset.snapId); });
});
container.querySelectorAll('.ge-history-snap-del').forEach(btn => {
btn.addEventListener('click', () => { if (typeof deleteSnapshot === 'function') deleteSnapshot(btn.dataset.snapId); });
});
}
// Refresh the floating panel (if open) and the docked History panel (if
// present) — every undo/redo/edit already calls this.
function refreshHistoryPanelIfOpen() {
if (state.historyPanelEl) {
renderSnapshots(state.historyPanelEl.querySelector('#ge-history-snapshots'));
renderHistoryList(state.historyPanelEl.querySelector('#ge-history-list'));
}
const dockList = document.getElementById('ge-dock-history-list');
if (dockList) {
let dockSnaps = document.getElementById('ge-dock-history-snapshots');
if (!dockSnaps) {
dockSnaps = document.createElement('div');
dockSnaps.id = 'ge-dock-history-snapshots';
dockSnaps.className = 'ge-history-snapshots';
dockList.parentNode?.insertBefore(dockSnaps, dockList);
}
renderSnapshots(dockSnaps);
renderHistoryList(dockList);
}
}
return { toggleHistoryPanel, refreshHistoryPanelIfOpen, renderHistoryList, renderSnapshots, jumpToHistory };
}
+54 -5
View File
@@ -11,7 +11,7 @@
* Ctrl+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+T start free transform (Ctrl+Alt+T also works)
* Ctrl+Alt+Shift+I invert selection (Ctrl+Alt+I also works)
* Ctrl+Alt+J new empty layer
* Ctrl/Cmd+J copy selected pixels to a layer, or duplicate the layer
@@ -20,6 +20,7 @@
* Ctrl+C/X copy / cut the selected surface (image clipboard
* + internal clipboard, preserving its document offset)
* Ctrl+V (handled by the paste event listener)
* Ctrl+ / - / 0 zoom in / out / fit (overrides browser zoom)
* Tool keys (V, B, E, L, …) → toolbar click
* Hold Space temporarily pan without changing the active tool
* [ / ] shrink / grow brush size proportionally
@@ -81,6 +82,12 @@ export function wireKeyboardShortcuts(deps) {
target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' || target.isContentEditable
);
// A focused range/checkbox/button widget is still an <input>, but it is not
// a text field: Delete/Backspace there should fall through to the editor
// (e.g. delete the selected layer) instead of being swallowed.
const isNonTextInput = (target) => target && target.tagName === 'INPUT' && (
!['text', 'search', 'email', 'url', 'password', 'number', 'tel'].includes(target.type)
);
const releaseTemporaryPan = () => setTemporaryPan?.(false);
document.addEventListener('keyup', (e) => {
@@ -93,7 +100,10 @@ export function wireKeyboardShortcuts(deps) {
if (!state.editorOpen || e.defaultPrevented || e.isComposing) return;
if (e.target?.closest?.('#styled-confirm-overlay')) return;
// Fields and text layers own native editing, including undo and clipboard.
if (isTypingTarget(e.target)) return;
// Let Delete/Backspace out of non-text widgets (range/checkbox/button) so
// they still delete the selected layer.
if (isTypingTarget(e.target) &&
!(isNonTextInput(e.target) && (e.key === 'Delete' || e.key === 'Backspace'))) return;
if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.key === 'Backspace') {
e.preventDefault();
e.stopPropagation();
@@ -175,7 +185,9 @@ export function wireKeyboardShortcuts(deps) {
return;
}
if (e.shiftKey && e.key === 'T') { e.preventDefault(); e.stopPropagation(); resizeCustomPrompt(); return; }
if (e.altKey && e.code === 'KeyT') { e.preventDefault(); e.stopPropagation(); startTransform(); return; }
// Ctrl/Cmd+T — free transform (Photoshop). Ctrl+Shift+T stays the canvas
// resize prompt and Ctrl+Alt+T remains a legacy alias.
if (!e.shiftKey && e.code === 'KeyT') { e.preventDefault(); e.stopPropagation(); startTransform(); return; }
// Ctrl+Alt+Shift+I (or the previous Ctrl+Alt+I) inverts the selection. Uses e.code so
// Alt-modified key values (e.g. `ˆ` on Mac with Option+I)
// don't break the match.
@@ -242,10 +254,30 @@ export function wireKeyboardShortcuts(deps) {
}
// Ctrl+V handled by the paste event listener.
if (e.key === 'v') { /* no-op here */ }
// Ctrl/Cmd +/- zoom the canvas in/out and Ctrl+0 fits — overriding the
// browser's page zoom (we own the shortcut inside the editor).
if (e.key === '+' || e.key === '=' || e.code === 'Equal' || e.code === 'NumpadAdd') {
e.preventDefault();
e.stopPropagation();
document.getElementById('ge-zoom-in')?.click();
return;
}
if (e.key === '-' || e.key === '_' || e.code === 'Minus' || e.code === 'NumpadSubtract') {
e.preventDefault();
e.stopPropagation();
document.getElementById('ge-zoom-out')?.click();
return;
}
if (e.key === '0' || e.code === 'Digit0' || e.code === 'Numpad0') {
e.preventDefault();
e.stopPropagation();
document.getElementById('ge-zoom-100')?.click();
return;
}
return;
}
// Tool shortcuts (only when not typing in an input).
if (isTypingTarget(e.target)) return;
if (isTypingTarget(e.target) && !isNonTextInput(e.target)) return;
// Delete pixels for a selection, otherwise delete the selected layer(s).
// Clipboard shortcuts above retain ownership of Ctrl/Cmd+X and C.
@@ -282,11 +314,28 @@ export function wireKeyboardShortcuts(deps) {
toggleQuickMask?.();
return;
}
// Photoshop-compatible colour keys (tool shortcuts use neither): X swaps
// the foreground/background colours, D resets them to black/white.
if (!e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
const colorKey = e.key.toLowerCase();
if (colorKey === 'x') {
e.preventDefault();
e.stopPropagation();
window.geSwapColors?.();
return;
}
if (colorKey === 'd') {
e.preventDefault();
e.stopPropagation();
window.geResetColors?.();
return;
}
}
const toolId = toolKeyMap[e.key.toLowerCase()];
if (toolId) {
e.preventDefault();
e.stopPropagation();
const toolBtn = toolbar.querySelector(`[data-tool="${toolId}"]`);
const toolBtn = toolbar.querySelector(`[data-tool="${toolId}"]`) || toolbar.querySelector(`[data-tool-option="${toolId}"]`);
if (toolBtn) toolBtn.click();
return;
}
+9 -13
View File
@@ -1,5 +1,7 @@
/** Alpha clipping for flat layer stacks and isolated groups. */
import { blendDraw } from './blend-modes.js';
function scopeByLayer(editorState) {
const scopes = new Map();
for (const group of editorState.layerGroups || []) {
@@ -18,7 +20,9 @@ export function clippingBaseForLayer(editorState, layerId) {
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
const candidate = layers[cursor];
if ((scopes.get(candidate.id) || null) !== scope) return null;
if (candidate.kind === 'adjustment') return null;
// Skip over adjacent adjustment layers so a stack of clipped adjustments
// all resolve to the same underlying pixel layer.
if (candidate.kind === 'adjustment') continue;
if (!candidate.clipped) return candidate;
}
return null;
@@ -61,9 +65,7 @@ export function drawLayerStack(target, editorState, layers, renderLayer, renderA
const drawNormal = layer => {
if (!layer.visible) return;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
target.globalAlpha = layer.opacity;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(renderLayer(layer), offset.x, offset.y);
blendDraw(target, renderLayer(layer), layer.blendMode, layer.opacity, offset.x, offset.y);
};
for (const layer of layers) {
if (layer.kind === 'adjustment' && renderAdjustment) {
@@ -88,9 +90,7 @@ export function drawLayerStack(target, editorState, layers, renderLayer, renderA
const baseOffset = editorState.layerOffsets.get(base.id) || { x: 0, y: 0 };
scratchCtx.drawImage(renderLayer(base), baseOffset.x, baseOffset.y);
scratchCtx.globalCompositeOperation = 'source-over';
target.globalAlpha = layer.opacity;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(scratch, 0, 0);
blendDraw(target, scratch, layer.blendMode, layer.opacity, 0, 0);
}
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
@@ -102,11 +102,9 @@ export async function drawLayerStackAsync(target, editorState, layers, renderLay
const drawNormal = async layer => {
if (!layer.visible || !shouldContinue()) return;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
target.globalAlpha = layer.opacity;
target.globalCompositeOperation = layer.blendMode || 'source-over';
const rendered = await renderLayer(layer);
if (!shouldContinue() || !rendered) return;
target.drawImage(rendered, offset.x, offset.y);
blendDraw(target, rendered, layer.blendMode, layer.opacity, offset.x, offset.y);
};
for (const layer of layers) {
if (!shouldContinue()) return;
@@ -135,9 +133,7 @@ export async function drawLayerStackAsync(target, editorState, layers, renderLay
const baseOffset = editorState.layerOffsets.get(base.id) || { x: 0, y: 0 };
scratchCtx.drawImage(renderedBase, baseOffset.x, baseOffset.y);
scratchCtx.globalCompositeOperation = 'source-over';
target.globalAlpha = layer.opacity;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(scratch, 0, 0);
blendDraw(target, scratch, layer.blendMode, layer.opacity, 0, 0);
}
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
+7 -6
View File
@@ -2,6 +2,7 @@
import { drawLayerStack, drawLayerStackAsync } from './layer-clipping.js';
import { renderEffects, renderEffectsAsync } from './effects.js';
import { blendDraw } from './blend-modes.js';
function groupMap(editorState) {
return new Map((editorState.layerGroups || []).map(group => [group.id, group]));
@@ -390,9 +391,9 @@ export function drawGroupedLayers(ctx, editorState, renderLayer, renderAdjustmen
// raw member fallback loses masks, blending, and group effects.
editorState.groupCompositeCanvases.set(group.id, groupOutput);
}
target.globalAlpha = group.opacity;
target.globalCompositeOperation = group.blendMode || 'source-over';
target.drawImage(groupOutput, 0, 0);
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
blendDraw(target, groupOutput, group.blendMode, group.opacity, 0, 0);
}
flushLayers();
target.globalAlpha = 1;
@@ -497,9 +498,9 @@ export async function drawGroupedLayersAsync(ctx, editorState, renderLayer, rend
if (groupOutput.width && groupOutput.height) {
editorState.groupCompositeCanvases.set(group.id, groupOutput);
}
target.globalAlpha = group.opacity;
target.globalCompositeOperation = group.blendMode || 'source-over';
target.drawImage(groupOutput, 0, 0);
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
blendDraw(target, groupOutput, group.blendMode, group.opacity, 0, 0);
}
await flushLayers();
target.globalAlpha = 1;
+27
View File
@@ -54,6 +54,7 @@ export function defaultAdjParams(type) {
switch (type) {
case 'brightness-contrast': return { brightness: 1, contrast: 1 };
case 'exposure': return { exposure: 0, offset: 0, gamma: 1 };
case 'develop': return { exposure: 0, contrast: 0, highlights: 0, shadows: 0, whites: 0, blacks: 0, temperature: 0, tint: 0, clarity: 0, vibrance: 0, saturation: 0 };
case 'white-balance': return { temperature: 0, tint: 0 };
case 'hue-saturation': return { hue: 0, saturation: 1, lightness: 0 };
case 'vibrance': return { vibrance: 0 };
@@ -95,6 +96,18 @@ export function defaultAdjParams(type) {
midpoint: 50,
reverse: false,
};
case 'invert': return {};
case 'posterize': return { levels: 4 };
case 'threshold': return { level: 128 };
case 'photo-filter': return { color: '#ec8a00', density: 25, preserve_luminosity: true };
case 'channel-mixer': return {
monochrome: false,
red: { r: 100, g: 0, b: 0 },
green: { r: 0, g: 100, b: 0 },
blue: { r: 0, g: 0, b: 100 },
constant: { r: 0, g: 0, b: 0 },
};
case 'color-lookup': return { preset: 'none', strength: 100 };
}
return {};
}
@@ -171,6 +184,7 @@ export function adjLayerLabel(type) {
return {
'brightness-contrast': 'Brightness/Contrast',
'exposure': 'Exposure',
'develop': 'Develop',
'white-balance': 'White Balance',
'hue-saturation': 'Hue/Saturation',
'vibrance': 'Vibrance',
@@ -181,6 +195,12 @@ export function adjLayerLabel(type) {
'color-balance': 'Color Balance',
'selective-color': 'Selective Color',
'gradient-map': 'Gradient Map',
'invert': 'Invert',
'posterize': 'Posterize',
'threshold': 'Threshold',
'photo-filter': 'Photo Filter',
'channel-mixer': 'Channel Mixer',
'color-lookup': 'Color Lookup',
}[type] || type;
}
@@ -193,6 +213,7 @@ export function adjLayerLabel(type) {
export const ADJ_ICONS = {
'brightness-contrast': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18Z" fill="currentColor" stroke="none"/></svg>',
'exposure': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 4.5 13H11l-1 9 8.5-11H12z"/></svg>',
'develop': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/><circle cx="9" cy="6" r="2" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="2" fill="currentColor" stroke="none"/><circle cx="8" cy="18" r="2" fill="currentColor" stroke="none"/></svg>',
'white-balance': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v10"/><circle cx="12" cy="17" r="4"/><path d="M9 6h3"/></svg>',
'hue-saturation': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="12" r="4"/><circle cx="15" cy="9.5" r="4"/><circle cx="15" cy="14.5" r="4"/></svg>',
'vibrance': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 18 10 6l4 12 6-12"/><path d="M7 14h10"/></svg>',
@@ -203,6 +224,12 @@ export const ADJ_ICONS = {
'color-balance': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12a9 9 0 0 1 9-9v18a9 9 0 0 1-9-9z" fill="currentColor" stroke="none"/></svg>',
'selective-color': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v6M21 12h-6M12 21v-6M3 12h6"/></svg>',
'gradient-map': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="18" height="12" rx="2"/><path d="M12 6v12"/></svg>',
'invert': '<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>',
'posterize': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><rect x="3" y="6" width="5" height="12" rx="1"/><rect x="10" y="6" width="4" height="12" rx="1"/><rect x="16" y="6" width="5" height="12" rx="1"/></svg>',
'threshold': '<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="10" cy="12" r="6"/><path d="M10 6a6 6 0 0 1 0 12Z" fill="currentColor" stroke="none"/><path d="M18 8v8M16 12h4"/></svg>',
'photo-filter': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="9.5" cy="9.5" r="2.5"/><circle cx="14.5" cy="14.5" r="2.5"/></svg>',
'channel-mixer': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 20V4M12 20V4M19 20V4"/><path d="M2 9h6M9 15h6M16 9h6"/></svg>',
'color-lookup': '<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"/><circle cx="16" cy="8" r="2"/></svg>',
};
+31
View File
@@ -0,0 +1,31 @@
// Auto-Select hit-testing (Move tool). Walks the layer stack front-to-back
// and returns the topmost layer whose rendered pixels are opaque at the
// requested document point. Kept dependency-free so it can run under node
// for unit tests; the caller injects layer-geometry and pixel sampling.
export const AUTO_SELECT_ALPHA_THRESHOLD = 8;
export function pickTopmostLayerAt({
layers,
point,
isHidden = () => false,
isLocked = () => false,
getOffset = () => ({ x: 0, y: 0 }),
sampleAlpha,
} = {}) {
if (!Array.isArray(layers) || !point || typeof sampleAlpha !== 'function') return null;
// Front-to-back: the last layer in the array paints on top.
for (let index = layers.length - 1; index >= 0; index -= 1) {
const layer = layers[index];
if (!layer?.visible || layer.kind === 'adjustment' || !layer.canvas) continue;
if (isHidden(layer)) continue;
if (isLocked(layer)) continue;
const offset = getOffset(layer) || { x: 0, y: 0 };
const x = Math.floor(point.x - offset.x);
const y = Math.floor(point.y - offset.y);
if (x < 0 || y < 0 || x >= layer.canvas.width || y >= layer.canvas.height) continue;
const alpha = sampleAlpha(layer, x, y);
if (alpha != null && alpha > AUTO_SELECT_ALPHA_THRESHOLD) return layer;
}
return null;
}
+47 -3
View File
@@ -506,7 +506,7 @@ export function createLayerPanelRenderer(deps) {
async function deleteLayers(layers) {
const targets = layers.filter(layer => state.layers.includes(layer));
if (!targets.length || targets.length >= state.layers.length) return false;
if (!targets.length) return false;
if (targets.some(layer => layer.isBase) && uiModule?.styledConfirm) {
const ok = await uiModule.styledConfirm(
`Delete ${targets.length === 1 ? 'the original photo layer' : `${targets.length} selected layers, including the original`}? Ctrl+Z brings ${targets.length === 1 ? 'it' : 'them'} back.`,
@@ -521,8 +521,10 @@ export function createLayerPanelRenderer(deps) {
state.layers = state.layers.filter(layer => !ids.has(layer.id));
for (const id of ids) state.layerOffsets.delete(id);
normalizeLayerClipping(state);
// Deleting every layer is allowed — the document becomes an empty canvas.
const fallback = state.layers[state.layers.length - 1];
selectOnlyLayer(state, fallback.id);
if (fallback) selectOnlyLayer(state, fallback.id);
else { state.activeLayerId = null; state.selectedLayerIds = []; state.activeGroupId = null; }
state.maskCanvas = null;
state.maskCtx = null;
composite();
@@ -1089,6 +1091,38 @@ export function createLayerPanelRenderer(deps) {
});
}
}
// Header opacity control (Photoshop-style Blend/Opacity row).
const headerOpacity = document.getElementById('ge-layer-header-opacity');
if (headerOpacity) {
headerOpacity.disabled = !selectedLayer && !selectedGroup;
headerOpacity.value = String(Math.round((selectedGroup?.opacity ?? selectedLayer?.opacity ?? 1) * 100));
if (!headerOpacity.dataset.wired) {
headerOpacity.dataset.wired = 'true';
let headerOpacitySaved = false;
headerOpacity.addEventListener('input', () => {
const next = Math.max(0, Math.min(1, (parseInt(headerOpacity.value, 10) || 0) / 100));
const activeGroup = normalizeLayerGroups(state).find(group => group.id === state.activeGroupId);
if (activeGroup) {
if (Math.abs(activeGroup.opacity - next) < 0.0001) return;
if (!headerOpacitySaved) { saveState(`Change opacity of group "${activeGroup.name}"`); headerOpacitySaved = true; }
activeGroup.opacity = next;
composite();
return;
}
const active = state.layers.find(layer => layer.id === state.activeLayerId);
if (!active) return;
const targets = rowTargets(active);
if (targets.every(layer => Math.abs(layer.opacity - next) < 0.0001)) return;
if (!headerOpacitySaved) {
saveState(targets.length > 1 ? `Change opacity of ${targets.length} layers` : `Change opacity of "${active.name}"`);
headerOpacitySaved = true;
}
for (const layer of targets) layer.opacity = next;
composite();
});
headerOpacity.addEventListener('change', () => { headerOpacitySaved = false; });
}
}
// 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');
@@ -1098,10 +1132,20 @@ export function createLayerPanelRenderer(deps) {
const firstRow = list.querySelector('.ge-layer-item');
const headerH = header ? header.offsetHeight : 52;
const rowH = firstRow ? firstRow.offsetHeight : 36;
// The Blend/Opacity row sits between the header and the list — it is
// not part of the header, so its height must be counted too (control
// kit restyling can grow it), or the footer's add/fx/delete row falls
// below the peek and off the phone viewport.
const blendRow = panel.querySelector('.ge-layer-blend-row');
const blendH = blendRow ? blendRow.offsetHeight : 0;
// Action tools now live in a footer below the list — include its
// height so the mobile peek still exposes the add/fx/delete row.
const footer = panel.querySelector('.ge-layers-footer');
const footerH = footer ? footer.offsetHeight : 0;
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`);
panel.style.setProperty('--peek-height', `${headerH + blendH + rows * rowH + footerH + 6}px`);
});
}
list.innerHTML = '';
+168
View File
@@ -0,0 +1,168 @@
// Named-look -> deterministic adjustment recipe.
//
// A "look" is a film, photographer, era or colour style (Blade Runner, National
// Geographic, Wes Anderson, Kodachrome, moody Scandinavian, ...). When the
// server has no authored recipe for the phrase, the agent still needs to turn
// it into an explicit, deterministic grade instead of a random tweak or a full
// repaint. This module maps look phrasings to concrete adjustment steps using
// the editor's own control ops, so the result is reproducible.
//
// Steps are `{ op, params }` pairs run through window.geEditorControl.invoke:
// photo.autoTone {}
// adjustments.apply { type, params } (brightness-contrast & vibrance are
// -100..100, 0 = neutral)
// filters.apply { id, ... } (vignette / clarity / noise-add ...)
const A = (type, params) => ({ op: 'adjustments.apply', params: { type, params } });
const VIG = (amount, feather) => ({ op: 'filters.apply', params: { id: 'vignette', amount, feather } });
const GRAIN = () => ({ op: 'filters.apply', params: { id: 'noise-add' } });
const BLUR = () => ({ op: 'filters.apply', params: { id: 'blur-gaussian' } });
const CLARITY = (amount, radius) => ({ op: 'filters.apply', params: { id: 'clarity', amount, radius } });
const WB = (temperature, tint = 0) => A('white-balance', { temperature, tint });
const BC = (brightness, contrast) => A('brightness-contrast', { brightness, contrast });
const VIB = (vibrance) => A('vibrance', { vibrance });
const SH = (shadows, highlights) => A('shadows-highlights', { shadows, highlights });
const CB = (shadows, midtones, highlights) => A('color-balance', { shadows, midtones, highlights });
const BW = (red = 35, green = 45, blue = 20) => A('black-white', { red, green, blue, constant: 0 });
const AUTO = () => ({ op: 'photo.autoTone', params: {} });
// Curated named looks. Match SPECIFIC names (film / photographer / era / colour
// style) so generic mood words ("dramatic", "nice") keep their existing
// behaviour; a generic look that names nothing here falls to the general
// deriver below.
export const NAMED_LOOKS = [
// "faded" intent is matched FIRST so "faded Kodachrome" reads as faded, not vivid.
{ name: 'faded / washed', re: /\bfaded\b|\bwashed[\s-]*out\b|\bmatte\b|\bhazy\b/i,
steps: [AUTO(), A('photo-filter', { color: '#d8c3a2', density: 42, preserve_luminosity: true }), CB({ r: 4, g: 2, b: 2 }, { r: 2, g: 0, b: 2 }, { r: 0, g: 0, b: 0 }), BC(14, -20), VIB(-18), SH(26, -10), GRAIN(), VIG(18, 68)] },
{ name: 'Wes Anderson', re: /\bwes\s*anderson\b/i,
steps: [AUTO(), A('photo-filter', { color: '#f3cfa4', density: 46, preserve_luminosity: true }), CB({ r: 0, g: 0, b: 12 }, { r: 6, g: 2, b: 6 }, { r: 16, g: 4, b: -8 }), VIB(-6), BC(6, 6), VIG(16, 70)] },
{ name: 'National Geographic', re: /\bnational\s*geographic\b|\bnat\s*geo\b|\bnatgeo\b/i,
steps: [AUTO(), VIB(26), CLARITY(24, 28), CB({ r: 0, g: 0, b: 8 }, { r: 2, g: 2, b: 0 }, { r: 8, g: 2, b: -6 }), BC(2, 14), SH(8, -12)] },
{ name: 'Ansel Adams', re: /\bansel\s*adams\b/i,
steps: [BW(30, 50, 20), CLARITY(34, 30), BC(0, 30), SH(-6, 14), VIG(20, 58)] },
{ name: 'Kurosawa', re: /\bkurosawa\b/i,
steps: [AUTO(), WB(10, 4), CB({ r: -8, g: 0, b: 12 }, { r: -4, g: -2, b: 6 }, { r: 10, g: 3, b: -8 }), VIB(-6), BC(0, 16), GRAIN(), VIG(22, 60)] },
{ name: 'Kodachrome / Kodak', re: /\bkodachrome\b|\bkodak\b|\bportra\b|\bektar\b/i,
steps: [AUTO(), WB(32, 10), A('photo-filter', { color: '#ffbe86', density: 18, preserve_luminosity: true }), CB({ r: 8, g: 2, b: -12 }, { r: 12, g: 2, b: -12 }, { r: 24, g: 4, b: -26 }), VIB(-6), BC(2, 4), SH(16, -28), VIG(14, 66)] },
{ name: 'teal and orange', re: /\bteal\s*(?:and|&|\+)\s*orange\b|\bcinematic\s+grade\b/i,
steps: [AUTO(), CB({ r: -14, g: 4, b: 26 }, { r: 4, g: 0, b: 0 }, { r: 26, g: 4, b: -20 }), VIB(14), BC(0, 16)] },
{ name: 'film noir', re: /\bfilm\s*noir\b|\bnoir\b/i,
steps: [BW(35, 45, 20), BC(-2, 26), VIG(30, 55)] },
{ name: '1980s / VHS', re: /\bvhs\b|\b1980s\b|\b80s\b/i,
steps: [AUTO(), WB(4, 28), VIB(-16), BC(12, -16), CB({ r: 10, g: -6, b: 12 }, { r: 0, g: 0, b: 2 }, { r: -6, g: 8, b: -10 }), BLUR(), GRAIN(), GRAIN(), VIG(28, 58)] },
{ name: 'golden hour', re: /\bgolden\s*hour\b|\bgolden\s+light\b/i,
steps: [WB(34, 10), VIB(22), BC(10, -6), SH(16, -14)] },
{ name: 'Technicolor', re: /\btechnicolor\b/i,
steps: [AUTO(), CB({ r: -6, g: 2, b: 16 }, { r: 12, g: 0, b: -8 }, { r: 18, g: 3, b: -14 }), VIB(38), BC(2, 14), A('photo-filter', { color: '#ffcf8a', density: 24, preserve_luminosity: true })] },
{ name: 'Wong Kar-wai', re: /\bwong\s*kar[\s-]?wai\b/i,
steps: [AUTO(), CB({ r: -12, g: 18, b: 12 }, { r: 0, g: 6, b: 6 }, { r: 22, g: 3, b: -10 }), VIB(26), BC(-2, 16)] },
{ name: 'Western', re: /\bwestern\b|\bspaghetti\s*western\b/i,
steps: [AUTO(), WB(26, 8), VIB(-18), BC(6, 12), SH(14, -8), GRAIN(), VIG(22, 60)] },
{ name: 'disposable camera', re: /\bdisposable\b|\b1990s\b|\b90s\b|\bpoint[\s-]*(?:and|&)[\s-]*shoot\b/i,
steps: [AUTO(), WB(16, 10), BC(9, -14), VIB(-10), GRAIN(), VIG(22, 64)] },
{ name: 'documentary', re: /\bdocumentary\b|\battenborough\b/i,
steps: [AUTO(), VIB(22), CLARITY(24, 28), CB({ r: 0, g: 0, b: 6 }, { r: 2, g: 2, b: 0 }, { r: 6, g: 2, b: -6 }), BC(1, 12), SH(6, -10)] },
{ name: 'vintage film', re: /\b(?:retro|vintage|analog|analogue)\b|\b1970s\b|\b70s\b|\bseventies\b|\bpolaroid\b|\binstant\s*(?:camera|photo)\b|\bfilm\s*photograph\b|\bold\s+photo\b|\bdisposable\s+camera\b/i,
steps: [AUTO(), WB(20, 10), BC(12, -18), VIB(-12), SH(20, -8), GRAIN(), VIG(24, 60)] },
{ name: 'sepia', re: /\bsepia\b|\btan\s+tone\b/i,
steps: [BW(40, 42, 18), A('photo-filter', { color: '#b98a52', density: 70, preserve_luminosity: true }), BC(0, 14)] },
{ name: 'black and white', re: /\bblack\s*(?:and|&)\s*white\b|\bgrayscale\b|\bgreyscale\b|\bmonochrome\b|\bb\s*&\s*w\b/i,
steps: [BW(35, 45, 20), BC(0, 22)] },
{ name: 'moody Scandinavian', re: /\bscandinavian\b|\bnordic\b|\bnorse\b|\bmoody\s+scandi\b/i,
steps: [AUTO(), WB(-24, -6), VIB(-18), BC(2, 12), SH(20, 0)] },
{ name: 'Fuji / natural film', re: /\bfuji\s*film\b|\bfujifilm\b|\bsuperia\b|\bvelvia\b/i,
steps: [AUTO(), WB(-8, 4), VIB(28), BC(1, 14)] },
{ name: 'Ilford / Tri-X', re: /\bilford\b|\btri[\s-]*x\b|\bhp5\b|\bhigh[\s-]*contrast\s+b\s*&?\s*w\b/i,
steps: [BW(30, 49, 21), BC(0, 32), VIG(20, 56)] },
];
// Generic tone words -> a modest deterministic recipe for a named look that has
// no authored recipe. Requires an actual look marker so plain tone edits keep
// their existing deterministic branches.
const LOOK_MARK = /\b(?:look|style|aesthetic|vibe|mood|film|era|grade|camera|kodak|fuji)\b/i;
function generalSteps(text) {
const t = String(text || '').toLowerCase();
const warm = /\b(?:warm|warmer|golden|amber|sunset|orange|sepia)\b/.test(t);
const cool = /\b(?:cool|cooler|cold|blue|teal|cyan|winter|scandinavian|nordic)\b/.test(t);
const muted = /\b(?:muted|desaturated?|faded|washed|pastel|soft|dreamy|hazy|matte)\b/.test(t);
const vivid = /\b(?:vibrant|vivid|saturated?|punchy|bold|rich|technicolor)\b/.test(t);
const contrasty = /\b(?:contrasty?|dramatic|gritty|noir|high[\s-]*contrast)\b/.test(t);
const faded = /\b(?:faded|washed|retro|vintage|film|analog|analogue|old|polaroid|kodak|8mm|16mm|vhs)\b/.test(t);
const dark = /\b(?:dark|moody|low[\s-]*key|shadow|night)\b/.test(t);
const bright = /\b(?:bright|airy|light|high[\s-]*key|clean)\b/.test(t);
if (!(warm || cool || muted || vivid || contrasty || faded || dark || bright)) return null;
const steps = [AUTO()];
if (warm) steps.push(WB(18, 2));
if (cool) steps.push(WB(-16));
if (vivid) steps.push(VIB(20));
if (muted) steps.push(VIB(-10));
if (contrasty) steps.push(BC(0, 14));
if (dark) steps.push(BC(-6, 8));
if (bright) steps.push(BC(8, -4));
if (faded) { steps.push(BC(5, -8)); steps.push(GRAIN()); }
if (faded || dark) steps.push(VIG(16, 70));
return steps.length > 1 ? steps : null;
}
// Looks that need a CONTENT change (new light, glow, an artistic medium) are
// not a colour grade: they route to the image model instead of a recipe.
export const CONTENT_LOOKS = /\b(?:blade\s*runner|neon|synthwave|vaporwave|cyberpunk|bloom|holograph\w*|ghibli|anime|manga|watercolo?ur|oil\s*paint|painting|painterly|comic(?:\s*book)?|cartoon|illustrat\w*|pixel\s*art|voxel|low\s*poly|steampunk|stained\s*glass|surreal|fantasy|3d\s*render|cgi|claymation|clay|mosaic|graffiti|glowing)\b/i;
function scaleTone(tone, k) {
return tone ? { r: Math.round(tone.r * k), g: Math.round(tone.g * k), b: Math.round(tone.b * k) } : tone;
}
// Scale a recipe's adjustment strength so grades read at a glance. Weights
// (black-white / channel-mixer) and colours (gradient-map) are left as-is.
function scaleParams(type, params, k) {
if (!params || k === 1) return params;
const out = { ...params };
if (type === 'color-balance') {
out.shadows = scaleTone(out.shadows, k);
out.midtones = scaleTone(out.midtones, k);
out.highlights = scaleTone(out.highlights, k);
} else if (type === 'black-white' || type === 'channel-mixer' || type === 'gradient-map') {
return out;
} else if (type === 'photo-filter') {
if (typeof out.density === 'number') out.density = Math.min(100, Math.round(out.density * k));
} else {
for (const key of ['temperature', 'tint', 'brightness', 'contrast', 'vibrance', 'saturation', 'hue', 'lightness', 'shadows', 'highlights', 'exposure']) {
if (typeof out[key] === 'number') out[key] = Math.round(out[key] * k * 10) / 10;
}
}
return out;
}
function scaleSteps(steps, k) {
if (!k || k === 1) return steps;
return steps.map((s) => {
if (s.op === 'adjustments.apply') {
return { op: s.op, params: { type: s.params.type, params: scaleParams(s.params.type, s.params.params, k) } };
}
if (s.op === 'filters.apply' && typeof s.params.amount === 'number') {
return { op: s.op, params: { ...s.params, amount: Math.round(s.params.amount * k) } };
}
return s;
});
}
// Returns { name, steps } when `text` names a look we can render deterministically,
// or null when it is not a look (leave it to the normal routing). `opts.strength`
// (default 1) scales the grade so it is clearly visible.
export function lookRecipeSteps(text, opts = {}) {
const s = String(text || '');
if (CONTENT_LOOKS.test(s)) return null;
const raw = Number(opts && opts.strength);
const strength = Number.isFinite(raw) && raw > 0 ? raw : 1;
for (const entry of NAMED_LOOKS) {
if (entry.re.test(s)) return { name: entry.name, steps: scaleSteps(entry.steps, strength) };
}
if (LOOK_MARK.test(s)) {
const steps = generalSteps(s);
if (steps) return { name: 'look grade', steps: scaleSteps(steps, strength) };
}
return null;
}
export function hasNamedLook(text) {
const s = String(text || '');
return NAMED_LOOKS.some((entry) => entry.re.test(s));
}
+62
View File
@@ -0,0 +1,62 @@
// Small, dependency-light mask helpers used by the editor-control mask
// operations (add-mask-from-selection, apply/invert/feather) and per-layer
// vision ops (get bounds). Pure data helpers are exported so they can be unit
// tested in Node without a real canvas.
// Bounding box of non-transparent pixels. `data` is RGBA (Uint8ClampedArray or
// plain array), row-major, length width*height*4. Returns {x,y,w,h} or null.
export function alphaBounds(data, width, height, threshold = 8) {
if (!data || !(width > 0) || !(height > 0)) return null;
let minX = width, minY = height, maxX = -1, maxY = -1;
for (let y = 0; y < height; y++) {
const row = y * width;
for (let x = 0; x < width; x++) {
const a = data[(row + x) * 4 + 3];
if (a > threshold) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX < 0) return null;
return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 };
}
// Invert a mask stored as white RGB + alpha (selected = alpha 255). Returns a
// NEW array; the source is not mutated.
export function invertMaskData(data) {
const out = new Uint8ClampedArray(data.length);
for (let i = 0; i < data.length; i += 4) {
out[i] = 255;
out[i + 1] = 255;
out[i + 2] = 255;
out[i + 3] = 255 - data[i + 3];
}
return out;
}
// Blur (feather) a mask canvas into a fresh canvas. `createCanvas(w,h)` must
// return a canvas-like object with getContext('2d'). Returns the new canvas.
export function featherMaskCanvas(createCanvas, srcCanvas, radius) {
const w = srcCanvas.width, h = srcCanvas.height;
const out = createCanvas(w, h);
const ctx = out.getContext('2d');
const r = Math.max(0, Number(radius) || 0);
if (r > 0 && 'filter' in ctx) ctx.filter = `blur(${r}px)`;
ctx.drawImage(srcCanvas, 0, 0);
if (r > 0 && 'filter' in ctx) ctx.filter = 'none';
return out;
}
// Bake a mask into a layer by multiplying the layer alpha (destination-in).
export function bakeMaskIntoLayer(layerCtx, maskCanvas, offsetX = 0, offsetY = 0) {
layerCtx.save();
layerCtx.globalCompositeOperation = 'destination-in';
layerCtx.drawImage(maskCanvas, offsetX, offsetY);
layerCtx.restore();
return true;
}
export default { alphaBounds, invertMaskData, featherMaskCanvas, bakeMaskIntoLayer };
+14 -2
View File
@@ -41,8 +41,14 @@ export function transformedLayerGeometry(snapshot, sourceBounds, target) {
const centerY = target.centerY + relativeX * sin + relativeY * cos;
const scaledWidth = snapshot.width * scaleX;
const scaledHeight = snapshot.height * scaleY;
const width = Math.max(1, Math.round(scaledWidth * absCos + scaledHeight * absSin));
const height = Math.max(1, Math.round(scaledWidth * absSin + scaledHeight * absCos));
const skewX = target.skewX || 0;
const skewY = target.skewY || 0;
const perspectiveX = target.perspectiveX || 0;
const perspectiveY = target.perspectiveY || 0;
const skewPadX = Math.abs(Math.tan(skewX * Math.PI / 180)) * scaledHeight;
const skewPadY = Math.abs(Math.tan(skewY * Math.PI / 180)) * scaledWidth;
const width = Math.max(1, Math.round(scaledWidth * absCos + scaledHeight * absSin + skewPadX));
const height = Math.max(1, Math.round(scaledWidth * absSin + scaledHeight * absCos + skewPadY));
return {
scaleX,
scaleY,
@@ -50,6 +56,10 @@ export function transformedLayerGeometry(snapshot, sourceBounds, target) {
signedScaleY,
rotation: target.rotation,
radians,
skewX,
skewY,
perspectiveX,
perspectiveY,
width,
height,
centerX,
@@ -94,6 +104,8 @@ export function planLayerTransform(editorState, snapshots, sourceBounds, target,
const numericTarget = [
target?.width, target?.height, target?.rotation,
target?.centerX, target?.centerY,
target?.skewX ?? 0, target?.skewY ?? 0,
target?.perspectiveX ?? 0, target?.perspectiveY ?? 0,
].every(Number.isFinite);
if (!numericTarget || target.width < 1 || target.height < 1) {
return { ok: false, reason: 'Transform values must be finite positive numbers.' };
+611
View File
@@ -0,0 +1,611 @@
// Natural-language editor driver. Turns a user prompt into a list of
// editor-control ops (via a server planner / LLM when available, otherwise a
// deterministic built-in "basic mode" planner), executes them one at a time
// through the editor control API, captures a screenshot after each step and
// finally collapses the whole batch into a single undo step.
//
// Region-aware: prompts that touch the subject/sky/background are mapped to
// SAM / color-range selections plus masked filters and adjustments, so the
// edit is confined to the intended region instead of the whole layer.
const DEFAULT_PLAN_URL = '/api/editor/plan';
const DEFAULT_CHAT_URL = '/api/chat';
const DEFAULT_SESSION_URL = '/api/default-chat';
// Compact op catalog handed to the model so it can plan with real ops.
export const OP_CATALOG = [
'document.info', 'layers.list', 'layers.select {id|index|name}', 'layers.addRaster',
'layers.duplicate', 'layers.delete', 'layers.setOpacity {opacity 0-1}',
'layers.setBlend {blendMode}', 'layers.group', 'layers.ungroup',
'layers.placeImage {dataUrl,masked,blend,opacity,x,y,w,h}', 'layers.addMaskFromSelection {invert}',
'layers.removeMask', 'layers.applyMask', 'mask.invert', 'mask.feather {radius}',
'layers.getImage {id|index|name}', 'layers.getBounds {id|index|name}',
'ai.removeBackground', 'ai.generativeFill {prompt}', 'ai.removeObject {prompt}',
'ai.harmonize {strength}', 'ai.generativeUpscale {scale}', 'ai.generateImage {prompt,width,height}',
'ai.generativeExpand {width,height,anchor,prompt}',
'selection.selectAll', 'selection.deselect', 'selection.invert', 'selection.rectangle {x,y,w,h}',
'selection.ellipse {x,y,w,h}', 'selection.subject {label}', 'selection.face', 'selection.sky', 'selection.background', 'selection.samText {text}',
'selection.colorRange {color,fuzziness}', 'selection.hasMask',
'adjustments.apply {type,params}', 'adjustments.applyMasked {brightness,contrast,saturation,warmth,invert}',
'photo.autoTone', 'retouch.skin {smooth,opacity,blemish}', 'retouch.blemish {radius,threshold}', 'retouch.teeth {desat,bright}',
'filters.apply {id}', 'filters.applyMasked {id,radius,amount,invert}',
'transform.start', 'transform.apply', 'transform.cancel', 'text.add {text,x,y,color,size}', 'text.layout {anchor,sizePct,marginPct}',
'layers.rename {name}', 'layers.setVisibility {visible}', 'layers.reorder {direction:up|down|top|bottom}',
'layers.rasterize', 'layers.mergeDown', 'layers.flatten', 'layers.align {mode:left|center|right|top|middle|bottom|distribute-h|distribute-v}',
'layers.addStyle {type,params}',
'selection.polygon {points}', 'selection.fill {color}', 'fill.flood {x,y,color,tolerance}', 'selection.delete', 'selection.copy {cut}',
'selection.paste', 'selection.feather {radius}', 'selection.expand {amount}', 'selection.contract {amount}',
'text.edit {content,font,size,color,align,tracking,lineHeight}',
'layer.flip {axis:h|v,id|index|name}', 'layer.rotate {degrees:90|180|270,id|index|name}',
'document.rotate {degrees:90|180|270}', 'document.flip {axis:h|v}',
'document.crop {x,y,width,height}', 'document.extend {width,height,anchor}', 'document.savePsd', 'document.exportLayers',
'document.list', 'document.duplicate {name}', 'document.create {width,height,name}', 'document.switch {id}', 'document.openPsd {dataUrl,name}',
'run_editor_script {steps}',
'export.png', 'export.gallery',
];
export const SYSTEM_INSTRUCTION = [ 'You are a photo-editing planner that controls an image editor through a fixed',
'operation API. Convert the user request into a JSON object',
'{"reply": string, "steps": [{"op": string, "params": object}]}.',
'Use ONLY these ops:',
OP_CATALOG.join('; '),
'You are given the current document state and a screenshot of the image.',
'Rules:',
'1) To edit only part of the image, first create a region selection',
'(selection.subject / selection.sky / selection.samText / selection.colorRange),',
'then apply the change with adjustments.applyMasked or filters.applyMasked.',
'2) adjustments.applyMasked {brightness,contrast,saturation,warmth,invert}: brightness,',
'contrast and warmth are pixel amounts -100..100 (0 = neutral); saturation is a',
'multiplier (1 = neutral, 1.4 = +40%). filters.applyMasked {id,radius,amount,invert}:',
'invert:true edits everything EXCEPT the selection (e.g. blur the background).',
'3) adjustments.apply {type,params} adds an adjustment layer. For brightness-contrast',
'pass brightness/contrast as -100..100 (0 neutral). For vibrance pass vibrance as',
'-100..100. For hue-saturation pass hue in degrees (-180..180) and saturation as a',
'multiplier (1 neutral). Valid types: brightness-contrast, vibrance, hue-saturation,',
'black-white, photo-filter, color-balance, levels, curves, exposure, shadows-highlights.',
'4) filters.apply {id} with id one of: blur-gaussian, sharpen, unsharp-mask.',
'5) Prefer ONE decisive step over many tiny ones. Use absolute, verified magnitudes',
'(no placeholders). Never invent op names or params outside this catalog.',
'6) You MUST emit the matching selection.* step BEFORE any adjustments.applyMasked',
'or filters.applyMasked in the SAME plan: face -> selection.face; sky -> selection.sky;',
'"blur the background behind the subject/person" -> selection.subject then',
'filters.applyMasked {id:"blur-gaussian",invert:true}. Never emit a masked op with no',
'preceding selection.',
'7) NEVER use the specialty effect types (invert, gradient-map, color-lookup, posterize,',
'threshold) unless the user explicitly names that effect; they cause false-colour or',
'banded output. For any lighting/tone fix use exposure, shadows-highlights or',
'brightness-contrast with modest amounts (each <=35).',
'8) For a format/platform request ("instagram ready", "square", "story", "post", "1:1",',
'"1080x1350", "profile"), you MUST emit document.crop with x/y/width/height computed for',
'the target aspect ratio (square = the largest centred 1:1 crop of the current document),',
'optionally followed by a light vibrance. Do not merely describe the crop.',
'9) Avoid imperceptible edits: for a generic request ("make it pop", "clean this up",',
'"edit this photo", "make it pretty", "fix the skin") make a clearly visible but natural',
'change (e.g. vibrance 20..35 plus contrast 8..18 plus a small warmth), not a token amount.',
'10) For a GENERIC intent ("edit this photo", "make it pretty", "make it pop", "colors nicer",',
'"clean this up", "make it look professional", "fix the lighting", "it\'s too dark") your FIRST',
'step MUST be photo.autoTone (Camera-Raw-style auto level/white-balance/exposure/contrast),',
'optionally followed by one visible stylistic step. Never return an empty or single token step.',
'11) "fix the skin"/"smooth skin"/"look better"/"retouch": first selection.face (or',
'selection.subject), then retouch.skin {smooth:24,opacity:0.85,blemish:true}; this uses',
'frequency separation (keeps pores) intersected with skin tones. Do NOT use a global blur.',
'12) "fix my teeth"/"whiten teeth": selection.samText {text:"teeth"} then retouch.teeth',
'{desat:0.6,bright:16}. If sampling is unavailable fall back to selection.face + retouch.teeth.',
'Style guide (map the intent to these edit characteristics; never over-process):',
'- "fix the lighting"/"it\'s too dark": raise exposure/shadows modestly (exposure 10..25',
'or shadows 20..35) with a small contrast lift; if the image is dark do NOT stack several',
'global lifts — one or two steps, and never a specialty effect type.',
'- "dramatic sky": LOWER the sky exposure slightly (-10..-25), RAISE contrast/clarity',
'(contrast 25..45), deepen blue (hue-saturation or photo-filter blue), and PROTECT',
'highlights/blown clouds. Do NOT brighten and do NOT push saturation above +25%.',
'- "cinematic": teal/orange split-tone (color-balance warm highlights / cool shadows),',
'lifted blacks, mild contrast; keep skin neutral.',
'- "vivid": +vibrance 20..40 only; avoid saturation clipping.',
'- "warm"/"cool": white-balance/photo-filter shift of +/-15..30, keep exposure.',
'- "black and white": black-white + a little contrast (15..35).',
'- "sharper": unsharp-mask 40..80; do not combine with strong saturation.',
'- "brighten/remove the face": select the face first (selection.face) then a',
'masked brightness lift that is VISIBLE but natural (brightness 18..28, optional',
'contrast 4..8). The face must read clearly brighter while skin stays natural; do',
'not use a token amount and never a global exposure change.',
'- "fix the skin"/"retouch"/"make me look better": selection.face (or selection.subject)',
'then retouch.skin {smooth:24,opacity:0.85,blemish:true}; keep an eye on the background (do',
'NOT blur anything outside the skin mask; the client masks it, but choose the face region).',
'- "fix my teeth"/"whiten teeth": selection.samText {text:"teeth"} then retouch.teeth.',
'- "clean this up"/"edit this photo"/"make it pretty"/"make it pop"/generic: photo.autoTone',
'first (auto level + white balance + exposure + contrast), then optionally vibrance 15..30',
'and contrast 6..12. The result must be a clearly visible, natural improvement.',
'Method choice (agent): prefer the deterministic ops above whenever they can',
'achieve the intent; use the AI image-edit tool (content-aware remove/replace an',
'object, background removal, upscale, denoise, face restore) only when the change',
'needs synthesised pixels; use image generation only to ADD content that does not',
'exist in the photo (e.g. "a full moon"). When using an AI edit or generation,',
'create the region selection first (selection.subject/sky/samText) so the result is',
'masked to that region, and the result is placed as a NEW layer on top of the',
'original (never overwriting it), inside the same undo group.',
'Return JSON only, with no prose before or after.',
].join(' ');
// Extra guidance appended for self-check calls so the live route returns a FULL
// corrected plan (the client reverts the previous attempt before running it).
export const CHECK_INSTRUCTION = [
'This is a self-check. Judge whether the current result matches the user',
'intent and is NOT over-processed (no blown highlights, no over-saturated',
'colour, not brighter when it should be darker). If it matches, reply',
'{"done": true, "reply": string, "steps": []}. Otherwise reply with a FULL',
'corrected plan {"reply": string, "steps": [...]} that achieves the intent',
'from the ORIGINAL image with corrected parameters; the previous attempt will',
'be REVERTED before your plan runs, so do not send a delta of extra steps and',
'do not describe a change. Prefer conservative amounts on the retry.',
'For a FACE-BRIGHTEN request, compare the FACE region specifically (use the',
'faceLuminance before/after numbers when provided): the face must be clearly',
'brighter. If the change is subtle or the face looks unchanged, return a corrected',
'plan with selection.face then adjustments.applyMasked {brightness: 22..30}.',
].join(' ');
// Shared intent->ops style guide (single source of truth), served by the server
// at GET /api/editor/style-guide. When present the browser fallback planner uses
// the server's guidance text + intent routes so the client and server rules can
// never drift. Falls back to the built-in SYSTEM_INSTRUCTION when unavailable.
let _STYLE_GUIDE = null;
export function setStyleGuide(guide) {
_STYLE_GUIDE = guide && typeof guide === 'object' ? guide : null;
return _STYLE_GUIDE;
}
export function getStyleGuide() { return _STYLE_GUIDE; }
export function activeGuidance() {
return (_STYLE_GUIDE && typeof _STYLE_GUIDE.guidance === 'string' && _STYLE_GUIDE.guidance)
? _STYLE_GUIDE.guidance
: SYSTEM_INSTRUCTION;
}
export async function fetchStyleGuide(url = '/api/editor/style-guide', fetchImpl = null) {
const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
if (!doFetch) return null;
try {
const res = await doFetch(url, { headers: { Accept: 'application/json' } });
if (!res || !res.ok) return null;
const data = await res.json();
return setStyleGuide(data);
} catch (_) { return null; }
}
function _matchGuideIntent(prompt) {
if (!_STYLE_GUIDE || !Array.isArray(_STYLE_GUIDE.intents)) return null;
const p = String(prompt || '').toLowerCase();
for (const intent of _STYLE_GUIDE.intents) {
const kws = Array.isArray(intent.keywords) ? intent.keywords : [];
for (const kw of kws) {
let hit = false;
try { hit = kw instanceof RegExp ? kw.test(p) : (kw && p.includes(String(kw).toLowerCase())); } catch (_) { hit = false; }
if (hit) return { reply: intent.reply || '', steps: JSON.parse(JSON.stringify(intent.steps || [])), planner: 'local', matched: intent.id };
}
}
return null;
}
export function buildPlannerMessage(prompt, state = {}, extra = {}) {
const lines = [`Document: ${JSON.stringify(state)}`];
if (extra.mode === 'check') {
lines.push('This is a self-check step. You are shown the BEFORE image and the AFTER image of the last attempt, plus the original intent. Judge whether the current result matches the intent and is NOT over-processed (no blown highlights, no clipped/over-saturated colour, no brighter-when-it-should-be-darker mistake). If it matches, reply {"reply": string, "done": true, "steps": []}. If it does not match, reply with a FULL corrected plan {"reply": string, "steps": [...]} that achieves the intent from the original image, with corrected parameters (we will REVERT the previous attempt and apply your corrected plan, so do not describe a delta). Prefer lower/more conservative amounts on the retry.');
if (Array.isArray(extra.applied) && extra.applied.length) {
lines.push(`Previous attempt (will be reverted and replaced by your corrected plan): ${JSON.stringify(extra.applied)}`);
}
if (extra.faceLuminance && (extra.faceLuminance.before != null || extra.faceLuminance.after != null)) {
lines.push(`Face-region mean luminance (0-255): before=${extra.faceLuminance.before}, after=${extra.faceLuminance.after}. A face-brighten is only done when the after value is clearly higher (roughly +8 or more); otherwise return a corrected plan with a larger masked brightness.`);
}
}
lines.push(`User request: ${prompt}`);
return lines.join('\n\n');
}
export function isGenericIntent(prompt = '') {
const p = String(prompt || '').toLowerCase().trim();
if (!p) return false;
return /^(edit this|edit the|make it (pretty|pop|look |nice|better|good|professional|amazing)|make the (colou?rs?|tones?|image|photo|picture) (nicer|better|pop|warmer|cooler|more vivid|more dramatic)|colou?rs? nicer|clean (this|it) up|fix (this|it|the (lighting|colors|colours|photo|picture))|improve (this|it|the)|enhance (this|it|the|photo)|touch (this|it) up|it'?s too (dark|bright|flat|dull)|(a ?bit )?(darker|brighter|more vivid|prettier))/.test(p);
}
// Named-colour resolver for fill/flood prompts.
const NAMED_COLORS = {
red: '#ff0000', blue: '#0000ff', green: '#00a000', yellow: '#ffff00',
orange: '#ff8000', purple: '#8000ff', pink: '#ff69b4', black: '#000000',
white: '#ffffff', grey: '#808080', gray: '#808080', cyan: '#00ffff',
magenta: '#ff00ff', brown: '#8b4513', teal: '#008080',
};
export function colorForName(name = '') {
const n = String(name).toLowerCase().trim();
if (/^#[0-9a-f]{3,6}$/.test(n)) return n;
return NAMED_COLORS[n] || null;
}
export function localPlan(prompt, state = {}) {
const guided = _matchGuideIntent(prompt);
if (guided && guided.steps.length) return guided;
const p = String(prompt || '').toLowerCase();
const steps = [];
const first = (arr) => arr[0];
const wantsSubject = /\bsubject\b|\bperson\b|\bpeople\b|\bportrait\b|\bbehind\b|\bforeground\b/.test(p);
const wantsSky = /\bsky\b/.test(p);
const wantsBackground = /\bbackground\b|\bbg\b/.test(p);
// Region-first: build a selection when the prompt references a region.
// `hasRegion` gates the masked ops so they only run when a selection exists
// (an adjustment-layer step does not count as a region).
let hasRegion = false;
if (wantsBackground || /\bbehind\b/.test(p)) {
steps.push({ op: 'selection.background', params: {} });
hasRegion = true;
} else if (wantsSky) {
steps.push({ op: 'selection.sky', params: {} });
hasRegion = true;
} else if (wantsSubject) {
steps.push({ op: 'selection.subject', params: { label: 'person' } });
hasRegion = true;
}
if (/\bblack\s*(and|&|n)?\s*white\b|\bgrayscale\b|\bgreyscale\b|\bmonochrome\b/.test(p)) {
if (hasRegion) steps.push({ op: 'adjustments.applyMasked', params: { saturation: 0 } });
else steps.push({ op: 'adjustments.apply', params: { type: 'black-white', params: {} } });
}
if (/\bwarm(er)?\b|\bgolden\b|\bsunset\b/.test(p)) {
if (hasRegion) steps.push({ op: 'adjustments.applyMasked', params: { warmth: 22, brightness: 8 } });
else steps.push({ op: 'adjustments.apply', params: { type: 'white-balance', params: { temperature: 22 } } });
}
if (/\bcool(er)?\b|\bcold\b/.test(p)) {
if (hasRegion) steps.push({ op: 'adjustments.applyMasked', params: { warmth: -22 } });
else steps.push({ op: 'adjustments.apply', params: { type: 'white-balance', params: { temperature: -22 } } });
}
if (/\bbright(er|en)?\b|\blight(er|en)?\b|\bexpos/.test(p)) {
if (hasRegion) steps.push({ op: 'adjustments.applyMasked', params: { brightness: 18 } });
else steps.push({ op: 'adjustments.apply', params: { type: 'brightness-contrast', params: { brightness: 25 } } });
}
if (/\bdark(er|en)?\b|\bmoody\b|\bdeeper\b/.test(p)) {
if (hasRegion) steps.push({ op: 'adjustments.applyMasked', params: { brightness: -16, contrast: 10 } });
else steps.push({ op: 'adjustments.apply', params: { type: 'brightness-contrast', params: { brightness: -20 } } });
}
if (/\bdramatic\b|\bsky\b|\bcontrast(y)?\b|\bvivid\b|\bsaturat|\bmoody\b/.test(p)) {
if (hasRegion) steps.push({ op: 'adjustments.applyMasked', params: { contrast: 28, saturation: 1.35 } });
else steps.push({ op: 'adjustments.apply', params: { type: 'vibrance', params: { vibrance: 40 } } });
}
if (/\bblur\b|\bdepth\s*of\s*field\b|\bbokeh\b/.test(p)) {
const radius = /\b(?:slight(?:ly)?|subtle|gently|gentle|a\s+little)\b/.test(p) ? 4
: /\b(?:bokeh|depth\s*of\s*field|strong(?:ly)?|heavy|blur\s+the\s+background)\b/.test(p) ? 18
: 10;
if (hasRegion) steps.push({ op: 'filters.applyMasked', params: { id: 'blur-gaussian', radius, invert: false } });
else steps.push({ op: 'filters.applyMasked', params: { id: 'blur-gaussian', radius, invert: true, full: true } });
} else if (/\bsoften\b/.test(p)) {
// "soften it" on a portrait means soften the skin, never a destructive
// full-frame blur (frequency separation keeps the pores sharp).
steps.push({ op: 'retouch.skin', params: { smooth: 22, opacity: 0.7 } });
}
if (/\bsharpen\b|\bcrisp(er)?\b/.test(p)) {
if (hasRegion) steps.push({ op: 'filters.applyMasked', params: { id: 'sharpen', amount: 65 } });
else steps.push({ op: 'filters.apply', params: { id: 'sharpen' } });
}
const wantsAddText = /\b(?:add|create|write|put|insert)\b[^.]*\b(?:title|caption|label|text|word|subtitle)\b/i.test(prompt)
|| /\bwith\s+the\s+(?:text|title|word|caption|label)\b/i.test(prompt)
|| /["'][^"']{1,40}["']/.test(prompt);
// "add a stroke to the text" applies an effect to EXISTING text — do not
// create a new text layer for it.
const existingText = /\b(?:to|on|for|of)\s+the\s+(?:text|title|caption|label|layer|word)\b/i.test(prompt);
if (wantsAddText && !existingText && !/\b(font|bigger|smaller|larger|colour|color)\b/.test(p)) {
const qm = prompt.match(/["']([^"']{1,40})["']/);
const capsM = prompt.match(/\b([A-Z][A-Z0-9]*(?:\s+[A-Z0-9]+){0,3})\b/);
const afterM = prompt.match(/\b(?:title|caption|label|word|text)\s*[:\-]?\s+([A-Za-z0-9][\w'-]*)/i);
const afterOk = afterM && !/^(?:and|the|to|at|in|on|for|with|under|over|it|is|text|title|caption|label|word)\b/i.test(afterM[1]);
let text = qm ? qm[1] : (capsM ? capsM[1] : (afterOk ? afterM[1] : (/\bsubtitle\b/i.test(prompt) ? 'Subtitle' : 'Title')));
text = String(text).trim().replace(/\s+(?:and|the|to|at|in|on|for|with|under|over|align|then|add)\b.*$/i, '').trim().replace(/[."]+$/, '');
if (!text) text = /\bsubtitle\b/i.test(prompt) ? 'Subtitle' : 'Title';
steps.push({ op: 'text.add', params: { text, x: 0, y: 0, color: '#ffffff', size: 64 } });
steps.push({ op: 'text.layout', params: { anchor: 'top-center', sizePct: 0.13, marginPct: 0.05 } });
}
// Flip / rotate — active layer by default, whole document when the prompt
// names the whole image/canvas/photo (Photoshop parity).
const wholeImage = /\b(?:whole|entire|full)\s+(?:image|canvas|photo|picture|document)\b/.test(p)
|| (/\b(?:canvas|document)\b/.test(p) && /\b(?:rotate|flip|mirror|turn)\b/.test(p));
const flipM = prompt.match(/\b(?:flip|mirror)\b/i);
if (flipM) {
const axis = /\bvertical(ly)?\b/i.test(p) ? 'v' : 'h';
steps.push(wholeImage ? { op: 'document.flip', params: { axis } } : { op: 'layer.flip', params: { axis } });
}
const rotM = prompt.match(/\b(?:rotate|turn)\b[^\d]*?\b(90|180|270)\b/i);
if (rotM) {
const degrees = Number(rotM[1]);
steps.push(wholeImage ? { op: 'document.rotate', params: { degrees } } : { op: 'layer.rotate', params: { degrees } });
}
// Fill a region with a colour (selection fill / paint bucket).
const FILL_COLORS = 'red|blue|green|yellow|orange|purple|pink|black|white|grey|gray|cyan|magenta|brown|teal|#[0-9a-f]{3,6}';
const fillM = prompt.match(new RegExp(`\\bfill\\b[^.]*?\\bwith\\s+(${FILL_COLORS})\\b`, 'i'))
|| prompt.match(new RegExp(`\\bfill\\b[^.]*?\\b(${FILL_COLORS})\\b`, 'i'));
if (fillM) {
const color = colorForName(fillM[1]);
if (color) {
if (hasRegion) steps.push({ op: 'selection.fill', params: { color } });
else steps.push({ op: 'fill.flood', params: { color, x: 0, y: 0 } });
}
}
// Text: "make the text bigger/smaller", recolour or change content.
if (/\btext\b|\btitle\b|\bcaption\b|\bfont\b/.test(p) && !/\badd\b/.test(p)) {
const reuse = {};
if (/\b(bigger|larger|increase)\b/.test(p)) reuse.size = 96;
else if (/\b(smaller|reduce|decrease)\b/.test(p)) reuse.size = 28;
const cm = prompt.match(/\b(?:to|in|make it)\s+(red|blue|green|yellow|orange|purple|pink|black|white|grey|gray|#(?:[0-9a-f]{3,6}))\b/i);
if (cm) reuse.color = colorForName(cm[1]);
if (Object.keys(reuse).length) steps.push({ op: 'text.edit', params: reuse });
}
// Align a layer / the logo / the selection.
const alignM = prompt.match(/\balign\b[^.]*?\b(centre|center|left|right|top|middle|bottom)\b/i);
if (alignM) {
const map = { centre: 'center', center: 'center', left: 'left', right: 'right', top: 'top', middle: 'middle', bottom: 'bottom' };
steps.push({ op: 'layers.align', params: { mode: map[alignM[1].toLowerCase()] || 'center' } });
}
// Layer styles (effects).
if (/\bdrop\s*shadow\b/.test(p)) steps.push({ op: 'layers.addStyle', params: { type: 'drop-shadow', params: {} } });
else if (/\bstroke\b|\boutline\b/.test(p)) steps.push({ op: 'layers.addStyle', params: { type: 'stroke', params: {} } });
else if (/\bcolo?ur?\s*overlay\b/.test(p)) steps.push({ op: 'layers.addStyle', params: { type: 'color-overlay', params: {} } });
// Copy / paste the selection.
if (/\bcopy\b/.test(p) && /\bselection\b|\bselected\b/.test(p)) steps.push({ op: 'selection.copy', params: {} });
if (/\bpaste\b/.test(p) && /\bselection\b|\bcopied\b/.test(p)) steps.push({ op: 'selection.paste', params: {} });
// Extend the canvas to a named aspect ratio.
const extM = prompt.match(/\bextend\b[^.]*?\b(16:9|9:16|4:5|1:1)\b/);
if (/\bextend\b/.test(p) && extM) {
const parts = extM[1].split(':').map(Number);
const ratio = parts[0] / parts[1];
const w = Number(state.width) || 0, h = Number(state.height) || 0;
if (w && h) {
let tw = w, th = h;
if (w / h < ratio) tw = Math.round(h * ratio); else th = Math.round(w / ratio);
steps.push({ op: 'document.extend', params: { width: tw, height: th, anchor: 'center' } });
}
}
if (!steps.length) {
steps.push({ op: 'adjustments.apply', params: { type: 'brightness-contrast', params: { brightness: 12, contrast: 12 } } });
return { reply: `No deterministic match for: ${prompt}`, steps, planner: 'local', fallback: true };
}
const reply = `Planned ${steps.length} editor step${steps.length === 1 ? '' : 's'} for: ${prompt}`;
return { reply, steps, planner: 'local' };
}
// Ops the model sometimes emits that have no editing effect (or cause side
// effects like saving a gallery copy). They are stripped from planner output.
const NOOP_OPS = new Set(['document.info', 'layers.list', 'history.mark', 'history.undo', 'history.redo', 'export.png', 'export.gallery']);
// Normalize a model plan before it runs: drop no-op/export steps, and if a
// masked op is emitted with no selection.* before it, insert the region
// selection implied by the prompt (so masked edits never target the whole
// layer by accident).
export function sanitizeSteps(steps, prompt = '') {
const p = String(prompt || '').toLowerCase();
const region = /\bface\b/.test(p)
? { op: 'selection.face', params: {} }
: /\bsky\b/.test(p)
? { op: 'selection.sky', params: {} }
: /\bbackground\b|\bbehind\b|\bbg\b/.test(p)
? { op: 'selection.background', params: {} }
: { op: 'selection.subject', params: { label: 'person' } };
const out = [];
let hasSelection = false;
for (const s of (Array.isArray(steps) ? steps : [])) {
if (!s || !s.op || NOOP_OPS.has(s.op)) continue;
if (String(s.op).startsWith('selection.')) hasSelection = true;
if (!hasSelection && (s.op === 'adjustments.applyMasked' || s.op === 'filters.applyMasked')) {
out.push(region);
hasSelection = true;
}
out.push({ op: s.op, params: s.params || {} });
}
return out;
}
// Deterministic platform-format guard: when the user asks for a platform or
// aspect format and the plan omits a crop, prepend a centred document.crop for
// the target aspect ratio (models frequently describe the crop but forget the op).
export function formatCropRect(prompt = '', width = 0, height = 0) {
const p = String(prompt || '').toLowerCase();
const w = Number(width) || 0, h = Number(height) || 0;
if (!w || !h) return null;
let ratio = null;
const dim = p.match(/(\d{2,5})\s*[x×*]\s*(\d{2,5})/);
if (dim) ratio = Number(dim[1]) / Number(dim[2]);
if (!ratio && /instagram|insta|square|profile|avatar|\b1:1\b/.test(p)) ratio = 1;
else if (!ratio && /story|reel|\b9:16\b/.test(p)) ratio = 9 / 16;
else if (!ratio && (/\b4:5\b|1080x1350/.test(p))) ratio = 4 / 5;
else if (!ratio && (/\b16:9\b|widescreen|youtube/.test(p))) ratio = 16 / 9;
if (!ratio) return null;
let cw, ch;
if (w / h > ratio) { ch = h; cw = Math.round(h * ratio); }
else { cw = w; ch = Math.round(w / ratio); }
const x = Math.round((w - cw) / 2), y = Math.round((h - ch) / 2);
return { x, y, width: cw, height: ch };
}
export function ensureFormatCrop(steps, prompt = '', width = 0, height = 0) {
const list = Array.isArray(steps) ? steps : [];
if (list.some((s) => s && s.op === 'document.crop')) return list;
const rect = formatCropRect(prompt, width, height);
if (!rect) return list;
return [{ op: 'document.crop', params: rect }, ...list];
}
export function ensureGenericAutoTone(steps, prompt = '') {
const list = Array.isArray(steps) ? steps : [];
if (!isGenericIntent(prompt)) return list;
if (list.some((s) => s && (s.op === 'photo.autoTone' || s.op === 'adjustments.autoTone'))) return list;
return [{ op: 'photo.autoTone', params: {} }, ...list];
}
export function extractJson(text) { if (!text) return null;
const fenced = String(text).match(/```(?:json)?\s*([\s\S]*?)```/i);
const raw = fenced ? fenced[1] : String(text);
const start = raw.indexOf('{');
const end = raw.lastIndexOf('}');
if (start < 0 || end <= start) return null;
try { return JSON.parse(raw.slice(start, end + 1)); } catch (_) { return null; }
}
export function createNaturalLanguageEditor({ control, planUrl = DEFAULT_PLAN_URL, chatUrl = DEFAULT_CHAT_URL, sessionUrl = DEFAULT_SESSION_URL, styleGuideUrl = '/api/editor/style-guide', fetchImpl = null, onStep = null } = {}) {
if (!control || typeof control.invoke !== 'function') throw new Error('createNaturalLanguageEditor requires a control with invoke()');
const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
let cachedSession = null;
let guideLoaded = false;
async function ensureGuide() {
if (guideLoaded) return;
guideLoaded = true;
try { await fetchStyleGuide(styleGuideUrl, doFetch); } catch (_) {}
}
async function postJson(url, body) {
const res = await doFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res || !res.ok) return null;
return res.json().catch(() => null);
}
// Strategy 1: a dedicated editor planning route (HARNESS /api/editor/plan).
// The route builds its own system prompt from {prompt, state}; we pass the
// op catalog + style guide through state.planner_guidance so the model gets
// the same strong instructions instead of the route's generic default.
async function requestPlanEndpoint(prompt, state, extra = {}) {
if (!doFetch || !planUrl) return null;
try {
const guidance = extra.mode === 'check' ? `${activeGuidance()} ${CHECK_INSTRUCTION}` : activeGuidance();
const data = await postJson(planUrl, { prompt, state: { ...state, planner_guidance: guidance }, ...extra });
if (!data) return null;
if (Array.isArray(data.steps)) return { reply: data.reply || '', steps: data.steps, planner: data.planner || 'llm', done: !!data.done };
if (data.done) return { reply: data.reply || 'Looks done.', steps: [], planner: 'llm', done: true };
return null;
} catch (_) {
return null;
}
}
// Strategy 2: reuse the app's existing model endpoint (/api/chat) from the
// browser with the user's own session. The model plans with vision-less
// state + the op catalog and returns JSON steps.
async function requestViaChat(prompt, state, extra = {}) {
if (!doFetch || !chatUrl) return null;
try {
if (!cachedSession) {
const r = await doFetch(sessionUrl);
if (r && r.ok) {
const d = await r.json().catch(() => null);
cachedSession = d && (d.session?.id || d.session_id || d.id || d.sessionId) || null;
}
}
if (!cachedSession) return null;
const message = `${activeGuidance()}\n\n${buildPlannerMessage(prompt, state, extra)}`;
const data = await postJson(chatUrl, { message, session: cachedSession });
if (!data) return null;
const parsed = extractJson(data.response || data.message || '');
if (!parsed || !Array.isArray(parsed.steps)) return null;
return { reply: parsed.reply || '', steps: parsed.steps, planner: 'llm' };
} catch (_) {
return null;
}
}
async function requestPlan(prompt, state, extra = {}) {
if (!doFetch) return null;
return (await requestPlanEndpoint(prompt, state, extra)) || (extra.mode ? null : await requestViaChat(prompt, state, extra));
}
async function execute(prompt, { useServer = true, maxIterations = 3, onStep: stepCb = null } = {}) {
const emitStep = stepCb || onStep;
if (!prompt || !String(prompt).trim()) throw new Error('empty prompt');
await ensureGuide();
let docState = {};
try { docState = (await control.invoke('document.info')).result || {}; } catch (_) {}
let beforeShot = null;
try { beforeShot = (await control.invoke('screenshot')).result; } catch (_) {}
let plan = useServer ? await requestPlan(prompt, docState, beforeShot && beforeShot.dataUrl ? { screenshot: beforeShot.dataUrl } : {}) : null;
const usedServer = !!plan;
if (!plan) plan = localPlan(prompt, docState);
plan = { ...plan, steps: ensureFormatCrop(ensureGenericAutoTone(sanitizeSteps(plan.steps, prompt), prompt), prompt, docState.width, docState.height) };
let mark = 0;
try { mark = (await control.invoke('history.mark')).result; } catch (_) {}
const trace = [];
let failed = null;
let reply = plan.reply;
let applied = [];
let faceLumBefore = null;
let faceLumAfter = null;
const runSteps = async (steps) => {
applied = [];
faceLumBefore = null;
faceLumAfter = null;
for (const step of steps) {
const started = Date.now();
const result = await control.invoke(step.op, step.params || {});
let shot = null;
try { shot = (await control.invoke('screenshot')).result; } catch (_) {}
const entry = { op: step.op, params: step.params || {}, ok: !!(result && result.ok), error: result && result.error ? result.error : null, ms: Date.now() - started, screenshot: shot && shot.dataUrl ? shot.dataUrl : null };
trace.push(entry);
if (emitStep) { try { emitStep(entry, trace.length, steps.length); } catch (_) {} }
if (!entry.ok) { failed = entry; return false; }
// Face-brighten check: capture face-region luminance right after the face
// selection and again after the first masked edit on that selection.
if (entry.ok && (step.op === 'selection.face' || step.op === 'adjustments.applyMasked' || step.op === 'filters.applyMasked')) {
try {
const lum = (await control.invoke('selection.regionLuminance')).result;
const value = lum && lum.luminance != null ? lum.luminance : null;
if (value != null) {
if (step.op === 'selection.face' && faceLumBefore == null) faceLumBefore = value;
else if (step.op !== 'selection.face' && faceLumBefore != null && faceLumAfter == null) faceLumAfter = value;
}
} catch (_) {}
}
applied.push({ op: step.op, params: step.params || {} });
}
return true;
};
await runSteps(plan.steps);
// Self-check loop (server planners only): show the model the BEFORE and
// AFTER images plus the intent; if it is not satisfied it returns a FULL
// corrected plan and we REVERT the previous attempt (undo its history
// entries) before applying it, so corrections never stack on top.
if (usedServer && !failed) {
let roundMark = mark;
for (let i = 0; i < maxIterations; i++) {
let shot = null;
try { shot = (await control.invoke('screenshot')).result; } catch (_) {}
if (!shot || !shot.dataUrl) break;
let check = null;
const checkExtra = { mode: 'check', screenshot: shot.dataUrl, applied };
if (beforeShot && beforeShot.dataUrl) checkExtra.before = beforeShot.dataUrl;
if (faceLumBefore != null || faceLumAfter != null) checkExtra.faceLuminance = { before: faceLumBefore, after: faceLumAfter };
try { check = await requestPlan(prompt, { ...docState, iteration: i + 1 }, checkExtra); } catch (_) {}
if (!check || check.done || !Array.isArray(check.steps) || !check.steps.length) break;
const next = ensureFormatCrop(sanitizeSteps(check.steps, prompt), prompt, docState.width, docState.height);
// If the correction would run nothing (only no-op/export steps), keep
// the current state instead of reverting to a net-zero result.
if (!next.length) break;
if (check.reply) reply = check.reply;
// Revert the attempt that produced this after image.
let nowMark = 0;
try { nowMark = (await control.invoke('history.mark')).result; } catch (_) {}
const entries = Math.max(0, (Number(nowMark) || 0) - (Number(roundMark) || 0));
for (let k = 0; k < entries; k++) { try { await control.invoke('history.undo'); } catch (_) {} }
try { roundMark = (await control.invoke('history.mark')).result; } catch (_) {}
const ok = await runSteps(next);
if (!ok) break;
}
}
try { await control.invoke('history.collapse', { mark, label: `AI edit: ${String(prompt).slice(0, 60)}` }); } catch (_) {}
return { ok: !failed, prompt, reply, planner: plan.planner, basicMode: plan.planner !== 'llm', steps: plan.steps, trace, failed };
}
return { planUrl, chatUrl, localPlan, requestPlan, requestViaChat, execute };
}
export default createNaturalLanguageEditor;
+174
View File
@@ -0,0 +1,174 @@
/**
* Side-panel visibility.
*
* Photoshop-style View > Panels toggles for every docked side panel (plus the
* pinned Layers strip and the Tool Options bar). Visibility is a per-user,
* document-independent preference persisted in localStorage so it survives
* reloads and applies to every document.
*
* The dock itself has no visibility API, so we apply visibility by tagging the
* panel wrapper, its tab strip item, and (when a whole group is hidden) the
* group element with `data-panel-hidden="1"`; CSS hides tagged nodes.
*/
export const PANELS = Object.freeze([
{ id: 'swatches', label: 'Swatches', group: 'Color' },
{ id: 'gradients', label: 'Gradients', group: 'Color' },
{ id: 'patterns', label: 'Patterns', group: 'Color' },
{ id: 'properties', label: 'Properties', group: 'Adjustments' },
{ id: 'adjustments', label: 'Adjustments', group: 'Adjustments' },
{ id: 'histogram', label: 'Histogram', group: 'Adjustments' },
{ id: 'info', label: 'Info', group: 'Adjustments' },
{ id: 'channels', label: 'Channels', group: 'Panels' },
{ id: 'paths', label: 'Paths', group: 'Panels' },
{ id: 'character', label: 'Character', group: 'Panels' },
{ id: 'navigator', label: 'Navigator', group: 'Panels' },
{ id: 'history', label: 'History', group: 'History' },
{ id: 'actions', label: 'Actions', group: 'History' },
{ id: 'layers', label: 'Layers', group: 'Pinned' },
{ id: 'options', label: 'Tool Options', group: 'Pinned' },
]);
export const PANEL_IDS = PANELS.map(panel => panel.id);
const STORAGE_KEY = 'ge-panel-visibility';
const DOCK_GROUPS = ['Color', 'Adjustments', 'Panels', 'History'];
const subscribers = new Set();
let hidden = null;
function readStored() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (_) {
return {};
}
}
function ensureLoaded() {
if (hidden) return;
const stored = readStored();
hidden = new Set(PANEL_IDS.filter(id => stored[id] === false));
}
function persist() {
const map = {};
for (const id of PANEL_IDS) map[id] = !hidden.has(id);
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); } catch (_) {}
}
export function isVisible(id) {
ensureLoaded();
return !hidden.has(id);
}
export function list() {
ensureLoaded();
return PANELS.map(panel => ({ ...panel, visible: !hidden.has(panel.id) }));
}
function notify() {
for (const fn of subscribers) { try { fn(); } catch (_) {} }
}
function markHidden(el, hide) {
if (!el) return;
if (hide) el.setAttribute('data-panel-hidden', '1');
else el.removeAttribute('data-panel-hidden');
}
export function apply() {
ensureLoaded();
for (const id of PANEL_IDS) {
const hide = hidden.has(id);
document.querySelectorAll(`.ge-dock-panel[data-panel="${id}"]`).forEach(el => markHidden(el, hide));
document.querySelectorAll(`.ge-dock-tab-item[data-panel="${id}"]`).forEach(el => markHidden(el, hide));
document.querySelectorAll(`.ge-dock-select option[value="${id}"]`).forEach(el => markHidden(el, hide));
}
// Collapse a whole group (and the divider that precedes it) when every one
// of its panels is hidden, so the rail reflows without an empty gap.
for (const group of DOCK_GROUPS) {
const members = PANELS.filter(panel => panel.group === group).map(panel => panel.id);
const allHidden = members.every(id => hidden.has(id));
document.querySelectorAll(`.ge-dock-group[data-group="${group}"]`).forEach(el => markHidden(el, allHidden));
}
document.querySelectorAll('.ge-dock-divider').forEach(div => {
const next = div.nextElementSibling;
markHidden(div, !!(next && next.matches('.ge-dock-group[data-panel-hidden="1"]')));
});
// Pinned panels live outside the dock.
markHidden(document.querySelector('.ge-layers'), hidden.has('layers'));
markHidden(document.querySelector('.ge-controls'), hidden.has('options'));
// If every dock group is hidden, collapse the dock entirely so the rail
// doesn't leave an empty column of blank space; hide the whole rail once
// the pinned panels are hidden too (the canvas takes the freed width).
const anyDockVisible = DOCK_GROUPS.some(group =>
PANELS.some(panel => panel.group === group && !hidden.has(panel.id)));
document.querySelectorAll('.ge-dock').forEach(el => markHidden(el, !anyDockVisible));
const railEmpty = !anyDockVisible && hidden.has('layers') && hidden.has('options');
document.querySelectorAll('.ge-right-panel').forEach(el => {
markHidden(el, railEmpty);
if (!anyDockVisible) el.setAttribute('data-panel-dock-hidden', '1');
else el.removeAttribute('data-panel-dock-hidden');
});
// Never leave a group showing a hidden panel: activate the first visible tab.
for (const group of DOCK_GROUPS) {
const members = PANELS.filter(panel => panel.group === group).map(panel => panel.id);
if (members.every(id => hidden.has(id))) continue;
const groupEl = document.querySelector(`.ge-dock-group[data-group="${group}"]`);
if (!groupEl) continue;
const activeTab = groupEl.querySelector('.ge-dock-tab-item.active');
const activeId = activeTab?.dataset.panel;
if (activeId && hidden.has(activeId)) {
const firstVisible = members.find(id => !hidden.has(id));
groupEl.querySelector(`.ge-dock-tab-item[data-panel="${firstVisible}"] .ge-dock-tab`)?.click();
}
}
}
export function setVisible(id, visible) {
ensureLoaded();
if (!PANEL_IDS.includes(id)) return false;
const on = visible !== false;
if (on) hidden.delete(id);
else hidden.add(id);
persist();
apply();
notify();
return on;
}
export function toggle(id) {
return setVisible(id, !isVisible(id));
}
export function showAll() {
ensureLoaded();
hidden.clear();
persist();
apply();
notify();
}
export function hideAll() {
ensureLoaded();
hidden = new Set(PANEL_IDS);
persist();
apply();
notify();
}
export function reset() {
return showAll();
}
export function subscribe(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn);
}
// Test seam: drop the in-memory cache so the next read re-loads localStorage.
export function _resetCache() {
hidden = null;
}
+446
View File
@@ -0,0 +1,446 @@
// Color + analysis panels for the image editor right dock (PhotoCraft parity).
// Pure DOM panels that read the shared editor `state` and call back into the
// editor for the few actions that need to mutate the document (color pick,
// layer visibility, pan, zoom). No global side effects beyond the state/hooks
// passed in by buildRightPanel/galleryEditor.
import { drawHistogram } from '../fx/histogram.js';
import { applyCanvasPan } from '../canvas-navigation.js';
import { attachColorPicker, getRecentColors, suggestColorsFor, recordRecentColor } from '../../colorPicker.js?v=20260910eyedropper1';
const SWATCHES = [
'#000000', '#404040', '#808080', '#c0c0c0', '#ffffff', '#ff0000', '#ff8000',
'#ffff00', '#80ff00', '#00ff00', '#00ff80', '#00ffff', '#0080ff', '#0000ff',
'#8000ff', '#ff00ff', '#ff0080', '#7f0000', '#7f4000', '#7f7f00', '#407f00',
'#007f00', '#007f40', '#007f7f', '#00407f', '#00007f', '#40007f', '#7f007f',
'#7f0040', '#ffcccc', '#ffd9b3', '#ffffcc', '#ccffcc', '#cce5ff', '#e5ccff',
'#ffcce5', '#8b4513', '#d2b48c', '#f5deb3', '#2f4f4f',
];
const GRADIENTS = [
['Black → White', ['#000000', '#ffffff']],
['White → Black', ['#ffffff', '#000000']],
['Red → Yellow', ['#ff0000', '#ffff00']],
['Yellow → Blue', ['#ffff00', '#0000ff']],
['Cyan → Blue', ['#00ffff', '#0000ff']],
['Magenta → Orange', ['#ff00ff', '#ff8000']],
['Green → Cyan', ['#00ff00', '#00ffff']],
['Spectrum', ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff']],
];
function el(tag, className, html) {
const node = document.createElement(tag);
if (className) node.className = className;
if (html != null) node.innerHTML = html;
return node;
}
function panel(id, title, body) {
const wrap = el('div', 'ge-panel');
wrap.id = `ge-panel-${id}`;
if (title) wrap.appendChild(el('div', 'ge-panel-title', title));
wrap.appendChild(body);
return wrap;
}
function activeLayer(state) {
if (!state || !Array.isArray(state.layers)) return null;
return state.layers.find((layer) => layer.id === state.activeLayerId) || state.layers[state.layers.length - 1] || null;
}
// Minimal HSV <-> hex conversion for the inline Swatches picker. Kept local so
// the panel does not depend on colorPicker.js internals (which only export the
// harmony/recent helpers) and to avoid pulling the shared popover singleton
// into the dock.
function hexToHsv(hex) {
const m = String(hex || '').trim().match(/^#?([0-9a-f]{6})$/i);
const int = m ? parseInt(m[1], 16) : 0;
const r = ((int >> 16) & 255) / 255;
const g = ((int >> 8) & 255) / 255;
const b = (int & 255) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
let h = 0;
if (d) {
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
const s = max ? (d / max) * 100 : 0;
return { h, s, v: max * 100 };
}
function hsvToHex(h, s, v) {
h = ((h % 360) + 360) % 360;
s = Math.max(0, Math.min(100, s)) / 100;
v = Math.max(0, Math.min(100, v)) / 100;
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; }
else if (h < 120) { r = x; g = c; }
else if (h < 180) { g = c; b = x; }
else if (h < 240) { g = x; b = c; }
else if (h < 300) { r = x; b = c; }
else { r = c; b = x; }
const to = (n) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
return `#${to(r)}${to(g)}${to(b)}`;
}
export function createColorPanels({
state,
canvasArea = null,
setColor = (hex) => { if (state) state.color = hex; },
requestRender = () => {},
zoomIn = null,
zoomOut = null,
zoomFit = null,
} = {}) {
if (!state) throw new Error('createColorPanels requires editor state');
// ---- Swatches ---------------------------------------------------------
// Reuse the app's shared color picker (colorPicker.js → `.cp-popover`: SL +
// hue, hex, suggestions, recent swatches, eyedropper) instead of a static
// preset grid.
const swatchBody = el('div', 'ge-swatch-body');
const colorInput = document.createElement('input');
colorInput.type = 'color';
colorInput.className = 'ge-color-input';
colorInput.value = (state.color || '#000000');
colorInput.title = 'Pick a color';
// colorInput is kept as an internal colour holder only (it is not appended);
// the panel shows the inline picker, a hex field, Suggestions and Recent.
// Suggestions + recent colours sit right next to the picker (reusing the
// app's colorPicker harmony/recent logic), so common picks are one click.
const swatchCol = el('div', 'ge-swatch-col');
const suggestRow = el('div', 'ge-swatch-row ge-swatch-suggest');
const recentRow = el('div', 'ge-swatch-row ge-swatch-recent');
const sugLabel = el('div', 'ge-swatch-row-label'); sugLabel.textContent = 'Suggestions';
const recLabel = el('div', 'ge-swatch-row-label'); recLabel.textContent = 'Recent';
swatchCol.append(sugLabel, suggestRow, recLabel, recentRow);
swatchBody.appendChild(swatchCol);
const _syncForeground = (hex) => {
setColor(hex);
// Mirror the pick into the rail's foreground swatch so the whole editor
// (brush/bucket colour, swap, footer) follows a click on a suggestion.
const fg = document.getElementById('ge-fg-color');
if (fg) { fg.value = hex; try { fg.dispatchEvent(new Event('input', { bubbles: true })); } catch (_) {} }
};
const renderInlineSwatches = () => {
const cur = state.color || colorInput.value || '#000000';
const sug = suggestColorsFor(cur);
suggestRow.innerHTML = sug.map(s =>
`<button type="button" class="ge-mini-swatch" title="${s.label}: ${s.hex}" data-hex="${s.hex}" style="background:${s.hex}"></button>`
).join('');
// Same number of Recent swatches as Suggestions, on a single row.
const recs = getRecentColors().slice(0, sug.length);
recentRow.innerHTML = recs.length
? recs.map(h => `<button type="button" class="ge-mini-swatch" title="${h}" data-hex="${h}" style="background:${h}"></button>`).join('')
: '<span class="ge-mini-empty">none yet</span>';
};
// Inline picker: an always-visible SV square + hue strip so the panel reads
// as a colour picker at a glance (the shared popover only appears after a
// click, so a static dock shot never shows it). Reuses the app's .cp-sl /
// .cp-hue styling so it matches the popover exactly.
const picker = el('div', 'ge-inline-picker');
const sl = el('div', 'cp-sl');
sl.innerHTML = '<div class="cp-sl-white"></div><div class="cp-sl-black"></div><div class="cp-sl-handle"></div>';
const hue = el('div', 'cp-hue');
hue.innerHTML = '<div class="cp-hue-handle"></div>';
picker.append(sl, hue);
swatchBody.appendChild(picker);
// Hex code field (e.g. #e06c75) — type a value to set the colour.
const hexRow = el('div', 'ge-hex-row');
const hexHash = el('span', 'ge-hex-hash'); hexHash.textContent = '#';
const hexInput = el('input', 'ge-hex-input');
hexInput.type = 'text';
hexInput.spellcheck = false;
hexInput.autocomplete = 'off';
hexInput.setAttribute('aria-label', 'Hex colour code');
hexInput.value = String(state.color || '#000000').replace(/^#/, '');
hexRow.append(hexHash, hexInput);
swatchBody.appendChild(hexRow);
const _setHex = (hex) => {
if (hexInput && document.activeElement !== hexInput) hexInput.value = String(hex || '').replace(/^#/, '');
};
hexInput.addEventListener('input', () => {
let v = String(hexInput.value || '').trim();
if (v && v[0] !== '#') v = `#${v}`;
if (v.length === 4) v = `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}`;
if (!/^#[0-9a-f]{6}$/i.test(v)) return;
_syncForeground(v);
colorInput.value = v;
setPickerFromColor(v);
renderInlineSwatches();
});
const slHandle = sl.querySelector('.cp-sl-handle');
const hueHandle = hue.querySelector('.cp-hue-handle');
let ipH = 0, ipS = 0, ipV = 0, ipDrag = null;
const paintPicker = () => {
sl.style.backgroundColor = hsvToHex(ipH, 100, 100);
slHandle.style.left = `${ipS}%`;
slHandle.style.top = `${100 - ipV}%`;
hueHandle.style.left = `${(ipH / 360) * 100}%`;
};
const setPickerFromColor = (hex) => {
const { h, s, v } = hexToHsv(hex);
ipH = h; ipS = s; ipV = v;
paintPicker();
};
const commitPicker = () => {
const hex = hsvToHex(ipH, ipS, ipV);
_syncForeground(hex);
colorInput.value = hex;
_setHex(hex);
renderInlineSwatches();
};
const pickFromEvent = (event) => {
if (ipDrag === 'sl') {
const r = sl.getBoundingClientRect();
ipS = Math.max(0, Math.min(100, ((event.clientX - r.left) / r.width) * 100));
ipV = Math.max(0, Math.min(100, (1 - (event.clientY - r.top) / r.height) * 100));
paintPicker();
commitPicker();
} else if (ipDrag === 'hue') {
const r = hue.getBoundingClientRect();
ipH = Math.max(0, Math.min(360, ((event.clientX - r.left) / r.width) * 360));
paintPicker();
commitPicker();
}
};
const startDrag = (kind, captureEl) => (event) => {
event.preventDefault();
ipDrag = kind;
try { captureEl.setPointerCapture(event.pointerId); } catch (_) {}
pickFromEvent(event);
};
sl.addEventListener('pointerdown', startDrag('sl', sl));
hue.addEventListener('pointerdown', startDrag('hue', hue));
picker.addEventListener('pointermove', (event) => { if (ipDrag) pickFromEvent(event); });
picker.addEventListener('pointerup', () => { ipDrag = null; });
picker.addEventListener('pointercancel', () => { ipDrag = null; });
setPickerFromColor(state.color || colorInput.value || '#000000');
swatchBody.addEventListener('click', (e) => {
const btn = e.target.closest('.ge-mini-swatch');
if (!btn) return;
const hex = btn.dataset.hex;
recordRecentColor(hex);
_syncForeground(hex);
colorInput.value = hex;
_setHex(hex);
setPickerFromColor(hex);
renderInlineSwatches();
});
colorInput.addEventListener('input', () => {
_syncForeground(colorInput.value);
_setHex(colorInput.value);
setPickerFromColor(colorInput.value);
renderInlineSwatches();
});
const swatches = panel('swatches', '', swatchBody);
renderInlineSwatches();
const refreshSwatches = () => {
const color = (state.color || '#000000').toLowerCase();
if (String(colorInput.value).toLowerCase() !== color) colorInput.value = color;
_setHex(color);
setPickerFromColor(color);
renderInlineSwatches();
};
// ---- Gradients --------------------------------------------------------
const gradientGrid = el('div', 'ge-gradient-grid');
GRADIENTS.forEach(([name, stops]) => {
const css = `linear-gradient(90deg, ${stops.join(', ')})`;
const btn = el('button', 'ge-gradient-swatch');
btn.type = 'button';
btn.title = name;
btn.style.background = css;
btn.addEventListener('click', () => {
const start = document.getElementById('ge-gradient-start');
const end = document.getElementById('ge-gradient-end');
const type = document.getElementById('ge-gradient-type');
if (start && end) {
start.value = stops[0];
end.value = stops[stops.length - 1];
start.dispatchEvent(new Event('input', { bubbles: true }));
end.dispatchEvent(new Event('input', { bubbles: true }));
if (type) { type.value = 'linear'; type.dispatchEvent(new Event('change', { bubbles: true })); }
requestRender();
}
});
gradientGrid.appendChild(btn);
});
const gradients = panel('gradients', '', gradientGrid);
// ---- Channels (layer visibility) -------------------------------------
const channelList = el('div', 'ge-channel-list');
const channels = panel('channels', 'CHANNELS', channelList);
const renderChannels = () => {
channelList.innerHTML = '';
const layers = Array.isArray(state.layers) ? state.layers : [];
if (!layers.length) {
channelList.appendChild(el('p', 'ge-panel-empty', 'No layers yet.'));
return;
}
layers.slice().reverse().forEach((layer) => {
const row = el('div', 'ge-channel-row');
row.classList.toggle('active', layer.id === state.activeLayerId);
const eye = el('button', 'ge-channel-eye');
eye.type = 'button';
eye.setAttribute('aria-pressed', layer.visible === false ? 'false' : 'true');
eye.title = 'Toggle visibility';
eye.textContent = layer.visible === false ? '◌' : '◉';
eye.addEventListener('click', () => {
layer.visible = layer.visible === false;
requestRender();
renderChannels();
});
const name = el('span', 'ge-channel-name', layer.name || 'Layer');
row.appendChild(eye);
row.appendChild(name);
row.addEventListener('click', (event) => {
if (event.target === eye) return;
state.activeLayerId = layer.id;
requestRender();
renderChannels();
});
channelList.appendChild(row);
});
};
// ---- Histogram --------------------------------------------------------
const histCanvas = el('canvas', 'ge-histogram-canvas');
histCanvas.width = 260;
histCanvas.height = 90;
const histBody = el('div', 'ge-histogram-body');
histBody.appendChild(histCanvas);
const histogram = panel('histogram', '', histBody);
const redrawHistogram = () => {
try { drawHistogram(histCanvas, activeLayer(state)); } catch { /* not ready */ }
};
// ---- Info -------------------------------------------------------------
const infoBody = el('div', 'ge-info-body');
const infoColor = el('span', 'ge-info-color');
const infoHex = el('span', 'ge-info-hex', '—');
const infoSize = el('span', 'ge-info-size', '—');
const infoZoom = el('span', 'ge-info-zoom', '—');
const infoLayer = el('span', 'ge-info-layer', '—');
[
['Color', [infoColor, infoHex]],
['Size', infoSize],
['Zoom', infoZoom],
['Layer', infoLayer],
].forEach(([label, nodes]) => {
const row = el('div', 'ge-info-row');
row.appendChild(el('span', 'ge-info-label', label));
const cell = el('span', 'ge-info-value');
(Array.isArray(nodes) ? nodes : [nodes]).forEach((n) => cell.appendChild(n));
row.appendChild(cell);
infoBody.appendChild(row);
});
const info = panel('info', 'INFO', infoBody);
const refreshInfo = () => {
infoSize.textContent = `${state.imgWidth || 0} × ${state.imgHeight || 0} px`;
infoZoom.textContent = `${Math.round((state.zoom || 1) * 100)}%`;
const layer = activeLayer(state);
infoLayer.textContent = layer ? (layer.name || 'Layer') : '—';
};
const sampleAt = (clientX, clientY) => {
const canvas = state.mainCanvas;
const ctx = state.mainCtx;
if (!canvas || !ctx) return;
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const x = Math.round((clientX - rect.left) / rect.width * (canvas.width || 1));
const y = Math.round((clientY - rect.top) / rect.height * (canvas.height || 1));
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) return;
try {
const data = ctx.getImageData(x, y, 1, 1).data;
const hex = `#${[data[0], data[1], data[2]].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
infoColor.style.background = hex;
infoHex.textContent = hex.toUpperCase();
} catch { /* tainted/empty */ }
};
if (canvasArea) {
canvasArea.addEventListener('pointermove', (event) => sampleAt(event.clientX, event.clientY));
}
// ---- Navigator --------------------------------------------------------
const navCanvas = el('canvas', 'ge-navigator-canvas');
navCanvas.width = 220;
navCanvas.height = 150;
const navBody = el('div', 'ge-navigator-body');
navBody.appendChild(navCanvas);
const navigator = panel('navigator', 'NAVIGATOR', navBody);
const navScale = () => {
const docW = state.imgWidth || state.mainCanvas?.width || 1;
const docH = state.imgHeight || state.mainCanvas?.height || 1;
return Math.min(navCanvas.width / docW, navCanvas.height / docH);
};
const drawNavigator = () => {
const ctx = navCanvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, navCanvas.width, navCanvas.height);
ctx.fillStyle = '#0b0d10';
ctx.fillRect(0, 0, navCanvas.width, navCanvas.height);
const source = state.mainCanvas;
if (source) {
try {
const s = navScale();
const w = (state.imgWidth || source.width) * s;
const h = (state.imgHeight || source.height) * s;
ctx.drawImage(source, 0, 0, source.width, source.height, 0, 0, w, h);
const zoom = state.zoom || 1;
const area = canvasArea?.getBoundingClientRect();
if (area && area.width && area.height) {
const viewX = (-(state.panX || 0)) / zoom * s;
const viewY = (-(state.panY || 0)) / zoom * s;
const viewW = area.width / zoom * s;
const viewH = area.height / zoom * s;
ctx.strokeStyle = '#ff5a5f';
ctx.lineWidth = 1.5;
ctx.strokeRect(viewX, viewY, viewW, viewH);
}
} catch { /* not ready */ }
}
};
const panFromNavigator = (event) => {
const rect = navCanvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const mx = (event.clientX - rect.left) / rect.width * navCanvas.width;
const my = (event.clientY - rect.top) / rect.height * navCanvas.height;
const s = navScale();
const docX = mx / s;
const docY = my / s;
const zoom = state.zoom || 1;
const area = canvasArea;
if (!area) return;
const panX = -docX * zoom + area.clientWidth / 2;
const panY = -docY * zoom + area.clientHeight / 2;
applyCanvasPan(state, area, panX, panY);
drawNavigator();
};
let navDragging = false;
navCanvas.addEventListener('pointerdown', (event) => { navDragging = true; navCanvas.setPointerCapture?.(event.pointerId); panFromNavigator(event); });
navCanvas.addEventListener('pointermove', (event) => { if (navDragging) panFromNavigator(event); });
navCanvas.addEventListener('pointerup', (event) => { navDragging = false; navCanvas.releasePointerCapture?.(event.pointerId); });
navCanvas.addEventListener('pointerleave', () => { navDragging = false; });
const refresh = () => {
refreshSwatches();
renderChannels();
redrawHistogram();
refreshInfo();
drawNavigator();
};
refresh();
return { swatches, gradients, channels, histogram, info, navigator, refresh };
}
+298
View File
@@ -0,0 +1,298 @@
/**
* Extra right-dock studio panels (PhotoCraft parity): Properties,
* Character/Paragraph, Paths, Patterns, Adjustments and History. Every
* panel is decoupled — live edits are delegated back through the
* callbacks passed in, so this module owns no editor state.
*/
import { WARP_STYLES, EDITOR_TEXT_FONTS, availableEditorFonts } from '../text-layer.js';
const FONTS = availableEditorFonts().length ? availableEditorFonts() : EDITOR_TEXT_FONTS.slice();
export const ADJUSTMENT_TYPES = Object.freeze([
['brightness-contrast', 'Brightness/Contrast'],
['exposure', 'Exposure'],
['develop', 'Develop'],
['white-balance', 'White Balance'],
['hue-saturation', 'Hue/Saturation'],
['vibrance', 'Vibrance'],
['black-white', 'Black & White'],
['shadows-highlights', 'Shadows/Highlights'],
['levels', 'Levels'],
['curves', 'Curves'],
['color-balance', 'Color Balance'],
['selective-color', 'Selective Color'],
['gradient-map', 'Gradient Map'],
['invert', 'Invert'],
['posterize', 'Posterize'],
['threshold', 'Threshold'],
['photo-filter', 'Photo Filter'],
['channel-mixer', 'Channel Mixer'],
['color-lookup', 'Color Lookup'],
]);
// Inline SVG glyphs (16x16, currentColor) for the Adjustments panel buttons.
// Kept as path strings so each button is a single self-contained SVG.
const ADJUSTMENT_ICONS = Object.freeze({
'brightness-contrast': '<circle cx="8" cy="8" r="6"/><path d="M8 2a6 6 0 0 1 0 12z" fill="currentColor" stroke="none"/>',
exposure: '<circle cx="8" cy="8" r="3.2"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.2 3.2l1.4 1.4M11.4 11.4l1.4 1.4M12.8 3.2l-1.4 1.4M4.6 11.4l-1.4 1.4"/>',
develop: '<path d="M2 4h12M2 8h12M2 12h12"/><circle cx="5" cy="4" r="1.6" fill="currentColor" stroke="none"/><circle cx="10" cy="8" r="1.6" fill="currentColor" stroke="none"/><circle cx="6" cy="12" r="1.6" fill="currentColor" stroke="none"/>',
'white-balance': '<circle cx="8" cy="8" r="6"/><path d="M8 2a6 6 0 0 0 0 12z" fill="currentColor" stroke="none"/>',
'hue-saturation': '<circle cx="8" cy="8" r="6"/><path d="M8 2v12M2 8h12" opacity=".7"/>',
vibrance: '<circle cx="8" cy="8" r="5.5"/><path d="M8 4.4l1 2.4 2.4 1-2.4 1-1 2.4-1-2.4-2.4-1 2.4-1z" fill="currentColor" stroke="none"/>',
'black-white': '<rect x="2" y="2" width="12" height="12" rx="2"/><path d="M8 2h4a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H8z" fill="currentColor" stroke="none"/>',
'shadows-highlights': '<circle cx="6" cy="8" r="4"/><circle cx="10" cy="8" r="4"/>',
levels: '<path d="M3 13V7M6.3 13V4M9.7 13V9M13 13V6"/>',
curves: '<path d="M2 13C6 13 6 3 14 3"/>',
'color-balance': '<circle cx="6" cy="6" r="3.6"/><circle cx="10" cy="6" r="3.6"/><circle cx="8" cy="10" r="3.6"/>',
'selective-color': '<circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.6" fill="currentColor" stroke="none"/>',
'gradient-map': '<rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M2 7h12M2 9h12M2 11h12" opacity=".55"/>',
invert: '<circle cx="8" cy="8" r="6"/><path d="M8 2a6 6 0 0 0 0 12z" fill="currentColor" stroke="none"/>',
posterize: '<rect x="2" y="2" width="6" height="6"/><rect x="8" y="8" width="6" height="6"/><rect x="8" y="2" width="6" height="6" fill="currentColor" stroke="none"/>',
threshold: '<rect x="2" y="2" width="12" height="12" rx="1.5"/><path d="M8 2h6v12H8z" fill="currentColor" stroke="none"/>',
'photo-filter': '<circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.6"/>',
'channel-mixer': '<path d="M3 4h10M3 8h10M3 12h10"/><circle cx="6" cy="4" r="1.6" fill="currentColor" stroke="none"/><circle cx="10" cy="8" r="1.6" fill="currentColor" stroke="none"/><circle cx="5" cy="12" r="1.6" fill="currentColor" stroke="none"/>',
'color-lookup': '<rect x="2" y="2" width="5.5" height="5.5"/><rect x="8.5" y="2" width="5.5" height="5.5"/><rect x="2" y="8.5" width="5.5" height="5.5"/><rect x="8.5" y="8.5" width="5.5" height="5.5"/>',
});
function adjIcon(type) {
const body = ADJUSTMENT_ICONS[type];
if (!body) return '';
return `<span class="ge-adj-icon" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">${body}</svg></span>`;
}
function el(tag, className, html) {
const node = document.createElement(tag);
if (className) node.className = className;
if (html != null) node.innerHTML = html;
return node;
}
function panel(id, title, body) {
const wrap = el('div', 'ge-panel');
wrap.id = `ge-panel-${id}`;
if (title) wrap.appendChild(el('div', 'ge-panel-title', title));
wrap.appendChild(body);
return wrap;
}
function row(label, control) {
const wrap = el('label', 'ge-text-field');
wrap.appendChild(el('span', null, label));
wrap.appendChild(control);
return wrap;
}
function activeLayerOf(state) {
return (state.layers || []).find(layer => layer.id === state.activeLayerId) || null;
}
function patternDataUrl(kind) {
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#20242c';
ctx.fillRect(0, 0, 32, 32);
ctx.fillStyle = '#c8cdd6';
if (kind === 'stripes') {
for (let x = 0; x < 32; x += 8) ctx.fillRect(x, 0, 4, 32);
} else if (kind === 'checker') {
for (let y = 0; y < 32; y += 8) {
for (let x = 0; x < 32; x += 8) if (((x + y) / 8) % 2 === 0) ctx.fillRect(x, y, 8, 8);
}
} else if (kind === 'dots') {
for (let y = 4; y < 32; y += 8) for (let x = 4; x < 32; x += 8) { ctx.beginPath(); ctx.arc(x, y, 2, 0, Math.PI * 2); ctx.fill(); }
} else if (kind === 'grid') {
ctx.strokeStyle = '#c8cdd6';
for (let i = 0; i <= 32; i += 8) { ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, 32); ctx.moveTo(0, i); ctx.lineTo(32, i); ctx.stroke(); }
} else if (kind === 'diagonal') {
for (let i = -32; i < 32; i += 8) { ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i + 32, 32); ctx.lineWidth = 3; ctx.strokeStyle = '#c8cdd6'; ctx.stroke(); }
} else if (kind === 'cross') {
ctx.fillRect(12, 0, 4, 32);
ctx.fillRect(0, 12, 32, 4);
}
return canvas.toDataURL('image/png');
}
const PATTERN_KINDS = ['stripes', 'checker', 'dots', 'grid', 'diagonal', 'cross'];
export function createStudioPanels({
state,
onSelectLayer = () => {},
onAddAdjustment = () => {},
onPattern = () => {},
updateText = () => {},
getTextLayer = () => null,
setLayerOpacity = () => {},
requestRender = () => {},
} = {}) {
if (!state) throw new Error('createStudioPanels requires editor state');
// ---- Properties -------------------------------------------------------
const propBody = el('div', 'ge-panel-body');
const propName = el('div', 'ge-info-row', '<span class="ge-info-label">Layer</span><span class="ge-info-value" data-prop="name">—</span>');
const propKind = el('div', 'ge-info-row', '<span class="ge-info-label">Type</span><span class="ge-info-value" data-prop="kind">—</span>');
const propOpacity = document.createElement('input');
propOpacity.type = 'range';
propOpacity.min = '0';
propOpacity.max = '100';
propOpacity.value = '100';
propOpacity.setAttribute('data-prop', 'opacity');
propOpacity.addEventListener('input', () => setLayerOpacity(Number(propOpacity.value) / 100));
const propBlend = document.createElement('select');
propBlend.setAttribute('data-prop', 'blend');
const liveBlend = document.getElementById('ge-layer-blend');
if (liveBlend) {
propBlend.innerHTML = liveBlend.innerHTML;
propBlend.addEventListener('change', () => {
const target = document.getElementById('ge-layer-blend');
if (target) { target.value = propBlend.value; target.dispatchEvent(new Event('change', { bubbles: true })); }
});
}
propBody.append(propName, propKind, row('Opacity', propOpacity), row('Blend', propBlend));
const properties = panel('properties', '', propBody);
// ---- Character / Paragraph -------------------------------------------
const charBody = el('div', 'ge-panel-body');
const charFont = document.createElement('select');
FONTS.forEach(font => charFont.appendChild(new Option(font, font)));
const charSize = document.createElement('input'); charSize.type = 'number'; charSize.min = '1'; charSize.max = '2000'; charSize.value = '48';
const charLine = document.createElement('input'); charLine.type = 'number'; charLine.step = '0.05'; charLine.min = '0.5'; charLine.max = '5'; charLine.value = '1.2';
const charTracking = document.createElement('input'); charTracking.type = 'number'; charTracking.min = '-100'; charTracking.max = '500'; charTracking.value = '0';
const charColor = document.createElement('input'); charColor.type = 'color'; charColor.className = 'ge-color-picker'; charColor.value = '#ffffff';
const charVertical = document.createElement('input'); charVertical.type = 'checkbox';
const charSmallCaps = document.createElement('input'); charSmallCaps.type = 'checkbox';
const charLigatures = document.createElement('input'); charLigatures.type = 'checkbox'; charLigatures.checked = true;
const charWarp = document.createElement('select');
WARP_STYLES.forEach(style => charWarp.appendChild(new Option(style, style)));
const alignButtons = el('div', 'ge-text-align-row');
const alignGroup = ['left', 'center', 'right'].map(value => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'ge-text-toggle';
button.dataset.charAlign = value;
button.textContent = value === 'left' ? 'L' : value === 'center' ? 'C' : 'R';
alignButtons.appendChild(button);
return button;
});
const emitText = patch => { updateText(patch); };
charFont.addEventListener('change', () => emitText({ fontFamily: charFont.value }));
[['fontSize', charSize], ['lineHeight', charLine], ['letterSpacing', charTracking]].forEach(([key, input]) => {
input.addEventListener('input', () => emitText({ [key]: Number(input.value) }));
});
charColor.addEventListener('input', () => emitText({ color: charColor.value }));
charVertical.addEventListener('change', () => emitText({ vertical: charVertical.checked }));
charSmallCaps.addEventListener('change', () => emitText({ smallCaps: charSmallCaps.checked }));
charLigatures.addEventListener('change', () => emitText({ ligatures: charLigatures.checked }));
charWarp.addEventListener('change', () => emitText({ warp: charWarp.value }));
alignGroup.forEach(button => button.addEventListener('click', () => {
emitText({ align: button.dataset.charAlign });
refreshCharacter();
}));
charBody.append(
row('Font', charFont), row('Size', charSize), row('Line height', charLine),
row('Tracking', charTracking), row('Color', charColor), alignButtons,
row('Vertical', charVertical), row('Small caps', charSmallCaps),
row('Ligatures', charLigatures), row('Warp', charWarp),
);
const character = panel('character', 'CHARACTER / PARAGRAPH', charBody);
// ---- Paths ------------------------------------------------------------
const pathBody = el('div', 'ge-panel-body ge-path-list');
const paths = panel('paths', 'PATHS', pathBody);
// ---- Patterns ---------------------------------------------------------
const patternGrid = el('div', 'ge-swatch-grid');
PATTERN_KINDS.forEach(kind => {
const url = patternDataUrl(kind);
const swatch = el('button', 'ge-swatch');
swatch.type = 'button';
swatch.title = kind;
swatch.style.backgroundImage = `url(${url})`;
swatch.style.background = `url(${url})`;
swatch.addEventListener('click', () => {
renderPatterns(kind);
onPattern(url, kind);
});
patternGrid.appendChild(swatch);
});
const patterns = panel('patterns', '', patternGrid);
// ---- Adjustments ------------------------------------------------------
const adjGrid = el('div', 'ge-adjustment-grid');
ADJUSTMENT_TYPES.forEach(([type, label]) => {
const button = el('button', 'ge-btn ge-btn-sm ge-adj-btn', `${adjIcon(type)}<span class="ge-adj-label">${label}</span>`);
button.type = 'button';
button.dataset.adjustmentType = type;
button.addEventListener('click', () => onAddAdjustment(type, button));
adjGrid.appendChild(button);
});
const adjustments = panel('adjustments', '', adjGrid);
// ---- History ----------------------------------------------------------
// Photoshop-style: the panel *is* the timeline — a scrollable list of
// states you can click to jump to. No "open in window" button: the timeline
// is already right here. The floating window is still reachable from the
// topbar clock button (#ge-history-btn) if you want it undocked.
const historyBody = el('div', 'ge-panel-body ge-history-body');
const historyList = el('div', 'ge-history-list');
historyList.id = 'ge-dock-history-list';
historyBody.appendChild(historyList);
const history = panel('history', 'HISTORY', historyBody);
function refreshProperties() {
const layer = activeLayerOf(state);
propName.querySelector('[data-prop="name"]').textContent = layer ? (layer.name || 'Layer') : '—';
propKind.querySelector('[data-prop="kind"]').textContent = layer ? (layer.kind || 'raster') : '—';
const opacity = layer && Number.isFinite(layer.opacity) ? layer.opacity : 1;
propOpacity.value = String(Math.round(opacity * 100));
const target = document.getElementById('ge-layer-blend');
if (target) propBlend.value = target.value;
}
function refreshCharacter() {
const layer = getTextLayer();
if (!layer || !layer.text) return;
const text = layer.text;
charFont.value = text.fontFamily || 'Arial';
charSize.value = String(text.fontSize ?? 48);
charLine.value = String(text.lineHeight ?? 1.2);
charTracking.value = String(text.letterSpacing ?? 0);
charColor.value = text.color || '#ffffff';
charVertical.checked = !!text.vertical;
charSmallCaps.checked = !!text.smallCaps;
charLigatures.checked = text.ligatures !== false;
charWarp.value = text.warp || 'none';
alignGroup.forEach(button => button.classList.toggle('active', button.dataset.charAlign === (text.align || 'left')));
}
function renderPaths() {
pathBody.innerHTML = '';
const shapeLayers = (state.layers || []).filter(layer => layer.kind === 'shape' && layer.shape);
if (!shapeLayers.length) {
pathBody.appendChild(el('p', 'ge-panel-empty', 'No vector shapes yet.'));
return;
}
shapeLayers.slice().reverse().forEach(layer => {
const button = el('button', 'ge-channel-row', `<span class="ge-channel-name">${layer.shape.type === 'path' ? 'Path' : layer.shape.type} · ${layer.name || layer.id.slice(0, 6)}</span>`);
button.type = 'button';
button.addEventListener('click', () => { onSelectLayer(layer.id); renderPaths(); });
pathBody.appendChild(button);
});
}
function renderPatterns(activeKind = null) {
patternGrid.querySelectorAll('.ge-swatch').forEach((swatch, index) => {
swatch.classList.toggle('active', PATTERN_KINDS[index] === activeKind);
});
}
function refresh() {
refreshProperties();
refreshCharacter();
renderPaths();
}
refresh();
return { properties, character, paths, patterns, adjustments, history, refresh };
}
+145
View File
@@ -0,0 +1,145 @@
// Patch / Content-Aware Move pixel engine.
//
// Deterministic and fully offline (no AI/network). Two operations:
// * patchRegion — Photoshop-style Patch: replace what is inside the
// selection with the same-shaped source region shifted by (dx,dy),
// blended at a feathered edge so the seam disappears.
// * contentAwareMove — cut the selection, paste it at +(dx,dy), then
// fill the vacated hole by diffusion (repeatedly averaging the four
// neighbouring colours inward from the hole boundary).
//
// All geometry is passed as canvases + a layer offset so the helpers stay
// dependency-light and can be unit-tested with a fake `createCanvas`.
// `createCanvas(w,h)` must return a canvas-like object with getContext('2d').
// Draw `source` into a fresh canvas, keeping only the pixels where `mask`
// is opaque (destination-in). Returns the new canvas.
function restrictToMask(createCanvas, source, mask, width, height) {
const out = createCanvas(width, height);
const ctx = out.getContext('2d');
ctx.drawImage(source, 0, 0);
ctx.globalCompositeOperation = 'destination-in';
ctx.drawImage(mask, 0, 0);
return out;
}
// Feathered copy of a mask (soft edge for blending a patch). Returns a NEW
// canvas; the source is not mutated.
function featherMask(createCanvas, mask, width, height, radius) {
const out = createCanvas(width, height);
const ctx = out.getContext('2d');
const r = Math.max(0, Number(radius) || 0);
if (r > 0 && 'filter' in ctx) ctx.filter = `blur(${r}px)`;
ctx.drawImage(mask, 0, 0);
if (r > 0 && 'filter' in ctx) ctx.filter = 'none';
return out;
}
// Translate a document-space mask into layer space (shift by -offset).
function maskInLayerSpace(createCanvas, mask, offset, width, height) {
const out = createCanvas(width, height);
const ctx = out.getContext('2d');
ctx.drawImage(mask, -(offset.x || 0), -(offset.y || 0));
return out;
}
/**
* Patch: fill the selection with content sampled from (dx,dy) away.
* @returns {*} a NEW canvas the size of `layerCanvas` with the patch applied.
*/
export function patchRegion(createCanvas, layerCanvas, maskCanvas, offset, dx, dy, feather = 0) {
const w = layerCanvas.width;
const h = layerCanvas.height;
const out = createCanvas(w, h);
const octx = out.getContext('2d');
octx.drawImage(layerCanvas, 0, 0);
let localMask = maskInLayerSpace(createCanvas, maskCanvas, offset, w, h);
if (feather > 0) localMask = featherMask(createCanvas, localMask, w, h, feather);
// Source pixels sampled at (x+dx, y+dy) == the layer drawn shifted by
// (-dx, -dy), then clipped to the selection.
const shifted = createCanvas(w, h);
shifted.getContext('2d').drawImage(layerCanvas, -dx, -dy);
const sourceInMask = restrictToMask(createCanvas, shifted, localMask, w, h);
octx.drawImage(sourceInMask, 0, 0);
return out;
}
/**
* Content-Aware Move: move the selection content by (dx,dy) and fill the
* hole it leaves behind with a diffusion fill grown from the boundary.
*/
export function contentAwareMove(createCanvas, layerCanvas, maskCanvas, offset, dx, dy, iterations = 16) {
const w = layerCanvas.width;
const h = layerCanvas.height;
const localMask = maskInLayerSpace(createCanvas, maskCanvas, offset, w, h);
// Content inside the selection, cut out on its own.
const cut = restrictToMask(createCanvas, layerCanvas, localMask, w, h);
// Fill the hole. Start with the surrounding content (hole transparent),
// then grow the edge colour inwards one pixel per pass in all four
// directions (nearest-boundary fill, "destination-over" only writes where
// the buffer is still transparent). This reliably covers even a large hole,
// unlike a plain diffusion which needs O(radius^2) iterations to converge.
const fill = createCanvas(w, h);
const fctx = fill.getContext('2d');
fctx.drawImage(layerCanvas, 0, 0);
fctx.globalCompositeOperation = 'destination-out';
fctx.drawImage(localMask, 0, 0);
fctx.globalCompositeOperation = 'source-over';
let bw = 0;
let bh = 0;
try {
const md = localMask.getContext('2d').getImageData(0, 0, w, h).data;
let minX = w, minY = h, maxX = -1, maxY = -1;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (md[(y * w + x) * 4 + 3] > 128) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX >= 0) { bw = maxX - minX + 1; bh = maxY - minY + 1; }
} catch (_) { /* no getImageData — use the requested pass count */ }
const radius = Math.ceil(Math.max(bw, bh) / 2);
const passes = Math.max(0, Math.min(256, Math.max(Math.round(iterations) || 0, radius + 2)));
fctx.globalCompositeOperation = 'destination-over';
for (let i = 0; i < passes; i++) {
fctx.drawImage(fill, 1, 0);
fctx.drawImage(fill, -1, 0);
fctx.drawImage(fill, 0, 1);
fctx.drawImage(fill, 0, -1);
}
fctx.globalCompositeOperation = 'source-over';
// Light smoothing pass so the grown fill is not blocky.
for (let i = 0; i < 3; i++) {
const avg = createCanvas(w, h);
const actx = avg.getContext('2d');
actx.globalAlpha = 0.25;
actx.drawImage(fill, 1, 0);
actx.drawImage(fill, -1, 0);
actx.drawImage(fill, 0, 1);
actx.drawImage(fill, 0, -1);
actx.globalAlpha = 1;
const maskedAvg = restrictToMask(createCanvas, avg, localMask, w, h);
fctx.drawImage(maskedAvg, 0, 0);
}
const filledHole = restrictToMask(createCanvas, fill, localMask, w, h);
const out = createCanvas(w, h);
const octx = out.getContext('2d');
octx.drawImage(layerCanvas, 0, 0);
octx.drawImage(filledHole, 0, 0);
octx.drawImage(cut, dx, dy);
return out;
}
export default { patchRegion, contentAwareMove };
+120
View File
@@ -0,0 +1,120 @@
/* Curve flattening helpers for vector paths.
The editor stores paths as plain polylines (`pathPoints: [{x,y}, …]`), which
the pen tool and the Shape tools already produce. To let users lay text on a
*curved* path we flatten analytic curves (ellipse/arc, quadratic and cubic
Bezier) into a dense polyline: the existing arc-length text renderer then
spaces glyphs evenly and rotates each one to the local tangent, so the text
follows the curve smoothly instead of reading as straight rotated runs.
All functions are pure and return arrays of finite {x, y} points. Subdivision
is chosen from arc length so a big curve gets proportionally more points. */
function stepSize(scale) {
const s = Number(scale);
if (!Number.isFinite(s) || s <= 0) return 6;
return Math.max(3, Math.min(24, s / 32));
}
/* Points along the ellipse ((cx,cy), rx, ry). `start`/`end` are radians and a
full sweep (the default) closes the ring. `rotation` rotates the whole ellipse
about its centre. The sweep is sampled densely enough that the chord sag stays
sub-pixel for the given radii. */
export function ellipsePoints(cx, cy, rx, ry, { start = 0, end = Math.PI * 2, rotation = 0, count = 0 } = {}) {
const centerX = Number(cx) || 0;
const centerY = Number(cy) || 0;
const radiusX = Math.abs(Number(rx)) || 0;
const radiusY = Math.abs(Number(ry)) || 0;
const sweep = (Number(end) || 0) - (Number(start) || 0);
const rough = Math.PI * (3 * (radiusX + radiusY) - Math.sqrt((3 * radiusX + radiusY) * (radiusX + 3 * radiusY)));
const arcLength = (Math.abs(sweep) / (Math.PI * 2)) * rough;
const n = Math.max(16, Math.min(2048, count || Math.ceil(arcLength / stepSize(Math.max(radiusX, radiusY)))));
const cosR = Math.cos(Number(rotation) || 0);
const sinR = Math.sin(Number(rotation) || 0);
const points = [];
for (let i = 0; i <= n; i += 1) {
const angle = (Number(start) || 0) + sweep * (i / n);
let dx = radiusX * Math.cos(angle);
let dy = radiusY * Math.sin(angle);
if (rotation) {
const rx2 = dx * cosR - dy * sinR;
const ry2 = dx * sinR + dy * cosR;
dx = rx2;
dy = ry2;
}
points.push({ x: centerX + dx, y: centerY + dy });
}
return points;
}
function controlLength(p0, p1, p2, p3) {
const seg = (a, b) => Math.hypot(b.x - a.x, b.y - a.y);
return seg(p0, p1) + seg(p1, p2) + seg(p2, p3);
}
/* Cubic Bezier p0..p3 flattened via de Casteljau at uniform parameter steps. */
export function cubicBezierPoints(p0, p1, p2, p3, count = 0) {
const a = p0 || { x: 0, y: 0 };
const b = p1 || a;
const c = p2 || b;
const d = p3 || c;
const control = controlLength(a, b, c, d);
const n = Math.max(8, Math.min(2048, count || Math.ceil(control / Math.max(3, Math.min(12, control / 48)))));
const points = [];
for (let i = 0; i <= n; i += 1) {
const t = i / n;
const u = 1 - t;
const x = u * u * u * a.x + 3 * u * u * t * b.x + 3 * u * t * t * c.x + t * t * t * d.x;
const y = u * u * u * a.y + 3 * u * u * t * b.y + 3 * u * t * t * c.y + t * t * t * d.y;
points.push({ x, y });
}
return points;
}
/* Quadratic Bezier p0,p1,p2 flattened similarly. */
export function quadraticBezierPoints(p0, p1, p2, count = 0) {
const a = p0 || { x: 0, y: 0 };
const b = p1 || a;
const c = p2 || b;
const seg = (p, q) => Math.hypot(q.x - p.x, q.y - p.y);
const length = seg(a, b) + seg(b, c);
const n = Math.max(8, Math.min(2048, count || Math.ceil(length / Math.max(3, Math.min(12, length / 48)))));
const points = [];
for (let i = 0; i <= n; i += 1) {
const t = i / n;
const u = 1 - t;
const x = u * u * a.x + 2 * u * t * b.x + t * t * c.x;
const y = u * u * a.y + 2 * u * t * b.y + t * t * c.y;
points.push({ x, y });
}
return points;
}
/* Smooth an existing polyline with a Catmull-Rom spline: useful to turn a few
coarse hand-drawn points into a flowing curve. Returns the original points
when there are fewer than three. */
export function smoothPathPoints(points, count = 0) {
const pts = (Array.isArray(points) ? points : []).filter(
(p) => p && Number.isFinite(Number(p.x)) && Number.isFinite(Number(p.y)),
).map((p) => ({ x: Number(p.x), y: Number(p.y) }));
if (pts.length < 3) return pts;
const out = [];
const n = Math.max(4, Math.min(64, count || 12));
const at = (i) => pts[Math.max(0, Math.min(pts.length - 1, i))];
for (let i = 0; i < pts.length - 1; i += 1) {
const p0 = at(i - 1);
const p1 = at(i);
const p2 = at(i + 1);
const p3 = at(i + 2);
for (let j = 0; j < n; j += 1) {
const t = j / n;
const t2 = t * t;
const t3 = t2 * t;
const x = 0.5 * ((2 * p1.x) + (-p0.x + p2.x) * t + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3);
const y = 0.5 * ((2 * p1.y) + (-p0.y + p2.y) * t + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3);
out.push({ x, y });
}
}
out.push(pts[pts.length - 1]);
return out;
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Perspective Crop — straighten an arbitrary quadrilateral drawn over the image
* into a rectangular crop, resizing the document to the quad's edge lengths.
*
* Pure functions plus one thin canvas adapter. The quad-to-rectangle homography
* (solveSquareToQuad, from perspective-warp.js) is applied FORWARD so the quad's
* contents fill the whole output rectangle; the output size comes from the
* quad's average edge lengths so the straightened result keeps its proportions.
*/
import { solveSquareToQuad } from './perspective-warp.js';
function dist(a, b) {
return Math.hypot((a?.x || 0) - (b?.x || 0), (a?.y || 0) - (b?.y || 0));
}
function clampDim(value) {
return Math.max(1, Math.round(Number(value) || 1));
}
/** Output size for a crop quad (document px): mean of the opposite edge lengths. */
export function perspectiveCropSize(corners, srcW = 0, srcH = 0) {
const c = Array.isArray(corners) ? corners : [];
if (c.length < 4) {
return { width: clampDim(srcW || 1), height: clampDim(srcH || 1) };
}
const [tl, tr, br, bl] = c;
const width = (dist(tl, tr) + dist(bl, br)) / 2;
const height = (dist(tl, bl) + dist(tr, br)) / 2;
return { width: clampDim(width), height: clampDim(height) };
}
/** Normalise document-space corners to source fractions for warpRgba. */
export function normalizeCropCorners(corners, srcW, srcH) {
const w = clampDim(srcW), h = clampDim(srcH);
const c = Array.isArray(corners) ? corners.slice(0, 4) : [];
while (c.length < 4) c.push({ x: 0, y: 0 });
return c.map(point => ({
x: Math.max(0, Math.min(1, (Number(point?.x) || 0) / w)),
y: Math.max(0, Math.min(1, (Number(point?.y) || 0) / h)),
}));
}
/**
* Straighten the quad in `sourceCanvas` to a `outW` x `outH` canvas.
* @returns {HTMLCanvasElement}
*/
export function perspectiveCropCanvas(sourceCanvas, corners, outW, outH) {
const w = clampDim(sourceCanvas?.width || 1);
const h = clampDim(sourceCanvas?.height || 1);
const width = clampDim(outW);
const height = clampDim(outH);
const srcData = sourceCanvas.getContext('2d').getImageData(0, 0, w, h).data;
const quad = normalizeCropCorners(corners, w, h);
const [a, b, c, d, e, f, g, hh] = solveSquareToQuad(quad);
const out = new Uint8ClampedArray(width * height * 4);
const denomX = width > 1 ? width - 1 : 1;
const denomY = height > 1 ? height - 1 : 1;
for (let y = 0; y < height; y += 1) {
const v = y / denomY;
for (let x = 0; x < width; x += 1) {
const u = x / denomX;
const wgt = g * u + hh * v + 1;
if (!wgt) continue;
const s = (a * u + b * v + c) / wgt;
const t = (d * u + e * v + f) / wgt;
if (s < 0 || s > 1 || t < 0 || t > 1) continue;
const sx = Math.min(w - 1, Math.max(0, Math.round(s * (w - 1))));
const sy = Math.min(h - 1, Math.max(0, Math.round(t * (h - 1))));
const si = (sy * w + sx) * 4;
const di = (y * width + x) * 4;
out[di] = srcData[si];
out[di + 1] = srcData[si + 1];
out[di + 2] = srcData[si + 2];
out[di + 3] = srcData[si + 3];
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
const image = ctx.createImageData(width, height);
image.data.set(out);
ctx.putImageData(image, 0, 0);
return canvas;
}
/** Default corner set: inset a little from the document corners. */
export function defaultPerspectiveCorners(srcW, srcH, inset = 0.06) {
const w = clampDim(srcW), h = clampDim(srcH);
const dx = Math.round(w * inset), dy = Math.round(h * inset);
return [
{ x: dx, y: dy },
{ x: w - dx, y: dy },
{ x: w - dx, y: h - dy },
{ x: dx, y: h - dy },
];
}
export default { perspectiveCropSize, normalizeCropCorners, perspectiveCropCanvas, defaultPerspectiveCorners };
+111
View File
@@ -0,0 +1,111 @@
// Perspective (keystone) warp via a projective homography.
//
// Pure array maths so it can run in the browser and in Node tests. The
// destination is described by four normalized corners (TL, TR, BR, BL) as
// fractions of the output size; source pixels are sampled by inverse mapping.
export function solveSquareToQuad(corners) {
const [p0, p1, p2, p3] = corners; // TL, TR, BR, BL
const dx1 = p1.x - p2.x;
const dx2 = p3.x - p2.x;
const dx3 = p0.x - p1.x + p2.x - p3.x;
const dy1 = p1.y - p2.y;
const dy2 = p3.y - p2.y;
const dy3 = p0.y - p1.y + p2.y - p3.y;
if (dx3 === 0 && dy3 === 0) {
// Affine.
return [p1.x - p0.x, p2.x - p1.x, p0.x, p1.y - p0.y, p2.y - p1.y, p0.y, 0, 0, 1];
}
const den = dx1 * dy2 - dx2 * dy1;
const g = (dx3 * dy2 - dx2 * dy3) / den;
const h = (dx1 * dy3 - dx3 * dy1) / den;
const a = p1.x - p0.x + g * p1.x;
const b = p3.x - p0.x + h * p3.x;
const c = p0.x;
const d = p1.y - p0.y + g * p1.y;
const e = p3.y - p0.y + h * p3.y;
const f = p0.y;
return [a, b, c, d, e, f, g, h, 1];
}
function invert3x3(m) {
const [a, b, c, d, e, f, g, h, i] = m;
const A = e * i - f * h;
const B = -(d * i - f * g);
const C = d * h - e * g;
const D = -(b * i - c * h);
const E = a * i - c * g;
const F = -(a * h - b * g);
const G = b * f - c * e;
const H = -(a * f - c * d);
const I = a * e - b * d;
const det = a * A + b * B + c * C;
if (!det) return null;
return [A / det, D / det, G / det, B / det, E / det, H / det, C / det, F / det, I / det];
}
/** Convert a `{ perspectiveX, perspectiveY }` amount pair (each −100..100) into
* destination corners for a unit square, keystoned about the centre. */
export function keystoneCorners(perspectiveX = 0, perspectiveY = 0) {
const topScale = Math.max(0.05, 1 - perspectiveX / 200); // >0 narrows the top
const sideScale = Math.max(0.05, 1 - perspectiveY / 200); // >0 narrows the sides
const insetX = (1 - topScale) / 2;
const insetY = (1 - sideScale) / 2;
return [
{ x: insetX, y: insetY }, // TL
{ x: 1 - insetX, y: insetY }, // TR
{ x: 1, y: 1 - insetY }, // BR
{ x: 0, y: 1 - insetY }, // BL
];
}
/** Warp an RGBA buffer (`Uint8ClampedArray`, `Uint8Array` or plain array) with
* an inverse-mapped homography. Returns a fresh `Uint8ClampedArray`. */
export function warpRgba(src, srcW, srcH, corners, outW, outH) {
const out = new Uint8ClampedArray(outW * outH * 4);
const H = solveSquareToQuad(corners);
const Hinv = invert3x3(H);
if (!Hinv) return out;
const [a, b, c, d, e, f, g, h] = Hinv;
for (let y = 0; y < outH; y++) {
const v = outH > 1 ? y / (outH - 1) : 0;
for (let x = 0; x < outW; x++) {
const u = outW > 1 ? x / (outW - 1) : 0;
const w = g * u + h * v + 1;
if (!w) continue;
const s = (a * u + b * v + c) / w;
const t = (d * u + e * v + f) / w;
if (s < 0 || s > 1 || t < 0 || t > 1) continue;
const sx = Math.min(srcW - 1, Math.max(0, Math.round(s * (srcW - 1))));
const sy = Math.min(srcH - 1, Math.max(0, Math.round(t * (srcH - 1))));
const si = (sy * srcW + sx) * 4;
const di = (y * outW + x) * 4;
out[di] = src[si];
out[di + 1] = src[si + 1];
out[di + 2] = src[si + 2];
out[di + 3] = src[si + 3];
}
}
return out;
}
/** Browser helper: return a new canvas holding the warped source canvas. */
export function perspectiveWarpCanvas(sourceCanvas, corners, outW, outH) {
const srcW = sourceCanvas.width;
const srcH = sourceCanvas.height;
const sctx = sourceCanvas.getContext('2d');
const src = sctx.getImageData(0, 0, srcW, srcH).data;
const warped = warpRgba(src, srcW, srcH, corners, outW, outH);
const canvas = document.createElement('canvas');
canvas.width = outW;
canvas.height = outH;
const ctx = canvas.getContext('2d');
const image = ctx.createImageData(outW, outH);
image.data.set(warped);
ctx.putImageData(image, 0, 0);
return canvas;
}
export function isPerspectiveActive(perspectiveX = 0, perspectiveY = 0) {
return Math.abs(perspectiveX) > 0.01 || Math.abs(perspectiveY) > 0.01;
}
+25 -7
View File
@@ -144,21 +144,24 @@ export function createPrecisionGuides({ canvasArea, composite, saveState, schedu
function drawDocumentOverlay(ctx) {
if (!ctx || !state.imgWidth || !state.imgHeight) return;
ctx.save();
if (state.gridVisible) {
const base = Math.max(2, Number(state.gridSize) || 16);
const multiplier = Math.max(1, Math.ceil(5 / Math.max(0.0001, base * state.zoom)));
const pixelBrush = !!state.brushPixel && (state.tool === 'brush' || state.tool === 'eraser');
if (state.gridVisible || pixelBrush) {
const base = pixelBrush
? Math.max(1, Math.round(Number(state.brushSize) || 1))
: Math.max(2, Number(state.gridSize) || 16);
const multiplier = pixelBrush ? 1 : Math.max(1, Math.ceil(5 / Math.max(0.0001, base * state.zoom)));
const spacing = base * multiplier;
const strong = pixelBrush ? 'rgba(122, 208, 255, 0.55)' : 'rgba(110, 180, 220, 0.42)';
const weak = pixelBrush ? 'rgba(122, 208, 255, 0.30)' : 'rgba(110, 180, 220, 0.22)';
ctx.lineWidth = 1 / state.zoom;
for (let x = spacing; x < state.imgWidth; x += spacing) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, state.imgHeight);
ctx.strokeStyle = Math.round(x / spacing) % 4 === 0
? 'rgba(110, 180, 220, 0.42)' : 'rgba(110, 180, 220, 0.22)';
ctx.strokeStyle = Math.round(x / spacing) % 4 === 0 ? strong : weak;
ctx.stroke();
}
for (let y = spacing; y < state.imgHeight; y += spacing) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(state.imgWidth, y);
ctx.strokeStyle = Math.round(y / spacing) % 4 === 0
? 'rgba(110, 180, 220, 0.42)' : 'rgba(110, 180, 220, 0.22)';
ctx.strokeStyle = Math.round(y / spacing) % 4 === 0 ? strong : weak;
ctx.stroke();
}
}
@@ -239,6 +242,21 @@ export function createPrecisionGuides({ canvasArea, composite, saveState, schedu
return {
drawDocumentOverlay,
redrawRulers,
addGuide(orientation, position) {
const axis = orientation === 'horizontal' ? 'horizontal' : 'vertical';
const limit = axis === 'horizontal' ? state.imgHeight : state.imgWidth;
const value = Math.max(0, Math.min(limit, Math.round(Number(position))));
if (!Number.isFinite(value)) return null;
const guides = normalizedGuides();
if (guides[axis].some(existing => Math.abs(existing - value) < 0.5)) return value;
saveState?.('Add guide');
guides[axis].push(value);
guides[axis].sort((a, b) => a - b);
schedulePersist?.();
composite();
redrawRulers();
return value;
},
syncVisibility() { redrawRulers(); composite(); },
clearGuides() {
const guides = normalizedGuides();
+253
View File
@@ -0,0 +1,253 @@
/**
* Editor setup checklist ("preflight").
*
* Shown over the canvas the first time the image editor opens (and
* re-openable from the footer "Setup" button). Each row is a setup step
* with a short how-to and an automatic ✓ / ✕ mark; a manual checkbox lets
* the user tick a step the detector cannot see. State is persisted in
* localStorage so the checklist doesn't nag once dismissed.
*/
import {
resolveQuickEditModel,
resolveQuickEditAgentModel,
getAIModelsStatus,
} from './ai-models.js';
const DISMISS_KEY = 'ge-preflight-dismissed';
const OVERRIDE_KEY = 'ge-preflight-done';
export const PREFLIGHT_STEPS = [
{
id: 'provider',
title: 'Image server / AI provider reachable',
hint: 'The editor sends edits and generations to your enabled image model (local Qwen). '
+ 'This loads automatically — click Retry if it is stale.',
action: 'retry',
actionLabel: 'Retry',
},
{
id: 'editModel',
title: 'Image edit model set',
hint: 'Open AI settings and pick the image edit model. “Auto” uses the local '
+ 'Qwen-Image-Edit model.',
action: 'model',
actionLabel: 'Open settings',
},
{
id: 'agentModel',
title: 'AI agent (vision) model set',
hint: 'Optional but recommended. Open AI settings and pick the vision agent — it '
+ 'plans edits and can double-check them when “Double-check AI edits” is on.',
action: 'agent',
actionLabel: 'Open settings',
optional: true,
},
{
id: 'bgRemover',
title: 'Background remover available',
hint: 'Removes the background via the image server. Use Quick Edit (“remove the '
+ 'background”) or the toolbar remove-background tool.',
action: 'retry',
actionLabel: 'Retry',
},
];
function esc(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function readOverride() {
try {
const raw = JSON.parse(localStorage.getItem(OVERRIDE_KEY) || '[]');
return new Set(Array.isArray(raw) ? raw : []);
} catch (_) { return new Set(); }
}
function writeOverride(set) {
try { localStorage.setItem(OVERRIDE_KEY, JSON.stringify([...set])); } catch (_) {}
}
export function createPreflight({ canvasArea }) {
const el = document.createElement('div');
el.className = 'ge-preflight';
el.hidden = true;
el.setAttribute('role', 'dialog');
el.setAttribute('aria-modal', 'true');
el.setAttribute('aria-label', 'Editor setup checklist');
// Compute automatic status for every step + a short detail line.
function detect() {
const s = getAIModelsStatus() || {};
const models = Array.isArray(s.models) ? s.models : [];
const usable = models.filter((m) => m && m.usable);
const provider = !!s.ready && usable.length > 0;
const edit = resolveQuickEditModel();
const editModel = !!(edit && (edit.model || edit.endpoint));
const agentSel = document.getElementById('ge-ai-command-agent-model');
const agentModel = !!(agentSel && String(agentSel.value || '').trim());
const bgRemover = provider;
return {
provider: {
done: provider,
detail: provider
? `${usable.length} image model${usable.length === 1 ? '' : 's'} online`
: (s.error ? `Not reachable — ${s.error}` : 'No image models loaded'),
},
editModel: {
done: editModel,
detail: editModel ? (edit.label || edit.model) : 'No edit model resolved',
},
agentModel: {
done: agentModel,
detail: agentModel
? ((resolveQuickEditAgentModel() || {}).label || 'Server default')
: 'No agent model selected',
},
bgRemover: {
done: bgRemover,
detail: bgRemover ? 'Serviced by the image server' : 'Unavailable while the server is offline',
},
};
}
function render() {
const detected = detect();
const override = readOverride();
let doneRequired = 0;
let requiredCount = 0;
const rows = PREFLIGHT_STEPS.map((step) => {
const info = detected[step.id] || { done: false, detail: '' };
const manual = override.has(step.id);
const done = info.done || manual;
const optional = !!step.optional;
if (!optional) { requiredCount += 1; if (done) doneRequired += 1; }
const stateClass = done ? 'is-done' : (optional ? 'is-optional' : 'is-todo');
const badge = done ? '✓' : (optional ? '○' : '✕');
// Only offer an action / manual tick while the step is still missing —
// otherwise a ✓ row would still say “Retry”, which is confusing.
const actions = done ? '' : `<div class="ge-preflight-actions">
<button type="button" class="ge-preflight-act" data-action="${step.action}">${esc(step.actionLabel)}</button>
<label class="ge-preflight-manual" title="Mark this step done myself">
<input type="checkbox" data-manual="${step.id}" ${manual ? 'checked' : ''}>
</label>
</div>`;
const optTag = optional ? '<span class="ge-preflight-opt">optional</span>' : '';
return `<div class="ge-preflight-row ${stateClass}" data-step="${step.id}">
<span class="ge-preflight-badge" aria-hidden="true">${badge}</span>
<div class="ge-preflight-body">
<div class="ge-preflight-title">${esc(step.title)}${optTag}</div>
<div class="ge-preflight-hint">${esc(step.hint)}</div>
<div class="ge-preflight-detail">${esc(info.detail)}</div>
</div>
${actions}
</div>`;
}).join('');
el.innerHTML = `
<div class="ge-preflight-card">
<div class="ge-preflight-head">
<div>
<h3>Editor setup</h3>
<p>A quick check before you start. Fix any ✕ below — optional steps can be skipped.</p>
</div>
<button type="button" class="ge-preflight-close" data-preflight-close aria-label="Close">×</button>
</div>
<div class="ge-preflight-rows">${rows}</div>
<div class="ge-preflight-foot">
<label class="ge-preflight-remember">
<input type="checkbox" data-preflight-dontshow> Don’t show on open
</label>
<div class="ge-preflight-foot-actions">
<span class="ge-preflight-summary">${doneRequired}/${requiredCount} required ready</span>
<button type="button" class="ge-preflight-start" data-preflight-start>Start editing</button>
</div>
</div>
</div>`;
const dontShow = el.querySelector('[data-preflight-dontshow]');
if (dontShow) {
let dismissed = false;
try { dismissed = localStorage.getItem(DISMISS_KEY) === '1'; } catch (_) {}
dontShow.checked = dismissed;
}
}
function open() {
render();
el.hidden = false;
}
function close() { el.hidden = true; }
function maybeOpen() {
let dismissed = false;
try { dismissed = localStorage.getItem(DISMISS_KEY) === '1'; } catch (_) {}
if (!dismissed) open();
}
const runAction = (name) => {
if (name === 'retry') {
window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated'));
} else if (name === 'model') {
window.geEditorAiSettings?.open('ge-ai-command-model');
} else if (name === 'agent') {
window.geEditorAiSettings?.open('ge-ai-command-agent-model');
}
setTimeout(() => { if (!el.hidden) render(); }, 80);
};
el.addEventListener('click', (e) => {
const act = e.target.closest('[data-action]');
if (act) { e.preventDefault(); runAction(act.dataset.action); return; }
if (e.target.closest('[data-preflight-close]') || e.target.closest('[data-preflight-start]')) {
e.preventDefault();
close();
return;
}
// Click on the dimmed backdrop closes the checklist.
if (e.target === el) close();
});
el.addEventListener('change', (e) => {
const manual = e.target.closest('[data-manual]');
if (manual) {
const set = readOverride();
if (manual.checked) set.add(manual.dataset.manual);
else set.delete(manual.dataset.manual);
writeOverride(set);
render();
return;
}
const dontShow = e.target.closest('[data-preflight-dontshow]');
if (dontShow) {
try {
if (dontShow.checked) localStorage.setItem(DISMISS_KEY, '1');
else localStorage.removeItem(DISMISS_KEY);
} catch (_) {}
}
});
// Re-render when model data changes or a model picker changes.
const refresh = () => { if (!el.hidden) render(); };
const onChange = (e) => {
const sel = e.target.closest('#ge-ai-inpaint, #ge-ai-command-model, #ge-ai-command-agent-model');
if (sel) refresh();
};
window.addEventListener('ge:ai-models-status', refresh);
document.addEventListener('change', onChange);
canvasArea.appendChild(el);
function destroy() {
window.removeEventListener('ge:ai-models-status', refresh);
document.removeEventListener('change', onChange);
el.remove();
}
return { element: el, open, close, maybeOpen, refresh, destroy };
}
+497
View File
@@ -0,0 +1,497 @@
/** Minimal Adobe Photoshop (PSD) reader/writer.
*
* Exports a flattened 8-bit RGB composite as a valid `8BPS` document with
* no layer records, and parses the same shape back. Also decodes RLE
* (PackBits) image data, which is what Photoshop normally writes.
*/
const SIGNATURE = 0x38425053; // '8BPS'
const COLOR_MODE_RGB = 3;
const COLOR_MODE_GRAYSCALE = 1;
const COMPRESSION_RAW = 0;
const COMPRESSION_RLE = 1;
function writeUint16(view, offset, value) {
view.setUint16(offset, value & 0xffff, false);
}
function writeUint32(view, offset, value) {
view.setUint32(offset, value >>> 0, false);
}
function writeUint64(view, offset, value) {
view.setBigUint64(offset, BigInt(value), false);
}
/** PackBits (RLE) encode a byte array. */
export function packBits(input) {
const out = [];
const length = input.length;
let i = 0;
while (i < length) {
let runLength = 1;
while (i + runLength < length && runLength < 128 && input[i + runLength] === input[i]) runLength++;
if (runLength >= 2) {
out.push((256 - (runLength - 1)) & 0xff, input[i]);
i += runLength;
continue;
}
let literalStart = i;
let literalLength = 0;
while (i < length && literalLength < 128) {
let nextRun = 1;
while (i + nextRun < length && nextRun < 128 && input[i + nextRun] === input[i]) nextRun++;
if (nextRun >= 2 && literalLength > 0) break;
i += 1;
literalLength += 1;
if (i >= length) break;
}
if (i === literalStart) i += 1;
const count = i - literalStart;
out.push(count - 1, ...input.slice(literalStart, i));
}
return Uint8Array.from(out);
}
/** PackBits (RLE) decode `length` bytes starting at `offset`. */
export function unpackBits(data, offset, length) {
const out = new Uint8Array(length);
let outPos = 0;
let pos = offset;
while (outPos < length && pos < data.length) {
const header = data[pos] < 128 ? data[pos] : data[pos] - 256;
pos += 1;
if (header >= 0) {
const count = header + 1;
for (let i = 0; i < count && outPos < length; i++) out[outPos++] = data[pos + i];
pos += count;
} else if (header !== -128) {
const count = 1 - header;
const value = data[pos++];
for (let i = 0; i < count && outPos < length; i++) out[outPos++] = value;
}
}
return out;
}
/** Serialize a canvas as a flattened 8-bit RGB PSD. */
export function exportPsd(sourceCanvas) {
const width = sourceCanvas.width;
const height = sourceCanvas.height;
const pixels = sourceCanvas.getContext('2d').getImageData(0, 0, width, height).data;
const pixelCount = width * height;
const red = new Uint8Array(pixelCount);
const green = new Uint8Array(pixelCount);
const blue = new Uint8Array(pixelCount);
for (let i = 0; i < pixelCount; i++) {
red[i] = pixels[i * 4];
green[i] = pixels[i * 4 + 1];
blue[i] = pixels[i * 4 + 2];
}
const channels = [red, green, blue];
const rowLengths = [];
const rows = [];
for (const channel of channels) {
let channelLength = 0;
for (let y = 0; y < height; y++) {
const row = packBits(channel.subarray(y * width, (y + 1) * width));
rowLengths.push(row.length);
rows.push(row);
channelLength += row.length;
}
}
const rleTableBytes = rowLengths.length * 2;
const rleBodyBytes = rows.reduce((sum, row) => sum + row.length, 0);
const headerBytes = 26;
const imageDataBytes = 2 + rleTableBytes + rleBodyBytes;
const total = headerBytes + 4 * 3 + imageDataBytes;
const buffer = new ArrayBuffer(total);
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
let offset = 0;
writeUint32(view, 0, SIGNATURE);
writeUint16(view, 4, 1);
offset = 10; // reserved 6 bytes
writeUint16(view, offset, 3); offset += 2;
writeUint32(view, offset, height); offset += 4;
writeUint32(view, offset, width); offset += 4;
writeUint16(view, offset, 8); offset += 2;
writeUint16(view, offset, COLOR_MODE_RGB); offset += 2;
// Color mode data + image resources + layer/mask sections are empty.
writeUint32(view, offset, 0); offset += 4;
writeUint32(view, offset, 0); offset += 4;
writeUint32(view, offset, 0); offset += 4;
writeUint16(view, offset, COMPRESSION_RLE); offset += 2;
for (const length of rowLengths) {
writeUint16(view, offset, length); offset += 2;
}
for (const row of rows) {
bytes.set(row, offset);
offset += row.length;
}
return bytes;
}
function readSection(view, bytes, offset, long = false) {
if (long) {
const length = Number(view.getBigUint64(offset, false));
return offset + 8 + length;
}
const length = view.getUint32(offset, false);
return offset + 4 + length;
}
/** Parse an 8BPS buffer into `{ width, height, canvas }`. */
export function parsePsd(arrayBuffer) {
const bytes = new Uint8Array(arrayBuffer);
const view = new DataView(arrayBuffer);
if (view.getUint32(0, false) !== SIGNATURE) throw new Error('Not a PSD file');
const version = view.getUint16(4, false);
if (version !== 1 && version !== 2) throw new Error('Unsupported PSD version');
const psb = version === 2;
const channels = view.getUint16(12, false);
const height = view.getUint32(14, false);
const width = view.getUint32(18, false);
const depth = view.getUint16(22, false);
const colorMode = view.getUint16(24, false);
if (depth !== 8) throw new Error('Unsupported PSD bit depth');
if (colorMode !== COLOR_MODE_RGB && colorMode !== COLOR_MODE_GRAYSCALE) {
throw new Error('Unsupported PSD color mode');
}
let offset = 26;
offset = readSection(view, bytes, offset); // color mode data (4-byte length)
offset = readSection(view, bytes, offset); // image resources (4-byte length)
offset = readSection(view, bytes, offset, psb); // layer & mask info (8-byte length in PSB)
const compression = view.getUint16(offset, false);
offset += 2;
const pixelCount = width * height;
const planes = [];
if (compression === COMPRESSION_RAW) {
for (let c = 0; c < channels; c++) {
planes.push(bytes.subarray(offset, offset + pixelCount));
offset += pixelCount;
}
} else if (compression === COMPRESSION_RLE) {
const rowCount = channels * height;
const rowLengths = [];
for (let i = 0; i < rowCount; i++) {
rowLengths.push(psb ? view.getUint32(offset, false) : view.getUint16(offset, false));
offset += psb ? 4 : 2;
}
for (let c = 0; c < channels; c++) {
const plane = new Uint8Array(pixelCount);
for (let y = 0; y < height; y++) {
const rowIndex = c * height + y;
const decoded = unpackBits(bytes, offset, rowLengths[rowIndex]);
plane.set(decoded.subarray(0, width), y * width);
offset += rowLengths[rowIndex];
}
planes.push(plane);
}
} else {
throw new Error('Unsupported PSD compression');
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
const image = ctx.createImageData(width, height);
const isGray = colorMode === COLOR_MODE_GRAYSCALE;
for (let i = 0; i < pixelCount; i++) {
const r = planes[0] ? planes[0][i] : 0;
const g = isGray ? r : (planes[1] ? planes[1][i] : 0);
const b = isGray ? r : (planes[2] ? planes[2][i] : 0);
image.data[i * 4] = r;
image.data[i * 4 + 1] = g;
image.data[i * 4 + 2] = b;
image.data[i * 4 + 3] = 255;
}
ctx.putImageData(image, 0, 0);
return { width, height, canvas };
}
// ---------------------------------------------------------------------------
// Layered PSD (8BPS v1, 8-bit RGB) — layer records + raw channel planes.
// Encoder/decoder operate on plain RGBA arrays (no canvas) so they are
// usable and testable outside the browser.
// ---------------------------------------------------------------------------
function rgbaToPlanes(data, width, height) {
const count = width * height;
const r = new Uint8Array(count);
const g = new Uint8Array(count);
const b = new Uint8Array(count);
const a = new Uint8Array(count);
for (let i = 0; i < count; i++) {
r[i] = data[i * 4];
g[i] = data[i * 4 + 1];
b[i] = data[i * 4 + 2];
a[i] = data[i * 4 + 3];
}
return [r, g, b, a];
}
function blendRgbaInto(target, layer, docWidth, docHeight) {
const lw = layer.width;
const lh = layer.height;
const ox = layer.x | 0;
const oy = layer.y | 0;
const alpha = layer.opacity == null ? 1 : layer.opacity;
const data = layer.data;
for (let y = 0; y < lh; y++) {
const ty = oy + y;
if (ty < 0 || ty >= docHeight) continue;
for (let x = 0; x < lw; x++) {
const tx = ox + x;
if (tx < 0 || tx >= docWidth) continue;
const si = (y * lw + x) * 4;
const ti = (ty * docWidth + tx) * 4;
const sa = (data[si + 3] / 255) * alpha;
if (sa <= 0) continue;
const da = target[ti + 3] / 255;
const outA = sa + da * (1 - sa);
for (let c = 0; c < 3; c++) {
const sc = data[si + c];
const dc = target[ti + c];
target[ti + c] = outA ? Math.round((sc * sa + dc * da * (1 - sa)) / outA) : 0;
}
target[ti + 3] = Math.round(outA * 255);
}
}
}
function latin1Bytes(text) {
const out = [];
for (const char of String(text)) out.push(char.charCodeAt(0) & 0xff);
return out;
}
/** Serialize layered RGBA layers + a flattened composite as an 8BPS PSD
* (version 1) or PSB (version 2, "large document format", 8-byte lengths). */
export function encodeLayeredPsd({ width, height, layers = [], psb = false } = {}) {
const longLen = psb ? 8 : 4;
const composite = new Uint8ClampedArray(width * height * 4);
const ordered = layers.slice().reverse(); // PSD stores bottom-first
for (const layer of ordered) {
if (layer.visible === false) continue;
blendRgbaInto(composite, layer, width, height);
}
const records = [];
const channelBlocks = [];
for (const layer of ordered) {
const lw = layer.width;
const lh = layer.height;
const planes = rgbaToPlanes(layer.data, lw, lh);
const channelIds = [0, 1, 2, -1];
const channelLength = 2 + lw * lh;
const name = latin1Bytes(layer.name || 'Layer');
const namePadded = 1 + name.length + ((4 - ((1 + name.length) % 4)) % 4);
const extraLength = 4 + 4 + namePadded;
const record = new Uint8Array(4 * 4 + 2 + channelIds.length * (2 + longLen) + 4 + 4 + 4 + 4 + extraLength);
const rv = new DataView(record.buffer);
let o = 0;
rv.setInt32(o, layer.y | 0, false); o += 4;
rv.setInt32(o, layer.x | 0, false); o += 4;
rv.setInt32(o, (layer.y | 0) + lh, false); o += 4;
rv.setInt32(o, (layer.x | 0) + lw, false); o += 4;
rv.setUint16(o, channelIds.length, false); o += 2;
for (const id of channelIds) {
rv.setInt16(o, id, false); o += 2;
if (psb) { rv.setBigUint64(o, BigInt(channelLength), false); o += 8; }
else { rv.setUint32(o, channelLength, false); o += 4; }
}
record.set([0x38, 0x42, 0x49, 0x4d], o); o += 4; // '8BIM'
record.set(latin1Bytes((layer.blendKey || 'norm').padEnd(4, ' ').slice(0, 4)), o); o += 4;
record[o++] = Math.round((layer.opacity == null ? 1 : layer.opacity) * 255);
record[o++] = 0; // clipping
record[o++] = layer.visible === false ? 2 : 0; // flags (bit1 = hidden)
record[o++] = 0; // filler
rv.setUint32(o, extraLength, false); o += 4;
rv.setUint32(o, 0, false); o += 4; // layer mask data length
rv.setUint32(o, 0, false); o += 4; // layer blending ranges length
record[o++] = name.length;
record.set(name, o); o += name.length;
while (o < record.length) record[o++] = 0; // name padding
records.push(record);
for (const plane of planes) {
const block = new Uint8Array(2 + plane.length);
block[0] = 0;
block[1] = COMPRESSION_RAW;
block.set(plane, 2);
channelBlocks.push(block);
}
}
const recordBytes = records.reduce((sum, record) => sum + record.length, 0);
const channelBytes = channelBlocks.reduce((sum, block) => sum + block.length, 0);
const layerInfoLength = 2 + recordBytes + channelBytes;
const layerInfo = new Uint8Array(longLen + layerInfoLength);
const liv = new DataView(layerInfo.buffer);
if (psb) liv.setBigUint64(0, BigInt(layerInfoLength), false); else liv.setUint32(0, layerInfoLength, false);
liv.setInt16(longLen, ordered.length, false);
let p = longLen + 2;
for (const record of records) { layerInfo.set(record, p); p += record.length; }
for (const block of channelBlocks) { layerInfo.set(block, p); p += block.length; }
const sectionDataLength = layerInfo.length + 4; // layerInfo blob + global mask (0)
const section = new Uint8Array(longLen + sectionDataLength);
const sv = new DataView(section.buffer);
if (psb) sv.setBigUint64(0, BigInt(sectionDataLength), false); else sv.setUint32(0, sectionDataLength, false);
section.set(layerInfo, longLen);
sv.setUint32(longLen + layerInfo.length, 0, false); // global layer mask info length
// Composite image data (RLE), matching exportPsd.
const compositePlanes = rgbaToPlanes(composite, width, height);
const rowLengths = [];
const rows = [];
for (const plane of compositePlanes) {
for (let y = 0; y < height; y++) {
const row = packBits(plane.subarray(y * width, (y + 1) * width));
rowLengths.push(row.length);
rows.push(row);
}
}
const rleTableBytes = rowLengths.length * (psb ? 4 : 2);
const rleBodyBytes = rows.reduce((sum, row) => sum + row.length, 0);
const imageDataBytes = 2 + rleTableBytes + rleBodyBytes;
const headerBytes = 26;
const total = headerBytes + 4 + 4 + section.length + imageDataBytes;
const buffer = new ArrayBuffer(total);
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
writeUint32(view, 0, SIGNATURE);
writeUint16(view, 4, psb ? 2 : 1);
writeUint16(view, 12, 3); // channels
writeUint32(view, 14, height);
writeUint32(view, 18, width);
writeUint16(view, 22, 8);
writeUint16(view, 24, COLOR_MODE_RGB);
writeUint32(view, 26, 0); // color mode data
writeUint32(view, 30, 0); // image resources
let offset = 34;
bytes.set(section, offset); offset += section.length;
writeUint16(view, offset, COMPRESSION_RLE); offset += 2;
for (const length of rowLengths) {
if (psb) { writeUint32(view, offset, length); offset += 4; }
else { writeUint16(view, offset, length); offset += 2; }
}
for (const row of rows) { bytes.set(row, offset); offset += row.length; }
return bytes;
}
/** Decode a layered PSD into `{ width, height, layers }` (RGBA planes, no canvas). */
export function decodeLayeredPsd(arrayBuffer) {
const bytes = new Uint8Array(arrayBuffer);
const view = new DataView(arrayBuffer);
if (view.getUint32(0, false) !== SIGNATURE) throw new Error('Not a PSD file');
const version = view.getUint16(4, false);
if (version !== 1 && version !== 2) throw new Error('Unsupported PSD version');
const psb = version === 2;
const longLen = psb ? 8 : 4;
const height = view.getUint32(14, false);
const width = view.getUint32(18, false);
if (view.getUint16(22, false) !== 8) throw new Error('Unsupported PSD bit depth');
let offset = 26;
offset = readSection(view, bytes, offset); // color mode data (4-byte length)
offset = readSection(view, bytes, offset); // image resources (4-byte length)
const sectionLength = psb ? Number(view.getBigUint64(offset, false)) : view.getUint32(offset, false);
offset += longLen;
if (!sectionLength) return { width, height, layers: [] };
const sectionEnd = offset + sectionLength;
const layerInfoLength = psb ? Number(view.getBigUint64(offset, false)) : view.getUint32(offset, false);
offset += longLen;
const layerInfoEnd = offset + layerInfoLength;
const rawCount = view.getInt16(offset, false);
offset += 2;
const layerCount = Math.abs(rawCount);
const metas = [];
for (let l = 0; l < layerCount; l++) {
const top = view.getInt32(offset, false); offset += 4;
const left = view.getInt32(offset, false); offset += 4;
const bottom = view.getInt32(offset, false); offset += 4;
const right = view.getInt32(offset, false); offset += 4;
const channelCount = view.getUint16(offset, false); offset += 2;
const channels = [];
for (let c = 0; c < channelCount; c++) {
const id = view.getInt16(offset, false); offset += 2;
const length = psb ? Number(view.getBigUint64(offset, false)) : view.getUint32(offset, false);
offset += longLen;
channels.push({ id, length });
}
offset += 4; // '8BIM'
const blendKey = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]); offset += 4;
const opacity = bytes[offset++] / 255;
offset += 1; // clipping
const flags = bytes[offset++];
offset += 1; // filler
const extraLength = view.getUint32(offset, false); offset += 4;
const extraEnd = offset + extraLength;
const maskLength = view.getUint32(offset, false); offset += 4 + maskLength;
const rangesLength = view.getUint32(offset, false); offset += 4 + rangesLength;
const nameLength = bytes[offset++];
let name = '';
for (let i = 0; i < nameLength; i++) name += String.fromCharCode(bytes[offset + i]);
offset = extraEnd;
metas.push({ top, left, bottom, right, channelCount, channels, blendKey, opacity, visible: (flags & 2) === 0, name });
}
// Channel image data follows the records; the metas loop above already
// left `offset` at the start of the channel data.
const layers = [];
for (const meta of metas) {
const lw = meta.right - meta.left;
const lh = meta.bottom - meta.top;
const count = lw * lh;
const planes = { 0: new Uint8Array(count), 1: new Uint8Array(count), 2: new Uint8Array(count), '-1': new Uint8Array(count) };
for (let c = 0; c < meta.channelCount; c++) {
const channel = meta.channels[c];
const compression = view.getUint16(offset, false);
offset += 2;
if (compression === COMPRESSION_RAW) {
const plane = bytes.subarray(offset, offset + count);
if (planes[channel.id]) planes[channel.id].set(plane);
offset += count;
} else if (compression === COMPRESSION_RLE) {
const rowLengths = [];
for (let y = 0; y < lh; y++) {
rowLengths.push(psb ? view.getUint32(offset, false) : view.getUint16(offset, false));
offset += psb ? 4 : 2;
}
const plane = planes[channel.id] || new Uint8Array(count);
for (let y = 0; y < lh; y++) {
const decoded = unpackBits(bytes, offset, rowLengths[y]);
plane.set(decoded.subarray(0, lw), y * lw);
offset += rowLengths[y];
}
planes[channel.id] = plane;
} else {
throw new Error('Unsupported PSD layer compression');
}
}
const data = new Uint8ClampedArray(count * 4);
const alpha = planes['-1'];
for (let i = 0; i < count; i++) {
data[i * 4] = planes[0][i];
data[i * 4 + 1] = planes[1][i];
data[i * 4 + 2] = planes[2][i];
data[i * 4 + 3] = alpha ? alpha[i] : 255;
}
layers.push({
name: meta.name,
x: meta.left,
y: meta.top,
width: lw,
height: lh,
opacity: meta.opacity,
visible: meta.visible,
blendKey: meta.blendKey,
data,
});
}
return { width, height, layers: layers.reverse() };
}
+433
View File
@@ -0,0 +1,433 @@
// Pixel-level retouch helpers for the editor. Pure/DOM-free so they can be
// unit-tested in Node. Each function takes/returns plain typed arrays so the
// caller (galleryEditor.js control ops) owns the canvas plumbing.
//
// Covers: Camera-Raw-style auto tone/colour/white-balance, frequency-separation
// skin smoothing (keeps texture), blemish removal, teeth whitening, and a
// mean-absolute-delta metric used to detect imperceptible edits.
function clamp255(v) {
return v < 0 ? 0 : v > 255 ? 255 : v;
}
// Separable box blur of an RGB(A) buffer, returning a new Uint8ClampedArray.
export function boxBlurRGB(src, width, height, radius) {
const r = Math.max(0, Math.round(radius));
const out = new Uint8ClampedArray(src);
if (!r) return out;
const tmp = new Uint8ClampedArray(src.length);
const win = r * 2 + 1;
// horizontal
for (let y = 0; y < height; y++) {
const row = y * width * 4;
for (let c = 0; c < 4; c++) {
let sum = 0;
for (let k = -r; k <= r; k++) sum += src[row + clamp255(0) * 0 + (Math.min(width - 1, Math.max(0, k)) * 4) + c];
for (let x = 0; x < width; x++) {
tmp[row + x * 4 + c] = sum / win;
const add = clampX(x + r + 1, width) * 4 + c;
const sub = clampX(x - r, width) * 4 + c;
sum += src[row + add] - src[row + sub];
}
}
}
// vertical
for (let x = 0; x < width; x++) {
for (let c = 0; c < 4; c++) {
let sum = 0;
for (let k = -r; k <= r; k++) sum += tmp[clampY(k, height) * width * 4 + x * 4 + c];
for (let y = 0; y < height; y++) {
out[y * width * 4 + x * 4 + c] = sum / win;
const add = clampY(y + r + 1, height) * width * 4 + x * 4 + c;
const sub = clampY(y - r, height) * width * 4 + x * 4 + c;
sum += tmp[add] - tmp[sub];
}
}
}
return out;
}
function clampX(v, w) { return v < 0 ? 0 : v >= w ? w - 1 : v; }
function clampY(v, h) { return v < 0 ? 0 : v >= h ? h - 1 : v; }
function toGray(data, i) { return 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; }
// Camera-Raw-style auto analysis: per-channel clipped black/white points,
// gray-world + white-patch blended white balance gains, and an exposure/contrast
// suggestion. Returns a plain params object.
export function analyzeAutoTone(data, width, height, opts = {}) {
const clipPct = opts.clipPct != null ? opts.clipPct : 0.006;
const hist = [new Uint32Array(256), new Uint32Array(256), new Uint32Array(256)];
const lumHist = new Uint32Array(256);
let sr = 0, sg = 0, sb = 0, total = 0;
let midR = 0, midG = 0, midB = 0, midN = 0;
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] < 8) continue;
const r = data[i], g = data[i + 1], b = data[i + 2];
hist[0][r]++; hist[1][g]++; hist[2][b]++;
const luma = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
lumHist[luma]++;
sr += r; sg += g; sb += b;
// gray-world only on mid-tone, reasonably-coloured pixels (skip near-black /
// near-white / neon) so dark or vivid images do not flip the whole cast.
const mx = Math.max(r, g, b), mn = Math.min(r, g, b);
const sat = mx <= 0 ? 0 : (mx - mn) / mx;
if (luma > 40 && luma < 225 && sat < 0.6) { midR += r; midG += g; midB += b; midN++; }
total++;
}
if (!total) return { levels: { r: [0, 255], g: [0, 255], b: [0, 255] }, gains: { r: 1, g: 1, b: 1 }, brightness: 0, contrast: 1 };
const clip = Math.max(1, Math.floor(total * clipPct));
const pctLo = (h) => { let acc = 0, l = 0; for (; l < 256; l++) { acc += h[l]; if (acc >= clip) break; } return l; };
const pctHi = (h) => { let acc = 0, hh = 255; for (; hh >= 0; hh--) { acc += h[hh]; if (acc >= clip) break; } return hh; };
const lumLo = pctLo(lumHist), lumHi = pctHi(lumHist);
const mr0 = sr / total, mg0 = sg / total, mb0 = sb / total;
const mean = (0.299 * mr0 + 0.587 * mg0 + 0.114 * mb0);
const lowKey = mean < 70; // very dark photos: never stretch (amplifies noise -> false colour)
const lo = [0, 0, 0], hi = [255, 255, 255];
if (!lowKey) {
for (let c = 0; c < 3; c++) {
let cl = pctLo(hist[c]), ch = pctHi(hist[c]);
if (ch - cl < 24) { const mid = (cl + ch) / 2; cl = Math.max(0, Math.round(mid - 32)); ch = Math.min(255, Math.round(mid + 32)); }
// blend each channel's points toward the luminance points and bound the
// divergence, so channels cannot be stretched independently (avoids the
// heavy colour casts / false colour that independent per-channel levels cause).
cl = Math.round(cl * 0.5 + lumLo * 0.5);
ch = Math.round(ch * 0.5 + lumHi * 0.5);
cl = Math.max(lumLo - 24, Math.min(cl, lumLo + 24));
ch = Math.max(lumHi - 24, Math.min(ch, lumHi + 24));
let l = Math.max(0, Math.min(cl, 250));
let h2 = Math.min(255, Math.max(ch, l + 8));
// bound the stretch to ~1.5x so dark/noisy regions are not amplified.
if (h2 - l < 170) { const mid = (l + h2) / 2; l = Math.max(0, Math.round(mid - 85)); h2 = Math.min(255, Math.round(mid + 85)); }
lo[c] = l; hi[c] = h2;
}
}
// gentle gray-world white balance (mid-tones only), blended toward identity.
let gains = { r: 1, g: 1, b: 1 };
if (!lowKey && midN > total * 0.05) {
const mr = midR / midN, mg = midG / midN, mb = midB / midN;
const gray = (mr + mg + mb) / 3 || 1;
const blendW = 0.4;
gains = {
r: clampRange(1 + (gray / (mr || 1) - 1) * blendW, 0.96, 1.04),
g: clampRange(1 + (gray / (mg || 1) - 1) * blendW, 0.96, 1.04),
b: clampRange(1 + (gray / (mb || 1) - 1) * blendW, 0.96, 1.04),
};
}
let brightness = clampRange((128 - mean) / 255, -0.08, 0.08);
let gamma = 1;
const dyn = ((hi[0] - lo[0]) + (hi[1] - lo[1]) + (hi[2] - lo[2])) / (3 * 255);
let contrast = (!lowKey && dyn < 0.9) ? clampRange(1 + (0.92 - dyn) * 0.35, 1.0, 1.15) : 1.0;
if (lowKey) {
// Dark photos: a gamma lift is visible but monotonic (255 stays 255) so it
// barely clips highlights, unlike a levels stretch which amplifies noise.
const target = Math.min(100, Math.max(52, mean * 1.7 + 16));
gamma = clampRange(Math.log(target / 255) / Math.log(Math.max(6, mean) / 255), 0.7, 0.95);
brightness = 0;
contrast = 1.06;
}
return { levels: { r: [lo[0], hi[0]], g: [lo[1], hi[1]], b: [lo[2], hi[2]] }, gains, brightness, contrast, gamma };
}
function clampRange(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
export function applyAutoTone(data, width, height, params) {
const out = new Uint8ClampedArray(data.length);
const L = params.levels || { r: [0, 255], g: [0, 255], b: [0, 255] };
const G = params.gains || { r: 1, g: 1, b: 1 };
const br = (params.brightness || 0) * 255;
const ct = params.contrast || 1;
const gm = params.gamma || 1;
const lut = [0, 1, 2].map((c) => {
const key = c === 0 ? 'r' : c === 1 ? 'g' : 'b';
const [lo, hi] = L[key], gain = G[key];
const span = Math.max(1, hi - lo);
const t = new Uint8ClampedArray(256);
for (let v = 0; v < 256; v++) {
let x = ((v - lo) / span) * 255 * gain;
if (gm !== 1) x = 255 * Math.pow(Math.max(0, Math.min(1, x / 255)), gm);
x = (x - 128) * ct + 128 + br;
t[v] = clamp255(Math.round(x));
}
return t;
});
for (let i = 0; i < data.length; i += 4) {
out[i] = lut[0][data[i]];
out[i + 1] = lut[1][data[i + 1]];
out[i + 2] = lut[2][data[i + 2]];
out[i + 3] = data[i + 3];
}
return out;
}
// YCrCb skin detection, optionally intersected with a region mask (alpha bytes).
export function skinMaskFromData(data, width, height, regionMask = null) {
const mask = new Uint8ClampedArray(width * height * 4);
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
if (a < 8) continue;
if (regionMask && regionMask[i + 3] < 64) continue;
const r = data[i], g = data[i + 1], b = data[i + 2];
let y = 0.299 * r + 0.587 * g + 0.114 * b;
const cr = (r - y) * 0.713 + 128;
const cb = (b - y) * 0.564 + 128;
const skin = cr >= 133 && cr <= 176 && cb >= 77 && cb <= 130 && y > 40;
if (skin) { mask[i] = 255; mask[i + 1] = 255; mask[i + 2] = 255; mask[i + 3] = 255; }
}
return mask;
}
// Frequency separation: blur the LOW frequencies further and add back the HIGH
// frequencies (pores/texture), while explicitly preserving the mean luminance
// and colour of the masked area so skin is never brightened or washed out.
// Skin smoothing that keeps texture: blend the original with a lightly blurred
// copy inside the mask. The blur is locally mean-preserving and clamped so a
// pixel can only ever be DARKENED (never brightened), which prevents the
// ghost-white / washed-out artifacts a reconstruction-based split produced.
export function frequencySeparationSmooth(src, width, height, mask, opts = {}) {
const r = Math.max(2, Math.min(24, Math.round(opts.smoothRadius || 8)));
const opacity = opts.opacity != null ? opts.opacity : 0.35; // gentle default (Photoshop Skin Smoothing is subtle)
const maxDown = opts.maxDown != null ? opts.maxDown : 18;
const maxUp = opts.maxUp != null ? opts.maxUp : 0;
const blur = boxBlurRGB(src, width, height, r);
const out = new Uint8ClampedArray(src);
for (let i = 0; i < src.length; i += 4) {
const m = mask ? mask[i + 3] / 255 : 1;
if (m <= 0) continue;
const w = m * opacity;
for (let c = 0; c < 3; c++) {
let d = (blur[i + c] - src[i + c]) * w;
if (d > maxUp) d = maxUp; // tiny lift to soften dark pores, never a whitening blow
else if (d < -maxDown) d = -maxDown; // may soften bright blotches a little
out[i + c] = clamp255(src[i + c] + d);
}
}
return out;
}
// Build an exclusion mask for facial features that must never be smoothed:
// dark features (eyes/brows/lashes/nostrils) and red lips, relative to the
// region's mean luminance. Callers dilate this and subtract it from the skin mask.
export function featureExclusionMask(data, width, height, regionMask = null) {
const mask = new Uint8ClampedArray(width * height * 4);
let sum = 0, n = 0;
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] < 8) continue;
if (regionMask && regionMask[i + 3] < 64) continue;
sum += 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
n++;
}
const meanY = n ? sum / n : 128;
const darkT = Math.max(30, meanY * 0.66);
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] < 8) continue;
if (regionMask && regionMask[i + 3] < 64) continue;
const r = data[i], g = data[i + 1], b = data[i + 2];
const y = 0.299 * r + 0.587 * g + 0.114 * b;
const dark = y < darkT;
const red = (r - g) > 26 && (r - b) > 22 && r > 90;
if (dark || red) { mask[i] = 255; mask[i + 1] = 255; mask[i + 2] = 255; mask[i + 3] = 255; }
}
return mask;
}
// Separable dilation of a mask's alpha channel (grows the feature exclusion).
export function dilateAlpha(mask, width, height, radius) {
const r = Math.max(0, Math.round(radius));
const out = new Uint8ClampedArray(mask);
if (!r) return out;
const a = new Uint8ClampedArray(width * height);
for (let p = 0; p < width * height; p++) a[p] = mask[p * 4 + 3];
const tmp = new Uint8ClampedArray(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let m = 0;
for (let k = -r; k <= r; k++) { const xx = x + k; if (xx >= 0 && xx < width) { const v = a[y * width + xx]; if (v > m) m = v; } }
tmp[y * width + x] = m;
}
}
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
let m = 0;
for (let k = -r; k <= r; k++) { const yy = y + k; if (yy >= 0 && yy < height) { const v = tmp[yy * width + x]; if (v > m) m = v; } }
const p = y * width + x;
out[p * 4] = 255; out[p * 4 + 1] = 255; out[p * 4 + 2] = 255; out[p * 4 + 3] = m;
}
}
return out;
}
// Subtract one mask's alpha (exclude) from another (base).
export function subtractMask(base, exclude) {
const out = new Uint8ClampedArray(base);
if (!exclude) return out;
for (let i = 0; i < base.length; i += 4) {
const ex = (exclude[i + 3] || 0) / 255;
if (ex > 0) out[i + 3] = clamp255(base[i + 3] * (1 - ex));
}
return out;
}
// Replace small high-contrast spots (blemishes) with the local neighbourhood
// value, masked.
export function blemishRemoval(src, width, height, mask, opts = {}) {
const radius = Math.max(2, Math.round(opts.radius || 4));
const threshold = opts.threshold != null ? opts.threshold : 16;
const smooth = boxBlurRGB(src, width, height, radius);
const out = new Uint8ClampedArray(src);
for (let i = 0; i < src.length; i += 4) {
if (data_alpha(src, i) < 8) continue;
const m = mask ? mask[i + 3] : 255;
if (m < 64) continue;
const dev = (Math.abs(src[i] - smooth[i]) + Math.abs(src[i + 1] - smooth[i + 1]) + Math.abs(src[i + 2] - smooth[i + 2])) / 3;
if (dev >= threshold) {
out[i] = smooth[i]; out[i + 1] = smooth[i + 1]; out[i + 2] = smooth[i + 2];
}
}
return out;
}
function data_alpha(d, i) { return d[i + 3]; }
// Reduce yellow cast + lift brightness inside a teeth mask.
export function teethWhitening(src, width, height, mask, opts = {}) {
const desat = opts.desat != null ? opts.desat : 0.7;
const bright = opts.bright != null ? opts.bright : 22;
// Yellow-only whitening: only pixels that actually look yellowed (teeth) are
// desaturated and brightened. Neutral brightening is deliberately NOT applied,
// because without a precise teeth mask it whitens cheeks/nose as a block.
const out = new Uint8ClampedArray(src);
for (let i = 0; i < src.length; i += 4) {
const m = mask ? mask[i + 3] / 255 : 1;
if (m <= 0) continue;
const r = src[i], g = src[i + 1], b = src[i + 2];
const y = 0.299 * r + 0.587 * g + 0.114 * b;
const yellow = Math.max(0, Math.min(1, (Math.min(r, g) - b) / 70));
const w = m * yellow;
if (w <= 0) continue;
for (let c = 0; c < 3; c++) {
const desatV = src[i + c] * (1 - desat * w) + y * (desat * w);
out[i + c] = clamp255(desatV + bright * w);
}
}
return out;
}
// Separable box blur of a single-channel Float32 buffer (edge-clamped).
function boxBlurFloat(src, width, height, radius) {
const r = Math.max(0, Math.round(radius));
const out = new Float32Array(src.length);
if (!r) { out.set(src); return out; }
const tmp = new Float32Array(src.length);
const win = r * 2 + 1;
for (let y = 0; y < height; y++) {
const row = y * width;
let sum = 0;
for (let k = -r; k <= r; k++) sum += src[row + Math.min(width - 1, Math.max(0, k))];
for (let x = 0; x < width; x++) {
tmp[row + x] = sum / win;
sum += src[row + clampX(x + r + 1, width)] - src[row + clampX(x - r, width)];
}
}
for (let x = 0; x < width; x++) {
let sum = 0;
for (let k = -r; k <= r; k++) sum += tmp[clampY(k, height) * width + x];
for (let y = 0; y < height; y++) {
out[y * width + x] = sum / win;
sum += tmp[clampY(y + r + 1, height) * width + x] - tmp[clampY(y - r, height) * width + x];
}
}
return out;
}
// Edge-aware local contrast ("clarity"). A guided filter (self-guided, per
// channel) produces the base layer: at high-contrast edges the guided filter
// follows the edge (variance >> eps => a -> 1 => q -> I), so the detail layer
// is ~0 there. On top of that a gradient-based edge mask suppresses any
// remaining detail right at strong edges (the guided base can still overshoot a
// little at a step transition), so there is NO overshoot/halo (unlike a
// large-radius unsharp). The detail delta is clamped as a final safety net.
// Alpha is preserved.
export function guidedClarity(src, width, height, opts = {}) {
const out = new Uint8ClampedArray(src);
const amount = Number.isFinite(Number(opts.amount)) ? Number(opts.amount) : 20;
const strength = Math.max(-1, Math.min(2, amount / 100));
const radius = Math.max(1, Math.round(Number(opts.radius) || 30));
const clamp = Math.max(0, Number.isFinite(Number(opts.clamp)) ? Number(opts.clamp) : 10);
const eps = Number.isFinite(Number(opts.eps)) ? Number(opts.eps) : 625;
const n = width * height;
if (strength === 0 || n <= 0) return out;
const guidedBase = (get) => {
const I = new Float32Array(n);
const II = new Float32Array(n);
for (let p = 0; p < n; p++) { const v = get(p); I[p] = v; II[p] = v * v; }
const meanI = boxBlurFloat(I, width, height, radius);
const meanII = boxBlurFloat(II, width, height, radius);
const a = new Float32Array(n);
const b = new Float32Array(n);
for (let p = 0; p < n; p++) {
const varI = Math.max(0, meanII[p] - meanI[p] * meanI[p]);
const ai = varI / (varI + eps);
a[p] = ai;
b[p] = meanI[p] * (1 - ai);
}
const meanA = boxBlurFloat(a, width, height, radius);
const meanB = boxBlurFloat(b, width, height, radius);
const res = new Float32Array(n);
for (let p = 0; p < n; p++) res[p] = meanA[p] * I[p] + meanB[p];
return res;
};
// Edge weight derived from the guided filter's own variance term: where the
// local luminance variance is high (a strong edge) the guided base is close
// to the source and we suppress the detail entirely, so there is no overshoot
// / halo at edges. The variance map is blurred over the same radius so the
// suppression covers the edge flank.
const gray = new Float32Array(n);
const grayII = new Float32Array(n);
for (let p = 0, i = 0; p < n; p++, i += 4) { const v = src[i] * 0.299 + src[i + 1] * 0.587 + src[i + 2] * 0.114; gray[p] = v; grayII[p] = v * v; }
const meanG = boxBlurFloat(gray, width, height, radius);
const meanGII = boxBlurFloat(grayII, width, height, radius);
const edgeVar = Number.isFinite(Number(opts.edgeVariance)) ? Number(opts.edgeVariance) : 220;
const weight = new Float32Array(n);
for (let p = 0; p < n; p++) {
const varG = Math.max(0, meanGII[p] - meanG[p] * meanG[p]);
weight[p] = edgeVar / (varG + edgeVar);
}
const qR = guidedBase(p => src[p * 4]);
const qG = guidedBase(p => src[p * 4 + 1]);
const qB = guidedBase(p => src[p * 4 + 2]);
for (let p = 0, i = 0; p < n; p++, i += 4) {
const w = weight[p];
let dr = (src[i] - qR[p]) * strength * w;
let dg = (src[i + 1] - qG[p]) * strength * w;
let db = (src[i + 2] - qB[p]) * strength * w;
dr = dr > clamp ? clamp : dr < -clamp ? -clamp : dr;
dg = dg > clamp ? clamp : dg < -clamp ? -clamp : dg;
db = db > clamp ? clamp : db < -clamp ? -clamp : db;
out[i] = src[i] + dr;
out[i + 1] = src[i + 1] + dg;
out[i + 2] = src[i + 2] + db;
out[i + 3] = src[i + 3];
}
return out;
}
// Mean absolute RGB delta in 0..1, used to flag imperceptible edits.
export function meanAbsDelta(a, b, width, height) {
const len = Math.min(a.length, b.length);
let sum = 0, n = 0;
for (let i = 0; i < len; i += 4) {
sum += Math.abs(a[i] - b[i]) + Math.abs(a[i + 1] - b[i + 1]) + Math.abs(a[i + 2] - b[i + 2]);
n += 3;
}
return n ? sum / (n * 255) : 0;
}
export default {
boxBlurRGB, analyzeAutoTone, applyAutoTone, skinMaskFromData,
frequencySeparationSmooth, blemishRemoval, teethWhitening, meanAbsDelta,
guidedClarity,
};
+41
View File
@@ -0,0 +1,41 @@
// Edge-fade affordance for horizontally scrolling bars (audit #5).
//
// On phones the editor's menu row (.ge-topbar-left) and tool rail (.ge-toolbar)
// scroll sideways with their scrollbars hidden, so they look static and people
// miss the off-screen File…Help menus and tools. This mirrors the doc-tab
// behaviour (static/js/document.js updateArrowVisibility): toggle is-at-left /
// is-at-right so the CSS mask gradient only fades the side that still has
// content to scroll to.
export function attachScrollAffordance(el) {
if (!el) return () => {};
if (el.__geScrollAffordance) return el.__geScrollAffordance;
el.classList.add('ge-scroll-fade');
const update = () => {
const max = el.scrollWidth - el.clientWidth;
const atLeft = el.scrollLeft <= 1;
const atRight = el.scrollLeft >= max - 1;
el.classList.toggle('is-at-left', atLeft);
el.classList.toggle('is-at-right', atRight);
};
el.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
let ro = null;
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(update);
ro.observe(el);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(update);
else update();
const detach = () => {
el.removeEventListener('scroll', update);
window.removeEventListener('resize', update);
if (ro) ro.disconnect();
el.classList.remove('ge-scroll-fade', 'is-at-left', 'is-at-right');
delete el.__geScrollAffordance;
};
el.__geScrollAffordance = detach;
return detach;
}
+196
View File
@@ -0,0 +1,196 @@
// Select and Mask workspace.
//
// A full-area overlay that lets the user refine the current selection with
// the same feather/expand/smooth/border controls as Refine Selection, plus a
// quick brush to add to or subtract from the selection directly, and an
// output-to choice (selection, layer mask, new layer, new layer with mask).
//
// The module owns only the overlay + a working document-space mask canvas; it
// never mutates editor state itself. The caller supplies `refine` (the shared
// refineSelectionMask) and receives the finished mask through `onCommit`.
import { refineSelectionMask } from './selection-mask.js';
const REFINE_KEYS = [
{ key: 'feather', label: 'Feather', min: 0, max: 100, value: 0, suffix: 'px' },
{ key: 'expand', label: 'Expand / Contract', min: -100, max: 100, value: 0, suffix: 'px' },
{ key: 'smooth', label: 'Smooth', min: 0, max: 50, value: 0, suffix: 'px' },
{ key: 'border', label: 'Border', min: 0, max: 100, value: 0, suffix: 'px' },
];
export function openSelectAndMask({
mask = null,
width = 0,
height = 0,
refine = refineSelectionMask,
onCommit,
onCancel,
} = {}) {
const docW = width || mask?.width || 512;
const docH = height || mask?.height || 512;
const work = document.createElement('canvas');
work.width = docW;
work.height = docH;
const workCtx = work.getContext('2d');
if (mask) workCtx.drawImage(mask, 0, 0, docW, docH);
const params = { feather: 0, expand: 0, smooth: 0, border: 0 };
let brushSize = Math.max(8, Math.round(Math.min(docW, docH) / 8));
let brushMode = 'add';
let painting = false;
const overlay = document.createElement('div');
overlay.className = 'ge-sam-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Select and Mask');
const refineRows = REFINE_KEYS.map((k) => `
<div class="ge-sam-row" data-sam-key="${k.key}">
<label>${k.label} <span class="ge-sam-value" data-sam-value="${k.key}">${k.value}${k.suffix}</span></label>
<input type="range" data-sam-range="${k.key}" min="${k.min}" max="${k.max}" value="${k.value}" step="1" />
</div>`).join('');
const outputOptions = [
['selection', 'Selection'],
['mask', 'Layer Mask'],
['newLayer', 'New Layer'],
['newLayerWithMask', 'New Layer with Mask'],
].map(([v, l]) => `<option value="${v}">${l}</option>`).join('');
overlay.innerHTML = `
<div class="ge-sam-panel">
<div class="ge-sam-head">
<span class="ge-sam-title">Select and Mask</span>
<label class="ge-sam-output-label">Output to
<select class="ge-sam-output">${outputOptions}</select>
</label>
</div>
<div class="ge-sam-body">
<canvas class="ge-sam-preview"></canvas>
<div class="ge-sam-controls">
${refineRows}
<div class="ge-sam-row ge-sam-brush-row">
<label>Refine brush</label>
<div class="ge-sam-brush-modes">
<button type="button" class="ge-btn ge-btn-sm ge-sam-brush-add active" data-sam-brush="add">Add</button>
<button type="button" class="ge-btn ge-btn-sm ge-sam-brush-subtract" data-sam-brush="subtract">Subtract</button>
</div>
</div>
<div class="ge-sam-row" data-sam-key="brushSize">
<label>Brush size <span class="ge-sam-value" data-sam-value="brushSize">${brushSize}px</span></label>
<input type="range" data-sam-brush-size min="4" max="400" value="${brushSize}" step="1" />
</div>
</div>
</div>
<div class="ge-sam-actions">
<button type="button" class="ge-btn ge-btn-sm ge-sam-cancel">Cancel</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-primary ge-sam-apply">Apply</button>
</div>
</div>`;
document.body.appendChild(overlay);
const preview = overlay.querySelector('.ge-sam-preview');
const previewCtx = preview.getContext('2d');
const fit = Math.min(1, 320 / docW, 260 / docH);
preview.width = Math.max(1, Math.round(docW * fit));
preview.height = Math.max(1, Math.round(docH * fit));
const refined = () => (typeof refine === 'function' ? refine(work, { ...params }) : work);
const render = () => {
const out = refined();
previewCtx.clearRect(0, 0, preview.width, preview.height);
previewCtx.fillStyle = '#11151b';
previewCtx.fillRect(0, 0, preview.width, preview.height);
previewCtx.drawImage(out, 0, 0, preview.width, preview.height);
};
const setValueLabel = (key, value, suffix = '') => {
const el = overlay.querySelector(`[data-sam-value="${key}"]`);
if (el) el.textContent = `${value}${suffix}`;
};
for (const k of REFINE_KEYS) {
const input = overlay.querySelector(`[data-sam-range="${k.key}"]`);
input?.addEventListener('input', () => {
params[k.key] = parseInt(input.value, 10);
setValueLabel(k.key, params[k.key], k.suffix);
render();
});
}
const brushSizeInput = overlay.querySelector('[data-sam-brush-size]');
brushSizeInput?.addEventListener('input', () => {
brushSize = parseInt(brushSizeInput.value, 10);
setValueLabel('brushSize', brushSize, 'px');
});
overlay.querySelectorAll('[data-sam-brush]').forEach((btn) => {
btn.addEventListener('click', () => {
brushMode = btn.dataset.samBrush;
overlay.querySelectorAll('[data-sam-brush]').forEach((b) => b.classList.toggle('active', b === btn));
});
});
const paintAt = (event) => {
const rect = preview.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width * docW;
const y = (event.clientY - rect.top) / rect.height * docH;
const radius = brushSize / 2;
workCtx.save();
workCtx.globalCompositeOperation = brushMode === 'subtract' ? 'destination-out' : 'source-over';
workCtx.fillStyle = '#ffffff';
workCtx.beginPath();
workCtx.arc(x, y, radius, 0, Math.PI * 2);
workCtx.fill();
workCtx.restore();
render();
};
preview.addEventListener('pointerdown', (e) => {
painting = true;
preview.setPointerCapture?.(e.pointerId);
paintAt(e);
});
preview.addEventListener('pointermove', (e) => {
if (painting) paintAt(e);
});
const endPaint = () => { painting = false; };
preview.addEventListener('pointerup', endPaint);
preview.addEventListener('pointercancel', endPaint);
preview.addEventListener('pointerleave', endPaint);
const close = () => {
document.removeEventListener('keydown', onKeydown);
overlay.remove();
};
const outputSel = overlay.querySelector('.ge-sam-output');
overlay.querySelector('.ge-sam-cancel')?.addEventListener('click', () => {
onCancel?.();
close();
});
overlay.querySelector('.ge-sam-apply')?.addEventListener('click', () => {
const finalMask = refined();
onCommit?.({ mask: finalMask, output: outputSel?.value || 'selection' });
close();
});
function onKeydown(event) {
if (event.key === 'Escape') {
event.preventDefault();
onCancel?.();
close();
} else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
overlay.querySelector('.ge-sam-apply')?.click();
}
}
document.addEventListener('keydown', onKeydown);
render();
return { close, getMask: () => refined() };
}
+174
View File
@@ -0,0 +1,174 @@
// Select Subject — one-click subject selection.
//
// This is an offline, deterministic heuristic (no network, no AI): it
// estimates the background colour from the image border, scores every cell
// by how far it is from that background (plus a small centre bias), splits
// the image with Otsu's threshold, keeps the largest connected component,
// and upsamples the result into a soft selection mask. It is a saliency
// approximation of Photoshop's on-device Select Subject, not the ML model.
function otsuThreshold(hist, total) {
let sum = 0;
for (let i = 0; i < 256; i++) sum += i * hist[i];
let sumB = 0;
let wB = 0;
let best = 0;
let threshold = 0;
for (let i = 0; i < 256; i++) {
wB += hist[i];
if (!wB) continue;
const wF = total - wB;
if (!wF) break;
sumB += i * hist[i];
const mB = sumB / wB;
const mF = (sum - sumB) / wF;
const between = wB * wF * (mB - mF) * (mB - mF);
if (between > best) {
best = between;
threshold = i;
}
}
return threshold;
}
const MAX_CHANNEL_DIST = 441.6729559300637; // sqrt(3 * 255^2)
export function computeSubjectMask(data, width, height, options = {}) {
if (!data || width < 2 || height < 2) return null;
const maxDim = Math.max(32, Math.min(512, Number(options.maxDim) || 200));
const scale = Math.min(1, maxDim / Math.max(width, height));
const sw = Math.max(2, Math.round(width * scale));
const sh = Math.max(2, Math.round(height * scale));
const cellW = width / sw;
const cellH = height / sh;
// Average source pixels into a small grid.
const cells = new Float32Array(sw * sh * 3);
for (let sy = 0; sy < sh; sy++) {
const y0 = Math.floor(sy * cellH);
const y1 = Math.max(y0 + 1, Math.min(height, Math.floor((sy + 1) * cellH)));
for (let sx = 0; sx < sw; sx++) {
const x0 = Math.floor(sx * cellW);
const x1 = Math.max(x0 + 1, Math.min(width, Math.floor((sx + 1) * cellW)));
let r = 0;
let g = 0;
let b = 0;
let n = 0;
for (let y = y0; y < y1; y++) {
let idx = (y * width + x0) * 4;
for (let x = x0; x < x1; x++, idx += 4) {
r += data[idx];
g += data[idx + 1];
b += data[idx + 2];
n++;
}
}
const o = (sy * sw + sx) * 3;
if (n) {
cells[o] = r / n;
cells[o + 1] = g / n;
cells[o + 2] = b / n;
}
}
}
// Background colour = average of the border cells.
let br = 0;
let bg = 0;
let bb = 0;
let bn = 0;
const addCell = (sx, sy) => {
const o = (sy * sw + sx) * 3;
br += cells[o];
bg += cells[o + 1];
bb += cells[o + 2];
bn++;
};
for (let sx = 0; sx < sw; sx++) {
addCell(sx, 0);
addCell(sx, sh - 1);
}
for (let sy = 1; sy < sh - 1; sy++) {
addCell(0, sy);
addCell(sw - 1, sy);
}
br /= bn;
bg /= bn;
bb /= bn;
// Saliency = distance from the background (+ a small centre bias).
const sal = new Float32Array(sw * sh);
let maxSal = 0;
for (let sy = 0; sy < sh; sy++) {
for (let sx = 0; sx < sw; sx++) {
const o = (sy * sw + sx) * 3;
const dr = cells[o] - br;
const dg = cells[o + 1] - bg;
const db = cells[o + 2] - bb;
const dist = Math.sqrt(dr * dr + dg * dg + db * db) / MAX_CHANNEL_DIST;
const nx = ((sx + 0.5) / sw) * 2 - 1;
const ny = ((sy + 0.5) / sh) * 2 - 1;
const center = Math.max(0, 1 - (nx * nx + ny * ny));
const v = dist + center * 0.12;
sal[sy * sw + sx] = v;
if (v > maxSal) maxSal = v;
}
}
// No clear foreground -> nothing to select.
if (maxSal < 0.12) return null;
const hist = new Int32Array(256);
for (let i = 0; i < sal.length; i++) {
hist[Math.min(255, Math.round((sal[i] / maxSal) * 255))]++;
}
const threshold = (otsuThreshold(hist, sal.length) / 255) * maxSal;
const bin = new Uint8Array(sw * sh);
for (let i = 0; i < sal.length; i++) bin[i] = sal[i] >= threshold ? 1 : 0;
// Keep the largest connected component (4-neighbour flood fill).
const seen = new Uint8Array(sw * sh);
let best = null;
for (let start = 0; start < bin.length; start++) {
if (!bin[start] || seen[start]) continue;
const group = [];
const stack = [start];
seen[start] = 1;
while (stack.length) {
const cell = stack.pop();
group.push(cell);
const cx = cell % sw;
const cy = (cell - cx) / sw;
if (cx > 0) { const nn = cell - 1; if (bin[nn] && !seen[nn]) { seen[nn] = 1; stack.push(nn); } }
if (cx < sw - 1) { const nn = cell + 1; if (bin[nn] && !seen[nn]) { seen[nn] = 1; stack.push(nn); } }
if (cy > 0) { const nn = cell - sw; if (bin[nn] && !seen[nn]) { seen[nn] = 1; stack.push(nn); } }
if (cy < sh - 1) { const nn = cell + sw; if (bin[nn] && !seen[nn]) { seen[nn] = 1; stack.push(nn); } }
}
if (!best || group.length > best.length) best = group;
}
if (!best || best.length < 0.005 * sw * sh) return null;
const small = document.createElement('canvas');
small.width = sw;
small.height = sh;
const sctx = small.getContext('2d');
const out = sctx.createImageData(sw, sh);
for (const cell of best) {
const o = cell * 4;
out.data[o] = 255;
out.data[o + 1] = 255;
out.data[o + 2] = 255;
out.data[o + 3] = 255;
}
sctx.putImageData(out, 0, 0);
const mask = document.createElement('canvas');
mask.width = width;
mask.height = height;
const mctx = mask.getContext('2d');
mctx.imageSmoothingEnabled = true;
mctx.imageSmoothingQuality = 'high';
mctx.drawImage(small, 0, 0, width, height);
return mask;
}
+14 -6
View File
@@ -38,9 +38,14 @@ export function normalizeConstrainedSelectionRect(start, end, width, height, opt
if (mode === 'size') {
const w = Math.max(1, Math.min(width, Math.round(Number(options.fixedWidth) || 1)));
const h = Math.max(1, Math.min(height, Math.round(Number(options.fixedHeight) || 1)));
// Snap the anchor to whole document pixels so a fixed-size marquee
// never straddles a pixel column/row (which antialiases into a
// 1px-taller selection on the trailing edge).
const ax = Math.round(sx);
const ay = Math.round(sy);
return {
x: Math.max(0, Math.min(width - w, sx)),
y: Math.max(0, Math.min(height - h, sy)),
x: Math.max(0, Math.min(width - w, ax)),
y: Math.max(0, Math.min(height - h, ay)),
w,
h,
};
@@ -67,11 +72,14 @@ export function normalizeConstrainedSelectionRect(start, end, width, height, opt
const scale = Math.min(1, availableW / Math.max(w, 0.0001), availableH / Math.max(h, 0.0001));
w *= scale;
h *= scale;
// Snap the anchor origin to whole document pixels, but keep w/h derived
// exactly from the ratio so the aspect stays exact (rounding w and h
// independently would drift the ratio).
return {
x: signX < 0 ? sx - w : sx,
y: signY < 0 ? sy - h : sy,
w,
h,
x: Math.round(signX < 0 ? sx - w : sx),
y: Math.round(signY < 0 ? sy - h : sy),
w: Math.max(1, w),
h: Math.max(1, h),
};
}
+49
View File
@@ -0,0 +1,49 @@
/** Shape boolean operations via canvas compositing. */
export const SHAPE_BOOLEAN_OPS = Object.freeze(['unite', 'subtract', 'intersect', 'exclude']);
const OP_COMPOSITE = Object.freeze({
unite: 'source-over',
subtract: 'destination-out',
intersect: 'destination-in',
exclude: 'xor',
});
/**
* Combine layer canvases into a single document-sized canvas.
* @param {Array<{canvas: HTMLCanvasElement}>} layers bottom-to-top order
* @param {string} op one of SHAPE_BOOLEAN_OPS
* @param {{width:number, height:number, offsets?: Array<{x:number,y:number}>}} options
* @returns {HTMLCanvasElement|null}
*/
export function combineLayers(layers, op, { width = 0, height = 0, offsets = [] } = {}) {
const list = Array.isArray(layers) ? layers.filter(layer => layer && layer.canvas) : [];
if (!list.length) return null;
const mode = SHAPE_BOOLEAN_OPS.includes(op) ? op : 'unite';
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width) || list[0].canvas.width);
canvas.height = Math.max(1, Math.round(height) || list[0].canvas.height);
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
const base = list[0];
const baseOffset = offsets[0] || { x: 0, y: 0 };
if (mode === 'unite') {
ctx.globalCompositeOperation = 'source-over';
list.forEach((layer, index) => {
const offset = offsets[index] || { x: 0, y: 0 };
ctx.drawImage(layer.canvas, offset.x, offset.y);
});
} else {
ctx.globalCompositeOperation = 'source-over';
ctx.drawImage(base.canvas, baseOffset.x, baseOffset.y);
ctx.globalCompositeOperation = OP_COMPOSITE[mode];
for (let index = 1; index < list.length; index += 1) {
const layer = list[index];
const offset = offsets[index] || { x: 0, y: 0 };
ctx.drawImage(layer.canvas, offset.x, offset.y);
}
}
ctx.globalCompositeOperation = 'source-over';
return canvas;
}
+14 -1
View File
@@ -20,6 +20,8 @@ export const DEFAULT_SHAPE_DATA = Object.freeze({
strokeWidth: 2,
cornerRadius: 0,
sides: 5,
pathPoints: Object.freeze([]),
closed: true,
transform: Object.freeze({ scaleX: 1, scaleY: 1, rotation: 0, flipH: false, flipV: false }),
});
@@ -36,7 +38,7 @@ export function normalizeShapeData(value = {}) {
const gradientMidPosition = finite(value.gradientMidPosition, DEFAULT_SHAPE_DATA.gradientMidPosition, 1, 99);
const gradientEnd = String(value.gradientEnd || DEFAULT_SHAPE_DATA.gradientEnd);
return {
type: ['rectangle', 'ellipse', 'line', 'polygon'].includes(value.type) ? value.type : 'rectangle',
type: ['rectangle', 'ellipse', 'line', 'polygon', 'path'].includes(value.type) ? value.type : 'rectangle',
width: finite(value.width, DEFAULT_SHAPE_DATA.width, 1, 10000),
height: finite(value.height, DEFAULT_SHAPE_DATA.height, 1, 10000),
fillColor: String(value.fillColor || DEFAULT_SHAPE_DATA.fillColor),
@@ -58,6 +60,10 @@ export function normalizeShapeData(value = {}) {
strokeWidth: finite(value.strokeWidth, DEFAULT_SHAPE_DATA.strokeWidth, 0, 500),
cornerRadius: finite(value.cornerRadius, DEFAULT_SHAPE_DATA.cornerRadius, 0, 5000),
sides: Math.round(finite(value.sides, DEFAULT_SHAPE_DATA.sides, 3, 24)),
pathPoints: Array.isArray(value.pathPoints)
? value.pathPoints.map(point => ({ x: finite(point?.x, 0, -100000, 100000), y: finite(point?.y, 0, -100000, 100000) }))
: [],
closed: value.closed !== false,
transform: {
scaleX: finite(transform.scaleX, 1, 0.01, 100),
scaleY: finite(transform.scaleY, 1, 0.01, 100),
@@ -89,6 +95,13 @@ function shapePath(ctx, shape, padding) {
if (!index) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.closePath();
} else if (shape.type === 'path' && shape.pathPoints.length) {
shape.pathPoints.forEach((point, index) => {
const px = x + point.x;
const py = y + point.y;
if (!index) ctx.moveTo(px, py); else ctx.lineTo(px, py);
});
if (shape.closed !== false) ctx.closePath();
} else {
const radius = Math.min(shape.cornerRadius, width / 2, height / 2);
if (ctx.roundRect) ctx.roundRect(x, y, width, height, radius);
+2 -1
View File
@@ -33,7 +33,8 @@ export function createShortcutsPopover() {
'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)',
'padding:12px 14px', 'min-width:min(540px,92vw)', 'max-width:min(720px,92vw)',
'max-height:86vh', 'overflow-y:auto',
'font-size:12px', 'line-height:1.5',
].join(';');
el.innerHTML = shortcutsPopupHTML();
+43
View File
@@ -50,6 +50,10 @@ export const state = {
transformPendingRot: 0,
transformPendingFlipH: false,
transformPendingFlipV: false,
transformPendingSkewX: 0,
transformPendingSkewY: 0,
transformPendingPerspX: 0,
transformPendingPerspY: 0,
transformAspectLock: true,
// Keep raster transforms source-backed unless the user explicitly opts out
// in the Transform popup.
@@ -84,6 +88,9 @@ export const state = {
wandMode: 'replace',
wandLiveRetune: false,
wandLastSeed: null,
// Quick Selection brush size (px) and Color Range fuzziness (0-200).
quickSelectBrushSize: 24,
colorRangeFuzz: 40,
// Cached layer pixel data (getImageData is O(pixels) — expensive for
// 4K layers; invalidated when the active layer changes).
wandSrcCache: null,
@@ -110,6 +117,13 @@ export const state = {
brushSpacing: 15,
brushSmoothing: 25,
brushBlendMode: 'source-over',
// Pixel brush: when true, the Brush/Eraser stamp crisp 1px hard pixels
// (integer fillRect + Bresenham line, no anti-aliasing) instead of a
// soft/feathered circle. Set by the "Pixel 1px" brush preset.
brushPixel: false,
// Paint Bucket: colour tolerance (0-255) for the flood fill. 0 fills only
// exactly-matching pixels; higher values spill across anti-aliased edges.
bucketTolerance: 32,
gradientStart: '#e06c75',
gradientMid: '#808080',
gradientMidPosition: 50,
@@ -127,6 +141,9 @@ export const state = {
pressureOpacity: false,
pressureFlow: true,
eyedropperSample: 'composite',
// Photoshop eyedropper "Sample Size": 1 = Point Sample, 3/5/11/31/51 =
// N-by-N average. Anything > 1 averages a square block under the cursor.
eyedropperSampleSize: 1,
// 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.
@@ -141,6 +158,10 @@ export const state = {
// original, not the in-progress stamp ring.
cloneSourceSnapshot: null,
cloneSourceLayerId: null,
// Patch tool — live drag offset (pixels) from the selection to the
// source region; applied on pointer-up.
patchDx: 0,
patchDy: 0,
// Where Clone / Healing reads pixels from. Keep the existing active-layer
// behavior as the default; composite sampling is useful for retouching a
// visible result while painting on a separate layer.
@@ -224,6 +245,9 @@ export const state = {
cropEnd: null,
cropRect: null,
cropAspectLock: null,
// Chosen crop aspect preset ('free' | 'original' | 'W:H'). Locks new crop
// drags to the ratio and is applied to an existing crop rect.
cropAspectPreset: 'free',
// True while the user drags the inside of an already-finished crop
// rect to reposition it.
cropMoving: false,
@@ -234,6 +258,12 @@ export const state = {
// is in progress or staged.
lassoPoints: [],
lassoActive: false,
// Magnetic lasso sub-mode: snap dragged points to the strongest
// local luminance gradient within `lassoMagneticWidth` px when the
// edge exceeds `lassoMagneticContrast` (0-100).
lassoMagnetic: false,
lassoMagneticWidth: 12,
lassoMagneticContrast: 20,
// ── Marquee selection ──
marqueeShape: 'rectangle',
@@ -299,10 +329,18 @@ export const state = {
gridSize: 16,
snapEnabled: false,
snapToGrid: false,
// Move-tool auto-select: OFF by default (Photoshop parity). When on, a
// Move-tool mousedown picks the topmost visible unlocked layer (or its
// group) under the pointer; Ctrl/Cmd+mousedown does it temporarily.
autoSelectLayer: false,
autoSelectTarget: 'layer',
guides: { vertical: [], horizontal: [] },
// Document dimensions in canvas pixels.
imgWidth: 0,
imgHeight: 0,
// Document color mode and bit depth (metadata; canvas is RGBA8).
colorMode: 'rgb',
bitDepth: 8,
// Gallery image id this editor session is editing, or null for
// blank-canvas drafts.
imageId: null,
@@ -342,6 +380,10 @@ export const state = {
// this invalidates a frame when the popup closes or changes layers.
adjPreviewLayer: null,
historyPanelEl: null,
// Named snapshots (Photoshop-style restore points). Each entry is an
// independent value snapshot { _snapshotId, _label, _ts, ... } that
// survives later edits and undo-stack trimming.
historySnapshots: [],
// Custom brush-cursor overlay element (circle following the mouse).
cursorEl: null,
// Hover-preview thumbnail floating element (singleton, repositioned).
@@ -352,6 +394,7 @@ export const state = {
// ── Draft persistence ──
draftId: null,
draftName: '',
documentId: null,
persistTimer: null,
// Current PUT/POST promise so concurrent saves can chain.
persistInFlight: null,
+88
View File
@@ -42,10 +42,63 @@ function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
ctx.restore();
}
// Crisp pixel brush: a hard square fillRect snapped to a grid whose cell is
// `size` px, so consecutive dabs land on the same lattice and connect cleanly.
function pixelDab(ctx, x, y, size, operation, color) {
const s = Math.max(1, Math.round(size));
const gx = Math.floor(x / s) * s;
const gy = Math.floor(y / s) * s;
ctx.save();
ctx.globalCompositeOperation = operation;
ctx.globalAlpha = 1;
ctx.fillStyle = color;
ctx.fillRect(gx, gy, s, s);
ctx.restore();
}
// Snap a drag vector to the nearest of 8 compass directions (0/45/90...)
// so the pixel brush draws perfectly straight lines.
function snapDirection(dx, dy) {
const step = Math.PI / 4;
const snapped = Math.round(Math.atan2(dy, dx) / step) * step;
return { x: Math.cos(snapped), y: Math.sin(snapped) };
}
// Connect two sampled points by walking whole grid cells (cell = `size` px)
// with a Bresenham line, so a fast drag fills every cell the line crosses and
// leaves no gaps and no half-cells.
function pixelLine(ctx, x0, y0, x1, y1, size, operation, color) {
const s = Math.max(1, Math.round(size));
let cx = Math.floor(Math.round(x0) / s);
let cy = Math.floor(Math.round(y0) / s);
const cx1 = Math.floor(Math.round(x1) / s);
const cy1 = Math.floor(Math.round(y1) / s);
const dx = Math.abs(cx1 - cx);
const dy = -Math.abs(cy1 - cy);
const sx = cx < cx1 ? 1 : -1;
const sy = cy < cy1 ? 1 : -1;
let err = dx + dy;
ctx.save();
ctx.globalCompositeOperation = operation;
ctx.globalAlpha = 1;
ctx.fillStyle = color;
for (;;) {
ctx.fillRect(cx * s, cy * s, s, s);
if (cx === cx1 && cy === cy1) break;
const e2 = 2 * err;
if (e2 >= dy) { err += dy; cx += sx; }
if (e2 <= dx) { err += dx; cy += sy; }
}
ctx.restore();
}
export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composite }) {
let strokeTool = null;
let compositeFrame = null;
let smudgeBuffer = null;
let pixelLast = null;
let pixelAnchor = null;
let pixelClickAnchor = null;
// Pointer events can arrive much faster than the display refresh rate.
// Stamps are applied synchronously, so only the visible document composite
@@ -246,6 +299,36 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
else if (tool === 'burn') { operation = 'multiply'; color = 'rgba(0,0,0,1)'; }
else if (target.paintingMask) { color = 'rgba(255,255,255,1)'; alpha = 1; softness = 0; }
else operation = isLayerTransparencyLocked(state, layer) ? 'source-atop' : state.brushBlendMode;
if (state.brushPixel && (tool === 'brush' || tool === 'eraser') && !target.paintingMask) {
// Square pixel brush: hard integer-aligned dabs at the chosen size.
// Freehand by default; hold Shift to constrain the stroke to the
// nearest of 8 directions (a straight line anchored at the stroke
// start, or at the previous Shift+click point).
const pixelSize = Math.max(1, Math.round(diameter));
const px = sample.x - target.xOffset;
const py = sample.y - target.yOffset;
const op = tool === 'eraser' ? 'destination-out' : 'source-over';
if (sample.shift) {
if (!pixelAnchor) pixelAnchor = { x: px, y: py };
const ddx = px - pixelAnchor.x;
const ddy = py - pixelAnchor.y;
if (Math.hypot(ddx, ddy) < 2) {
pixelDab(target.ctx, px, py, pixelSize, op, color);
pixelLast = { x: px, y: py };
return;
}
const dir = snapDirection(ddx, ddy);
const len = ddx * dir.x + ddy * dir.y;
const end = { x: pixelAnchor.x + dir.x * len, y: pixelAnchor.y + dir.y * len };
pixelLine(target.ctx, pixelAnchor.x, pixelAnchor.y, end.x, end.y, pixelSize, op, color);
pixelLast = end;
return;
}
if (pixelLast) pixelLine(target.ctx, pixelLast.x, pixelLast.y, px, py, pixelSize, op, color);
else pixelDab(target.ctx, px, py, pixelSize, op, color);
pixelLast = { x: px, y: py };
return;
}
circleStamp(target.ctx, sample.x - target.xOffset, sample.y - target.yOffset, radius, softness, color, alpha, operation);
}
@@ -259,6 +342,8 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
function beginStroke(sample, tool = state.tool) {
strokeTool = tool;
smudgeBuffer = null;
pixelLast = null;
pixelAnchor = (sample && sample.shift && pixelClickAnchor) ? { x: pixelClickAnchor.x, y: pixelClickAnchor.y } : null;
sampler.begin(sample);
state.lastX = sample.x;
state.lastY = sample.y;
@@ -274,8 +359,11 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
function endStroke(sample) {
sampler.end(sample);
if (pixelLast) pixelClickAnchor = { x: pixelLast.x, y: pixelLast.y };
strokeTool = null;
smudgeBuffer = null;
pixelLast = null;
pixelAnchor = null;
flushComposite();
}
+4
View File
@@ -92,6 +92,10 @@ export function wireStrokeToolSliders({ onPresetApplied } = {}) {
document.getElementById('ge-pressure-opacity')?.addEventListener('change', e => { state.pressureOpacity = e.target.checked; });
document.getElementById('ge-pressure-flow')?.addEventListener('change', e => { state.pressureFlow = e.target.checked; });
document.getElementById('ge-eyedropper-sample')?.addEventListener('change', e => { state.eyedropperSample = e.target.value; });
document.getElementById('ge-eyedropper-sample-size')?.addEventListener('change', e => {
const size = Math.max(1, Math.round(Number(e.target.value) || 1));
state.eyedropperSampleSize = size;
});
let presets = loadBrushPresets();
const presetSelect = document.getElementById('ge-brush-preset');
+33
View File
@@ -0,0 +1,33 @@
/**
* Photoshop-style nested flyout menus for the editor topbar.
*
* A `.ge-menu-parent` row reveals its sibling `.ge-submenu` panel. Hover is
* handled in CSS; this wires the click toggle (and aligns the flyout with the
* clicked row). The children stay in the same menu subtree, so the existing
* delegated `[data-*-action]` handlers keep firing.
*
* Shared by the Image / Filter / Selection menus (wire-topbar-menus.js) and
* the Window > Workspace submenu (wire-extra-menus.js).
*/
export function wireSubmenuToggles(menu) {
menu.addEventListener('click', (e) => {
const parent = e.target.closest('.ge-menu-parent');
if (!parent) return;
e.stopPropagation();
const willOpen = !parent.classList.contains('open');
menu.querySelectorAll('.ge-menu-parent.open').forEach((other) => {
if (other === parent) return;
other.classList.remove('open');
other.setAttribute('aria-expanded', 'false');
});
parent.classList.toggle('open', willOpen);
parent.setAttribute('aria-expanded', String(willOpen));
if (willOpen) {
// Align the flyout with the clicked row rather than the menu top.
const panel = parent.nextElementSibling;
if (panel && panel.classList.contains('ge-submenu')) {
panel.style.top = parent.offsetTop + 'px';
}
}
});
}
+237 -2
View File
@@ -16,9 +16,45 @@ export const DEFAULT_TEXT_DATA = Object.freeze({
frameHeight: 0,
autoWidth: true,
verticalAlign: 'top',
vertical: false,
smallCaps: false,
kerning: true,
ligatures: true,
warp: 'none',
warpAmount: 0,
path: null,
transform: Object.freeze({ scaleX: 1, scaleY: 1, rotation: 0, flipH: false, flipV: false }),
});
export const WARP_STYLES = Object.freeze([
'none', 'arc', 'arch', 'bulge', 'flag', 'wave', 'rise', 'fisheye', 'inflate', 'squeeze', 'twist',
]);
/* Self-hosted editor text fonts (see @font-face in 00-tokens.css). Bundled so
every device renders distinct faces; the trailing two are kept as optional
system extras but are filtered out at runtime when unavailable. */
export const EDITOR_TEXT_FONTS = Object.freeze([
'Inter', 'Roboto', 'Playfair Display', 'Lobster', 'Bebas Neue', 'Roboto Mono',
'Georgia', 'Courier New',
]);
/* Ask the browser to fetch each family. Resolves once they settle so a canvas
re-render can pick up faces that were not yet loaded on first paint. */
export function loadEditorFonts() {
if (typeof document === 'undefined' || !document.fonts || !document.fonts.load) return Promise.resolve([]);
return Promise.all(EDITOR_TEXT_FONTS.map((family) => {
try { return document.fonts.load(`400 48px "${family}"`).catch(() => []); } catch (_) { return Promise.resolve([]); }
})).catch(() => []);
}
/* Only offer families the browser can actually render distinctly. */
export function availableEditorFonts() {
if (typeof document === 'undefined' || !document.fonts || !document.fonts.check) return EDITOR_TEXT_FONTS.slice();
return EDITOR_TEXT_FONTS.filter((family) => {
try { return document.fonts.check(`48px "${family}"`); } catch (_) { return true; }
});
}
function finite(value, fallback, min = -Infinity, max = Infinity) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
@@ -42,6 +78,17 @@ export function normalizeTextData(value = {}) {
frameHeight: finite(value.frameHeight, DEFAULT_TEXT_DATA.frameHeight, 0, 10000),
autoWidth: value.autoWidth !== false,
verticalAlign: ['top', 'middle', 'bottom'].includes(value.verticalAlign) ? value.verticalAlign : 'top',
vertical: !!value.vertical,
smallCaps: !!value.smallCaps,
kerning: value.kerning !== false,
ligatures: value.ligatures !== false,
warp: WARP_STYLES.includes(value.warp) ? value.warp : 'none',
warpAmount: finite(value.warpAmount, DEFAULT_TEXT_DATA.warpAmount, -100, 100),
path: Array.isArray(value.path)
? value.path
.filter((point) => Number.isFinite(Number(point?.x)) && Number.isFinite(Number(point?.y)))
.map((point) => ({ x: Number(point.x), y: Number(point.y) }))
: null,
transform: {
scaleX: finite(transform.scaleX, 1, 0.01, 100),
scaleY: finite(transform.scaleY, 1, 0.01, 100),
@@ -104,16 +151,63 @@ function wrapText(ctx, content, width, spacing) {
return result.length ? result : [''];
}
function applyFontOptions(ctx, text) {
try { ctx.fontKerning = text.kerning === false ? 'none' : 'normal'; } catch {}
try { if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = text.smallCaps ? 'small-caps' : 'normal'; } catch {}
try { if ('fontVariantLigatures' in ctx) ctx.fontVariantLigatures = text.ligatures === false ? 'none' : 'normal'; } catch {}
}
function baseTextCanvas(text) {
const measure = document.createElement('canvas');
const measureCtx = measure.getContext('2d');
measureCtx.font = fontString(text);
applyFontOptions(measureCtx, text);
const padding = Math.ceil(Math.max(4, text.strokeWidth + 3));
const lineHeightPx = text.fontSize * text.lineHeight;
if (text.vertical) {
const lines = String(text.content).split('\n');
const advance = Math.max(1, lineHeightPx + text.letterSpacing);
const columnWidth = Math.max(1, lineHeightPx + text.letterSpacing);
const maxChars = Math.max(1, ...lines.map(line => [...line].length));
const contentWidth = Math.max(1, lines.length * columnWidth);
const contentHeight = Math.max(1, maxChars * advance);
const frameWidth = text.autoWidth ? contentWidth : Math.max(contentWidth, Math.max(1, text.frameWidth));
const frameHeight = text.frameHeight > 0 ? Math.max(contentHeight, text.frameHeight) : contentHeight;
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.ceil(frameWidth + padding * 2));
canvas.height = Math.max(1, Math.ceil(frameHeight + padding * 2));
const ctx = canvas.getContext('2d');
ctx.font = fontString(text);
applyFontOptions(ctx, text);
ctx.textBaseline = 'top';
ctx.textAlign = 'center';
ctx.fillStyle = text.color;
ctx.strokeStyle = text.strokeColor;
ctx.lineWidth = text.strokeWidth * 2;
ctx.lineJoin = 'round';
const columnsWidth = lines.length * columnWidth;
const baseX = text.align === 'center'
? (canvas.width - columnsWidth) / 2
: text.align === 'right' ? canvas.width - padding - columnsWidth : padding;
const verticalOffset = text.verticalAlign === 'middle'
? (frameHeight - contentHeight) / 2
: text.verticalAlign === 'bottom' ? frameHeight - contentHeight : 0;
lines.forEach((line, column) => {
const x = baseX + column * columnWidth + columnWidth / 2;
[...line].forEach((char, index) => {
const y = padding + verticalOffset + index * advance;
if (text.strokeWidth > 0) ctx.strokeText(char, x, y);
ctx.fillText(char, x, y);
});
});
return canvas;
}
const requestedWidth = text.autoWidth ? 0 : Math.max(1, text.frameWidth);
const lines = wrapText(measureCtx, text.content, requestedWidth, text.letterSpacing);
const measuredWidth = Math.max(1, ...lines.map(line => measureSpacedText(measureCtx, line || ' ', text.letterSpacing)));
const contentWidth = text.autoWidth ? measuredWidth : requestedWidth;
const lineHeightPx = text.fontSize * text.lineHeight;
const contentHeight = Math.max(1, lines.length * lineHeightPx);
const frameHeight = text.frameHeight > 0 ? Math.max(contentHeight, text.frameHeight) : contentHeight;
const canvas = document.createElement('canvas');
@@ -139,11 +233,152 @@ function baseTextCanvas(text) {
return canvas;
}
export function warpCanvas(base, style, amount) {
if (!base || !style || style === 'none' || !amount) return base;
const w = base.width;
const h = base.height;
if (w < 2 || h < 2) return base;
const src = base.getContext('2d').getImageData(0, 0, w, h);
const out = document.createElement('canvas');
out.width = w;
out.height = h;
const octx = out.getContext('2d');
const dst = octx.createImageData(w, h);
const a = Math.max(-1, Math.min(1, amount / 100));
for (let y = 0; y < h; y += 1) {
const ny = (y + 0.5) / h - 0.5;
for (let x = 0; x < w; x += 1) {
const nx = (x + 0.5) / w - 0.5;
let sx = x;
let sy = y;
switch (style) {
case 'arc': sy = y - a * h * 0.35 * (1 - 4 * nx * nx); break;
case 'arch': sy = y + a * h * 0.35 * (1 - 4 * nx * nx); break;
case 'flag': sy = y - a * h * 0.25 * Math.sin(nx * Math.PI * 2); break;
case 'wave': sy = y - a * h * 0.2 * Math.sin(nx * Math.PI * 4); break;
case 'rise': sy = y - a * h * (nx + 0.5); break;
case 'bulge': { const f = 1 + a * 0.5 * (1 - 4 * ny * ny); sx = (x - w / 2) / Math.max(0.2, f) + w / 2; sy = (y - h / 2) / Math.max(0.2, f) + h / 2; break; }
case 'fisheye': { const f = 1 + a * (1 - Math.min(1, Math.hypot(nx, ny) * 2)); sx = (x - w / 2) / Math.max(0.2, f) + w / 2; sy = (y - h / 2) / Math.max(0.2, f) + h / 2; break; }
case 'inflate': { const f = 1 - a * 0.5 * (1 - 4 * ny * ny); sx = (x - w / 2) / Math.max(0.2, f) + w / 2; break; }
case 'squeeze': { const f = 1 + a * 0.5 * (1 - 4 * ny * ny); sx = (x - w / 2) / Math.max(0.2, f) + w / 2; break; }
case 'twist': {
const angle = a * Math.PI * 0.5 * (1 - Math.min(1, Math.hypot(nx, ny) * 2));
const c = Math.cos(angle);
const s = Math.sin(angle);
const dx = x - w / 2;
const dy = y - h / 2;
sx = w / 2 + dx * c - dy * s;
sy = h / 2 + dx * s + dy * c;
break;
}
default: break;
}
const sxi = sx < 0 ? 0 : sx >= w ? w - 1 : Math.round(sx);
const syi = sy < 0 ? 0 : sy >= h ? h - 1 : Math.round(sy);
const si = (syi * w + sxi) * 4;
const di = (y * w + x) * 4;
dst.data[di] = src.data[si];
dst.data[di + 1] = src.data[si + 1];
dst.data[di + 2] = src.data[si + 2];
dst.data[di + 3] = src.data[si + 3];
}
}
octx.putImageData(dst, 0, 0);
return out;
}
/* Distance (in path-local pixels) kept around the path so glyphs laid along it
are never clipped at the canvas edges. Shared by the renderer and by the
caller that positions the text layer over the path. */
export function pathTextPadding(text) {
const t = text || {};
return Math.ceil(Math.max(8, (Number(t.strokeWidth) || 0) + (Number(t.fontSize) || 48) * 0.8));
}
export function hasTextPath(value) {
const text = value || {};
return Array.isArray(text.path) && text.path.length >= 2;
}
/* Lay a single line of text out along a polyline path, advancing each glyph by
its measured width (plus letterSpacing) and rotating it to the local tangent
of the segment it lands on. Coordinates are the path's own local pixels; the
caller offsets the layer so the drawn path matches where the user drew it. */
export function pathTextCanvas(text) {
const points = text.path;
const pad = pathTextPadding(text);
const xs = points.map((point) => point.x);
const ys = points.map((point) => point.y);
const width = Math.max(1, Math.ceil(Math.max(...xs) + pad));
const height = Math.max(1, Math.ceil(Math.max(...ys) + pad));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.font = fontString(text);
applyFontOptions(ctx, text);
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.fillStyle = text.color;
ctx.strokeStyle = text.strokeColor;
ctx.lineWidth = text.strokeWidth * 2;
ctx.lineJoin = 'round';
const segments = [];
let total = 0;
for (let i = 0; i < points.length - 1; i += 1) {
const a = points[i];
const b = points[i + 1];
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
segments.push({ a, dx, dy, len, start: total });
total += len;
}
const atDistance = (distance) => {
const clamped = Math.max(0, Math.min(total, distance));
for (let i = 0; i < segments.length; i += 1) {
const seg = segments[i];
if (clamped <= seg.start + seg.len || i === segments.length - 1) {
const t = seg.len ? (clamped - seg.start) / seg.len : 0;
return { x: seg.a.x + seg.dx * t, y: seg.a.y + seg.dy * t, angle: Math.atan2(seg.dy, seg.dx) };
}
}
const last = points[points.length - 1];
return { x: last.x, y: last.y, angle: 0 };
};
const glyphs = [...String(text.content || '')];
let textWidth = 0;
glyphs.forEach((char, index) => {
textWidth += ctx.measureText(char).width;
if (index < glyphs.length - 1) textWidth += Number(text.letterSpacing) || 0;
});
// Centre the run of glyphs on the path so a closed curve (circle) reads as
// symmetric instead of piling every glyph at the start of the sweep.
let distance = Math.max(0, (total - textWidth) / 2);
for (const char of glyphs) {
const glyphWidth = ctx.measureText(char).width;
const mid = distance + glyphWidth / 2;
const pos = atDistance(mid);
ctx.save();
ctx.translate(pos.x, pos.y);
ctx.rotate(pos.angle);
if (text.strokeWidth > 0) ctx.strokeText(char, 0, 0);
ctx.fillText(char, 0, 0);
ctx.restore();
distance += glyphWidth + (Number(text.letterSpacing) || 0);
}
return canvas;
}
export function renderTextLayer(layer) {
if (!layer?.canvas || !layer?.ctx) return null;
layer.kind = 'text';
layer.text = normalizeTextData(layer.text);
const base = baseTextCanvas(layer.text);
const base = hasTextPath(layer.text)
? pathTextCanvas(layer.text)
: warpCanvas(baseTextCanvas(layer.text), layer.text.warp, layer.text.warpAmount);
const transform = layer.text.transform;
const scaledWidth = Math.max(1, base.width * transform.scaleX);
const scaledHeight = Math.max(1, base.height * transform.scaleY);
+1
View File
@@ -4,4 +4,5 @@ export const TOOL_SHORTCUTS = Object.freeze({
brush: 'B', gradient: 'G', eraser: 'E', eyedropper: 'I',
smudge: 'Y', clone: 'S', heal: 'J', dodge: 'O',
marquee: 'M', lasso: 'L', wand: 'W', pen: 'P',
zoom: 'Z',
});
+141
View File
@@ -0,0 +1,141 @@
/**
* Paint Bucket (flood fill) tool.
*
* `floodFillImageData` and `hexToRgb` are pure helpers (exported for tests);
* `createBucketTool` fills the active layer's contiguous matching pixels —
* confined to the active selection mask when there is one — like Photoshop's
* contiguous Paint Bucket.
*/
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
export function hexToRgb(color) {
const hex = String(color || '').replace('#', '');
const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
if (full.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(full)) return [0, 0, 0];
const n = parseInt(full, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function matches(data, i, target, tolerance) {
return Math.abs(data[i] - target[0]) <= tolerance
&& Math.abs(data[i + 1] - target[1]) <= tolerance
&& Math.abs(data[i + 2] - target[2]) <= tolerance
&& Math.abs(data[i + 3] - target[3]) <= tolerance;
}
/**
* Scanline flood fill over an RGBA buffer, in place.
*
* @param {Uint8ClampedArray} data RGBA pixels (width*height*4).
* @param {number} width
* @param {number} height
* @param {number} startX
* @param {number} startY
* @param {[number,number,number]} rgb fill colour (alpha forced opaque).
* @param {{tolerance?: number, mask?: Uint8ClampedArray|null}} options
* `mask` is an optional RGBA alpha buffer; only pixels whose mask alpha
* is >= 128 are eligible (confines the fill to a selection).
* @returns {number} number of pixels filled.
*/
export function floodFillImageData(data, width, height, startX, startY, rgb, options = {}) {
const tolerance = Math.max(0, Math.min(255, options.tolerance == null ? 32 : options.tolerance));
const mask = options.mask || null;
const x0 = Math.floor(startX);
const y0 = Math.floor(startY);
if (x0 < 0 || y0 < 0 || x0 >= width || y0 >= height) return 0;
const startIdx = (y0 * width + x0) * 4;
if (mask && mask[startIdx + 3] < 128) return 0;
const target = [data[startIdx], data[startIdx + 1], data[startIdx + 2], data[startIdx + 3]];
const fill = [rgb[0], rgb[1], rgb[2], 255];
if (target[3] === 255
&& Math.abs(target[0] - fill[0]) <= tolerance
&& Math.abs(target[1] - fill[1]) <= tolerance
&& Math.abs(target[2] - fill[2]) <= tolerance) return 0;
const visited = new Uint8Array(width * height);
const eligible = (px, py) => {
const vi = py * width + px;
if (visited[vi]) return false;
const i = vi * 4;
if (mask && mask[i + 3] < 128) return false;
return matches(data, i, target, tolerance);
};
const stack = [[x0, y0]];
let filled = 0;
while (stack.length) {
const [sx, sy] = stack.pop();
let x = sx;
while (x >= 0 && eligible(x, sy)) x--;
x++;
let spanUp = false;
let spanDown = false;
while (x < width && eligible(x, sy)) {
visited[sy * width + x] = 1;
const i = (sy * width + x) * 4;
data[i] = fill[0];
data[i + 1] = fill[1];
data[i + 2] = fill[2];
data[i + 3] = 255;
filled++;
if (sy > 0) {
const up = eligible(x, sy - 1);
if (up && !spanUp) { stack.push([x, sy - 1]); spanUp = true; }
else if (!up) spanUp = false;
}
if (sy < height - 1) {
const down = eligible(x, sy + 1);
if (down && !spanDown) { stack.push([x, sy + 1]); spanDown = true; }
else if (!down) spanDown = false;
}
x++;
}
}
return filled;
}
export function createBucketTool({ activeLayer, saveState, composite, renderLayerPanel, invalidateLayerCaches, showToast }) {
function maskFor(layer) {
if (state.wandMask
&& state.wandMask.width === layer.canvas.width
&& state.wandMask.height === layer.canvas.height) {
return state.wandMask.getContext('2d')
.getImageData(0, 0, layer.canvas.width, layer.canvas.height).data;
}
return null;
}
// Flood-fill `layer` at layer-local pixel (x, y). Returns pixels filled.
function fillAtPoint(layer, x, y, { color, tolerance } = {}) {
if (!layer?.canvas || !layer.ctx) return 0;
const lx = Math.floor(x);
const ly = Math.floor(y);
if (lx < 0 || ly < 0 || lx >= layer.canvas.width || ly >= layer.canvas.height) return 0;
const tol = Math.max(0, Math.min(255, tolerance == null ? (Number(state.bucketTolerance) || 0) : Number(tolerance)));
const img = layer.ctx.getImageData(0, 0, layer.canvas.width, layer.canvas.height);
const count = floodFillImageData(
img.data, layer.canvas.width, layer.canvas.height, lx, ly,
hexToRgb(color || state.color), { tolerance: tol, mask: maskFor(layer) },
);
if (!count) return 0;
saveState?.();
layer.ctx.putImageData(img, 0, 0);
invalidateLayerCaches?.(layer);
composite?.();
renderLayerPanel?.();
return count;
}
function fill(e) {
const layer = activeLayer();
if (!layer?.canvas || !layer.ctx) return 0;
const point = canvasCoords(e, state.mainCanvas);
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const count = fillAtPoint(layer, point.x - off.x, point.y - off.y);
if (!count) showToast?.('Nothing to fill there');
return count;
}
return { fill, fillAtPoint };
}
+42
View File
@@ -0,0 +1,42 @@
// Color Range selection — pure helpers.
export function hexToRgb(hex) {
const value = String(hex || '').trim().replace(/^#/, '');
const full = value.length === 3
? value.split('').map(ch => ch + ch).join('')
: value.padEnd(6, '0').slice(0, 6);
const int = parseInt(full, 16);
if (Number.isNaN(int)) return { r: 0, g: 0, b: 0 };
return { r: (int >> 16) & 255, g: (int >> 8) & 255, b: int & 255 };
}
// Global (non-connected) color match. Returns a canvas whose alpha marks
// every pixel within `fuzziness` of `target` (RGBA squared-distance, the
// same scaling the magic wand uses for tolerance). Fully transparent
// source pixels are never selected.
export function colorRangeMask(srcData, width, height, target, fuzziness) {
const out = document.createElement('canvas');
out.width = width;
out.height = height;
const ctx = out.getContext('2d');
const image = ctx.createImageData(width, height);
const threshold = Math.max(0, Number(fuzziness) || 0) * 4.42;
const thresholdSq = threshold * threshold;
const tr = target?.r ?? 0;
const tg = target?.g ?? 0;
const tb = target?.b ?? 0;
for (let i = 0; i < srcData.length; i += 4) {
if (srcData[i + 3] === 0) continue;
const dr = srcData[i] - tr;
const dg = srcData[i + 1] - tg;
const db = srcData[i + 2] - tb;
if (dr * dr + dg * dg + db * db <= thresholdSq) {
image.data[i] = 255;
image.data[i + 1] = 255;
image.data[i + 2] = 255;
image.data[i + 3] = 255;
}
}
ctx.putImageData(image, 0, 0);
return out;
}
+60 -10
View File
@@ -32,7 +32,15 @@ export function createCropTool({ composite, showCropApply, snapFrame }) {
state.cropMoveStart = null;
state.cropAspectLock = null;
state.activeSnapGuides = null;
state.cropRect = data?.previousRect ? { ...data.previousRect } : null;
// Switching tools abandons the crop entirely — do not restore a
// half-drawn rect, and tear down any Apply panel a commit may have
// mounted mid-gesture.
if (reason === 'tool-switch') {
state.cropRect = null;
state.container?.querySelector('.ge-crop-apply')?.remove();
} else {
state.cropRect = data?.previousRect ? { ...data.previousRect } : null;
}
composite();
if (state.cropRect && reason !== 'tool-switch') showCropApply();
},
@@ -97,10 +105,28 @@ export function createCropTool({ composite, showCropApply, snapFrame }) {
}
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.
// Clamp the dragged corner to the document so a pointer that
// overshoots onto a panel (or past the canvas edge) still yields a
// rect inside the image rather than negative/off-canvas coords.
const end = canvasCoords(e, state.mainCanvas);
state.cropEnd = {
x: Math.max(0, Math.min(state.mainCanvas.width, end.x)),
y: Math.max(0, Math.min(state.mainCanvas.height, end.y)),
};
// Aspect lock. Shift-held snapshots the current aspect on first press
// and stays locked until released. Otherwise a chosen crop preset
// (state.cropAspectPreset) locks the ratio for new drags.
let presetRatio = null;
const preset = state.cropAspectPreset;
if (preset && preset !== 'free') {
if (preset === 'original') {
presetRatio = (state.imgWidth && state.imgHeight) ? state.imgWidth / state.imgHeight : null;
} else {
const m = /^(\d+):(\d+)$/.exec(preset);
presetRatio = m ? Number(m[1]) / Number(m[2]) : null;
}
}
let lockRatio = null;
if (e.shiftKey) {
const rawDx = state.cropEnd.x - state.cropStart.x;
const rawDy = state.cropEnd.y - state.cropStart.y;
@@ -109,21 +135,27 @@ export function createCropTool({ composite, showCropApply, snapFrame }) {
const rawH = Math.abs(rawDy) || 1;
state.cropAspectLock = rawW / rawH;
}
lockRatio = state.cropAspectLock;
} else {
state.cropAspectLock = null;
lockRatio = presetRatio;
}
if (lockRatio) {
const rawDx = state.cropEnd.x - state.cropStart.x;
const rawDy = state.cropEnd.y - state.cropStart.y;
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) {
if (absDx >= absDy * lockRatio) {
dx = rawDx;
dy = Math.sign(rawDy || 1) * (absDx / state.cropAspectLock);
dy = Math.sign(rawDy || 1) * (absDx / lockRatio);
} else {
dy = rawDy;
dx = Math.sign(rawDx || 1) * (absDy * state.cropAspectLock);
dx = Math.sign(rawDx || 1) * (absDy * lockRatio);
}
state.cropEnd = { x: state.cropStart.x + dx, y: state.cropStart.y + dy };
} else {
state.cropAspectLock = null;
}
composite();
// Draw crop overlay.
@@ -176,5 +208,23 @@ export function createCropTool({ composite, showCropApply, snapFrame }) {
cancel(reason) {
return gesture.cancel(reason);
},
// Unconditional teardown used when the user switches tools. Unlike
// cancel(), this clears a rect even if the gesture already committed
// (e.g. a stray pointerdown restarted it mid-drag) and removes any
// mounted Apply panel.
reset() {
gesture.cancel('tool-switch');
state.cropping = false;
state.cropMoving = false;
state.cropStart = null;
state.cropEnd = null;
state.cropMoveStart = null;
state.cropAspectLock = null;
state.activeSnapGuides = null;
state.cropRect = null;
state.container?.querySelector('.ge-crop-apply')?.remove();
composite();
},
};
}
+35 -5
View File
@@ -5,6 +5,24 @@ function hexByte(value) {
return Math.max(0, Math.min(255, value)).toString(16).padStart(2, '0');
}
// Average an N-by-N block centred on (cx, cy), clamped to the canvas edges.
// Photoshop's "Sample Size" option: 1 = Point Sample, 3/5/11/31/51 = N-by-N.
function averageBlock(ctx, cx, cy, size, width, height) {
const half = Math.floor(size / 2);
const x0 = Math.max(0, cx - half);
const y0 = Math.max(0, cy - half);
const x1 = Math.min(width - 1, cx + half);
const y1 = Math.min(height - 1, cy + half);
const w = x1 - x0 + 1;
const h = y1 - y0 + 1;
if (w <= 0 || h <= 0) return null;
const data = ctx.getImageData(x0, y0, w, h).data;
let r = 0, g = 0, b = 0, a = 0;
const n = data.length / 4;
for (let i = 0; i < data.length; i += 4) { r += data[i]; g += data[i + 1]; b += data[i + 2]; a += data[i + 3]; }
return { r: Math.round(r / n), g: Math.round(g / n), b: Math.round(b / n), a: a / n };
}
export function createEyedropperTool({ activeLayer, composite, syncColor, syncPreview, showToast }) {
function sample(e, refreshComposite = false) {
const point = canvasCoords(e, state.mainCanvas);
@@ -21,11 +39,23 @@ export function createEyedropperTool({ activeLayer, composite, syncColor, syncPr
} else if (refreshComposite) {
composite();
}
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) return null;
const pixel = canvas.getContext('2d').getImageData(Math.floor(x), Math.floor(y), 1, 1).data;
const rgb = [pixel[0], pixel[1], pixel[2]];
if (!pixel[3]) return { color: null, rgb, canvas, x, y };
return { color: `#${hexByte(pixel[0])}${hexByte(pixel[1])}${hexByte(pixel[2])}`, rgb, canvas, x, y };
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= canvas.width || iy >= canvas.height) return null;
const ctx = canvas.getContext('2d');
const size = Math.max(1, Math.round(Number(state.eyedropperSampleSize) || 1));
let r, g, b, a;
if (size <= 1) {
const pixel = ctx.getImageData(ix, iy, 1, 1).data;
r = pixel[0]; g = pixel[1]; b = pixel[2]; a = pixel[3];
} else {
const avg = averageBlock(ctx, ix, iy, size, canvas.width, canvas.height);
if (!avg) return null;
r = avg.r; g = avg.g; b = avg.b; a = avg.a;
}
const rgb = [r, g, b];
if (!a) return { color: null, rgb, canvas, x, y };
return { color: `#${hexByte(r)}${hexByte(g)}${hexByte(b)}`, rgb, canvas, x, y };
}
return {
preview(e) {
+76 -1
View File
@@ -4,9 +4,17 @@
* mouse-up keeps the selection visible (the panel's action buttons
* read `state.lassoPoints` to act on it).
*
* Magnetic sub-mode: when `state.lassoMagnetic` is on, each sampled
* point is snapped to the strongest local image gradient within
* `state.lassoMagneticWidth` px, so the outline hugs edges. Edge
* strength below `state.lassoMagneticContrast` (0-100) is ignored.
*
* Owns its own begin/drag/end handlers and reads/writes shared state.
*
* @param {{
* activeLayer: () => object | null,
* saveState: (label?: string) => void,
* commitSelectionMask: (mask, layer, mode, source, offset) => void,
* composite: () => void,
* drawLassoOverlay: () => void,
* syncToolClearIndicators: () => void,
@@ -17,16 +25,69 @@ import { selectionModeForEvent } from '../selection-modifiers.js';
import { canvasCoords } from '../canvas-coords.js';
import { buildLassoMask } from './lasso-mask.js';
// Luminance-gradient magnitude field for the active layer, used by the
// magnetic sub-mode. Built once per stroke; null when unavailable.
function buildEdgeField(layer, width, height) {
if (!layer || !layer.ctx || !width || !height) return null;
try {
const data = layer.ctx.getImageData(0, 0, width, height).data;
const lum = new Float32Array(width * height);
for (let i = 0; i < width * height; i++) {
lum[i] = 0.299 * data[i * 4] + 0.587 * data[i * 4 + 1] + 0.114 * data[i * 4 + 2];
}
const mag = new Float32Array(width * height);
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const i = y * width + x;
const gx = lum[i + 1] - lum[i - 1];
const gy = lum[i + width] - lum[i - width];
mag[i] = Math.abs(gx) + Math.abs(gy);
}
}
return { mag, width, height };
} catch {
return null;
}
}
function snapToEdge(field, x, y, radius, contrast) {
const r = Math.max(1, Math.round(radius));
const threshold = contrast * 2.55;
let best = null;
let bestScore = -Infinity;
const cx = Math.round(x);
const cy = Math.round(y);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const px = cx + dx;
const py = cy + dy;
if (px < 1 || py < 1 || px >= field.width - 1 || py >= field.height - 1) continue;
const m = field.mag[py * field.width + px];
if (m < threshold) continue;
const score = m - Math.hypot(dx, dy) * 2;
if (score > bestScore) {
bestScore = score;
best = { x: px, y: py };
}
}
}
return best;
}
export function createLassoTool({
activeLayer, saveState, commitSelectionMask,
composite, drawLassoOverlay, syncToolClearIndicators,
}) {
let pendingMode = 'replace';
let edgeField = null;
return {
begin(e) {
pendingMode = selectionModeForEvent(e, state.wandMode || 'replace');
state.lassoPoints = [];
state.lassoActive = true;
edgeField = state.lassoMagnetic
? buildEdgeField(activeLayer?.(), state.imgWidth, state.imgHeight)
: null;
const coords = canvasCoords(e, state.mainCanvas);
state.lassoPoints.push(coords);
},
@@ -34,7 +95,19 @@ export function createLassoTool({
drag(e) {
if (!state.lassoActive) return;
e.preventDefault();
const coords = canvasCoords(e, state.mainCanvas);
let coords = canvasCoords(e, state.mainCanvas);
if (state.lassoMagnetic && edgeField) {
const snapped = snapToEdge(
edgeField,
coords.x,
coords.y,
state.lassoMagneticWidth || 12,
state.lassoMagneticContrast ?? 20,
);
if (snapped) coords = snapped;
}
const last = state.lassoPoints[state.lassoPoints.length - 1];
if (last && Math.hypot(coords.x - last.x, coords.y - last.y) < 1) return;
state.lassoPoints.push(coords);
// Live overlay: dashed white outline + translucent red fill.
composite();
@@ -57,6 +130,7 @@ export function createLassoTool({
end() {
state.lassoActive = false;
edgeField = null;
if (state.lassoPoints.length < 3) {
state.lassoPoints = [];
composite();
@@ -86,6 +160,7 @@ export function createLassoTool({
cancel() {
if (!state.lassoActive && !state.lassoPoints.length) return false;
state.lassoActive = false;
edgeField = null;
state.lassoPoints = [];
composite();
syncToolClearIndicators();
+88
View File
@@ -0,0 +1,88 @@
// Object Selection brush — drag a rough box over the subject and let the
// server-side segmentation model (SAM, via /api/image/mask) pick it out.
// The stroke is sampled into positive/negative points plus a bounding
// box, both already supported by the backend endpoint.
import { selectionModeForEvent } from '../selection-modifiers.js';
export function createObjectSelectTool({
activeLayer,
saveState,
requestSam,
canvasCoords,
layerOffset,
showToast,
syncToolClearIndicators,
}) {
let active = false;
let layer = null;
let points = [];
let box = null;
let mode = 'replace';
const addPoint = (coords, event) => {
if (!layer) return;
const off = layerOffset(layer) || { x: 0, y: 0 };
const lx = Math.floor(coords.x - off.x);
const ly = Math.floor(coords.y - off.y);
if (lx < 0 || ly < 0 || lx >= layer.canvas.width || ly >= layer.canvas.height) return;
const last = points[points.length - 1];
if (last && Math.hypot(lx - last.x, ly - last.y) < 8) return;
points.push({ x: lx, y: ly, label: event.altKey ? 0 : 1 });
if (!box) box = [lx, ly, lx, ly];
else {
box[0] = Math.min(box[0], lx);
box[1] = Math.min(box[1], ly);
box[2] = Math.max(box[2], lx);
box[3] = Math.max(box[3], ly);
}
};
const begin = (event) => {
layer = activeLayer();
if (!layer) {
showToast?.('Select a layer');
return;
}
active = true;
points = [];
box = null;
mode = selectionModeForEvent(event, 'replace');
const coords = canvasCoords(event);
if (coords) addPoint(coords, event);
};
const drag = (event) => {
if (!active) return;
const coords = canvasCoords(event);
if (coords) addPoint(coords, event);
};
const end = async () => {
if (!active) return;
active = false;
const target = layer;
const pts = points;
const bx = points.length ? box : null;
points = [];
box = null;
layer = null;
if (!target || !pts.length) return;
saveState();
try {
await requestSam(target, { points: pts, box: bx }, mode);
} catch (err) {
if (err?.name !== 'AbortError') showToast?.('Object selection failed');
}
syncToolClearIndicators?.();
};
const cancel = () => {
active = false;
points = [];
box = null;
layer = null;
};
return { begin, drag, end, cancel };
}
+69
View File
@@ -0,0 +1,69 @@
// Patch tool — with a selection active, drag from inside it to a clean
// source region; on release the selected content is replaced by that
// source region (feathered at the seam). The pixel work lives in
// ../patch-ops.js; this module only tracks the drag offset.
//
// begin()/drag()/end() are dispatched from the canvas event handler the
// same way as the object-selection brush.
import { state } from '../state.js';
export function createPatchTool({
activeLayer,
canvasCoords,
saveState,
showToast,
onApply,
}) {
let active = false;
let start = null;
const begin = (event) => {
const layer = activeLayer();
if (!layer) {
showToast?.('Select a layer to patch');
return;
}
if (!state.wandMask) {
showToast?.('Make a selection, then drag the Patch tool to a source area');
return;
}
const coords = canvasCoords(event);
if (!coords) return;
start = coords;
state.patchDx = 0;
state.patchDy = 0;
active = true;
if (state.mainCanvas) state.mainCanvas.style.cursor = 'copy';
};
const drag = (event) => {
if (!active || !start) return;
const coords = canvasCoords(event);
if (!coords) return;
state.patchDx = Math.round(coords.x - start.x);
state.patchDy = Math.round(coords.y - start.y);
};
const end = () => {
if (!active) return;
active = false;
start = null;
if (state.mainCanvas) state.mainCanvas.style.cursor = '';
const dx = Number(state.patchDx) || 0;
const dy = Number(state.patchDy) || 0;
state.patchDx = 0;
state.patchDy = 0;
if (dx === 0 && dy === 0) return;
saveState?.('Patch');
onApply?.(dx, dy);
};
const cancel = () => {
active = false;
start = null;
if (state.mainCanvas) state.mainCanvas.style.cursor = '';
};
return { begin, drag, end, cancel };
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Vector pen / path tool — click to drop anchors, double-click (or the
* Finish button / Enter) to close the path. The draft is rendered live as
* a `shape` layer of type `path`; the caller owns layer creation, geometry
* updates and cleanup so this module stays free of editor state.
*/
export function createPenPathTool({
saveState = () => {},
canvasCoords = () => ({ x: 0, y: 0 }),
mainCanvas = null,
ensurePathLayer = () => null,
drawPath = () => {},
commitPath = () => {},
cancelPath = () => {},
composite = () => {},
syncToolClearIndicators = () => {},
} = {}) {
let draft = null;
function addPoint(e) {
const point = canvasCoords(e, mainCanvas);
if (!Number.isFinite(point?.x) || !Number.isFinite(point?.y)) return false;
if (!draft) {
saveState('Add path');
const layer = ensurePathLayer();
if (!layer) return false;
draft = { layer, points: [point] };
} else {
draft.points.push(point);
}
drawPath(draft.layer, draft.points);
composite();
return true;
}
function begin(e) {
addPoint(e);
if (e && e.detail >= 2) finish();
return true;
}
function drag() {}
function end() {}
function finish() {
if (!draft) return false;
if (draft.points.length >= 2) {
commitPath(draft.layer, draft.points);
} else {
cancelPath(draft.layer);
}
draft = null;
composite();
syncToolClearIndicators();
return true;
}
function cancel() {
if (!draft) return false;
cancelPath(draft.layer);
draft = null;
composite();
return true;
}
return { begin, drag, end, finish, cancel, hasDraft: () => !!draft };
}
+145
View File
@@ -0,0 +1,145 @@
/**
* Perspective Crop tool — overlay four draggable corner handles over the
* canvas; on Apply the quadrilateral is straightened (and resized) by the pure
* engine in ../perspective-crop.js. The overlay is plain DOM (fixed-position
* handles + an SVG outline) so it does not touch the canvas draw pipeline.
*/
import { state } from '../state.js';
const HANDLE = 14;
function clamp(value, min, max) {
return value < min ? min : value > max ? max : value;
}
export function createPerspectiveCropTool({ getCanvas, onApply, showToast = () => {} } = {}) {
let overlay = null;
let corners = null; // document-space {x,y} x4 (TL,TR,BR,BL)
let dragging = -1;
function canvasRect() {
const canvas = getCanvas?.();
const r = canvas?.getBoundingClientRect?.();
return r && r.width > 0 ? r : null;
}
function toDisplay(point, rect) {
return { x: rect.left + (point.x / state.imgWidth) * rect.width, y: rect.top + (point.y / state.imgHeight) * rect.height };
}
function toDoc(clientX, clientY, rect) {
return {
x: clamp(((clientX - rect.left) / rect.width) * state.imgWidth, 0, state.imgWidth),
y: clamp(((clientY - rect.top) / rect.height) * state.imgHeight, 0, state.imgHeight),
};
}
function sizeHint() {
const width = Math.round((Math.hypot(corners[1].x - corners[0].x, corners[1].y - corners[0].y) + Math.hypot(corners[2].x - corners[3].x, corners[2].y - corners[3].y)) / 2);
const height = Math.round((Math.hypot(corners[3].x - corners[0].x, corners[3].y - corners[0].y) + Math.hypot(corners[2].x - corners[1].x, corners[2].y - corners[1].y)) / 2);
return `${Math.max(1, width)} × ${Math.max(1, height)} px`;
}
function render() {
if (!overlay) return;
const rect = canvasRect();
if (!rect) return;
overlay.style.left = rect.left + 'px';
overlay.style.top = rect.top + 'px';
overlay.style.width = rect.width + 'px';
overlay.style.height = rect.height + 'px';
const pts = corners.map(c => toDisplay(c, rect));
overlay.querySelector('polygon')?.setAttribute('points', pts.map(p => `${p.x - rect.left},${p.y - rect.top}`).join(' '));
overlay.querySelectorAll('.ge-pcrop-handle').forEach((h, i) => {
const p = pts[i];
h.style.left = (p.x - rect.left) + 'px';
h.style.top = (p.y - rect.top) + 'px';
});
const hint = overlay.querySelector('.ge-pcrop-size');
if (hint) hint.textContent = sizeHint();
}
function onPointerDown(i, event) {
event.preventDefault();
event.stopPropagation();
dragging = i;
event.currentTarget.setPointerCapture?.(event.pointerId);
document.addEventListener('pointermove', onPointerMove, true);
document.addEventListener('pointerup', onPointerUp, true);
}
function onPointerMove(event) {
if (dragging < 0) return;
const rect = canvasRect();
if (!rect) return;
corners[dragging] = toDoc(event.clientX, event.clientY, rect);
render();
}
function onPointerUp() {
dragging = -1;
document.removeEventListener('pointermove', onPointerMove, true);
document.removeEventListener('pointerup', onPointerUp, true);
}
function onKey(event) {
if (event.key === 'Escape') { event.preventDefault(); close(); }
else if (event.key === 'Enter') { event.preventDefault(); apply(); }
}
function apply() {
const next = corners.map(c => ({ ...c }));
close();
onApply?.(next);
}
function open() {
close();
const inset = 0.06;
corners = [
{ x: state.imgWidth * inset, y: state.imgHeight * inset },
{ x: state.imgWidth * (1 - inset), y: state.imgHeight * inset },
{ x: state.imgWidth * (1 - inset), y: state.imgHeight * (1 - inset) },
{ x: state.imgWidth * inset, y: state.imgHeight * (1 - inset) },
];
overlay = document.createElement('div');
overlay.className = 'ge-pcrop-overlay';
overlay.innerHTML = `
<svg class="ge-pcrop-svg" preserveAspectRatio="none"><polygon points=""></polygon></svg>
<div class="ge-pcrop-bar">
<span class="ge-pcrop-title">Perspective Crop</span>
<span class="ge-pcrop-size"></span>
<span class="ge-pcrop-spacer"></span>
<button type="button" class="ge-btn ge-btn-sm" data-pcrop="cancel">Cancel</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-primary" data-pcrop="apply">Apply</button>
</div>
${corners.map((_, i) => `<div class="ge-pcrop-handle" data-corner="${i}"></div>`).join('')}
`;
document.body.appendChild(overlay);
overlay.querySelectorAll('.ge-pcrop-handle').forEach((handle, i) => {
handle.addEventListener('pointerdown', (event) => onPointerDown(i, event));
});
overlay.querySelector('[data-pcrop="apply"]').addEventListener('click', apply);
overlay.querySelector('[data-pcrop="cancel"]').addEventListener('click', close);
document.addEventListener('keydown', onKey, true);
window.addEventListener('resize', render);
render();
showToast?.('Drag the four corners, then Apply');
}
function close() {
if (!overlay) return;
document.removeEventListener('keydown', onKey, true);
window.removeEventListener('resize', render);
document.removeEventListener('pointermove', onPointerMove, true);
document.removeEventListener('pointerup', onPointerUp, true);
overlay.remove();
overlay = null;
corners = null;
dragging = -1;
}
return { open, close, isOpen: () => !!overlay, render };
}
export default { createPerspectiveCropTool };
+63
View File
@@ -0,0 +1,63 @@
// Quick Selection brush — drag to grow a wand-style selection.
// Reuses the existing magic-wand flood fill by re-sampling along the
// stroke; intermediate samples skip the undo snapshot (retune mode) so a
// whole drag collapses to one history entry (taken on begin).
import { selectionModeForEvent } from '../selection-modifiers.js';
export function createQuickSelectTool({
activeLayer,
saveState,
composite,
runMagicWand,
canvasCoords,
brushSize,
syncToolClearIndicators,
}) {
let painting = false;
let last = null;
const modeFor = (event) => selectionModeForEvent(event, 'add');
const sample = (event) => {
const coords = canvasCoords(event);
if (!coords) return;
runMagicWand(coords.x, coords.y, modeFor(event), { retune: true });
};
const begin = (event) => {
const layer = activeLayer();
if (!layer) return;
saveState();
painting = true;
last = null;
sample(event);
const coords = canvasCoords(event);
if (coords) last = coords;
};
const drag = (event) => {
if (!painting) return;
const coords = canvasCoords(event);
if (!coords) return;
const gap = Math.max(3, (brushSize?.() || 24) / 4);
if (last && Math.hypot(coords.x - last.x, coords.y - last.y) < gap) return;
sample(event);
last = coords;
};
const end = () => {
if (!painting) return;
painting = false;
last = null;
composite();
syncToolClearIndicators?.();
};
const cancel = () => {
painting = false;
last = null;
};
return { begin, drag, end, cancel };
}

Some files were not shown because too many files have changed in this diff Show More