mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 11:52:25 +02:00
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>
270 lines
20 KiB
JavaScript
270 lines
20 KiB
JavaScript
/**
|
|
* Build the editor's left-side tool palette.
|
|
*
|
|
* Pure DOM construction — no module state. The big tool-switch logic
|
|
* (cursor swap, control-section toggle, transform entry, inpaint
|
|
* mask plumbing, etc.) stays in the caller and arrives here as the
|
|
* `onSelectTool` callback.
|
|
*
|
|
* 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'|'sam') => void,
|
|
* }} ctx
|
|
* @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 toolKeyMap = {};
|
|
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);
|
|
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>${clearBadge}`;
|
|
btn.querySelector('.ge-tool-clear')?.addEventListener('click', (ev) => {
|
|
ev.stopPropagation();
|
|
onClearSelection(ev.currentTarget.dataset.clearTool);
|
|
});
|
|
};
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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 };
|
|
}
|