Lineage subway tree view + viewer/modal polish

- Lineage "subway" lanes: branch columns, spine + S-curve connectors, per-node
  expand/fold, header expand-all toggle, right-click Copy Path
- Lineage fixes: show family when an image is opened by file path; new-gen banner
  moved off the lineage header to top-center; transparent scrollbar corner;
  hover-only branch controls; stale-refetch staleness guard + edge-keyed
  connectors + anomaly instrumentation
- Modal-setup persistence: 3D scene, inpaint mask, prompt, upscale dials restore
  across reopen
- Re-pose modal: auto-restore last setup, Re-Apply, pan/zoom + before/after Compare
- Upscale modal: before/after Compare wipe
- Pose Studio: name-your-file scene export; $name{} block rename mirroring
- docs: README wordmark hero
This commit is contained in:
chrmc40
2026-06-18 22:06:57 -07:00
parent 4a3eb29968
commit 68cbef51ec
27 changed files with 2477 additions and 1300 deletions
+17
View File
@@ -204,6 +204,23 @@ async def open_file(request):
return web.json_response({"ok": True})
@routes.get("/promptchain/file-path")
async def file_path(request):
"""Return the resolved absolute disk path for an image so the viewer can copy
it to the clipboard.
Loopback-only: the path is a SERVER-machine filesystem path — meaningless on a
remote client and not worth leaking. The frontend only offers Copy Path to
local clients; this is the backstop.
"""
if request.remote not in ("127.0.0.1", "::1"):
return error_response("file path is only available from the server machine", 403)
path, err = _resolve_request_path(request)
if err:
return error_response(err, 404)
return web.json_response({"path": os.path.normpath(path)})
def _show_file_properties_win(path):
"""Show the native Windows file-properties sheet for `path`.
@@ -1,5 +1,5 @@
import { d as delegate, p as push, a as prop, u as user_effect, D as comment, v as first_child, i as if_block, k as append, l as pop, g as get, s as state, f as sibling, o as bind_this, e as set, t as template_effect, y as set_text, j as delegated, A as from_html, n as child } from "./disclose-version-BjTnIIw0.js";
import { a as action } from "./actions-Ckx5huYg.js";
import { d as delegate, p as push, a as prop, u as user_effect, D as comment, v as first_child, i as if_block, k as append, l as pop, g as get, s as state, f as sibling, o as bind_this, e as set, t as template_effect, y as set_text, j as delegated, A as from_html, n as child } from "./disclose-version-uq4tn5Y6.js";
import { a as action } from "./actions-WPfqiWYB.js";
function portal(node) {
document.body.appendChild(node);
return { destroy() {
@@ -87,4 +87,4 @@ export {
ConfirmModal as C,
portal as p
};
//# sourceMappingURL=ConfirmModal-DSeVjwuq.js.map
//# sourceMappingURL=ConfirmModal-BGcVYndw.js.map
@@ -1,9 +1,9 @@
import { d as delegate, p as push, a as prop, u as user_effect, i as if_block, g as get, o as bind_this, t as template_effect, k as append, l as pop, m as user_derived, n as child, v as first_child, f as sibling, j as delegated, r as event, A as from_html, s as state, y as set_text, w as each, h as set_class, z as index, e as set, c as proxy, C as tick, x as set_attribute, D as comment } from "./disclose-version-BjTnIIw0.js";
import { s as set_style } from "./style-CbOHK2KU.js";
import { d as delegate, p as push, a as prop, u as user_effect, i as if_block, g as get, o as bind_this, t as template_effect, k as append, l as pop, m as user_derived, n as child, v as first_child, f as sibling, j as delegated, r as event, A as from_html, s as state, y as set_text, w as each, h as set_class, z as index, e as set, c as proxy, C as tick, x as set_attribute, D as comment } from "./disclose-version-uq4tn5Y6.js";
import { s as set_style } from "./style-Boi27oOu.js";
import { app } from "/scripts/app.js";
import { j as justifiedLayout } from "./justified-layout-cyVM7i96.js";
import { a as action } from "./actions-Ckx5huYg.js";
import { p as portal, C as ConfirmModal } from "./ConfirmModal-DSeVjwuq.js";
import { a as action } from "./actions-WPfqiWYB.js";
import { p as portal, C as ConfirmModal } from "./ConfirmModal-BGcVYndw.js";
const GALLERY_ROW_HEIGHT_MIN = 40;
const GALLERY_ROW_HEIGHT_MAX = 400;
const ZOOM_FACTOR = 1.15;
@@ -782,4 +782,4 @@ export {
clampGalleryRowHeight as c,
zoomGalleryRowHeight as z
};
//# sourceMappingURL=GeneratedGallery-D27T_RcN.js.map
//# sourceMappingURL=GeneratedGallery-Dw0CC3-N.js.map
@@ -1,10 +1,10 @@
import { d as delegate, p as push, a as prop, u as user_effect, g as get, e as set, v as first_child, i as if_block, f as sibling, k as append, l as pop, s as state, m as user_derived, o as bind_this, j as delegated, n as child, D as comment, w as each, z as index, A as from_html, t as template_effect, h as set_class, r as event, y as set_text, x as set_attribute } from "./disclose-version-BjTnIIw0.js";
import { o as onDestroy } from "./index-client-m0VtlDjX.js";
import { h as html, P as PopupAnchor } from "./PopupAnchor-sxqUMRLP.js";
import { a as action } from "./actions-Ckx5huYg.js";
import { s as set_style } from "./style-CbOHK2KU.js";
import { d as delegate, p as push, a as prop, u as user_effect, g as get, e as set, v as first_child, i as if_block, f as sibling, k as append, l as pop, s as state, m as user_derived, o as bind_this, j as delegated, n as child, D as comment, w as each, z as index, A as from_html, t as template_effect, h as set_class, r as event, y as set_text, x as set_attribute } from "./disclose-version-uq4tn5Y6.js";
import { o as onDestroy } from "./index-client-iMRCrpBY.js";
import { h as html, P as PopupAnchor } from "./PopupAnchor-C6OWGGTI.js";
import { a as action } from "./actions-WPfqiWYB.js";
import { s as set_style } from "./style-Boi27oOu.js";
import { app } from "/scripts/app.js";
import { b as bind_value } from "./input-Bjai8x-c.js";
import { b as bind_value } from "./input-DFQhebEz.js";
var root_1$2 = from_html(`<div class="pcr-mode-menu-search-container"><input type="text" class="pcr-mode-menu-search" placeholder="Search options..."/></div> <div class="pcr-mode-menu-separator"></div>`, 1);
var root_2$2 = from_html(`<div class="pcr-mode-menu-empty">No matching options</div>`);
var root_5$1 = from_html(`<span class="pcr-mode-menu-check"></span>`);
@@ -592,4 +592,4 @@ export {
Menubar as M,
SearchableList as S
};
//# sourceMappingURL=Menubar-PsG9jYP0.js.map
//# sourceMappingURL=Menubar-DI-z1NzN.js.map
@@ -1,5 +1,5 @@
import { t as template_effect, R as active_effect, S as assign_nodes, T as get_first_child, U as remove_effect_dom, V as create_element, W as NAMESPACE_SVG, X as NAMESPACE_MATHML, b as block, Y as EFFECT_TRANSPARENT, B as BranchManager, c as proxy, d as delegate, p as push, a as prop, u as user_effect, o as bind_this, j as delegated, k as append, l as pop, A as from_html, n as child } from "./disclose-version-BjTnIIw0.js";
import { a as onMount, o as onDestroy } from "./index-client-m0VtlDjX.js";
import { t as template_effect, S as active_effect, T as assign_nodes, U as get_first_child, V as remove_effect_dom, W as create_element, X as NAMESPACE_SVG, Y as NAMESPACE_MATHML, b as block, Z as EFFECT_TRANSPARENT, B as BranchManager, c as proxy, d as delegate, p as push, a as prop, u as user_effect, o as bind_this, j as delegated, k as append, l as pop, A as from_html, n as child } from "./disclose-version-uq4tn5Y6.js";
import { a as onMount, o as onDestroy } from "./index-client-iMRCrpBY.js";
function html(node, get_value, is_controlled = false, svg = false, mathml = false, skip_warning = false) {
var anchor = node;
var value = "";
@@ -155,4 +155,4 @@ export {
PopupAnchor as P,
html as h
};
//# sourceMappingURL=PopupAnchor-sxqUMRLP.js.map
//# sourceMappingURL=PopupAnchor-C6OWGGTI.js.map
@@ -1,5 +1,5 @@
import { d as delegate, p as push, a as prop, s as state, c as proxy, u as user_effect, e as set, i as if_block, g as get, f as sibling, t as template_effect, x as set_attribute, q as set_value, j as delegated, k as append, l as pop, m as user_derived, n as child, o as bind_this, w as each, h as set_class, y as set_text, z as index, A as from_html } from "./disclose-version-BjTnIIw0.js";
import { s as set_style } from "./style-CbOHK2KU.js";
import { d as delegate, p as push, a as prop, s as state, c as proxy, u as user_effect, e as set, i as if_block, g as get, f as sibling, t as template_effect, x as set_attribute, q as set_value, j as delegated, k as append, l as pop, m as user_derived, n as child, o as bind_this, w as each, h as set_class, y as set_text, z as index, A as from_html } from "./disclose-version-uq4tn5Y6.js";
import { s as set_style } from "./style-Boi27oOu.js";
var root_2 = from_html(`<div class="pcr-slider-zone svelte-nuvs0d"></div>`);
var root_3 = from_html(`<div class="pcr-slider-marker svelte-nuvs0d"></div>`);
var root_4 = from_html(`<div><span class="pcr-slider-tick-label svelte-nuvs0d"> </span></div>`);
@@ -267,4 +267,4 @@ delegate([
export {
SettingsSlider as S
};
//# sourceMappingURL=SettingsSlider-Bxw-taga.js.map
//# sourceMappingURL=SettingsSlider-CKF_XmgB.js.map
@@ -1,4 +1,4 @@
import { a3 as effect, E as untrack, a8 as render_effect, a9 as deep_read_state, aa as safe_not_equal } from "./disclose-version-BjTnIIw0.js";
import { a4 as effect, E as untrack, a9 as render_effect, aa as deep_read_state, ab as safe_not_equal } from "./disclose-version-uq4tn5Y6.js";
function action(dom, action2, get_value) {
effect(() => {
var payload = untrack(() => action2(dom, get_value == null ? void 0 : get_value()) || {});
@@ -29,4 +29,4 @@ function action(dom, action2, get_value) {
export {
action as a
};
//# sourceMappingURL=actions-Ckx5huYg.js.map
//# sourceMappingURL=actions-WPfqiWYB.js.map
@@ -1,4 +1,4 @@
import { O as setContext, N as getContext } from "./disclose-version-BjTnIIw0.js";
import { P as setContext, O as getContext } from "./disclose-version-uq4tn5Y6.js";
const API_KEY = "pcr-api";
function provideApi(api) {
setContext(API_KEY, api);
@@ -41,4 +41,4 @@ export {
safeJson as s,
useApi as u
};
//# sourceMappingURL=api-context-D3RUFV-0.js.map
//# sourceMappingURL=api-context-BFKo1mCD.js.map
+132 -4
View File
@@ -1374,12 +1374,16 @@
height: 36px;
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between;
gap: 6px;
padding: 0 8px;
box-sizing: border-box;
font-size: 11px;
color: #888;
border-bottom: 1px solid #222;
flex-shrink: 0;
}
.pcr-lin-count.svelte-5cciiw { white-space: nowrap; }
.pcr-viewer-lineage-strip.svelte-5cciiw {
flex: 1;
overflow-y: auto;
@@ -1513,6 +1517,129 @@
color: #666;
flex-shrink: 0;
}
/* ── subway lanes: the rail grows horizontally into parallel vertical lanes ── */
.pcr-viewer-lineage-visible.lanes.svelte-5cciiw {
width: auto;
max-width: 62vw;
}
.pcr-lin-lanes.svelte-5cciiw {
position: relative;
flex: 1;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 22px;
padding: 8px 12px;
overflow: auto;
}
.pcr-lin-lanes.svelte-5cciiw::-webkit-scrollbar { width: 7px; height: 7px; }
.pcr-lin-lanes.svelte-5cciiw::-webkit-scrollbar-track { background: transparent; }
.pcr-lin-lanes.svelte-5cciiw::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
/* both axes scroll here, so kill the default white corner where they meet */
.pcr-lin-lanes.svelte-5cciiw::-webkit-scrollbar-corner { background: transparent; }
.pcr-lin-col.svelte-5cciiw {
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
flex: 0 0 auto;
}
/* SVG overlay lives inside the scroll content, so it translates with scroll for
free — geometry is only recomputed on expand/fold/resize/img-load. */
.pcr-lin-connectors.svelte-5cciiw {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
overflow: visible;
}
.pcr-lin-connectors.svelte-5cciiw path:where(.svelte-5cciiw) {
stroke: #4a4a4a;
stroke-width: 2;
fill: none;
}
.pcr-lin-expander.svelte-5cciiw {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 3px;
box-sizing: border-box;
font-size: 10px;
font-weight: 600;
line-height: 1;
border-radius: 4px;
border: 1px solid rgba(120, 120, 120, 0.6);
background: rgba(20, 20, 20, 0.82);
color: #cdd6e0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.pcr-lin-expander.svelte-5cciiw:hover {
background: rgba(60, 60, 60, 0.95);
border-color: #888;
}
.pcr-lin-fold.svelte-5cciiw {
position: absolute;
bottom: 2px;
left: 2px;
width: 16px;
height: 16px;
padding: 0;
font-size: 11px;
line-height: 1;
border-radius: 4px;
border: 1px solid rgba(120, 120, 120, 0.6);
background: rgba(20, 20, 20, 0.82);
color: #cdd6e0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.pcr-lin-fold.svelte-5cciiw:hover {
background: rgba(60, 60, 60, 0.95);
border-color: #888;
}
/* ⊞/⊟ are actions, not status — keep them off the thumb until the node is
hovered so the strip isn't cluttered at rest (dot + layers badge stay). */
.pcr-lin-expander.svelte-5cciiw,
.pcr-lin-fold.svelte-5cciiw {
opacity: 0;
pointer-events: none;
transition: opacity 0.12s;
}
.pcr-viewer-lineage-node.svelte-5cciiw:hover .pcr-lin-expander:where(.svelte-5cciiw),
.pcr-viewer-lineage-node.svelte-5cciiw:hover .pcr-lin-fold:where(.svelte-5cciiw) {
opacity: 1;
pointer-events: auto;
}
.pcr-lin-toggle.svelte-5cciiw {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
flex-shrink: 0;
border: 1px solid #333;
border-radius: 4px;
background: #1a1a1a;
color: #9bb4c7;
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.pcr-lin-toggle.svelte-5cciiw:hover { background: #262626; border-color: #555; color: #cfe2f0; }
.pcr-lin-toggle.on.svelte-5cciiw { background: rgba(79, 195, 247, 0.15); border-color: #4fc3f7; color: #4fc3f7; }
/* compare-picking: dropdown open → strip thumbnails are click targets */
.pcr-viewer-lineage-node.compare-candidate.svelte-5cciiw {
border-color: rgba(255, 221, 114, 0.6);
@@ -1549,7 +1676,8 @@
.pcr-viewer-newgen-banner.svelte-5cciiw {
position: absolute;
top: 14px;
left: 14px;
left: 50%;
transform: translateX(-50%);
z-index: 30;
padding: 7px 13px;
font-size: 12.5px;
@@ -1562,8 +1690,8 @@
pointer-events: none;
}
@keyframes svelte-5cciiw-pcr-newgen-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
from { opacity: 0; transform: translate(-50%, -8px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
@keyframes svelte-5cciiw-pcr-newgen-out {
to { opacity: 0; }
@@ -4285,37 +4285,38 @@ export {
autofocus as H,
to_array as I,
from_svg as J,
mount as K,
unmount as L,
effect_root as M,
getContext as N,
setContext as O,
clsx as P,
text as Q,
active_effect as R,
assign_nodes as S,
get_first_child as T,
remove_effect_dom as U,
create_element as V,
NAMESPACE_SVG as W,
NAMESPACE_MATHML as X,
EFFECT_TRANSPARENT as Y,
component_context as Z,
lifecycle_outside_component as _,
noop as K,
mount as L,
unmount as M,
effect_root as N,
getContext as O,
setContext as P,
clsx as Q,
text as R,
active_effect as S,
assign_nodes as T,
get_first_child as U,
remove_effect_dom as V,
create_element as W,
NAMESPACE_SVG as X,
NAMESPACE_MATHML as Y,
EFFECT_TRANSPARENT as Z,
component_context as _,
prop as a,
set_selected as a0,
listen_to_event_and_reset_event as a1,
current_batch as a2,
effect as a3,
is_array as a4,
select_multiple_invalid_value as a5,
is as a6,
teardown as a7,
render_effect as a8,
deep_read_state as a9,
safe_not_equal as aa,
to_style as ab,
queue_micro_task as ac,
lifecycle_outside_component as a0,
set_selected as a1,
listen_to_event_and_reset_event as a2,
current_batch as a3,
effect as a4,
is_array as a5,
select_multiple_invalid_value as a6,
is as a7,
teardown as a8,
render_effect as a9,
deep_read_state as aa,
safe_not_equal as ab,
to_style as ac,
queue_micro_task as ad,
block as b,
proxy as c,
delegate as d,
@@ -4342,4 +4343,4 @@ export {
set_text as y,
index as z
};
//# sourceMappingURL=disclose-version-BjTnIIw0.js.map
//# sourceMappingURL=disclose-version-uq4tn5Y6.js.map
@@ -1,4 +1,4 @@
import { Z as component_context, _ as lifecycle_outside_component, u as user_effect, E as untrack } from "./disclose-version-BjTnIIw0.js";
import { _ as component_context, a0 as lifecycle_outside_component, u as user_effect, E as untrack } from "./disclose-version-uq4tn5Y6.js";
function onMount(fn) {
if (component_context === null) {
lifecycle_outside_component();
@@ -23,4 +23,4 @@ export {
onMount as a,
onDestroy as o
};
//# sourceMappingURL=index-client-m0VtlDjX.js.map
//# sourceMappingURL=index-client-iMRCrpBY.js.map
@@ -1,4 +1,4 @@
import { a1 as listen_to_event_and_reset_event, a2 as current_batch, C as tick, E as untrack, a8 as render_effect, a6 as is, a7 as teardown, ac as queue_micro_task } from "./disclose-version-BjTnIIw0.js";
import { a2 as listen_to_event_and_reset_event, a3 as current_batch, C as tick, E as untrack, a9 as render_effect, a7 as is, a8 as teardown, ad as queue_micro_task } from "./disclose-version-uq4tn5Y6.js";
function bind_value(input, get, set = get) {
var batches = /* @__PURE__ */ new WeakSet();
listen_to_event_and_reset_event(input, "input", async (is_reset) => {
@@ -150,4 +150,4 @@ export {
bind_value as b,
bind_group as c
};
//# sourceMappingURL=input-Bjai8x-c.js.map
//# sourceMappingURL=input-DFQhebEz.js.map
+5 -5
View File
@@ -5,11 +5,11 @@ var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
var _activeNodeId, _treeRoots, _openTabs, _activeWildcardTab, _compiledOutput, _compiledNegOutput, _imageUrl, _previewUrl, _progress, _isGenerating, _sidebarView, _fontSize, _renamingNodeId;
import { d as delegate, p as push, a as prop, i as if_block, f as sibling, t as template_effect, e as set, g as get, j as delegated, k as append, l as pop, s as state, n as child, A as from_html, x as set_attribute, u as user_effect, h as set_class, r as event, m as user_derived, Q as text, y as set_text, o as bind_this, w as each, z as index, D as comment, v as first_child, c as proxy, L as unmount, K as mount } from "./disclose-version-BjTnIIw0.js";
import { a as onMount, o as onDestroy } from "./index-client-m0VtlDjX.js";
import { s as set_style } from "./style-CbOHK2KU.js";
import { b as bind_value } from "./input-Bjai8x-c.js";
import { M as Menubar, S as SearchableList } from "./Menubar-PsG9jYP0.js";
import { d as delegate, p as push, a as prop, i as if_block, f as sibling, t as template_effect, e as set, g as get, j as delegated, k as append, l as pop, s as state, n as child, A as from_html, x as set_attribute, u as user_effect, h as set_class, r as event, m as user_derived, R as text, y as set_text, o as bind_this, w as each, z as index, D as comment, v as first_child, c as proxy, M as unmount, L as mount } from "./disclose-version-uq4tn5Y6.js";
import { a as onMount, o as onDestroy } from "./index-client-iMRCrpBY.js";
import { s as set_style } from "./style-Boi27oOu.js";
import { b as bind_value } from "./input-DFQhebEz.js";
import { M as Menubar, S as SearchableList } from "./Menubar-DI-z1NzN.js";
var root_1$4 = from_html(`<img class="pcr-fs-topbar-logo svelte-93wsqh" alt="PromptChain"/>`);
var root$5 = from_html(`<div class="pcr-fs-topbar svelte-93wsqh"><div class="pcr-fs-topbar-left svelte-93wsqh"><!> <span class="pcr-fs-topbar-title svelte-93wsqh"></span></div> <div class="pcr-fs-topbar-right svelte-93wsqh"><div class="pcr-fs-topbar-actions svelte-93wsqh"><input type="number" class="pcr-fs-batch-input svelte-93wsqh" min="1" max="99" title="Batch count"/> <span class="pcr-fs-run-btn svelte-93wsqh" title="Queue prompt (Ctrl+Enter)"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"></path></svg> <span>Run</span></span> <span class="pcr-fs-cancel-btn pcr-fs-inactive svelte-93wsqh" title="Cancel execution"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"></path></svg></span> <span class="pcr-fs-queue-badge pcr-fs-inactive svelte-93wsqh">0 active</span></div> <div class="pcr-fs-panel-toggles svelte-93wsqh"><span class="pcr-fs-header-btn svelte-93wsqh" title="Toggle 3D Poser panel" data-fs-action="pose"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20.5 6c-2.61.7-5.67 1-8.5 1s-5.89-.3-8.5-1L3 8c1.86.5 4 .83 6 1v13h2v-6h2v6h2V9c2-.17 4.14-.5 6-1l-.5-2zM12 6c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"></path></svg></span> <span class="pcr-fs-header-btn svelte-93wsqh" title="Toggle output panel" data-fs-action="output"><svg width="16" height="16" viewBox="0 -960 960 960" fill="currentColor"><path d="M400-280h160v-80H400v80Zm0-160h280v-80H400v80ZM280-600h400v-80H280v80Zm200 120ZM80-80v-80h102q-48-23-77.5-68T75-330q0-79 55.5-134.5T265-520v80q-45 0-77.5 32T155-330q0 39 24 69t61 38v-97h80v240H80Zm320-40v-80h360v-560H200v160h-80v-160q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H400Z"></path></svg></span> <span class="pcr-fs-header-btn svelte-93wsqh" title="Toggle image preview" data-fs-action="image"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"></path></svg></span></div> <span class="pcr-fs-close-btn svelte-93wsqh" title="Close (Escape)"></span></div></div>`);
function Topbar($$anchor, $$props) {
+2 -2
View File
@@ -1,5 +1,5 @@
import { L as unmount, K as mount } from "./disclose-version-BjTnIIw0.js";
import { G as GeneratedGallery } from "./GeneratedGallery-D27T_RcN.js";
import { M as unmount, L as mount } from "./disclose-version-uq4tn5Y6.js";
import { G as GeneratedGallery } from "./GeneratedGallery-Dw0CC3-N.js";
function mountGallery(target, props) {
return mount(GeneratedGallery, { target, props });
}
+4 -4
View File
@@ -1,9 +1,9 @@
import { p as push, a as prop, D as comment, v as first_child, i as if_block, g as get, k as append, l as pop, s as state, e as set, f as sibling, t as template_effect, r as event, A as from_html, n as child, y as set_text, x as set_attribute, d as delegate, c as proxy, u as user_effect, E as untrack, o as bind_this, j as delegated, C as tick, m as user_derived, w as each, z as index, h as set_class, F as update, L as unmount, K as mount } from "./disclose-version-BjTnIIw0.js";
import { s as set_style } from "./style-CbOHK2KU.js";
import { b as bind_value } from "./input-Bjai8x-c.js";
import { p as push, a as prop, D as comment, v as first_child, i as if_block, g as get, k as append, l as pop, s as state, e as set, f as sibling, t as template_effect, r as event, A as from_html, n as child, y as set_text, x as set_attribute, d as delegate, c as proxy, u as user_effect, E as untrack, o as bind_this, j as delegated, C as tick, m as user_derived, w as each, z as index, h as set_class, F as update, M as unmount, L as mount } from "./disclose-version-uq4tn5Y6.js";
import { s as set_style } from "./style-Boi27oOu.js";
import { b as bind_value } from "./input-DFQhebEz.js";
import { e as extractPrecisions, r as resolveFilesForPrecision } from "./model-constants-WRFJ51jF.js";
import { api } from "/scripts/api.js";
import { s as safeJson, H as HttpError } from "./api-context-D3RUFV-0.js";
import { s as safeJson, H as HttpError } from "./api-context-BFKo1mCD.js";
var root_2$4 = from_html(`<div class="pcr-hover-card-version svelte-oulz7g"> </div>`);
var root_3$4 = from_html(`<div class="pcr-hover-card-meta svelte-oulz7g"> </div>`);
var root_4$3 = from_html(`<div class="pcr-hover-card-tags svelte-oulz7g"> </div>`);
+6 -6
View File
@@ -1,10 +1,10 @@
import { d as delegate, p as push, a as prop, s as state, c as proxy, u as user_effect, e as set, f as sibling, o as bind_this, t as template_effect, g as get, x as set_attribute, q as set_value, j as delegated, k as append, l as pop, m as user_derived, n as child, A as from_html, w as each, z as index, i as if_block, G as set_checked, y as set_text, h as set_class, v as first_child, D as comment, I as to_array, a0 as set_selected, Q as text, E as untrack, L as unmount, K as mount } from "./disclose-version-BjTnIIw0.js";
import { s as set_style } from "./style-CbOHK2KU.js";
import { d as delegate, p as push, a as prop, s as state, c as proxy, u as user_effect, e as set, f as sibling, o as bind_this, t as template_effect, g as get, x as set_attribute, q as set_value, j as delegated, k as append, l as pop, m as user_derived, n as child, A as from_html, w as each, z as index, i as if_block, G as set_checked, y as set_text, h as set_class, v as first_child, D as comment, I as to_array, a1 as set_selected, R as text, E as untrack, M as unmount, L as mount } from "./disclose-version-uq4tn5Y6.js";
import { s as set_style } from "./style-Boi27oOu.js";
import { F as FAMILIES, A as ARCHITECTURES } from "./model-constants-WRFJ51jF.js";
import { S as SettingsSlider } from "./SettingsSlider-Bxw-taga.js";
import { b as bind_value } from "./input-Bjai8x-c.js";
import { b as bind_select_value } from "./select-Dgaht2aI.js";
import { a as action } from "./actions-Ckx5huYg.js";
import { S as SettingsSlider } from "./SettingsSlider-CKF_XmgB.js";
import { b as bind_value } from "./input-DFQhebEz.js";
import { b as bind_select_value } from "./select-Dp4ExMMc.js";
import { a as action } from "./actions-WPfqiWYB.js";
var root$7 = from_html(`<div class="pcr-slider-container svelte-1pxowmh"><div class="pcr-slider-track svelte-1pxowmh"><div class="pcr-slider-zone pcr-slider-zone-editable svelte-1pxowmh"></div> <div class="pcr-slider-thumb pcr-thumb-blue svelte-1pxowmh"></div> <div class="pcr-slider-thumb pcr-thumb-blue svelte-1pxowmh"></div></div> <input type="number" class="pcr-slider-input pcr-slider-input-half svelte-1pxowmh"/> <span class="pcr-slider-range-sep svelte-1pxowmh">&ndash;</span> <input type="number" class="pcr-slider-input pcr-slider-input-half svelte-1pxowmh"/></div>`);
function RangeSlider($$anchor, $$props) {
push($$props, true);
+8 -8
View File
@@ -5,15 +5,15 @@ var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
var _mode, _switchIndex, _locked, _disabled, _collapsed, _connectedCount, _hasLabels, _switchLabel, _iterateCurrent, _iterateTotal, _iterateCycle, _switchOptions, _outputPanelOpen, _imagePanelVisible, _aiAssistantOpen, _posePanelVisible, _hasPoseStudio, _compiledOutput, _compiledNegOutput, _imageUrl, _previewUrl, _progress, _isGenerating, _rollSelected;
import { p as push, o as bind_this, k as append, l as pop, A as from_html, d as delegate, a as prop, g as get, j as delegated, m as user_derived, s as state, c as proxy, u as user_effect, i as if_block, f as sibling, r as event, e as set, C as tick, n as child, D as comment, v as first_child, w as each, t as template_effect, y as set_text, h as set_class, x as set_attribute, P as clsx, z as index, Q as text, E as untrack, L as unmount, K as mount } from "./disclose-version-BjTnIIw0.js";
import { u as useApi, p as provideApi } from "./api-context-D3RUFV-0.js";
import { M as Menubar } from "./Menubar-PsG9jYP0.js";
import { o as onDestroy, a as onMount } from "./index-client-m0VtlDjX.js";
import { s as set_style } from "./style-CbOHK2KU.js";
import { h as html, P as PopupAnchor } from "./PopupAnchor-sxqUMRLP.js";
import { p as push, o as bind_this, k as append, l as pop, A as from_html, d as delegate, a as prop, g as get, j as delegated, m as user_derived, s as state, c as proxy, u as user_effect, i as if_block, f as sibling, r as event, e as set, C as tick, n as child, D as comment, v as first_child, w as each, t as template_effect, y as set_text, h as set_class, x as set_attribute, Q as clsx, z as index, R as text, E as untrack, M as unmount, L as mount } from "./disclose-version-uq4tn5Y6.js";
import { u as useApi, p as provideApi } from "./api-context-BFKo1mCD.js";
import { M as Menubar } from "./Menubar-DI-z1NzN.js";
import { o as onDestroy, a as onMount } from "./index-client-iMRCrpBY.js";
import { s as set_style } from "./style-Boi27oOu.js";
import { h as html, P as PopupAnchor } from "./PopupAnchor-C6OWGGTI.js";
import { api } from "/scripts/api.js";
import { c as clampGalleryRowHeight, z as zoomGalleryRowHeight, G as GeneratedGallery } from "./GeneratedGallery-D27T_RcN.js";
import { b as bind_value } from "./input-Bjai8x-c.js";
import { c as clampGalleryRowHeight, z as zoomGalleryRowHeight, G as GeneratedGallery } from "./GeneratedGallery-Dw0CC3-N.js";
import { b as bind_value } from "./input-DFQhebEz.js";
import { app } from "/scripts/app.js";
import { r as readNodePrompt, c as cryptoId, e as extractNGrams, m as matchCharactersInDb, i as isStandaloneMainPromptChain, a as applyPromptText } from "./ai-patch-helpers-Bayqv0oF.js";
var root$c = from_html(`<div class="pcr-editor-frame"></div>`);
+7 -7
View File
@@ -1,11 +1,11 @@
import { c as proxy, M as effect_root, u as user_effect, d as delegate, p as push, r as event, $ as $window, v as first_child, w as each, f as sibling, i as if_block, g as get, o as bind_this, t as template_effect, j as delegated, k as append, l as pop, s as state, e as set, m as user_derived, n as child, A as from_html, J as from_svg, h as set_class, y as set_text, z as index, q as set_value, x as set_attribute, a as prop, N as getContext, C as tick, D as comment, O as setContext, K as mount, L as unmount } from "./disclose-version-BjTnIIw0.js";
import { a as onMount } from "./index-client-m0VtlDjX.js";
import { s as set_style } from "./style-CbOHK2KU.js";
import { c as proxy, N as effect_root, u as user_effect, d as delegate, p as push, r as event, $ as $window, v as first_child, w as each, f as sibling, i as if_block, g as get, o as bind_this, t as template_effect, j as delegated, k as append, l as pop, s as state, e as set, m as user_derived, n as child, A as from_html, J as from_svg, h as set_class, y as set_text, z as index, q as set_value, x as set_attribute, a as prop, O as getContext, C as tick, D as comment, P as setContext, L as mount, M as unmount } from "./disclose-version-uq4tn5Y6.js";
import { a as onMount } from "./index-client-iMRCrpBY.js";
import { s as set_style } from "./style-Boi27oOu.js";
import { j as justifiedLayout } from "./justified-layout-cyVM7i96.js";
import { a as action } from "./actions-Ckx5huYg.js";
import { b as bind_value, c as bind_group } from "./input-Bjai8x-c.js";
import { p as portal, C as ConfirmModal } from "./ConfirmModal-DSeVjwuq.js";
import { s as safeJson, H as HttpError } from "./api-context-D3RUFV-0.js";
import { a as action } from "./actions-WPfqiWYB.js";
import { b as bind_value, c as bind_group } from "./input-DFQhebEz.js";
import { p as portal, C as ConfirmModal } from "./ConfirmModal-BGcVYndw.js";
import { s as safeJson, H as HttpError } from "./api-context-BFKo1mCD.js";
const VIEW_MODES = [
{ id: "grid", label: "Grid" },
{ id: "justified", label: "Justified" },
+5 -5
View File
@@ -1,9 +1,9 @@
import { d as delegate, p as push, a as prop, w as each, z as index, g as get, f as sibling, i as if_block, k as append, l as pop, m as user_derived, n as child, h as set_class, j as delegated, A as from_html, t as template_effect, x as set_attribute, q as set_value, v as first_child, y as set_text, D as comment, s as state, c as proxy, u as user_effect, e as set, I as to_array, E as untrack, o as bind_this, G as set_checked, L as unmount, K as mount } from "./disclose-version-BjTnIIw0.js";
import { a as onMount, o as onDestroy } from "./index-client-m0VtlDjX.js";
import { a as action } from "./actions-Ckx5huYg.js";
import { a as bind_checked, b as bind_value } from "./input-Bjai8x-c.js";
import { d as delegate, p as push, a as prop, w as each, z as index, g as get, f as sibling, i as if_block, k as append, l as pop, m as user_derived, n as child, h as set_class, j as delegated, A as from_html, t as template_effect, x as set_attribute, q as set_value, v as first_child, y as set_text, D as comment, s as state, c as proxy, u as user_effect, e as set, I as to_array, E as untrack, o as bind_this, G as set_checked, M as unmount, L as mount } from "./disclose-version-uq4tn5Y6.js";
import { a as onMount, o as onDestroy } from "./index-client-iMRCrpBY.js";
import { a as action } from "./actions-WPfqiWYB.js";
import { a as bind_checked, b as bind_value } from "./input-DFQhebEz.js";
import { b as buildInsertText } from "./tag-builder-utils-ng134QDV.js";
import { b as bind_select_value } from "./select-Dgaht2aI.js";
import { b as bind_select_value } from "./select-Dp4ExMMc.js";
const MULTI_SELECT_GROUPS = ["props"];
const CUSTOMIZABLE_CLOTHING_GROUPS = [
"legwear",
+5 -5
View File
@@ -1,8 +1,8 @@
import { d as delegate, p as push, a as prop, s as state, c as proxy, u as user_effect, e as set, f as sibling, n as child, i as if_block, g as get, t as template_effect, j as delegated, k as append, l as pop, v as first_child, h as set_class, y as set_text, A as from_html, m as user_derived, w as each, z as index, I as to_array, C as tick, o as bind_this, D as comment, x as set_attribute, q as set_value, Q as text, L as unmount, K as mount } from "./disclose-version-BjTnIIw0.js";
import { a as onMount } from "./index-client-m0VtlDjX.js";
import { a as action } from "./actions-Ckx5huYg.js";
import { s as set_style } from "./style-CbOHK2KU.js";
import { a as bind_checked, b as bind_value } from "./input-Bjai8x-c.js";
import { d as delegate, p as push, a as prop, s as state, c as proxy, u as user_effect, e as set, f as sibling, n as child, i as if_block, g as get, t as template_effect, j as delegated, k as append, l as pop, v as first_child, h as set_class, y as set_text, A as from_html, m as user_derived, w as each, z as index, I as to_array, C as tick, o as bind_this, D as comment, x as set_attribute, q as set_value, R as text, M as unmount, L as mount } from "./disclose-version-uq4tn5Y6.js";
import { a as onMount } from "./index-client-iMRCrpBY.js";
import { a as action } from "./actions-WPfqiWYB.js";
import { s as set_style } from "./style-Boi27oOu.js";
import { a as bind_checked, b as bind_value } from "./input-DFQhebEz.js";
import { b as isStructuralLine, d as isInNegativeBlock } from "./ai-patch-helpers-Bayqv0oF.js";
import { f as formatTagsForModel } from "./tag-builder-utils-ng134QDV.js";
var root_1$3 = from_html(`<div class="pcr-atb2-cust-loading svelte-1mwxie1">Loading…</div>`);
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { a1 as listen_to_event_and_reset_event, a2 as current_batch, a3 as effect, a4 as is_array, a5 as select_multiple_invalid_value, a6 as is, a7 as teardown } from "./disclose-version-BjTnIIw0.js";
import { a2 as listen_to_event_and_reset_event, a3 as current_batch, a4 as effect, a5 as is_array, a6 as select_multiple_invalid_value, a7 as is, a8 as teardown } from "./disclose-version-uq4tn5Y6.js";
function select_option(select, value, mounting = false) {
if (select.multiple) {
if (value == void 0) {
@@ -97,4 +97,4 @@ export {
init_select as i,
select_option as s
};
//# sourceMappingURL=select-Dgaht2aI.js.map
//# sourceMappingURL=select-Dp4ExMMc.js.map
@@ -1,4 +1,4 @@
import { ab as to_style } from "./disclose-version-BjTnIIw0.js";
import { ac as to_style } from "./disclose-version-uq4tn5Y6.js";
function update_styles(dom, prev = {}, next, priority) {
for (var key in next) {
var value = next[key];
@@ -36,4 +36,4 @@ function set_style(dom, value, prev_styles, next_styles) {
export {
set_style as s
};
//# sourceMappingURL=style-CbOHK2KU.js.map
//# sourceMappingURL=style-Boi27oOu.js.map
+9 -2
View File
@@ -8240,11 +8240,17 @@ async function mountViewport(node, container) {
// Svelte bundle's CSS isn't present.
let exportModal = null; // built lazily on first export (after the teardown chain is in place)
const closeExport = () => { if (exportModal) exportModal.backdrop.style.display = "none"; };
// Remember the last name the author exported as (device-local), so the next
// export defaults to it instead of the generic placeholder. The browser still
// auto-suffixes " (1)" etc. on its own if that file already exists on disk.
const EXPORT_NAME_KEY = "pcr.pose-export-name";
const attemptExport = () => {
const raw = exportModal.input.value.trim();
if (!raw) return; // confirm is disabled while empty; guard the Enter path too
closeExport();
downloadScene(sanitizeSceneFilename(raw));
const name = sanitizeSceneFilename(raw);
try { localStorage.setItem(EXPORT_NAME_KEY, name); } catch { /* localStorage blocked */ }
downloadScene(name);
};
const buildExportModal = () => {
const backdrop = document.createElement("div");
@@ -8328,7 +8334,8 @@ async function mountViewport(node, container) {
const openExportScene = () => {
if (!exportModal) buildExportModal();
exportModal.backdrop.style.zIndex = String((node.properties?.pcrFullscreenZ || 10001) + 10); // above this poser's flyout menus
const defaultName = "promptchain-pose-scene.json";
let defaultName = "promptchain-pose-scene.json";
try { const saved = localStorage.getItem(EXPORT_NAME_KEY); if (saved) defaultName = saved; } catch { /* localStorage blocked */ }
exportModal.input.value = defaultName;
exportModal.refreshConfirm();
exportModal.backdrop.style.display = "flex";
+531 -90
View File
@@ -6,6 +6,7 @@
import RePoseModal from "./RePoseModal.svelte";
import ConfirmModal from "./sidebar/ConfirmModal.svelte";
import LineageContextMenu from "./LineageContextMenu.svelte";
import { buildRender, ancestry } from "../lib/lineage-lanes.js";
import { buildFigureRegions, matchRegionByOverlap } from "../lib/region-binding.js";
let {
@@ -247,14 +248,17 @@
displayedHash = hash;
}
let lineageDisplay = $derived((() => {
const asNodes = (list) => list.map(item => ({ kind: "node", item }));
if (lineageList.length < 2) return asNodes(lineageList);
const byHash = new Map(lineageList.map(f => [f.hash, f]));
if (!byHash.has(displayedHash)) return asNodes(lineageList);
// Topology primitives for the family tree, hoisted out of lineageDisplay so the
// strip, the context-menu "expand branches" gate, and the subway lane builder
// all read ONE source. byHash/childrenOf rebuild parent→children edges from each
// node's parent_hash (a content sha256, so acyclic by construction). roots is the
// true root plus any node orphaned by a deleted-and-absent parent, so the lane
// builder can surface it as its own root rather than dropping it.
let lineageTree = $derived((() => {
const list = lineageList;
const byHash = new Map(list.map(f => [f.hash, f]));
const childrenOf = new Map();
for (const f of lineageList) {
for (const f of list) {
if (!f.parent_hash || !byHash.has(f.parent_hash)) continue;
if (!childrenOf.has(f.parent_hash)) childrenOf.set(f.parent_hash, []);
childrenOf.get(f.parent_hash).push(f);
@@ -262,6 +266,15 @@
for (const kids of childrenOf.values()) {
kids.sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
}
const roots = list.filter(f => !f.parent_hash || !byHash.has(f.parent_hash)).map(f => f.hash);
return { byHash, childrenOf, roots };
})());
let lineageDisplay = $derived((() => {
const asNodes = (list) => list.map(item => ({ kind: "node", item }));
if (lineageList.length < 2) return asNodes(lineageList);
const { byHash, childrenOf } = lineageTree;
if (!byHash.has(displayedHash)) return asNodes(lineageList);
// parent_hash is a content sha256, so the family is acyclic by
// construction — the visited set is cheap defense against malformed data
@@ -341,6 +354,159 @@
);
let lineageHiddenCount = $derived(lineageList.length - lineageVisible.length);
// ── subway lanes (right-click "Expand branches") ──
// brokenOut holds CHILD-SUBTREE ROOT hashes the user has peeled into their own
// vertical column. The rail switches from the compact strip to the lane view
// whenever anything is broken out. subwayAnchor reuses the stable lineageFocusHash
// so navigating between nodes never re-partitions the lanes, and the displayed
// image is always on column 0 (anchored on the anchor's root→tip spine).
let brokenOut = $state(new Set());
let subwayAnchor = $derived(
lineageFocusHash && lineageTree.byHash.has(lineageFocusHash) ? lineageFocusHash : displayedHash
);
let laneRender = $derived(buildRender(lineageTree, subwayAnchor, brokenOut));
let lanesActive = $derived(brokenOut.size > 0 && lineageList.length > 1);
// The child that CONTINUES a node's lane — "expand branches" peels the SIBLINGS,
// never this. Matches buildLane's primary rule (spine-ward child, else oldest).
function primaryChildHash(item) {
const kids = lineageTree.childrenOf.get(item?.hash) || [];
if (!kids.length) return null;
const a = ancestry(lineageTree, subwayAnchor);
return (kids.find(k => a.set.has(k.hash)) || kids[0]).hash;
}
function canExpand(item) {
const primary = primaryChildHash(item);
return (lineageTree.childrenOf.get(item?.hash) || [])
.some(k => k.hash !== primary && !brokenOut.has(k.hash));
}
function expandBranches(item) {
const primary = primaryChildHash(item);
const next = new Set(brokenOut);
for (const k of lineageTree.childrenOf.get(item?.hash) || [])
if (k.hash !== primary) next.add(k.hash);
brokenOut = next;
}
function foldLane(childRoot) {
// folding a column folds its whole broken-out subtree with it
const next = new Set(brokenOut);
const stack = [childRoot];
while (stack.length) {
const h = stack.pop();
next.delete(h);
for (const k of lineageTree.childrenOf.get(h) || []) stack.push(k.hash);
}
brokenOut = next;
}
function collapseAllLanes() { brokenOut = new Set(); }
// the family has at least one fork worth fanning out
let hasBranches = $derived((() => {
for (const kids of lineageTree.childrenOf.values()) if (kids.length > 1) return true;
return false;
})());
// header "expand" affordance — fan EVERY branch out at once (the full subway):
// add every off-primary child across the whole family to brokenOut.
function expandAll() {
const a = ancestry(lineageTree, subwayAnchor);
const next = new Set(brokenOut);
for (const kids of lineageTree.childrenOf.values()) {
if (kids.length < 2) continue;
const primary = (kids.find(k => a.set.has(k.hash)) || kids[0]).hash;
for (const k of kids) if (k.hash !== primary) next.add(k.hash);
}
brokenOut = next;
}
// Keep the displayed image materialized if it sits inside a folded branch: peel
// the chain of off-primary roots from the anchor spine down to it. Guarded so it
// only ever ADDS new roots (never loops).
$effect(() => {
if (!lanesActive) return;
if (laneRender.columns.some(c => c.nodes.some(n => n.hash === displayedHash))) return;
const { byHash, childrenOf } = lineageTree;
const a = ancestry(lineageTree, subwayAnchor);
const toBreak = [];
let cur = displayedHash;
const guard = new Set();
while (cur && byHash.has(cur) && !guard.has(cur) && !a.set.has(cur)) {
guard.add(cur);
const parent = byHash.get(cur).parent_hash;
if (!parent || !byHash.has(parent)) break;
const kids = childrenOf.get(parent) || [];
const primary = (kids.find(k => a.set.has(k.hash)) || kids[0])?.hash;
if (cur !== primary) toBreak.push(cur);
cur = parent;
}
const additions = toBreak.filter(h => !brokenOut.has(h));
if (additions.length) {
const next = new Set(brokenOut);
for (const h of additions) next.add(h);
brokenOut = next;
}
});
// SVG connector geometry — edges are DATA from laneRender; the rAF only MEASURES
// pixel positions of known nodes (never infers topology from the DOM). The SVG
// lives inside the scrolling wrapper so it translates with scroll for free; we
// recompute only on expand/fold/resize/thumbnail-load — never on scroll, never on
// a timer (event/observer-driven per CLAUDE.md).
let lanesWrapEl = $state(null);
let connectorGeom = $state([]);
let connectorBox = $state({ w: 0, h: 0 });
let measureScheduled = false;
// Row pitch for vertically offsetting broken-out columns so children sit one
// generation below their parent. MUST match .pcr-viewer-lineage-node height (64px)
// + .pcr-lin-col gap (22px).
const LANE_ROW_H = 86;
// Layout coords relative to a content root — independent of scroll AND of CSS
// transform, so the same measurement serves the P3 zoom panel unchanged.
function contentXY(el, root) {
let x = 0, y = 0;
for (let n = el; n && n !== root; n = n.offsetParent) { x += n.offsetLeft; y += n.offsetTop; }
return { x, y, w: el.offsetWidth, h: el.offsetHeight };
}
function measureConnectors() {
const wrap = lanesWrapEl;
if (!wrap) return;
const geom = [];
for (const e of laneRender.edges) {
const a = wrap.querySelector(`[data-hash="${e.from}"]`);
const b = wrap.querySelector(`[data-hash="${e.to}"]`);
if (!a || !b) continue;
const ra = contentXY(a, wrap), rb = contentXY(b, wrap);
const px = ra.x + ra.w / 2, pb = ra.y + ra.h; // parent bottom-center
const cx = rb.x + rb.w / 2, ct = rb.y; // child top-center
// key by edge identity, not array index — when the edge set changes, a
// keyed {#each} won't reuse a stale path d-string under a shifted index
// (that was the one-frame connector "points at the old node" jitter).
const key = `${e.from}>${e.to}`;
if (Math.abs(px - cx) < 1) {
geom.push({ key, d: `M ${px} ${pb} L ${cx} ${ct}` }); // spine: straight down
} else {
const my = (pb + ct) / 2; // branch: smooth S-curve
geom.push({ key, d: `M ${px} ${pb} C ${px} ${my}, ${cx} ${my}, ${cx} ${ct}` });
}
}
connectorGeom = geom;
connectorBox = { w: wrap.scrollWidth, h: wrap.scrollHeight };
}
function scheduleMeasure() {
if (measureScheduled) return;
measureScheduled = true;
requestAnimationFrame(() => { measureScheduled = false; measureConnectors(); });
}
$effect(() => {
if (!lanesActive) return;
const cols = laneRender.columns; // read → track expand/fold/family changes
if (cols) scheduleMeasure();
});
$effect(() => {
if (!lanesWrapEl) return;
const ro = new ResizeObserver(() => scheduleMeasure());
ro.observe(lanesWrapEl);
return () => ro.disconnect();
});
let compareTargets = $derived(
lineageList.filter(item => item?.hash !== displayedHash)
);
@@ -408,12 +574,24 @@
.then(r => r.ok ? r.json() : null)
.then(data => { imageInfo = data; })
.catch(onMetaError("image meta"));
fetchApi(`/promptchain/lineage/${displayedHash}`)
// Capture the hash this fetch was issued for: displayedHash can change
// (fast navigation / save-then-navigate) before the response lands, and a
// late loser must NOT overwrite the current family — same staleness guard
// the browse branch above uses. Deliberately not nulling lineageData first:
// in-family navigation keeps the same family, so blanking it would flicker
// the tree on every step.
const reqHash = displayedHash;
fetchApi(`/promptchain/lineage/${reqHash}`)
.then(r => r.ok ? r.json() : null)
.then(data => { lineageData = data; })
.then(data => {
if (displayedHash !== reqHash) return; // stale response — a newer hash is shown
lineageData = data;
reportLineageAnomaly(reqHash, data);
})
.catch((e) => {
if (displayedHash !== reqHash) return;
lineageData = null;
console.error(`[PromptChain] lineage fetch failed for ${displayedHash}:`, e);
console.error(`[PromptChain] lineage fetch failed for ${reqHash}:`, e);
});
}
});
@@ -1068,6 +1246,26 @@
} catch { /* diagnostics must never break the handback */ }
}
// A healthy lineage response contains the queried node WITH its parent present
// in the same family. If the node is absent, or its parent_hash points outside
// the returned family (so the tree renders it as a stray root), log it via
// comfyui.log — that is the data signature behind a node appearing
// "mis-attributed" until a refresh. Event-driven (fires only on a landed
// response), no timers. We couldn't capture the live case, so this catches the
// next one. A true root (parent_hash null) is healthy and never logs.
function reportLineageAnomaly(reqHash, data) {
const fam = data?.family;
if (!Array.isArray(fam) || fam.length === 0) return;
const me = fam.find(f => f?.hash === reqHash);
const parentOrphaned = !!(me?.parent_hash && !fam.some(f => f?.hash === me.parent_hash));
if (me && !parentOrphaned) return; // healthy
const fams = fam.map(f => (f?.hash || "").slice(0, 8)).join(",");
reportHandback(
"lineage-tree-anomaly",
`node ${(reqHash || "").slice(0, 8)} ${me ? "orphaned: parent " + (me.parent_hash || "").slice(0, 8) + " absent" : "ABSENT"} from its own family [${fams}]`
);
}
async function handleUpscaleToEdit(doneState) {
const ctx = upscaleEditCtx;
const viewUrl = (img) => apiURL(`/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${img.type || "output"}&rand=${Math.random()}`);
@@ -1270,10 +1468,47 @@
lineageMenu = { x: e.clientX, y: e.clientY, item };
}
let copyPathTimer = null;
let copyPathToast = $state(false);
async function copyTextToClipboard(text) {
try { await navigator.clipboard.writeText(text); return true; } catch {}
// The async clipboard API can refuse after an awaited fetch (transient
// activation expires); a hidden textarea + execCommand still copies.
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.top = "-1000px";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand("copy");
document.body.removeChild(ta);
return ok;
} catch { return false; }
}
async function copyPath(hash) {
try {
const r = await fetchApi?.(`/promptchain/file-path?hash=${encodeURIComponent(hash)}`);
const d = r && r.ok ? await r.json() : null;
if (!d?.path) return;
if (await copyTextToClipboard(d.path)) {
copyPathToast = true;
clearTimeout(copyPathTimer);
copyPathTimer = setTimeout(() => { copyPathToast = false; }, 1800);
}
} catch {}
}
function lineageMenuAction(action, item) {
const hash = item?.hash;
if (!hash) return;
if (action === "edit") {
if (action === "expand-branches") {
expandBranches(item);
} else if (action === "copy-path") {
copyPath(hash);
} else if (action === "edit") {
lineageJump(hash); // bring it on screen so editDocHash matches what we edit
openEdit(hash);
} else if (action === "delete") {
@@ -1404,86 +1639,154 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pcr-viewer" bind:this={viewerEl} style="transform: translateY({viewerTransformY}px); opacity: {viewerOpacity}">
{#if newGenBanner}
<div class="pcr-viewer-newgen-banner">✨ {newGenBanner}</div>
{/if}
<!-- left: lineage panel -->
{#snippet lineageNode(item, lane)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pcr-viewer-lineage-node"
data-hash={item.hash}
class:current={item.hash === displayedHash}
class:orphaned={item.orphaned === 1}
class:compare-candidate={compareDropdownOpen && item.hash !== displayedHash}
class:compare-left={compareMode && item.hash === displayedHash}
class:compare-right={compareMode && item.hash === compareTargetHash}
onclick={() => {
if (compareDropdownOpen) {
if (item.hash !== displayedHash) enterCompareWith(item.hash, item.filename || item.hash.slice(0, 8));
return;
}
lineageJump(item.hash); exitCompare();
}}
onmouseenter={(e) => showTip(e, item)}
onmouseleave={hideTip}
oncontextmenu={(e) => { hideTip(); openLineageMenu(e, item); }}
>
<img src={thumbUrl(item.hash)} alt="" draggable="false" loading="lazy" onload={scheduleMeasure} />
<div class="pcr-viewer-lineage-dot"
class:dot-root={item.hash === laneRender.rootHash}
class:dot-current={item.hash === displayedHash}
class:dot-tip={item.hash === lane.nodes[lane.nodes.length - 1].hash && item.hash !== displayedHash}
></div>
{#if editDocLayers[item.hash]}
<div class="pcr-viewer-lineage-layers">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round">
<path d="M12 2 2 7l10 5 10-5-10-5Z" /><path d="m2 17 10 5 10-5" /><path d="m2 12 10 5 10-5" />
</svg>
</div>
{/if}
{#if laneRender.junctionsByNode.get(item.hash)?.hidden.length}
<button class="pcr-lin-expander" title="Break this node's {laneRender.junctionsByNode.get(item.hash).hidden.length} other branch(es) into their own columns"
onclick={(e) => { e.stopPropagation(); expandBranches(item); }}
>⊞{laneRender.junctionsByNode.get(item.hash).hidden.length}</button>
{/if}
{#if brokenOut.has(lane.startHash) && item.hash === lane.startHash}
<button class="pcr-lin-fold" title="Fold this column back into its branch"
onclick={(e) => { e.stopPropagation(); foldLane(lane.startHash); }}
>⊟</button>
{/if}
</div>
{/snippet}
{#if lineageList.length > 1}
<div class="pcr-viewer-lineage pcr-viewer-lineage-visible">
<div class="pcr-viewer-lineage pcr-viewer-lineage-visible" class:lanes={lanesActive}>
<div class="pcr-viewer-lineage-header">
{lineageCurrentIdx + 1} / {lineageList.length}
<span class="pcr-lin-count">{lineageCurrentIdx + 1} / {lineageList.length}</span>
{#if hasBranches}
<button
class="pcr-lin-toggle"
class:on={lanesActive}
onclick={() => (lanesActive ? collapseAllLanes() : expandAll())}
title={lanesActive ? "Collapse the subway back to the strip" : "Expand every branch into a subway map"}
>{lanesActive ? "⊟" : "⊞"}</button>
{/if}
</div>
<div class="pcr-viewer-lineage-strip">
{#each lineageDisplay as d, i (d.kind === "node" ? d.item.hash : `${d.kind}:${d.at}`)}
{#if d.kind === "node"}
{@const item = d.item}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pcr-viewer-lineage-node"
class:current={i === lineageDisplayCurrentIdx}
class:ancestor={i < lineageDisplayCurrentIdx}
class:descendant={i > lineageDisplayCurrentIdx}
class:branch={d.branch}
class:orphaned={item.orphaned === 1}
class:compare-candidate={compareDropdownOpen && item.hash !== displayedHash}
class:compare-left={compareMode && item.hash === displayedHash}
class:compare-right={compareMode && item.hash === compareTargetHash}
onclick={() => {
// With the compare dropdown open, the strip doubles as the
// target picker — clicking a thumbnail compares against it
// instead of navigating away.
if (compareDropdownOpen) {
if (item.hash !== displayedHash) enterCompareWith(item.hash, item.filename || item.hash.slice(0, 8));
return;
}
lineageJump(item.hash); exitCompare();
}}
onmouseenter={(e) => showTip(e, item)}
onmouseleave={hideTip}
oncontextmenu={(e) => { hideTip(); openLineageMenu(e, item); }}
>
<img src={thumbUrl(item.hash)} alt="" draggable="false" loading="lazy" />
<div class="pcr-viewer-lineage-dot"
class:dot-root={item.hash === lineageList[0]?.hash}
class:dot-current={i === lineageDisplayCurrentIdx}
class:dot-tip={i === lineageDisplay.length - 1 && i !== lineageDisplayCurrentIdx}
></div>
{#if editDocLayers[item.hash]}
<div class="pcr-viewer-lineage-layers">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round">
<path d="M12 2 2 7l10 5 10-5-10-5Z" /><path d="m2 17 10 5 10-5" /><path d="m2 12 10 5 10-5" />
</svg>
</div>
{/if}
{#if lanesActive}
<div class="pcr-lin-lanes" bind:this={lanesWrapEl}>
<svg class="pcr-lin-connectors" width={connectorBox.w} height={connectorBox.h} aria-hidden="true">
{#each connectorGeom as g (g.key)}<path d={g.d} />{/each}
</svg>
{#each laneRender.columns as lane (lane.startHash)}
<div class="pcr-lin-col" style="margin-top: {lane.topRow * LANE_ROW_H}px">
{#each lane.nodes as item (item.hash)}
{@render lineageNode(item, lane)}
{/each}
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pcr-viewer-lineage-bundle"
class:expanded={d.kind === "collapse"}
onclick={() => toggleBundle(d.at)}
title={d.kind === "collapse"
? "Collapse this branch"
: `${d.count} other generation${d.count > 1 ? "s" : ""} branch off here — click to expand`}
>
{#if d.kind === "bundle"}
<div class="pcr-viewer-lineage-bundle-stack">
{#each d.preview as p (p.hash)}
<img src={thumbUrl(p.hash)} alt="" draggable="false" loading="lazy" />
{/each}
</div>
<span class="pcr-viewer-lineage-bundle-count">+{d.count}</span>
{:else}
<span class="pcr-viewer-lineage-bundle-count">▴</span>
{/if}
</div>
{/if}
{/each}
</div>
{#if lineageHiddenCount > 0}
<div class="pcr-viewer-lineage-hidden">{lineageHiddenCount} hidden</div>
{/each}
</div>
{:else}
<div class="pcr-viewer-lineage-strip">
{#each lineageDisplay as d, i (d.kind === "node" ? d.item.hash : `${d.kind}:${d.at}`)}
{#if d.kind === "node"}
{@const item = d.item}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pcr-viewer-lineage-node"
class:current={i === lineageDisplayCurrentIdx}
class:ancestor={i < lineageDisplayCurrentIdx}
class:descendant={i > lineageDisplayCurrentIdx}
class:branch={d.branch}
class:orphaned={item.orphaned === 1}
class:compare-candidate={compareDropdownOpen && item.hash !== displayedHash}
class:compare-left={compareMode && item.hash === displayedHash}
class:compare-right={compareMode && item.hash === compareTargetHash}
onclick={() => {
// With the compare dropdown open, the strip doubles as the
// target picker — clicking a thumbnail compares against it
// instead of navigating away.
if (compareDropdownOpen) {
if (item.hash !== displayedHash) enterCompareWith(item.hash, item.filename || item.hash.slice(0, 8));
return;
}
lineageJump(item.hash); exitCompare();
}}
onmouseenter={(e) => showTip(e, item)}
onmouseleave={hideTip}
oncontextmenu={(e) => { hideTip(); openLineageMenu(e, item); }}
>
<img src={thumbUrl(item.hash)} alt="" draggable="false" loading="lazy" />
<div class="pcr-viewer-lineage-dot"
class:dot-root={item.hash === lineageList[0]?.hash}
class:dot-current={i === lineageDisplayCurrentIdx}
class:dot-tip={i === lineageDisplay.length - 1 && i !== lineageDisplayCurrentIdx}
></div>
{#if editDocLayers[item.hash]}
<div class="pcr-viewer-lineage-layers">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round">
<path d="M12 2 2 7l10 5 10-5-10-5Z" /><path d="m2 17 10 5 10-5" /><path d="m2 12 10 5 10-5" />
</svg>
</div>
{/if}
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pcr-viewer-lineage-bundle"
class:expanded={d.kind === "collapse"}
onclick={() => toggleBundle(d.at)}
title={d.kind === "collapse"
? "Collapse this branch"
: `${d.count} other generation${d.count > 1 ? "s" : ""} branch off here — click to expand`}
>
{#if d.kind === "bundle"}
<div class="pcr-viewer-lineage-bundle-stack">
{#each d.preview as p (p.hash)}
<img src={thumbUrl(p.hash)} alt="" draggable="false" loading="lazy" />
{/each}
</div>
<span class="pcr-viewer-lineage-bundle-count">+{d.count}</span>
{:else}
<span class="pcr-viewer-lineage-bundle-count">▴</span>
{/if}
</div>
{/if}
{/each}
</div>
{#if lineageHiddenCount > 0}
<div class="pcr-viewer-lineage-hidden">{lineageHiddenCount} hidden</div>
{/if}
{/if}
</div>
{:else}
@@ -1511,6 +1814,15 @@
ondblclick={handleDblClick}
style="cursor: {isPanning ? 'grabbing' : 'grab'}"
>
<!-- Transient notices live over the image, not the corners: top-left is the
lineage header and top-right is the close/counter. The native ComfyUI toast
can't be used here — it caps at z-index 10000, one below this viewer. -->
{#if newGenBanner}
<div class="pcr-viewer-newgen-banner">✨ {newGenBanner}</div>
{/if}
{#if copyPathToast}
<div class="pcr-viewer-newgen-banner">📋 Path copied to clipboard</div>
{/if}
{#if displayedHash}
{#if !imageLoaded && !imageError}
<div class="pcr-viewer-spinner"></div>
@@ -2061,6 +2373,7 @@
<LineageContextMenu
x={lineageMenu.x}
y={lineageMenu.y}
canExpand={lineageMenu.item ? canExpand(lineageMenu.item) : false}
canEdit={!!onEditSave}
canDelete={!!onDelete}
isLocal={isLocalClient}
@@ -2098,12 +2411,16 @@
height: 36px;
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between;
gap: 6px;
padding: 0 8px;
box-sizing: border-box;
font-size: 11px;
color: #888;
border-bottom: 1px solid #222;
flex-shrink: 0;
}
.pcr-lin-count { white-space: nowrap; }
.pcr-viewer-lineage-strip {
flex: 1;
overflow-y: auto;
@@ -2237,6 +2554,129 @@
color: #666;
flex-shrink: 0;
}
/* ── subway lanes: the rail grows horizontally into parallel vertical lanes ── */
.pcr-viewer-lineage-visible.lanes {
width: auto;
max-width: 62vw;
}
.pcr-lin-lanes {
position: relative;
flex: 1;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 22px;
padding: 8px 12px;
overflow: auto;
}
.pcr-lin-lanes::-webkit-scrollbar { width: 7px; height: 7px; }
.pcr-lin-lanes::-webkit-scrollbar-track { background: transparent; }
.pcr-lin-lanes::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
/* both axes scroll here, so kill the default white corner where they meet */
.pcr-lin-lanes::-webkit-scrollbar-corner { background: transparent; }
.pcr-lin-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
flex: 0 0 auto;
}
/* SVG overlay lives inside the scroll content, so it translates with scroll for
free — geometry is only recomputed on expand/fold/resize/img-load. */
.pcr-lin-connectors {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
overflow: visible;
}
.pcr-lin-connectors path {
stroke: #4a4a4a;
stroke-width: 2;
fill: none;
}
.pcr-lin-expander {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 3px;
box-sizing: border-box;
font-size: 10px;
font-weight: 600;
line-height: 1;
border-radius: 4px;
border: 1px solid rgba(120, 120, 120, 0.6);
background: rgba(20, 20, 20, 0.82);
color: #cdd6e0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.pcr-lin-expander:hover {
background: rgba(60, 60, 60, 0.95);
border-color: #888;
}
.pcr-lin-fold {
position: absolute;
bottom: 2px;
left: 2px;
width: 16px;
height: 16px;
padding: 0;
font-size: 11px;
line-height: 1;
border-radius: 4px;
border: 1px solid rgba(120, 120, 120, 0.6);
background: rgba(20, 20, 20, 0.82);
color: #cdd6e0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.pcr-lin-fold:hover {
background: rgba(60, 60, 60, 0.95);
border-color: #888;
}
/* ⊞/⊟ are actions, not status — keep them off the thumb until the node is
hovered so the strip isn't cluttered at rest (dot + layers badge stay). */
.pcr-lin-expander,
.pcr-lin-fold {
opacity: 0;
pointer-events: none;
transition: opacity 0.12s;
}
.pcr-viewer-lineage-node:hover .pcr-lin-expander,
.pcr-viewer-lineage-node:hover .pcr-lin-fold {
opacity: 1;
pointer-events: auto;
}
.pcr-lin-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
flex-shrink: 0;
border: 1px solid #333;
border-radius: 4px;
background: #1a1a1a;
color: #9bb4c7;
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.pcr-lin-toggle:hover { background: #262626; border-color: #555; color: #cfe2f0; }
.pcr-lin-toggle.on { background: rgba(79, 195, 247, 0.15); border-color: #4fc3f7; color: #4fc3f7; }
/* compare-picking: dropdown open → strip thumbnails are click targets */
.pcr-viewer-lineage-node.compare-candidate {
border-color: rgba(255, 221, 114, 0.6);
@@ -2273,7 +2713,8 @@
.pcr-viewer-newgen-banner {
position: absolute;
top: 14px;
left: 14px;
left: 50%;
transform: translateX(-50%);
z-index: 30;
padding: 7px 13px;
font-size: 12.5px;
@@ -2286,8 +2727,8 @@
pointer-events: none;
}
@keyframes pcr-newgen-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
from { opacity: 0; transform: translate(-50%, -8px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
@keyframes pcr-newgen-out {
to { opacity: 0; }
@@ -3,6 +3,7 @@
let {
x, y,
canExpand = false,
canEdit = false,
canDelete = false,
isLocal = false,
@@ -44,6 +45,10 @@
</script>
<div use:portal class="pcr-ctx" bind:this={menuEl} style="left:{pos.left}px;top:{pos.top}px;">
{#if canExpand}
<button class="pcr-ctx-item" onclick={() => act("expand-branches")}>Expand branches</button>
{#if canEdit || canDelete || isLocal}<div class="pcr-ctx-sep"></div>{/if}
{/if}
{#if canEdit}
<button class="pcr-ctx-item" onclick={() => act("edit")}>Edit</button>
{/if}
@@ -52,6 +57,7 @@
{/if}
{#if isLocal}
{#if canEdit || canDelete}<div class="pcr-ctx-sep"></div>{/if}
<button class="pcr-ctx-item" disabled={orphaned} onclick={() => act("copy-path")}>Copy Path</button>
<button class="pcr-ctx-item" disabled={orphaned} onclick={() => act("open-file")}>Open File</button>
<button class="pcr-ctx-item" disabled={orphaned} onclick={() => act("open-folder")}>Open Folder</button>
{#if isWindows}
+102
View File
@@ -0,0 +1,102 @@
// Lineage "subway" lane builder — pure topology, no Svelte. Shared by the in-rail
// lane view (ImageViewer) and the pop-out full-graph panel. Consumes the hoisted
// `lineageTree` ({ byHash, childrenOf, roots }) and a `brokenOut` Set of
// child-subtree ROOT hashes (the nodes the user has peeled into their own columns).
//
// Mental model: the family is a pure tree (single parent_hash, acyclic by
// content-sha256, never merges). A LANE is one vertical run following a primary
// path; at each node the OTHER children are "junctions" that can break out into
// their own lane. A column is the same recursive shape as column 0, so nested
// sub-junctions have somewhere to attach.
// Climb parent_hash from `hash` to its root. Returns { root, set } where `set`
// holds every node on the path (used as column 0's primary-path preference so the
// displayed image always sits on the leftmost lane).
export function ancestry(tree, hash) {
const { byHash } = tree;
const set = new Set();
let cur = hash;
let root = hash;
while (cur && byHash.has(cur) && !set.has(cur)) {
set.add(cur);
root = cur;
cur = byHash.get(cur).parent_hash;
}
return { root, set };
}
// Build one lane from `startHash`: start → primary child → … → tip, recording each
// off-primary fork as a junction. `spineSet` (optional) pulls the lane through a
// preferred path — on column 0 it's the displayed image's ancestry, so the lane
// flows root → displayed → tip; broken-out lanes pass no spineSet and follow the
// oldest child (childrenOf is pre-sorted ascending by created_at).
export function buildLane(tree, startHash, spineSet) {
const { byHash, childrenOf } = tree;
const nodes = [];
const junctions = [];
const guard = new Set();
let cur = startHash;
while (cur && byHash.has(cur) && !guard.has(cur)) {
guard.add(cur);
nodes.push(byHash.get(cur));
const kids = childrenOf.get(cur) || [];
let primary = spineSet ? kids.find(k => spineSet.has(k.hash)) : null;
if (!primary) primary = kids[0] || null;
const offPrimary = kids.filter(k => k !== primary).map(k => k.hash);
if (offPrimary.length) junctions.push({ atHash: cur, childRoots: offPrimary });
cur = primary ? primary.hash : null;
}
return { startHash, nodes, junctions };
}
// Full render: column 0 anchored on `anchorHash`'s spine, then DFS the broken-out
// lanes (each appears just right of the junction it forks from). Returns:
// columns — lanes in left-to-right DFS order (column 0 first)
// edges — { from: forkHash, to: childRootHash } per broken-out lane,
// consumed as DATA by the SVG connector layer (geometry is
// measured from the DOM; topology is never inferred from it)
// junctionsByNode — Map<atHash, { shown:[], hidden:[] }> powering the ⊞ expander
// badge and the context-menu "Expand branches" gate
// rootHash — column 0's first node (for the dot-root badge)
// topRow is the column's vertical offset measured in ROWS from the top of the
// whole lane area. A broken-out child column starts one generation BELOW its fork
// (topRow = parentTopRow + forkRowInParent + 1), i.e. the SAME row as the sibling
// that stayed inline in the parent column. So a parent sits a row above all its
// children, children of children another row down — a proper top-down hierarchy
// instead of every column top-aligned (which made parents look like peers).
export function buildRender(tree, anchorHash, brokenOut) {
const a = ancestry(tree, anchorHash);
const col0 = buildLane(tree, a.root, a.set);
const columns = [];
const edges = [];
const started = new Set();
const junctionsByNode = new Map();
const visit = (lane, topRow) => {
lane.topRow = topRow;
columns.push(lane);
// spine edges: every consecutive pair in a column is a parent→primary-child
// link, drawn as a straight vertical connector (same column = same x).
for (let i = 0; i + 1 < lane.nodes.length; i++) {
edges.push({ from: lane.nodes[i].hash, to: lane.nodes[i + 1].hash });
}
for (const j of lane.junctions) {
const forkRow = lane.nodes.findIndex(n => n.hash === j.atHash);
let rec = junctionsByNode.get(j.atHash);
if (!rec) { rec = { shown: [], hidden: [] }; junctionsByNode.set(j.atHash, rec); }
for (const childRoot of j.childRoots) {
if (brokenOut.has(childRoot)) {
rec.shown.push(childRoot);
if (!started.has(childRoot)) {
started.add(childRoot);
edges.push({ from: j.atHash, to: childRoot });
visit(buildLane(tree, childRoot), topRow + forkRow + 1);
}
} else {
rec.hidden.push(childRoot);
}
}
}
};
visit(col0, 0);
return { columns, edges, junctionsByNode, rootHash: col0.nodes.length ? col0.nodes[0].hash : null };
}