mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Merge branch 'story/layout-composer' into story/integrate
# Conflicts: # src/web/public/story-mode.js
This commit is contained in:
commit
9c080cb144
3 files changed
+378
-27
No files matched your search
@@ -15,6 +15,20 @@
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Registered so they always compute to a plain value: story-mode.js reads --story-panel-min back
|
||||
with parseFloat (an unregistered property holding calc() would come back as its raw text, and
|
||||
caption fitting would silently stop). --story-k scales the comic text with the row height. */
|
||||
@property --story-panel-min {
|
||||
syntax: '<length>';
|
||||
inherits: true;
|
||||
initial-value: 270px;
|
||||
}
|
||||
@property --story-k {
|
||||
syntax: '<number>';
|
||||
inherits: true;
|
||||
initial-value: 1;
|
||||
}
|
||||
|
||||
/* Session Options → Story (story-mode.js): shown while Story Mode is on. Sits in the sheet's
|
||||
header tray beside the close button, on every device. */
|
||||
#sessionOptionsModal .set-head-actions .story-options-btn {
|
||||
@@ -69,6 +83,8 @@
|
||||
--story-body: 'Manrope', system-ui, sans-serif;
|
||||
--story-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
|
||||
--story-drawer-w: min(560px, 44vw);
|
||||
/* The Director's cut wire beside the sheet grows with the window. */
|
||||
--story-wire-w: clamp(320px, 25vw, 460px);
|
||||
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -277,14 +293,16 @@ body.story-open {
|
||||
/* Main: the sheet and the wire */
|
||||
.story-main {
|
||||
overflow: auto;
|
||||
padding: 16px max(16px, env(safe-area-inset-right, 0px)) 40px max(16px, env(safe-area-inset-left, 0px));
|
||||
/* The scrollbar's room is kept whether the page scrolls or not, so the sheet's width (and the row
|
||||
height story-mode.js derives from it) never depends on whether the rows overflow. */
|
||||
scrollbar-gutter: stable;
|
||||
padding: 16px max(16px, env(safe-area-inset-right, 0px)) 16px max(16px, env(safe-area-inset-left, 0px));
|
||||
}
|
||||
.story-overlay.has-drawer .story-main {
|
||||
padding-right: calc(var(--story-drawer-w) + 16px);
|
||||
}
|
||||
.story-note {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto 14px;
|
||||
margin: 0 0 14px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
@@ -299,35 +317,47 @@ body.story-open {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.story-overlay.is-director .story-stage {
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
grid-template-columns: minmax(0, 1fr) var(--story-wire-w);
|
||||
}
|
||||
/* The wire runs the full height beside the note too (it starts at the top of the main area): the
|
||||
note ends where the sheet does, at the stage's 16px gap before the wire. */
|
||||
.story-overlay.is-director .story-note {
|
||||
margin-right: calc(var(--story-wire-w) + 16px);
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.story-overlay.is-director .story-stage {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.story-overlay.is-director .story-note {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
.story-sheet {
|
||||
container: story-sheet / inline-size;
|
||||
/* Down to the visible bottom even when a short page's rows stop short of it (a row is capped). */
|
||||
min-height: calc(var(--story-view-h, 0px) - var(--story-note-h, 0px));
|
||||
background: var(--story-sheet);
|
||||
}
|
||||
.story-page {
|
||||
/* The shortest row: story-mode.js sets each row from the visible height (plain px values, in
|
||||
grid-template-rows) and this to the shortest, for any row past those and as a panel's default. */
|
||||
--story-row-h: 270px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
/* A row grows only for the final panel, whose words and chips are in flow (never clipped). */
|
||||
grid-auto-rows: minmax(270px, auto);
|
||||
/* A row grows past it only for words that need more room (the final panel's are in flow). */
|
||||
grid-auto-rows: minmax(var(--story-row-h), auto);
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
/* Panels */
|
||||
.story-panel {
|
||||
/* The panel's usual height, read back as its scene's stage: a long caption or ending makes the
|
||||
panel taller (inline min-height), and the scene then stands at the bottom, never scaled up. */
|
||||
--story-panel-min: 270px;
|
||||
/* The panel's usual height, read back as its scene's stage: its row's, which story-mode.js writes
|
||||
on the panel (plain px). A long caption or ending makes the panel taller (inline min-height),
|
||||
and the scene then stands at the bottom, never scaled up. */
|
||||
--story-panel-min: var(--story-row-h);
|
||||
position: relative;
|
||||
margin: 0;
|
||||
min-height: var(--story-panel-min);
|
||||
@@ -387,7 +417,7 @@ body.story-open {
|
||||
background: var(--story-cap);
|
||||
color: var(--story-cap-ink);
|
||||
border: 2.5px solid var(--story-cap-ink);
|
||||
font: 17px/1.13 var(--story-letter);
|
||||
font: calc(17px * var(--story-k, 1)) / 1.13 var(--story-letter);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.story-cap-dir {
|
||||
@@ -395,7 +425,7 @@ body.story-open {
|
||||
}
|
||||
.story-overlay.is-director .story-cap {
|
||||
max-width: min(74%, 46ch);
|
||||
font: 500 11px/1.45 var(--story-mono);
|
||||
font: 500 calc(11px * var(--story-k, 1)) / 1.45 var(--story-mono);
|
||||
}
|
||||
.story-overlay.is-director .story-cap-story {
|
||||
display: none;
|
||||
@@ -430,7 +460,7 @@ body.story-open {
|
||||
background: var(--story-balloon);
|
||||
color: var(--story-balloon-ink);
|
||||
border: 2.5px solid var(--story-balloon-ink);
|
||||
font: 17px/1.12 var(--story-letter);
|
||||
font: calc(17px * var(--story-k, 1)) / 1.12 var(--story-letter);
|
||||
text-wrap: balance;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -489,7 +519,8 @@ body.story-open .toast-container {
|
||||
}
|
||||
.story-balloon--you {
|
||||
left: 16px;
|
||||
top: 74px;
|
||||
/* Below the caption, which grows with the comic text (--story-k). */
|
||||
top: calc(74px * var(--story-k, 1));
|
||||
max-width: 54%;
|
||||
}
|
||||
.story-balloon--you::before {
|
||||
@@ -511,7 +542,7 @@ body.story-open .toast-container {
|
||||
/* A turn somebody else started (a background task, another session): a notice, not your speech. */
|
||||
.story-balloon--note {
|
||||
left: 16px;
|
||||
top: 74px;
|
||||
top: calc(74px * var(--story-k, 1));
|
||||
max-width: 54%;
|
||||
border-radius: 4px;
|
||||
border-style: dashed;
|
||||
@@ -603,7 +634,7 @@ body.story-open .toast-container {
|
||||
.story-splash-title {
|
||||
/* Room for the tilt: rotated about its bottom-left corner, its right end rises. */
|
||||
margin: 0.4em 0 0;
|
||||
font: 400 clamp(28px, 4.4cqi, 44px) / 0.92 var(--story-display);
|
||||
font: 400 calc(clamp(28px, 4.4cqi, 44px) * var(--story-k, 1)) / 0.92 var(--story-display);
|
||||
color: var(--story-yellow);
|
||||
-webkit-text-stroke: 2.5px var(--story-line);
|
||||
paint-order: stroke fill;
|
||||
@@ -622,7 +653,7 @@ body.story-open .toast-container {
|
||||
.story-chips li > * {
|
||||
display: block;
|
||||
padding: 4px 8px;
|
||||
font: 500 12px/1.2 var(--story-mono) !important;
|
||||
font: 500 calc(12px * var(--story-k, 1)) / 1.2 var(--story-mono) !important;
|
||||
background: var(--story-balloon);
|
||||
color: var(--story-balloon-ink) !important;
|
||||
border: 2px solid var(--story-line);
|
||||
@@ -716,13 +747,17 @@ body.story-open .toast-container {
|
||||
}
|
||||
}
|
||||
|
||||
/* The wire */
|
||||
/* The wire: beside the sheet, a column as tall as the main area's visible height (story-mode.js
|
||||
measures it), sticky, its list scrolling inside. It starts at the top of the main area even when
|
||||
a note sits above the sheet: pulled up by the note's height, which the note's own margin clears. */
|
||||
.story-wire {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - 120px);
|
||||
height: var(--story-view-h, calc(100vh - 120px));
|
||||
margin-top: calc(-1 * var(--story-note-h, 0px));
|
||||
position: sticky;
|
||||
/* 0: a sticky box already stops inside its scroll container's padding. */
|
||||
top: 0;
|
||||
background: var(--story-sheet);
|
||||
border: 2.5px solid var(--story-ink);
|
||||
@@ -733,7 +768,9 @@ body.story-open .toast-container {
|
||||
@media (max-width: 1100px) {
|
||||
.story-wire {
|
||||
position: static;
|
||||
height: auto;
|
||||
max-height: 460px;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
.story-wire-head {
|
||||
|
||||
@@ -49,6 +49,11 @@ const STORY_STAGE_MAX = 680;
|
||||
const STORY_FPS_INTRO = 30;
|
||||
/** Panel backgrounds kept for reuse (kind × size × palette). */
|
||||
const STORY_BG_CACHE = 32;
|
||||
/**
|
||||
* And at most this many bytes of them: rows grow with the window, and 32 backgrounds of a 1440p
|
||||
* screen at 2x came to 342 MB (a panel still showing one keeps it; only reuse is lost).
|
||||
*/
|
||||
const STORY_BG_CACHE_BYTES = 160e6;
|
||||
/** Horizontal bands a new background is built in, one per frame. */
|
||||
const STORY_BG_BANDS = 4;
|
||||
const storyBgCache = new Map();
|
||||
@@ -1577,6 +1582,108 @@ const STORY_LEVEL_DEFAULT = 'director';
|
||||
/** Where the reader's pick is kept, per device. */
|
||||
const STORY_LEVEL_KEY = 'codeman:story-level';
|
||||
|
||||
/** Shortest row a page draws, in CSS px: the height the scenes and the comic text were drawn for. */
|
||||
const STORY_ROW_MIN = 270;
|
||||
/**
|
||||
* Tallest row, in CSS px: twice the drawn size. A row past it holds mostly background (a turn that
|
||||
* just started is one row of two panels), and the comic text, which stops growing at
|
||||
* STORY_TEXT_MAX, would look lost in art drawn more than twice its size.
|
||||
*/
|
||||
const STORY_ROW_MAX = 540;
|
||||
/**
|
||||
* The narrowest stage, in scene units, that keeps all of a scene's art on the panel: the mission's
|
||||
* exclamation mark beside Claude's head (at 0.78 of the width) runs off the right edge below about
|
||||
* 278. A row is never so tall that its narrowest panel's stage gets narrower, so a narrow panel
|
||||
* stays about as wide as it is tall, never a portrait.
|
||||
*/
|
||||
const STORY_STAGE_MIN = 280;
|
||||
/**
|
||||
* The final panel's narrowest stage: under 420 units its scene centres Claude, behind the words on
|
||||
* its left. Its row may still be taller (it takes what the narrow rows cannot); the scene then
|
||||
* stands at the bottom, the words above it, as it does when the words make the panel taller.
|
||||
*/
|
||||
const STORY_FINAL_STAGE_MIN = 440;
|
||||
/** The comic text grows with the rows up to this factor (`--story-k`); a caption in a narrow panel would wrap past its band beyond it. */
|
||||
const STORY_TEXT_MAX = 1.45;
|
||||
|
||||
/** Each item's row, and the items in each row, for `spans` on a page `cols` wide. */
|
||||
function storyLayoutRows(spans, cols) {
|
||||
const rowOf = [];
|
||||
const rows = [];
|
||||
let used = 0;
|
||||
spans.forEach((span, i) => {
|
||||
if (!used) rows.push([]);
|
||||
rows[rows.length - 1].push(i);
|
||||
rowOf.push(rows.length - 1);
|
||||
used += span;
|
||||
if (used >= cols) used = 0;
|
||||
});
|
||||
return { rowOf, rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Row heights, in whole CSS px, that make a page that fits fill the visible height, and each item's
|
||||
* stage (the height its scene is drawn at, `--story-panel-min`). The height is shared evenly; a row
|
||||
* never goes under STORY_ROW_MIN (a longer page keeps its rows and scrolls) nor over its cap
|
||||
* (STORY_ROW_MAX, and the height at which a panel's stage would fall under STORY_STAGE_MIN wide),
|
||||
* and what a capped row cannot take goes to the rows that can (the final panel's full-width row,
|
||||
* mostly). Rounded down, so the rows never overflow by a fraction and raise a scrollbar.
|
||||
* `box` is the page's content box: `width` across, `height` the visible height the rows may take,
|
||||
* `gap` between rows and columns; `stageMin` each item's narrowest stage in scene units (default
|
||||
* STORY_STAGE_MIN; 0 for an item without art, the pencils); and `lastRow` the height the last row
|
||||
* takes once its words made it taller (the final panel's are in flow): the other rows give that up,
|
||||
* so the page still ends at the bottom. The text scale `k` (the shortest row over STORY_ROW_MIN) is
|
||||
* settled before that, so the words that set `lastRow` never change with it. One column (a phone)
|
||||
* gives null: its panels keep their natural heights.
|
||||
* @returns {{ heights: number[], stage: number[], rowOf: number[], k: number, fill: number, caps: number[] } | null}
|
||||
*/
|
||||
function storyRowHeights(spans, cols, { width, height, gap = 0, stageMin = [], lastRow = 0 }) {
|
||||
if (!(cols >= 2) || !spans?.length || !(width > 0) || !(height > 0)) return null;
|
||||
const { rowOf, rows } = storyLayoutRows(spans, cols);
|
||||
const col = (width - gap * (cols - 1)) / cols;
|
||||
// The tallest row an item allows: its stage, its width wide in scene units, no narrower than `min`.
|
||||
const tallest = (i, min) => {
|
||||
const n = Math.min(cols, spans[i]);
|
||||
return min > 0 ? ((n * col + (n - 1) * gap) * StoryArt.VH) / min : Infinity;
|
||||
};
|
||||
const own = (i) => stageMin[i] ?? STORY_STAGE_MIN;
|
||||
const caps = rows.map((items) =>
|
||||
Math.max(STORY_ROW_MIN, Math.min(STORY_ROW_MAX, ...items.map((i) => tallest(i, Math.min(own(i), STORY_STAGE_MIN)))))
|
||||
);
|
||||
/** `total` shared by the rows in `which`, evenly, up to each row's cap; the rest to the others. */
|
||||
const share = (which, total) => {
|
||||
const out = new Map();
|
||||
let open = which;
|
||||
let left = total;
|
||||
while (open.length) {
|
||||
const even = left / open.length;
|
||||
const full = open.filter((r) => caps[r] < even);
|
||||
if (!full.length) {
|
||||
for (const r of open) out.set(r, even);
|
||||
break;
|
||||
}
|
||||
for (const r of full) {
|
||||
out.set(r, caps[r]);
|
||||
left -= caps[r];
|
||||
}
|
||||
open = open.filter((r) => caps[r] >= even);
|
||||
}
|
||||
return which.map((r) => Math.max(STORY_ROW_MIN, Math.floor(out.get(r))));
|
||||
};
|
||||
const n = rows.length;
|
||||
const avail = height - gap * (n - 1);
|
||||
const all = rows.map((_, r) => r);
|
||||
let heights = share(all, avail);
|
||||
const k = Math.round(Math.min(STORY_TEXT_MAX, Math.min(...heights) / STORY_ROW_MIN) * 1000) / 1000;
|
||||
const others = heights.slice(0, -1).reduce((a, h) => a + h, 0);
|
||||
if (n > 1 && lastRow > heights[n - 1] && others + lastRow > avail)
|
||||
heights = [...share(all.slice(0, -1), avail - lastRow), heights[n - 1]].map((h, r) => Math.min(h, heights[r]));
|
||||
const stage = spans.map((_, i) =>
|
||||
Math.max(STORY_ROW_MIN, Math.min(heights[rowOf[i]], Math.floor(tallest(i, own(i)))))
|
||||
);
|
||||
return { heights, stage, rowOf, k, fill: avail / n, caps };
|
||||
}
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/** Per-device setting, default OFF. */
|
||||
isStoryButtonEnabled() {
|
||||
@@ -2057,7 +2164,36 @@ Object.assign(CodemanApp.prototype, {
|
||||
_storyStartRuntime() {
|
||||
const st = this._story;
|
||||
const bar = st.root.querySelector('.story-bar');
|
||||
const sheet = st.pageEl.parentElement;
|
||||
st.sheetW = undefined;
|
||||
st.pageH = undefined;
|
||||
cancelAnimationFrame(st.layoutRaf || 0);
|
||||
st.layoutRaf = 0;
|
||||
st.ro = new ResizeObserver((entries) => {
|
||||
// The main area's box (the window, the drawer's padding, a bar above or below it) or the
|
||||
// sheet's width (the Director's cut wire takes some) sets the row heights: once per delivery,
|
||||
// before the panels re-size to them. The page's height changes with its words too (another
|
||||
// reading level, a caption that grew, the fonts arriving), which the final panel's row
|
||||
// measures: that pass waits for the next frame, so a pass that moves the page's own height
|
||||
// is never reported back in the same one. Same boxes, same values, nothing written: no loop.
|
||||
let relayout = false;
|
||||
let later = false;
|
||||
for (const entry of entries) {
|
||||
if (entry.target === st.mainEl) relayout = true;
|
||||
else if (entry.target === sheet && Math.abs(entry.contentRect.width - (st.sheetW ?? -1)) > 0.5) {
|
||||
st.sheetW = entry.contentRect.width;
|
||||
relayout = true;
|
||||
} else if (entry.target === st.pageEl && Math.abs(entry.contentRect.height - (st.pageH ?? -1)) > 0.5) {
|
||||
st.pageH = entry.contentRect.height;
|
||||
later = true;
|
||||
}
|
||||
}
|
||||
if (relayout) this._storyLayoutPage();
|
||||
if (later && !st.layoutRaf)
|
||||
st.layoutRaf = requestAnimationFrame(() => {
|
||||
st.layoutRaf = 0;
|
||||
this._storyLayoutPage();
|
||||
});
|
||||
for (const entry of entries) {
|
||||
// The bar's height places the phone drawer below it, and changes when the phone turns.
|
||||
if (entry.target === bar) {
|
||||
@@ -2069,6 +2205,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
});
|
||||
if (bar) st.ro.observe(bar);
|
||||
st.ro.observe(st.mainEl);
|
||||
if (sheet) st.ro.observe(sheet);
|
||||
st.ro.observe(st.pageEl);
|
||||
st.io = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const rt = st.panels.find((p) => p.el === entry.target);
|
||||
@@ -2383,6 +2522,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
else st.pageEl.prepend(rt.el);
|
||||
}
|
||||
});
|
||||
// Spans first, then the row height they make: a new panel is sized at its final width and height.
|
||||
next.forEach((rt, i) => {
|
||||
rt.el.style.setProperty('--story-span6', String(spans6[i]));
|
||||
rt.el.style.setProperty('--story-span2', String(spans2[i]));
|
||||
});
|
||||
st.spans6 = spans6;
|
||||
st.spans2 = spans2;
|
||||
this._storyLayoutPage();
|
||||
want.forEach((panel, i) => {
|
||||
const rt = st.panels[i];
|
||||
rt.panel = panel;
|
||||
@@ -2391,8 +2538,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
rt.isNew = false;
|
||||
this._storySizePanel(rt);
|
||||
}
|
||||
rt.el.style.setProperty('--story-span6', String(spans6[i]));
|
||||
rt.el.style.setProperty('--story-span2', String(spans2[i]));
|
||||
this._storyFillPanelText(rt, page, i);
|
||||
this._storyQueueDraw(rt);
|
||||
});
|
||||
@@ -2416,6 +2561,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
pencils.style.setProperty('--story-span2', String(spans2[want.length]));
|
||||
st.pageEl.append(pencils);
|
||||
} else pencils?.remove();
|
||||
// Again now that the words are in: the final panel's may take more of the height than its row.
|
||||
if (st.panels[st.panels.length - 1]?.splash) this._storyLayoutPage();
|
||||
if (focusAt >= 0 && !document.contains(focused)) {
|
||||
const rt = st.panels[Math.min(focusAt, st.panels.length - 1)];
|
||||
if (rt && !rt.w) this._storySizePanel(rt);
|
||||
@@ -2706,14 +2853,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
const w = rt.el.clientWidth;
|
||||
const h = rt.el.clientHeight;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
if (w === rt.w && h === rt.h && dpr === rt.dpr) return;
|
||||
rt.w = w;
|
||||
rt.h = h;
|
||||
// A panel its words made taller (a long caption, the final panel's ending) keeps its scene at the
|
||||
// usual size, standing at the bottom: the stage is the panel's usual height inside its border.
|
||||
// That height is its row's (a plain px value _storyLayoutPage writes on the panel), so the art
|
||||
// grows with the rows; read before the early return, since a row can change height while this
|
||||
// panel's box, held by a taller neighbour, does not.
|
||||
const minH = parseFloat(getComputedStyle(rt.el).getPropertyValue('--story-panel-min')) || 0;
|
||||
const border = rt.el.offsetHeight - rt.el.clientHeight;
|
||||
rt.stageH = minH > border ? Math.min(h, minH - border) : h;
|
||||
const stageH = minH > border ? Math.min(h, minH - border) : h;
|
||||
if (w === rt.w && h === rt.h && dpr === rt.dpr && stageH === rt.stageH) return;
|
||||
rt.w = w;
|
||||
rt.h = h;
|
||||
rt.stageH = stageH;
|
||||
rt.dpr = dpr;
|
||||
rt.canvas.width = Math.max(1, Math.round(w * dpr));
|
||||
rt.canvas.height = Math.max(1, Math.round(h * dpr));
|
||||
@@ -2721,6 +2872,73 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._storyQueueDraw(rt);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fits the page to the main area: the rows take the visible height (storyRowHeights), the comic
|
||||
* text grows with them (`--story-k`), and the sheet and the Director's cut wire span that height.
|
||||
* It reads the main area's box and the page's width, never the sheet's height, so nothing it
|
||||
* writes feeds back into it, and it writes a property only when its value changes. Called when the
|
||||
* page is rendered and by the resize observer (window, drawer, the wire's toggle).
|
||||
*/
|
||||
_storyLayoutPage() {
|
||||
const st = this._story;
|
||||
if (!st?.open) return;
|
||||
const px = (v) => parseFloat(v) || 0;
|
||||
const main = st.mainEl;
|
||||
const mcs = getComputedStyle(main);
|
||||
const view = Math.max(0, main.clientHeight - px(mcs.paddingTop) - px(mcs.paddingBottom));
|
||||
const noteH = st.note.hidden ? 0 : st.note.offsetHeight + px(getComputedStyle(st.note).marginBottom);
|
||||
const pcs = getComputedStyle(st.pageEl);
|
||||
// The column count the container queries chose: the page's resolved tracks, else their thresholds.
|
||||
const tracks = pcs.gridTemplateColumns || '';
|
||||
const sheetW = st.pageEl.parentElement?.getBoundingClientRect().width || 0;
|
||||
const cols =
|
||||
(tracks && !tracks.includes('(') && tracks.trim().split(/\s+/).length) ||
|
||||
(sheetW > 1000 ? 6 : sheetW > 600 ? 2 : 1);
|
||||
const spans = cols === 6 ? st.spans6 : cols === 2 ? st.spans2 : null;
|
||||
const box = {
|
||||
width: st.pageEl.clientWidth - px(pcs.paddingLeft) - px(pcs.paddingRight),
|
||||
height: view - noteH - px(pcs.paddingTop) - px(pcs.paddingBottom),
|
||||
gap: px(pcs.rowGap),
|
||||
};
|
||||
const set = (el, name, value) => {
|
||||
if (value === null) el.style.removeProperty(name);
|
||||
else if (el.style.getPropertyValue(name) !== value) el.style.setProperty(name, value);
|
||||
};
|
||||
set(st.stage, '--story-view-h', `${view}px`);
|
||||
set(st.stage, '--story-note-h', `${noteH}px`);
|
||||
// Plain px values, never calc() or clamp(): every panel reads its row back with parseFloat
|
||||
// (--story-panel-min), as its scene's stage. No fit (one column): the stylesheet's own heights.
|
||||
const apply = (f) => {
|
||||
set(st.pageEl, '--story-row-h', f ? `${Math.min(...f.heights)}px` : null);
|
||||
set(st.pageEl, '--story-k', f ? String(f.k) : null);
|
||||
set(st.pageEl, 'grid-template-rows', f ? f.heights.map((h) => `minmax(${h}px, auto)`).join(' ') : null);
|
||||
st.panels.forEach((rt, i) => set(rt.el, '--story-panel-min', f ? `${f.stage[i]}px` : null));
|
||||
};
|
||||
// The pencils (after the panels) draw no art; the final panel's scene needs a wide stage.
|
||||
box.stageMin = st.panels.map((rt) => (rt.splash ? STORY_FINAL_STAGE_MIN : STORY_STAGE_MIN)).concat([0]);
|
||||
let fit = storyRowHeights(spans, cols, box);
|
||||
apply(fit);
|
||||
// The final panel's words are in flow and may make its row taller at this text size (it sits
|
||||
// alone in the last row): measured now, the other rows give that height up. The words depend on
|
||||
// the text size and the width only, never on the row heights, so this settles in one step.
|
||||
const last = st.panels[st.panels.length - 1];
|
||||
if (fit && last?.splash && last.panel && fit.rowOf[st.panels.length - 1] === fit.heights.length - 1) {
|
||||
const lastRow = last.el.offsetHeight;
|
||||
if (lastRow > fit.heights[fit.heights.length - 1]) {
|
||||
fit = storyRowHeights(spans, cols, { ...box, lastRow });
|
||||
apply(fit);
|
||||
}
|
||||
}
|
||||
const sig = fit ? `${fit.heights.join(',')}|${fit.stage.join(',')}|${fit.k}` : '';
|
||||
const changed = sig !== st.layoutSig;
|
||||
st.layoutSig = sig;
|
||||
st.fit = fit;
|
||||
// A panel held at its size by a taller neighbour gets no resize report: its stage still follows
|
||||
// its row. Sizing only queues the draws (the frame loop rations them); a panel not sized yet is
|
||||
// sized when it is filled.
|
||||
if (changed) for (const rt of st.panels) if (rt.w) this._storySizePanel(rt);
|
||||
},
|
||||
|
||||
/**
|
||||
* A caption taller than the band above the art makes its panel taller by the difference, and the
|
||||
* scene stands below it (`rt.stageH`): a long caption never covers a test board or a code page.
|
||||
@@ -2769,7 +2987,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
canvas.height = rt.canvas.height;
|
||||
job = { canvas, next: 0, total: STORY_BG_BANDS };
|
||||
storyBgCache.set(key, job);
|
||||
if (storyBgCache.size > STORY_BG_CACHE) storyBgCache.delete(storyBgCache.keys().next().value);
|
||||
const bytes = () => [...storyBgCache.values()].reduce((n, j) => n + j.canvas.width * j.canvas.height * 4, 0);
|
||||
while (storyBgCache.size > 1 && (storyBgCache.size > STORY_BG_CACHE || bytes() > STORY_BG_CACHE_BYTES))
|
||||
storyBgCache.delete(storyBgCache.keys().next().value);
|
||||
}
|
||||
rt.bg = job;
|
||||
rt.bgKey = key;
|
||||
|
||||
@@ -1521,6 +1521,100 @@ describe('story-mode.js layout', () => {
|
||||
}
|
||||
expect(spans([1, 1, 1], 2)).toEqual([1, 1, 2]);
|
||||
});
|
||||
|
||||
it('rows fill the visible height: shared evenly, never under 270, capped for narrow panels and one row', () => {
|
||||
boot(null);
|
||||
type Box = { width: number; height: number; gap: number; stageMin?: number[]; lastRow?: number };
|
||||
type Fit = { heights: number[]; stage: number[]; k: number } | null;
|
||||
const rows = (spans: number[], cols: number, box: Box) => win.storyRowHeights(spans, cols, box) as Fit;
|
||||
// A 1505x870 window: the page's rows may take 753 px, 1437 px across.
|
||||
const box = { width: 1437, height: 753, gap: 14 };
|
||||
// The mission and one step over the final panel: two rows of 369 px fill it, text 1.367x.
|
||||
const two = rows([3, 3, 6], 6, box)!;
|
||||
expect(two.heights).toEqual([369, 369]);
|
||||
expect(two.stage).toEqual([369, 369, 369]);
|
||||
expect(two.k).toBe(1.367);
|
||||
// More rows than fit at 270: they stay 270 and the page scrolls; the text keeps its drawn size.
|
||||
const long = rows([3, 3, 2, 2, 2, 6, 6], 6, box)!;
|
||||
expect(long.heights).toEqual([270, 270, 270, 270]);
|
||||
expect(long.k).toBe(1);
|
||||
// One row (a turn that just started): twice the drawn size at most, not the whole window.
|
||||
expect(rows([3, 3], 6, box)!.heights).toEqual([540]);
|
||||
// Two columns of 365 px: the narrow row stops where its stage would fall under 280 units, the
|
||||
// full-width row takes the rest, and the final panel's scene keeps a stage wide enough to stand
|
||||
// beside its words (440 units), at the bottom of its taller row.
|
||||
const narrow = rows([1, 1, 2], 2, { width: 744, height: 870, gap: 14, stageMin: [280, 280, 440] })!;
|
||||
expect(narrow.heights).toEqual([Math.floor((365 * 260) / 280), 517]);
|
||||
expect(narrow.heights[0] + narrow.heights[1] + 14).toBeGreaterThan(868);
|
||||
expect(narrow.stage).toEqual([338, 338, Math.floor((744 * 260) / 440)]);
|
||||
// The final panel's words make its row 410 px tall: the other row gives that up, the text stays.
|
||||
const fixed = rows([3, 3, 6], 6, { ...box, lastRow: 410 })!;
|
||||
expect(fixed.heights).toEqual([329, 369]);
|
||||
expect(fixed.k).toBe(two.k);
|
||||
// One column (a phone) keeps its natural heights; no visible height, nothing to fit.
|
||||
expect(rows([1, 1, 1], 1, box)).toBeNull();
|
||||
expect(rows([3, 3, 6], 6, { ...box, height: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it('every panel reads its row back as a plain px value, and its scene is drawn at that height', async () => {
|
||||
boot(storyOf([prompt('first job'), ...read('r1', '/work/demo-shop/a.ts'), said('Done.'), end(2000)]));
|
||||
app.openStoryMode(SID);
|
||||
await settle();
|
||||
const st = app._story;
|
||||
// JSDOM lays nothing out: the boxes a 1505x870 window gives the overlay, the paddings the
|
||||
// stylesheet sets.
|
||||
const fix = (el: HTMLElement, prop: string, value: number) =>
|
||||
Object.defineProperty(el, prop, { configurable: true, get: () => value });
|
||||
st.mainEl.style.padding = '16px';
|
||||
st.pageEl.style.padding = '14px';
|
||||
st.pageEl.style.rowGap = '14px';
|
||||
fix(st.mainEl, 'clientHeight', 813);
|
||||
fix(st.pageEl, 'clientWidth', 1465);
|
||||
let sheetW = 1465;
|
||||
st.pageEl.parentElement.getBoundingClientRect = () => ({ width: sheetW }) as DOMRect;
|
||||
app._storyLayoutPage();
|
||||
expect(st.pageEl.style.getPropertyValue('grid-template-rows')).toBe('minmax(369px, auto) minmax(369px, auto)');
|
||||
expect(st.pageEl.style.getPropertyValue('--story-k')).toBe('1.367');
|
||||
expect(st.stage.style.getPropertyValue('--story-view-h')).toBe('781px');
|
||||
// What _storySizePanel and _storyFitCaption read with parseFloat: a finite px value, never the
|
||||
// raw text of a calc() or clamp().
|
||||
for (const p of st.panels) {
|
||||
const raw = win.getComputedStyle(p.el).getPropertyValue('--story-panel-min');
|
||||
expect(raw).toMatch(/^\d+px$/);
|
||||
expect(parseFloat(raw)).toBeGreaterThan(0);
|
||||
}
|
||||
const rt = st.panels[0];
|
||||
fix(rt.el, 'clientWidth', 712);
|
||||
fix(rt.el, 'clientHeight', 363);
|
||||
fix(rt.el, 'offsetHeight', 369);
|
||||
app._storySizePanel(rt);
|
||||
expect(rt.stageH).toBe(363); // 369 inside the 3 px border: the art grows with the row
|
||||
// The final panel's words make its row 410 px tall: the first row gives the height up, and the
|
||||
// panel already sized redraws its scene at its new row.
|
||||
fix(st.panels[st.panels.length - 1].el, 'offsetHeight', 410);
|
||||
app._storyLayoutPage();
|
||||
expect(st.pageEl.style.getPropertyValue('grid-template-rows')).toBe('minmax(329px, auto) minmax(369px, auto)');
|
||||
expect(win.getComputedStyle(rt.el).getPropertyValue('--story-panel-min')).toBe('329px');
|
||||
expect(rt.stageH).toBe(323);
|
||||
// One column (a phone): nothing inline, the stylesheet's own heights apply.
|
||||
sheetW = 500;
|
||||
app._storyLayoutPage();
|
||||
expect(st.pageEl.style.getPropertyValue('grid-template-rows')).toBe('');
|
||||
expect(st.pageEl.style.getPropertyValue('--story-k')).toBe('');
|
||||
expect(rt.el.style.getPropertyValue('--story-panel-min')).toBe('');
|
||||
});
|
||||
|
||||
it('the stylesheet keeps the row height one plain length, registered, with one default', () => {
|
||||
const css = readFileSync(new URL('../src/web/public/story-mode.css', import.meta.url), 'utf-8');
|
||||
// Registered: whatever a later rule puts in it, it computes to a length parseFloat reads.
|
||||
expect(css).toMatch(/@property --story-panel-min \{[^}]*syntax: '<length>';/);
|
||||
// One default on the page, used by the rows and by every panel (no second 270 anywhere).
|
||||
expect(css).toMatch(
|
||||
/\.story-page \{[^}]*--story-row-h: 270px;[^}]*grid-auto-rows: minmax\(var\(--story-row-h\), auto\);/
|
||||
);
|
||||
expect(css).toMatch(/\.story-panel \{[^}]*--story-panel-min: var\(--story-row-h\);/);
|
||||
expect(css).not.toMatch(/minmax\(270px/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('story-mode.js reading levels', () => {
|
||||
|
||||
Reference in new issue
Block a user