mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-20 05:42:20 +02:00
Consolidate Odysseus agent harness and tool contracts
This commit is contained in:
@@ -5,6 +5,8 @@
|
||||
* el.querySelector after appending.
|
||||
*/
|
||||
|
||||
import { TOOL_SHORTCUTS } from '../tool-shortcuts.js';
|
||||
|
||||
/** Keyboard-shortcuts popover. */
|
||||
export function shortcutsPopupHTML() {
|
||||
return `
|
||||
@@ -27,12 +29,11 @@ export function shortcutsPopupHTML() {
|
||||
<div><kbd>T</kbd> Text</div>
|
||||
<div><kbd>B</kbd> Brush</div>
|
||||
<div><kbd>E</kbd> Eraser</div>
|
||||
<div><kbd>K</kbd> Clone Stamp <span style="opacity:0.5">(Alt-click = set source)</span></div>
|
||||
<div><kbd>${TOOL_SHORTCUTS.clone}</kbd> Clone Stamp <span style="opacity:0.5">(Alt-click = set source)</span></div>
|
||||
<div><kbd>L</kbd> Lasso</div>
|
||||
<div><kbd>W</kbd> Wand</div>
|
||||
<div><kbd>M</kbd> Inpaint</div>
|
||||
<div><kbd>${TOOL_SHORTCUTS.marquee}</kbd> Marquee</div>
|
||||
<div><kbd>C</kbd> Crop</div>
|
||||
<div><kbd>S</kbd> Sharpen</div>
|
||||
</div>
|
||||
<div class="ge-shortcuts-col">
|
||||
<h5>Edit</h5>
|
||||
@@ -48,10 +49,11 @@ export function shortcutsPopupHTML() {
|
||||
<div class="ge-shortcuts-col">
|
||||
<h5>Selection</h5>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>A</kbd> Select All</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>D</kbd> Deselect</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>C</kbd> Copy to layer</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>X</kbd> Cut lasso</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>D</kbd> Delete pixels</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>D</kbd> Deselect</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>C</kbd> Copy</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>X</kbd> Cut</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>J</kbd> Copy selection to layer / duplicate layer</div>
|
||||
<div><kbd>Delete</kbd> Delete pixels</div>
|
||||
<div><kbd>Esc</kbd> Cancel selection / crop</div>
|
||||
</div>
|
||||
<div class="ge-shortcuts-col">
|
||||
|
||||
@@ -13,38 +13,41 @@
|
||||
* }} ctx
|
||||
* @returns {{ toolbar: HTMLDivElement, toolKeyMap: Record<string,string> }}
|
||||
*/
|
||||
import { TOOL_SHORTCUTS } from '../tool-shortcuts.js';
|
||||
|
||||
export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'ge-toolbar';
|
||||
const tools = [
|
||||
{ id: 'move', label: 'Move', icon: '✥', key: 'V' },
|
||||
{ id: 'hand', label: 'Hand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-4 0v4"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 10V5a2 2 0 0 0-4 0v9"/><path d="M6 13.5 4.5 12A2.1 2.1 0 0 0 2 15l4.5 5A6 6 0 0 0 11 22h2a7 7 0 0 0 7-7v-4a2 2 0 0 0-4 0v1"/></svg>', key: 'H' },
|
||||
{ id: 'crop', label: 'Crop', icon: '✂', key: 'C' },
|
||||
{ id: '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>', key: 'T' },
|
||||
{ id: 'shape', label: 'Shape', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="12" height="12" rx="1"/><circle cx="17" cy="15" r="4"/></svg>', key: 'U' },
|
||||
{ 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>', key: 'B' },
|
||||
{ id: 'gradient', label: 'Gradient', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20 20 4"/><path d="M6 18 18 6" opacity=".45"/><path d="M8 16 16 8" opacity=".2"/></svg>', key: 'G' },
|
||||
{ id: 'eraser', label: 'Eraser', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19.4 14.6 14.6 19.4a2 2 0 0 1-2.83 0L4.6 12.23a2 2 0 0 1 0-2.83l7.17-7.17a2 2 0 0 1 2.83 0l4.8 4.8a2 2 0 0 1 0 2.83Z"/><line x1="22" y1="21" x2="7" y2="21"/><line x1="14" y1="3" x2="9" y2="8"/></svg>', key: 'E' },
|
||||
{ id: 'eyedropper', label: 'Eyedropper', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 3 2 2-9.5 9.5-3-3Z"/><path d="m8.5 11.5-5 5V21h4.5l5-5"/></svg>', key: 'I' },
|
||||
{ id: 'smudge', label: 'Smudge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19c2-3 4-5 6-6 2-1 4-3 4-6 0-2-1-4-3-4s-3 2-3 4v5"/><path d="M9 13c-2 0-4 1-5 3-.7 1.2-.1 3 1.4 3H18c1.7 0 3-1.3 3-3 0-1.1-.9-2-2-2h-4"/></svg>', key: 'Y' },
|
||||
{ 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>', key: 'K' },
|
||||
{ id: 'heal', label: 'Healing', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m5 19 14-14"/><path d="M7 5h4M9 3v4M13 17h4M15 15v4"/></svg>', key: 'J' },
|
||||
{ id: 'dodge', label: 'Dodge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="10" cy="10" r="6"/><path d="m14.5 14.5 6 6"/></svg>', key: 'O' },
|
||||
{ id: 'burn', label: 'Burn', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 22c4 0 7-3 7-7 0-5-4-8-7-13-3 5-7 8-7 13 0 4 3 7 7 7Z"/><path d="M9 16c1.5 1 4.5 1 6 0"/></svg>', key: 'D' },
|
||||
{ id: 'marquee', label: 'Marquee', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg>', key: 'R' },
|
||||
{ id: 'lasso', label: 'Lasso', icon: '⟡', key: 'L' },
|
||||
{ id: 'wand', label: 'Wand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8L19 13"/><path d="M15 9h0"/><path d="M17.8 6.2L19 5"/><path d="M3 21l9-9"/><path d="M12.2 6.2L11 5"/></svg>', key: 'W' },
|
||||
{ id: '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: '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>', key: 'M' },
|
||||
{ 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: '◈', key: 'S' },
|
||||
{ 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';
|
||||
|
||||
@@ -51,7 +51,6 @@ export function buildTopbar() {
|
||||
<span class="ge-topbar-sep"></span>
|
||||
</div>
|
||||
<div class="ge-topbar-right">
|
||||
<span class="ge-draft-status" id="ge-draft-status" role="status" aria-live="polite" title="Draft status">Not saved</span>
|
||||
<span class="ge-canvas-size" id="ge-canvas-size" title="Canvas size" hidden></span>
|
||||
<div class="ge-view-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-view-menu-btn" title="Canvas view" aria-haspopup="true">
|
||||
@@ -74,6 +73,7 @@ export function buildTopbar() {
|
||||
<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"><span>Fill selection / mask</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="canvas-size">
|
||||
<span class="dropdown-icon">⤢</span>
|
||||
<span>Canvas Size...</span>
|
||||
@@ -107,8 +107,8 @@ export function buildTopbar() {
|
||||
<span class="ge-stacked-label">SELECT</span>
|
||||
</button>
|
||||
<div class="ge-selection-menu dropdown" id="ge-selection-menu" hidden>
|
||||
<button class="dropdown-item-compact" data-selection-action="all"><span>Select All</span><span class="dropdown-shortcut">Ctrl+Alt+A</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="deselect"><span>Deselect</span><span class="dropdown-shortcut">Ctrl+Shift+D</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="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+I</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="transform"><span>Transform Selection</span></button>
|
||||
@@ -177,8 +177,11 @@ export function buildTopbar() {
|
||||
</button>
|
||||
<div class="ge-save-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-btn-primary ge-stacked-btn" id="ge-save-menu-btn" title="Save options">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">SAVE</span>
|
||||
<span class="ge-stacked-glyph">
|
||||
<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-label" id="ge-draft-status" role="status" aria-live="polite">Saved</span>
|
||||
</button>
|
||||
<div class="ge-save-menu dropdown" id="ge-save-menu" hidden>
|
||||
<div class="dropdown-section-label">Image</div>
|
||||
|
||||
@@ -43,7 +43,12 @@ import {
|
||||
syncPanCursor,
|
||||
} from './canvas-navigation.js';
|
||||
|
||||
let canvasWindowBindings;
|
||||
|
||||
export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, cancelDraw, updateBrushCursor, updateEyedropperPreview, syncZoomControls, onViewportChange }) {
|
||||
canvasWindowBindings?.abort();
|
||||
canvasWindowBindings = new AbortController();
|
||||
const { signal } = canvasWindowBindings;
|
||||
let suppressMouseUntil = 0;
|
||||
// Mouse — mousedown stays on the canvas; mousemove/up are bound to
|
||||
// the WINDOW so a drag can continue (and end) past the canvas edge.
|
||||
@@ -56,11 +61,11 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (Date.now() < suppressMouseUntil) return;
|
||||
continueDraw(e);
|
||||
});
|
||||
}, { signal });
|
||||
window.addEventListener('mouseup', (e) => {
|
||||
if (Date.now() < suppressMouseUntil) return;
|
||||
endDraw(e);
|
||||
});
|
||||
}, { signal });
|
||||
// Preserve pressure and browser-coalesced samples for pen input.
|
||||
// Compatibility mouse events are briefly suppressed to avoid a
|
||||
// duplicate stroke after pointerup.
|
||||
@@ -266,6 +271,21 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
};
|
||||
canvasArea.addEventListener('pointerup', endPan);
|
||||
canvasArea.addEventListener('pointercancel', endPan);
|
||||
// Treat focus loss as release: preserve the work already drawn, but never
|
||||
// resume the gesture when the user returns without a fresh pointer press.
|
||||
window.addEventListener('blur', () => {
|
||||
if (!state.editorOpen || !canvasArea.contains(state.mainCanvas)) return;
|
||||
endDraw();
|
||||
if (activePenId !== null) {
|
||||
try { state.mainCanvas.releasePointerCapture(activePenId); } catch {}
|
||||
activePenId = null;
|
||||
}
|
||||
multiActive = false;
|
||||
endPan();
|
||||
state.spacePanActive = false;
|
||||
syncPanCursor(state, canvasArea, false);
|
||||
if (state.cursorEl) state.cursorEl.style.display = 'none';
|
||||
}, { signal });
|
||||
// Reset offset whenever zoom/fit changes the canvas size.
|
||||
canvasArea._resetPan = () => applyOffset(0, 0);
|
||||
const navigation = {
|
||||
|
||||
@@ -29,15 +29,21 @@
|
||||
import { state } from './state.js';
|
||||
import { createPlacedData, renderPlacedLayer } from './placed-layer.js';
|
||||
|
||||
let clipboardBindings;
|
||||
|
||||
export function wireClipboardAndDrop({
|
||||
container, saveState, createLayer, renderLayerPanel, composite,
|
||||
handleImportedImage, uiModule,
|
||||
}) {
|
||||
clipboardBindings?.abort();
|
||||
clipboardBindings = new AbortController();
|
||||
const { signal } = clipboardBindings;
|
||||
// ── Paste ──
|
||||
window.addEventListener('paste', (e) => {
|
||||
if (!state.editorOpen) return;
|
||||
if (!state.editorOpen || state.container !== container || e.defaultPrevented) return;
|
||||
if (e.target?.isContentEditable || e.target?.closest?.('input, textarea, select, [role="dialog"]')) return;
|
||||
|
||||
function pasteAsLayer(imgSource, label) {
|
||||
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);
|
||||
@@ -45,14 +51,15 @@ export function wireClipboardAndDrop({
|
||||
// Keep it source-backed with an identity matrix so future transforms do
|
||||
// not repeatedly resample the pasted pixels.
|
||||
layer.kind = 'placed';
|
||||
layer.placed = createPlacedData(imgSource, [1, 0, 0, 1, 0, 0], label || 'Pasted');
|
||||
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.tool = 'move';
|
||||
state.selectedLayerIds = [layer.id];
|
||||
state.activeGroupId = null;
|
||||
const tb = state.container?.querySelector('.ge-toolbar');
|
||||
if (tb) tb.querySelectorAll('.ge-tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === 'move'));
|
||||
tb?.querySelector('[data-tool="move"]')?.click();
|
||||
renderLayerPanel();
|
||||
composite();
|
||||
uiModule.showToast('Pasted as new layer');
|
||||
@@ -62,7 +69,7 @@ export function wireClipboardAndDrop({
|
||||
if (state.internalClipboard) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
pasteAsLayer(state.internalClipboard, 'Pasted Selection');
|
||||
pasteAsLayer(state.internalClipboard, 'Pasted Selection', state.internalClipboardOffset || { x: 0, y: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -83,7 +90,7 @@ export function wireClipboardAndDrop({
|
||||
img.src = url;
|
||||
break;
|
||||
}
|
||||
}, true); // capture phase so we beat chat input
|
||||
}, { capture: true, signal });
|
||||
|
||||
// ── Drag-and-drop ──
|
||||
// Visual drop-zone overlay appears mid-drag; routes via
|
||||
|
||||
@@ -8,22 +8,21 @@
|
||||
* Enter confirm in-progress transform
|
||||
* Esc cancel transform / lasso / crop (in priority order)
|
||||
* Ctrl+Z undo (Shift adds redo)
|
||||
* Ctrl+Shift+D deselect (clears wand + lasso)
|
||||
* Ctrl+D deselect (clears wand + lasso)
|
||||
* Ctrl+S save (Shift = save as / export to gallery)
|
||||
* Ctrl+Shift+T open resize popup
|
||||
* Ctrl+Alt+T start free transform
|
||||
* Ctrl+Alt+I invert wand / lasso selection
|
||||
* Ctrl+Alt+J new empty layer
|
||||
* Ctrl/Cmd+J duplicate the active layer
|
||||
* Ctrl/Cmd+J copy selected pixels to a layer, or duplicate the layer
|
||||
* Ctrl+Alt+G create/release clipping mask
|
||||
* Ctrl+Alt+A select all canvas (lasso polygon = full bounds)
|
||||
* Ctrl+C/X copy / cut wand or lasso selection (image clipboard
|
||||
* + internal clipboard)
|
||||
* Ctrl+A select all canvas
|
||||
* Ctrl+C/X copy / cut the selected surface (image clipboard
|
||||
* + internal clipboard, preserving its document offset)
|
||||
* Ctrl+V (handled by the paste event listener)
|
||||
* Tool keys (V, B, E, L, …) → toolbar click
|
||||
* Hold Space temporarily pan without changing the active tool
|
||||
* [ / ] shrink / grow brush size proportionally
|
||||
* D, C, M (when lasso has 3+ points) → delete / copy / convert mask
|
||||
* Delete / Backspace (wand or lasso) → delete pixels
|
||||
*
|
||||
* @param {{
|
||||
@@ -46,9 +45,7 @@
|
||||
* wandCopyToNewLayer: () => void,
|
||||
* lassoDeleteSelection: () => void,
|
||||
* lassoCopyToLayer: () => void,
|
||||
* lassoToMask: () => void,
|
||||
* buildLassoMask: (w: number, h: number, offX: number, offY: number, feather: number, grow: number) => HTMLCanvasElement,
|
||||
* drawLassoOverlay: () => void,
|
||||
* copyPixelsToClipboard: (options: {cut: boolean}) => Promise<void>,
|
||||
* activeLayer: () => object | null,
|
||||
* deleteSelectedLayers: () => boolean | Promise<boolean>,
|
||||
* duplicateActiveLayer: () => boolean,
|
||||
@@ -57,18 +54,22 @@
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { isAltGrEvent } from '../platform.js';
|
||||
import { createMarqueeMask, selectionMaskForLayer } from './selection-mask.js';
|
||||
import { createMarqueeMask } from './selection-mask.js';
|
||||
|
||||
let keyboardBindings;
|
||||
|
||||
export function wireKeyboardShortcuts(deps) {
|
||||
keyboardBindings?.abort();
|
||||
keyboardBindings = new AbortController();
|
||||
const { signal } = keyboardBindings;
|
||||
const {
|
||||
toolbar, toolKeyMap,
|
||||
composite, saveState, undo, redo,
|
||||
toggleShortcuts, confirmTransform, cancelTransform, startTransform, nudgeTransform,
|
||||
resizeCustomPrompt, addEmptyLayer, brushSizeSync,
|
||||
invertSelection,
|
||||
wandDeleteSelection, wandCopyToNewLayer,
|
||||
lassoDeleteSelection, lassoCopyToLayer, lassoToMask,
|
||||
buildLassoMask, drawLassoOverlay,
|
||||
wandDeleteSelection, wandCopyToNewLayer, copyPixelsToClipboard,
|
||||
lassoDeleteSelection, lassoCopyToLayer,
|
||||
activeLayer, deleteSelectedLayers, duplicateActiveLayer, uiModule,
|
||||
setTemporaryPan,
|
||||
nudgeActiveLayer, endLayerNudge,
|
||||
@@ -77,18 +78,22 @@ export function wireKeyboardShortcuts(deps) {
|
||||
} = deps;
|
||||
|
||||
const isTypingTarget = (target) => target && (
|
||||
target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
|
||||
target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' || target.isContentEditable
|
||||
);
|
||||
|
||||
const releaseTemporaryPan = () => setTemporaryPan?.(false);
|
||||
document.addEventListener('keyup', (e) => {
|
||||
if (e.code === 'Space') releaseTemporaryPan();
|
||||
if (e.key.startsWith('Arrow')) endLayerNudge?.();
|
||||
});
|
||||
window.addEventListener('blur', releaseTemporaryPan);
|
||||
}, { signal });
|
||||
window.addEventListener('blur', releaseTemporaryPan, { signal });
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!state.editorOpen) return;
|
||||
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;
|
||||
if (e.code === 'Space' && !isTypingTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
setTemporaryPan?.(true);
|
||||
@@ -145,22 +150,26 @@ export function wireKeyboardShortcuts(deps) {
|
||||
// still act — AltGr+5 / AltGr+8 stay as the [ ] brush-size shortcut on
|
||||
// AZERTY / QWERTZ.
|
||||
if ((e.ctrlKey || e.metaKey) && !isAltGrEvent(e)) {
|
||||
if (e.key === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); }
|
||||
// Ctrl+Shift+D = Deselect: clears the wand selection (and
|
||||
// lasso if active) without affecting layers.
|
||||
if (e.shiftKey && (e.key === 'D' || e.key === 'd')) {
|
||||
if (state.wandMask || state.lassoPoints.length) {
|
||||
e.preventDefault();
|
||||
deselectSelection?.();
|
||||
}
|
||||
if (e.key.toLowerCase() === 'z') {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
if (e.shiftKey) redo(); else undo();
|
||||
return;
|
||||
}
|
||||
// Preserve the old chord as an alias while supporting the familiar one.
|
||||
if (!e.altKey && e.key.toLowerCase() === 'd') {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
deselectSelection?.();
|
||||
return;
|
||||
}
|
||||
// Save shortcuts — match the hints shown in the Save dropdown.
|
||||
if ((e.key === 's' || e.key === 'S') && !e.altKey) {
|
||||
e.preventDefault();
|
||||
document.getElementById(e.shiftKey ? 'ge-export-gallery' : 'ge-save')?.click();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey && e.key === 'T') { e.preventDefault(); resizeCustomPrompt(); }
|
||||
if (e.altKey && e.key === 't') { e.preventDefault(); startTransform(); }
|
||||
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+Alt+I — invert current selection. Uses e.code so
|
||||
// Alt-modified key values (e.g. `ˆ` on Mac with Option+I)
|
||||
// don't break the match.
|
||||
@@ -169,19 +178,23 @@ export function wireKeyboardShortcuts(deps) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Ctrl+Alt+J — new empty layer.
|
||||
if (e.altKey && e.code === 'KeyJ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
addEmptyLayer();
|
||||
return;
|
||||
}
|
||||
// Ctrl/Cmd+J duplicates the active layer through the layer panel's
|
||||
// existing implementation, which preserves masks and effects.
|
||||
if (!e.altKey && e.code === 'KeyJ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
duplicateActiveLayer?.();
|
||||
if (state.wandMask) wandCopyToNewLayer();
|
||||
else if (state.lassoPoints.length >= 3) lassoCopyToLayer();
|
||||
else duplicateActiveLayer?.();
|
||||
return;
|
||||
}
|
||||
// Ctrl+Alt+G — Photoshop-compatible clipping mask shortcut.
|
||||
@@ -194,128 +207,18 @@ export function wireKeyboardShortcuts(deps) {
|
||||
e.stopPropagation();
|
||||
button.click();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Wand selection: Delete = erase pixels. Ctrl+X = cut to
|
||||
// clipboard + new layer + erase. Ctrl+C = copy.
|
||||
// (Legacy `&& !_wandActive` clause referenced an undeclared
|
||||
// variable — removed; the wand is selection-only and has no
|
||||
// "active drag" state.)
|
||||
if (state.wandMask) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
wandDeleteSelection();
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'x' || e.key === 'c')) {
|
||||
e.preventDefault();
|
||||
const isCut = e.key === 'x';
|
||||
const src = activeLayer();
|
||||
if (!src) return;
|
||||
// Clip source by wand mask into a temp canvas.
|
||||
const w = src.canvas.width, h = src.canvas.height;
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = w; tmp.height = h;
|
||||
const tCtx = tmp.getContext('2d');
|
||||
tCtx.drawImage(src.canvas, 0, 0);
|
||||
tCtx.globalCompositeOperation = 'destination-in';
|
||||
const off = state.layerOffsets.get(src.id) || { x: 0, y: 0 };
|
||||
tCtx.drawImage(selectionMaskForLayer(
|
||||
state.wandMask,
|
||||
state.wandMaskSpace || 'layer',
|
||||
off,
|
||||
w,
|
||||
h,
|
||||
), 0, 0);
|
||||
state.internalClipboard = tmp;
|
||||
tmp.toBlob(blob => {
|
||||
if (blob && navigator.clipboard?.write) {
|
||||
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => {
|
||||
uiModule.showToast(isCut ? 'Cut to clipboard' : 'Copied to clipboard');
|
||||
}).catch(() => uiModule.showToast(isCut ? 'Cut (editor only)' : 'Copied (editor only)'));
|
||||
}
|
||||
}, 'image/png');
|
||||
if (isCut) {
|
||||
// Cut is one user action: make one history checkpoint, then move
|
||||
// the selected pixels and erase the source without nested saves.
|
||||
saveState('Cut selection');
|
||||
const cutLayer = wandCopyToNewLayer({ saveHistory: false, activate: false, announce: false });
|
||||
wandDeleteSelection({ saveHistory: false, message: 'Selection cut' });
|
||||
if (cutLayer) {
|
||||
state.activeLayerId = cutLayer.id;
|
||||
document.querySelectorAll('.ge-layer-item[data-layer-id]').forEach(row => {
|
||||
row.classList.toggle('active', row.dataset.layerId === cutLayer.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((e.key === 'x' || e.key === 'c') && state.lassoPoints.length >= 3) {
|
||||
if (!e.altKey && (e.key.toLowerCase() === 'c' || e.key.toLowerCase() === 'x')) {
|
||||
e.preventDefault();
|
||||
const layer = activeLayer();
|
||||
if (!layer) return;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const feather = parseInt(document.getElementById('ge-lasso-feather')?.value || '0');
|
||||
const grow = parseInt(document.getElementById('ge-lasso-grow')?.value || '0');
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
const mask = buildLassoMask(w, h, off.x, off.y, feather, grow);
|
||||
const srcData = layer.ctx.getImageData(0, 0, w, h);
|
||||
const maskData = mask.getContext('2d').getImageData(0, 0, w, h);
|
||||
// Build clipped image.
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = w; tmp.height = h;
|
||||
const tCtx = tmp.getContext('2d');
|
||||
const outData = tCtx.createImageData(w, h);
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
const mv = maskData.data[i * 4] / 255;
|
||||
if (mv > 0) {
|
||||
outData.data[i*4] = srcData.data[i*4];
|
||||
outData.data[i*4+1] = srcData.data[i*4+1];
|
||||
outData.data[i*4+2] = srcData.data[i*4+2];
|
||||
outData.data[i*4+3] = Math.round(srcData.data[i*4+3] * mv);
|
||||
}
|
||||
}
|
||||
tCtx.putImageData(outData, 0, 0);
|
||||
state.internalClipboard = tmp;
|
||||
const isCut = e.key === 'x';
|
||||
tmp.toBlob(blob => {
|
||||
if (blob && navigator.clipboard?.write) {
|
||||
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => {
|
||||
uiModule.showToast(isCut ? 'Cut to clipboard' : 'Copied to clipboard');
|
||||
}).catch(() => uiModule.showToast(isCut ? 'Cut (editor only)' : 'Copied (editor only)'));
|
||||
}
|
||||
}, 'image/png');
|
||||
if (e.key === 'x') {
|
||||
const savedPts = [...state.lassoPoints];
|
||||
state.lassoPoints = savedPts;
|
||||
lassoDeleteSelection();
|
||||
} else {
|
||||
state.lassoPoints = [];
|
||||
composite();
|
||||
}
|
||||
e.stopPropagation();
|
||||
void copyPixelsToClipboard({ cut: e.key.toLowerCase() === 'x' });
|
||||
return;
|
||||
}
|
||||
// Ctrl+C with no active selection → copy the entire active layer
|
||||
// to the system clipboard as a PNG. Gives a "just copy this image"
|
||||
// shortcut without having to lasso-select-all first. The
|
||||
// selection-aware Ctrl+C paths above run first (wand + lasso),
|
||||
// so this only fires when neither is active.
|
||||
if (e.key === 'c' && !e.shiftKey && !state.wandMask && state.lassoPoints.length < 3) {
|
||||
const layer = activeLayer();
|
||||
if (layer && layer.canvas && layer.canvas.width > 0) {
|
||||
e.preventDefault();
|
||||
layer.canvas.toBlob(blob => {
|
||||
if (blob && navigator.clipboard?.write) {
|
||||
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||
.then(() => uiModule.showToast('Layer copied to clipboard'))
|
||||
.catch(() => uiModule.showToast('Copy failed (clipboard permission denied?)'));
|
||||
}
|
||||
}, 'image/png');
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Ctrl+Alt+A = select all canvas.
|
||||
if (e.altKey && e.key === 'a' && state.imgWidth > 0 && state.imgHeight > 0) {
|
||||
// Keep Ctrl+Alt+A as an alias for existing users.
|
||||
if (e.key.toLowerCase() === 'a' && state.imgWidth > 0 && state.imgHeight > 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
saveState('Select all');
|
||||
state.wandMask = createMarqueeMask(
|
||||
state.imgWidth,
|
||||
@@ -375,8 +278,11 @@ export function wireKeyboardShortcuts(deps) {
|
||||
}
|
||||
const toolId = toolKeyMap[e.key.toLowerCase()];
|
||||
if (toolId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const toolBtn = toolbar.querySelector(`[data-tool="${toolId}"]`);
|
||||
if (toolBtn) toolBtn.click();
|
||||
return;
|
||||
}
|
||||
// Bracket keys for brush size — ±10% multiplier mirrors the
|
||||
// exponential slider curve so each press feels the same at any
|
||||
@@ -386,12 +292,5 @@ export function wireKeyboardShortcuts(deps) {
|
||||
state.brushSize = Math.max(1, Math.min(800, Math.round(state.brushSize * factor)));
|
||||
try { brushSizeSync(null); } catch {}
|
||||
}
|
||||
// Lasso shortcuts (when selection exists).
|
||||
if (state.lassoPoints.length >= 3) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); lassoDeleteSelection(); }
|
||||
if (e.key === 'd') { e.preventDefault(); lassoDeleteSelection(); }
|
||||
if (e.key === 'c') { e.preventDefault(); lassoCopyToLayer(); }
|
||||
if (e.key === 'm') { e.preventDefault(); lassoToMask(); }
|
||||
}
|
||||
});
|
||||
}, { signal });
|
||||
}
|
||||
|
||||
@@ -117,6 +117,43 @@ export function createLayerPanelRenderer(deps) {
|
||||
: [layer];
|
||||
}
|
||||
|
||||
let previewSession;
|
||||
const previewSignatures = new Map();
|
||||
|
||||
function refreshPreviews() {
|
||||
const list = document.getElementById('ge-layers-list');
|
||||
if (!list || !state.editorOpen) return;
|
||||
if (previewSession !== state.editorSessionToken) {
|
||||
previewSession = state.editorSessionToken;
|
||||
previewSignatures.clear();
|
||||
}
|
||||
const initialized = previewSignatures.size > 0;
|
||||
const currentIds = new Set();
|
||||
for (const row of list.children) {
|
||||
const id = row.dataset.layerId || row.dataset.groupId || row.dataset.maskId || row.dataset.groupMaskId;
|
||||
const thumb = row.querySelector('.ge-layer-inline-thumb');
|
||||
if (!id || !thumb) continue;
|
||||
currentIds.add(id);
|
||||
thumb._refreshPreview?.();
|
||||
const layer = state.layers.find(item => item.id === id);
|
||||
const signature = thumb.toDataURL() + JSON.stringify(layer ? {
|
||||
offset: state.layerOffsets.get(id), opacity: layer.opacity,
|
||||
visible: layer.visible, locked: layer.locked, name: layer.name,
|
||||
} : {});
|
||||
const previous = previewSignatures.get(id);
|
||||
previewSignatures.set(id, signature);
|
||||
if (initialized && previous !== signature) {
|
||||
row.classList.remove('ge-layer-action-flash');
|
||||
void row.offsetWidth;
|
||||
row.classList.add('ge-layer-action-flash');
|
||||
row.addEventListener('animationend', () => row.classList.remove('ge-layer-action-flash'), { once: true });
|
||||
}
|
||||
}
|
||||
for (const id of previewSignatures.keys()) {
|
||||
if (!currentIds.has(id)) previewSignatures.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function createInlineThumbnail(source, title, extraClass = '') {
|
||||
const thumb = document.createElement('canvas');
|
||||
thumb.className = `ge-layer-inline-thumb${extraClass ? ` ${extraClass}` : ''}`;
|
||||
@@ -126,23 +163,28 @@ export function createLayerPanelRenderer(deps) {
|
||||
thumb.setAttribute('role', 'img');
|
||||
thumb.setAttribute('aria-label', title);
|
||||
const ctx = thumb.getContext('2d');
|
||||
if (!ctx || !source?.width || !source?.height) return thumb;
|
||||
const tile = 8;
|
||||
for (let y = 0; y < thumb.height; y += tile) {
|
||||
for (let x = 0; x < thumb.width; x += tile) {
|
||||
ctx.fillStyle = ((x / tile + y / tile) & 1) ? '#464646' : '#303030';
|
||||
ctx.fillRect(x, y, tile, tile);
|
||||
const draw = () => {
|
||||
const image = typeof source === 'function' ? source() : source;
|
||||
if (!ctx || !image?.width || !image?.height) return;
|
||||
const tile = 8;
|
||||
for (let y = 0; y < thumb.height; y += tile) {
|
||||
for (let x = 0; x < thumb.width; x += tile) {
|
||||
ctx.fillStyle = ((x / tile + y / tile) & 1) ? '#464646' : '#303030';
|
||||
ctx.fillRect(x, y, tile, tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
const scale = Math.min(thumb.width / source.width, thumb.height / source.height);
|
||||
const width = source.width * scale;
|
||||
const height = source.height * scale;
|
||||
ctx.drawImage(source, (thumb.width - width) / 2, (thumb.height - height) / 2, width, height);
|
||||
const scale = Math.min(thumb.width / image.width, thumb.height / image.height);
|
||||
const width = image.width * scale;
|
||||
const height = image.height * scale;
|
||||
ctx.drawImage(image, (thumb.width - width) / 2, (thumb.height - height) / 2, width, height);
|
||||
};
|
||||
thumb._refreshPreview = draw;
|
||||
draw();
|
||||
return thumb;
|
||||
}
|
||||
|
||||
function createMaskThumbnail(mask) {
|
||||
return createInlineThumbnail(mask.canvas, `${mask.name || 'Mask'} preview`, 'ge-mask-inline-thumb');
|
||||
return createInlineThumbnail(() => mask.canvas, `${mask.name || 'Mask'} preview`, 'ge-mask-inline-thumb');
|
||||
}
|
||||
|
||||
function applyLayerMask(layer, mask) {
|
||||
@@ -1152,8 +1194,7 @@ export function createLayerPanelRenderer(deps) {
|
||||
// Retained text/shape layers keep their editable metadata separately
|
||||
// from the raster canvas. Use the normal renderer when available so
|
||||
// their layer previews match what the document actually displays.
|
||||
const previewCanvas = renderLayer?.(layer) || layer.canvas;
|
||||
const thumb = createInlineThumbnail(previewCanvas, `${layer.name} preview`);
|
||||
const thumb = createInlineThumbnail(() => renderLayer?.(layer) || layer.canvas, `${layer.name} preview`);
|
||||
item.appendChild(thumb);
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
@@ -2062,6 +2103,7 @@ export function createLayerPanelRenderer(deps) {
|
||||
// shortcuts so undo, confirmations, and layer normalization stay aligned.
|
||||
return {
|
||||
render,
|
||||
refreshPreviews,
|
||||
deleteSelectedLayers: () => deleteLayers(selectedLayers(state)),
|
||||
duplicateActiveLayer: () => {
|
||||
const button = [...document.querySelectorAll('button[title="Duplicate layer"]')]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export function selectionModeForEvent(event, fallback = 'replace') {
|
||||
if (event.shiftKey && event.altKey) return 'intersect';
|
||||
if (event.shiftKey) return 'add';
|
||||
if (event.altKey) return 'subtract';
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Shared by the palette, dispatch map and shortcut reference.
|
||||
export const TOOL_SHORTCUTS = Object.freeze({
|
||||
move: 'V', hand: 'H', crop: 'C', text: 'T', shape: 'U',
|
||||
brush: 'B', gradient: 'G', eraser: 'E', eyedropper: 'I',
|
||||
smudge: 'Y', clone: 'S', heal: 'J', dodge: 'O',
|
||||
marquee: 'M', lasso: 'L', wand: 'W',
|
||||
});
|
||||
@@ -13,6 +13,7 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { selectionModeForEvent } from '../selection-modifiers.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import { buildLassoMask } from './lasso-mask.js';
|
||||
|
||||
@@ -23,9 +24,7 @@ export function createLassoTool({
|
||||
let pendingMode = 'replace';
|
||||
return {
|
||||
begin(e) {
|
||||
pendingMode = state.wandMode || 'replace';
|
||||
if (e.shiftKey) pendingMode = 'add';
|
||||
else if (e.altKey) pendingMode = 'subtract';
|
||||
pendingMode = selectionModeForEvent(e, state.wandMode || 'replace');
|
||||
state.lassoPoints = [];
|
||||
state.lassoActive = true;
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Rectangle and ellipse marquee interaction producing a document-space mask. */
|
||||
|
||||
import { state } from '../state.js';
|
||||
import { selectionModeForEvent } from '../selection-modifiers.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import {
|
||||
createMarqueeMask,
|
||||
@@ -60,9 +61,7 @@ export function createMarqueeTool({
|
||||
gesture.begin(e, { mode: 'move' }, { captureTarget: e.currentTarget });
|
||||
return;
|
||||
}
|
||||
pendingMode = state.wandMode || 'replace';
|
||||
if (e.shiftKey) pendingMode = 'add';
|
||||
else if (e.altKey) pendingMode = 'subtract';
|
||||
pendingMode = selectionModeForEvent(e, state.wandMode || 'replace');
|
||||
state.marqueeStart = coords;
|
||||
state.marqueeRect = normalizeConstrainedSelectionRect(
|
||||
coords,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { selectionModeForEvent } from '../selection-modifiers.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
export function createWandTool({ activeLayer, saveState, composite, wandHits, runMagicWand, deselectSelection }) {
|
||||
@@ -28,9 +29,7 @@ export function createWandTool({ activeLayer, saveState, composite, wandHits, ru
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
// Persistent toggle sets the default mode; Shift forces add, Alt
|
||||
// forces subtract regardless of the toggle (modifiers always win).
|
||||
let mode = state.wandMode || 'replace';
|
||||
if (e.shiftKey) mode = 'add';
|
||||
else if (e.altKey) mode = 'subtract';
|
||||
const mode = selectionModeForEvent(e, state.wandMode || 'replace');
|
||||
// Click INSIDE the existing selection with no modifier → deselect.
|
||||
if (mode === 'replace' && wandHits(coords.x, coords.y)) {
|
||||
if (deselectSelection) {
|
||||
|
||||
@@ -15,7 +15,12 @@
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
let disposeTopbar;
|
||||
|
||||
export function wireTopbarOverflow({ container }) {
|
||||
disposeTopbar?.();
|
||||
const cleanups = [];
|
||||
disposeTopbar = () => cleanups.forEach(cleanup => cleanup());
|
||||
// Canvas-size badge updater (kept simple — it lives in the topbar).
|
||||
const sizeLabel = document.getElementById('ge-canvas-size');
|
||||
function updateSizeLabel() {
|
||||
@@ -30,33 +35,53 @@ export function wireTopbarOverflow({ container }) {
|
||||
container.querySelector('#ge-ai-model'),
|
||||
...container.querySelectorAll('.ge-topbar span[style*="font-size:9px"]'),
|
||||
].filter(Boolean);
|
||||
let lastWidth = 0;
|
||||
|
||||
// Native popovers keep the existing DOM/event ownership while escaping
|
||||
// the toolbar's horizontal scroll clip and the editor's stacking context.
|
||||
topbar?.querySelectorAll('.dropdown[hidden]').forEach(menu => {
|
||||
if (!menu.showPopover) return;
|
||||
const anchor = menu.parentElement.querySelector('button');
|
||||
if (!anchor) return;
|
||||
menu.setAttribute('popover', 'manual');
|
||||
const position = () => {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
menu.style.top = `${rect.bottom + 4}px`;
|
||||
menu.style.right = `${Math.max(8, window.innerWidth - rect.right)}px`;
|
||||
menu.style.left = 'auto';
|
||||
};
|
||||
const sync = () => {
|
||||
if (menu.hidden) {
|
||||
if (menu.matches(':popover-open')) menu.hidePopover();
|
||||
} else if (menu.isConnected) {
|
||||
position();
|
||||
if (!menu.matches(':popover-open')) menu.showPopover();
|
||||
}
|
||||
};
|
||||
const observer = new MutationObserver(sync);
|
||||
observer.observe(menu, { attributes: true, attributeFilter: ['hidden'] });
|
||||
topbar.addEventListener('scroll', position, { passive: true });
|
||||
window.addEventListener('resize', position);
|
||||
cleanups.push(() => {
|
||||
observer.disconnect();
|
||||
topbar.removeEventListener('scroll', position);
|
||||
window.removeEventListener('resize', position);
|
||||
if (menu.matches(':popover-open')) menu.hidePopover();
|
||||
});
|
||||
});
|
||||
|
||||
function syncOverflow() {
|
||||
if (!topbar) return;
|
||||
// The reflow changes the topbar height but not its width. Ignore that
|
||||
// follow-up ResizeObserver notification so the class does not oscillate.
|
||||
const width = topbar.clientWidth;
|
||||
if (width === lastWidth && topbar.classList.contains('ge-topbar-overflow')) return;
|
||||
lastWidth = width;
|
||||
topbar.classList.remove('ge-topbar-overflow');
|
||||
aiGroup.forEach(el => { el.style.display = ''; });
|
||||
if (topbar.scrollWidth > topbar.clientWidth) {
|
||||
// Hide AI group first — bulky and least essential at narrow widths.
|
||||
aiGroup.forEach(el => { el.style.display = 'none'; });
|
||||
// If the essential controls still do not fit, make the right side a
|
||||
// second row instead of letting Save and its menu fall outside the
|
||||
// editor window.
|
||||
const isMobile = window.matchMedia?.('(max-width: 700px)').matches;
|
||||
if (!isMobile && topbar.scrollWidth > topbar.clientWidth) {
|
||||
topbar.classList.add('ge-topbar-overflow');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (topbar && window.ResizeObserver) {
|
||||
const ro = new ResizeObserver(() => syncOverflow());
|
||||
ro.observe(topbar);
|
||||
cleanups.push(() => ro.disconnect());
|
||||
}
|
||||
// Initial pass after layout settles.
|
||||
requestAnimationFrame(syncOverflow);
|
||||
|
||||
@@ -150,7 +150,7 @@ export function wireTopbar(deps) {
|
||||
});
|
||||
|
||||
// Edge popup — Width input + Feather / Delete action buttons.
|
||||
function applyEdgeAction(hardDelete) {
|
||||
async function applyEdgeAction(hardDelete) {
|
||||
const layer = activeLayer();
|
||||
if (!layer || isLayerPixelLocked(state, layer) || isLayerTransparencyLocked(state, layer)) {
|
||||
uiModule.showToast('Unlock image and transparent pixels before changing edges');
|
||||
@@ -159,8 +159,7 @@ export function wireTopbar(deps) {
|
||||
const widthInput = document.getElementById('ge-edge-width');
|
||||
const width = parseInt(widthInput?.value || '8');
|
||||
if (isNaN(width) || width < 1) { uiModule.showToast('Invalid width'); return; }
|
||||
saveState();
|
||||
applyEdgeFeather(layer, width, hardDelete);
|
||||
if (!await applyEdgeFeather(layer, width, hardDelete)) return;
|
||||
composite();
|
||||
uiModule.showToast(hardDelete ? `Edges deleted ${width}px` : `Edges feathered ${width}px`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user