Files
odysseus/static/js/editor/build/dock.js
T
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

497 lines
20 KiB
JavaScript

/**
* 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; },
};
}