diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-05 22:24:22 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-05 22:24:22 +0900 |
| commit | 63c1a9dfa8b04ab9725a9a49ef34cd3fc6ade56c (patch) | |
| tree | 9a5b0970585a927bb37db09d23ba7bb1fc4190be /public/bluebey-studio/src | |
| parent | 39a6c31beed18f0d1fc211fda919fae7ce2d9505 (diff) | |
bluebey-studio: 小物の3軸回転・線画対応と、まんがのコマ枠・共有リンク短縮
小物にX/Y/Zの3軸回転とフラット/線画スタイル対応を追加し、鉛筆の芯を円錐に埋め、ノートと消しゴムを追加。まんがは各コマに枠を描きzip出力を廃止。共有リンクは既定値の省略で約2,429→321文字に短縮。縦書きは句読点のぶら下げと長音・小書き文字の位置を補正。線画の帽子にトーンを付け、removeMeshでマテリアルを復元して切替時の破損を修正。
Diffstat (limited to 'public/bluebey-studio/src')
| -rw-r--r-- | public/bluebey-studio/src/caption.js | 87 | ||||
| -rw-r--r-- | public/bluebey-studio/src/main.js | 53 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 6 | ||||
| -rw-r--r-- | public/bluebey-studio/src/props.js | 34 | ||||
| -rw-r--r-- | public/bluebey-studio/src/styles.js | 31 | ||||
| -rw-r--r-- | public/bluebey-studio/src/urlState.js | 79 |
6 files changed, 258 insertions, 32 deletions
diff --git a/public/bluebey-studio/src/caption.js b/public/bluebey-studio/src/caption.js index 1543f0e..a315971 100644 --- a/public/bluebey-studio/src/caption.js +++ b/public/bluebey-studio/src/caption.js @@ -59,6 +59,37 @@ const WIDE_RANGES = [ ]; /** + * Punctuation that may hang past the bottom of a vertical column. + * + * Japanese line breaking (禁則処理) forbids starting a line with a closing + * mark, so when one of these would land at the top of a new column it is kept + * on the previous column and drawn past that column's last cell instead. + */ +const HANGING_PUNCTUATION = new Set(['、', '。', ',', '.', ',', '.']); + +/** Small kana, which sit toward the upper-right of their cell when stacked. */ +const SMALL_KANA = 'ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮ'; + +/** + * Per-character offset for vertical (縦書き) text, in em. + * + * A column draws one glyph per cell, so a few characters need a nudge to read + * the way a Japanese typesetter would place them: the prolonged sound mark + * becomes an upright tick, small kana tuck into the upper-right, and the + * brackets lean toward the ends of the span they enclose. Everything else + * stays dead-centre. Sharing this table is what keeps the canvas and the SVG + * export in step. + */ +const VERTICAL_OFFSETS = new Map(); +for (const ch of SMALL_KANA) VERTICAL_OFFSETS.set(ch, { dx: 0.12, dy: -0.12 }); +VERTICAL_OFFSETS.set('ー', { dx: 0.22, dy: -0.3 }); +for (const ch of '「『(〔') VERTICAL_OFFSETS.set(ch, { dx: 0.14, dy: -0.12 }); +for (const ch of '」』)〕') VERTICAL_OFFSETS.set(ch, { dx: -0.14, dy: 0.12 }); + +/** Offset used for the characters that need no special placement. */ +const NO_OFFSET = Object.freeze({ dx: 0, dy: 0 }); + +/** * The `ctx.font` string for a caption. * * Canvas wants size, weight and family in one string, and the size has to carry @@ -356,8 +387,9 @@ function drawText(ctx, layout, state, font) { const chars = [...lines[i].text]; const x = left + (lines.length - 1 - i) * step; for (let j = 0; j < chars.length; j++) { - const y = top + j * fontSize + metrics.ascent; - ctx.fillText(chars[j], x, y); + const offset = verticalOffsetOf(chars[j]); + const y = top + j * fontSize + metrics.ascent + offset.dy * fontSize; + ctx.fillText(chars[j], x + offset.dx * fontSize, y); } } return; @@ -408,8 +440,9 @@ function liveText(state, layout, font, fill, places) { const chars = [...lines[i].text]; const x = left + (lines.length - 1 - i) * step; for (let j = 0; j < chars.length; j++) { - const y = top + j * fontSize + metrics.ascent; - tspans.push(`<tspan x="${formatNumber(x, places)}" y="${formatNumber(y, places)}">${escapeText(chars[j])}</tspan>`); + const offset = verticalOffsetOf(chars[j]); + const y = top + j * fontSize + metrics.ascent + offset.dy * fontSize; + tspans.push(`<tspan x="${formatNumber(x + offset.dx * fontSize, places)}" y="${formatNumber(y, places)}">${escapeText(chars[j])}</tspan>`); } } return `<text ${attributes}>${tspans.join('')}</text>`; @@ -475,9 +508,26 @@ function layoutBlock(font, text, options) { } /** + * Offset of one character inside its vertical cell, in em. + * + * Shared by `drawText` and `liveText` so a glyph that needs a nudge lands in + * the same place on screen and in the export. + */ +function verticalOffsetOf(ch) { + return VERTICAL_OFFSETS.get(ch) ?? NO_OFFSET; +} + +/** * Vertical (縦書き) layout: each paragraph becomes one column, characters stacked * top to bottom, columns running right to left. A paragraph longer than the * available height is split into more columns. + * + * Closing punctuation hangs: when `、` or `。` (or `,`/`.`) would fall at the top + * of a new column, it stays on the previous one and is drawn just below that + * column's last cell instead. Such a hanging character does not count toward + * the column's length, so it never makes the block a cell taller or adds a + * column of its own. `lines[i].hang` reports how many characters on that line + * are hanging. */ function layoutVertical(text, { fontSize, lineHeight, maxHeight }) { const limit = Number.isFinite(maxHeight) && maxHeight > 0 @@ -487,18 +537,37 @@ function layoutVertical(text, { fontSize, lineHeight, maxHeight }) { for (const paragraph of String(text).split(/\r\n|\r|\n/)) { const chars = [...paragraph]; if (chars.length === 0) { - columns.push(''); + columns.push({ text: '', count: 0 }); continue; } - for (let i = 0; i < chars.length; i += limit) { - columns.push(chars.slice(i, i + limit).join('')); + let current = ''; + let count = 0; + for (const ch of chars) { + if (count >= limit) { + // A closing mark that would start a column hangs off the previous one. + if (HANGING_PUNCTUATION.has(ch)) { + current += ch; + continue; + } + columns.push({ text: current, count }); + current = ''; + count = 0; + } + current += ch; + count += 1; } + columns.push({ text: current, count }); } - const longest = columns.reduce((max, column) => Math.max(max, [...column].length), 0); + const longest = columns.reduce((max, column) => Math.max(max, column.count), 0); return { - lines: columns.map((column) => ({ text: column, width: fontSize })), + lines: columns.map((column) => ({ + text: column.text, + width: fontSize, + hang: [...column.text].length - column.count, + })), width: columns.length * fontSize * lineHeight, height: longest * fontSize, + vertical: true, }; } diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js index cecdce5..23ed017 100644 --- a/public/bluebey-studio/src/main.js +++ b/public/bluebey-studio/src/main.js @@ -21,7 +21,6 @@ import { buildProp, PROP_DEFAULTS, disposeProp, applyPropText } from './props.js import { rollAll, decodeSeed } from './gacha.js'; import { encodeState, decodeState } from './urlState.js'; import { createMouthFlap, levelToMouth } from './mouthFlap.js'; -import { createZip } from './zip.js'; import { createScreenOutline, BEHIND_LABEL, SOLID_LABEL, LEAF_LABEL, NOSE_LABEL } from './outline.js'; import { gionSheetUrl, stampRect, imageAspect, imageReady, drawGion, DEFAULT_STAMP_WIDTH } from './gion.js'; @@ -522,9 +521,9 @@ async function init() { // Hats are built at runtime, so they are not in the GLB's mesh list. Register // them with the style system (and the current hat) so they follow flat/line art // and get an outline hull like every other part. - hatStyleRegister = (mesh) => styles.addMesh(mesh); + hatStyleRegister = (mesh) => styles.addMesh(mesh, { tone: true }); hatStyleUnregister = (mesh) => styles.removeMesh(mesh); - for (const mesh of hatMeshes) styles.addMesh(mesh); + for (const mesh of hatMeshes) styles.addMesh(mesh, { tone: true }); const face = new Face({ eyeMesh: model.parts.eyeMesh, mouthMesh: model.parts.mouthMesh, @@ -951,11 +950,21 @@ async function init() { if (signature === propSignature) return; propSignature = signature; for (const child of [...propRoot.children]) { + // Hand every mesh back to the style system before the prop is disposed. + // `removeMesh` restores each mesh's own material (a prop under 線画 is + // wearing the shared paper material, which must not be disposed). Collect + // the meshes first: `removeMesh` deletes the outline hull, a child of the + // prop group. + const meshes = []; + child.traverse((object) => { + if (styles.originals.has(object)) meshes.push(object); + }); + for (const mesh of meshes) styles.removeMesh(mesh); propRoot.remove(child); disposeProp(child); } for (const item of items) { - const def = PROP_DEFAULTS[item.kind] ?? { x: 0, y: 0, z: 0, rotY: 0, scale: 1 }; + const def = PROP_DEFAULTS[item.kind] ?? { x: 0, y: 0, z: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 }; let group; try { group = buildProp(item.kind, { colors: state.render.colors, scale: item.scale ?? def.scale ?? 1 }); @@ -964,12 +973,22 @@ async function init() { continue; } group.position.set(item.x ?? def.x ?? 0, item.y ?? def.y ?? 0, item.z ?? def.z ?? 0); - group.rotation.y = ((item.rotY ?? def.rotY ?? 0) * DEG); + group.rotation.set( + (item.rotX ?? def.rotX ?? 0) * DEG, + (item.rotY ?? def.rotY ?? 0) * DEG, + (item.rotZ ?? def.rotZ ?? 0) * DEG, + ); propRoot.add(group); + // Register with the style system, so フラット and 線画 reach the props too + // and each part gets an outline hull like the body and the hats. + group.traverse((object) => { + if (object.isMesh) styles.addMesh(object); + }); // 看板 only: paint the saved text onto the freshly built face (no-op for // every other prop, which has no writing surface). applyPropText(group, item.text); } + refreshOutlineExclusion?.(); app.needsRender = true; } @@ -2376,13 +2395,15 @@ async function init() { } function addProp(kind) { - const def = PROP_DEFAULTS[kind] ?? { x: 1.6, y: 0, z: 0, rotY: 0, scale: 1 }; + const def = PROP_DEFAULTS[kind] ?? { x: 1.6, y: 0, z: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 }; state.props.items = [...(state.props.items ?? []), { kind, x: def.x ?? 0, y: def.y ?? 0, z: def.z ?? 0, + rotX: def.rotX ?? 0, rotY: def.rotY ?? 0, + rotZ: def.rotZ ?? 0, scale: def.scale ?? 1, }]; applyState({}, { scope: 'render', sync: true }); @@ -2464,10 +2485,9 @@ async function init() { } /** - * まんが: replay the panels, capture each one, and hand back a sheet of them - * plus a zip of the individual frames. + * まんが: replay the panels, capture each one, and hand back a framed sheet. */ - async function saveStory({ scale = 2, includeZip = true } = {}) { + async function saveStory({ scale = 2 } = {}) { const panels = state.story?.panels ?? []; if (!panels.length) { toast('コマがありません。「今の状態をコマに追加」で作ってください'); @@ -2509,6 +2529,11 @@ async function init() { const numbered = columns >= 2 && captures.length >= 3; if (numbered) await ensureCaptionFont(); + // A manga page reads by its panel frames. They are drawn inside each cell so + // the artwork keeps its full size, and are thick enough to survive the 1× + // export. + const frame = Math.max(3, Math.round(Math.min(cellW, cellH) * 0.012)); + const sheet = document.createElement('canvas'); sheet.width = padding * 2 + cellW * columns + gap * (columns - 1); sheet.height = padding * 2 + cellH * rows + gap * (rows - 1); @@ -2519,18 +2544,14 @@ async function init() { const x = padding + (index % columns) * (cellW + gap); const y = padding + Math.floor(index / columns) * (cellH + gap); ctx.drawImage(capture.canvas, x, y, cellW, cellH); + ctx.lineWidth = frame; + ctx.strokeStyle = '#2a1e33'; + ctx.strokeRect(x + frame / 2, y + frame / 2, cellW - frame, cellH - frame); if (numbered) drawPanelNumber(ctx, index + 1, x, y, cellW, cellH); }); const stamp = exporter.timestamp(); exporter.downloadBlob(await exporter.canvasToBlob(sheet), `bluebey-comic-${stamp}.png`); - if (includeZip) { - const files = await Promise.all(captures.map(async (capture, index) => ({ - name: `panel-${String(index + 1).padStart(2, '0')}.png`, - data: new Uint8Array(await capture.blob.arrayBuffer()), - }))); - exporter.downloadBlob(createZip(files), `bluebey-comic-${stamp}.zip`); - } toast(`まんがを書き出しました(${captures.length}コマ)`); } diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index 6d8e8ba..f7053cb 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -2105,7 +2105,9 @@ export function buildPanel(app, root) { x: slider({ label: 'よこ(X)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('x') }), y: slider({ label: 'たかさ(Y)', min: 0, max: 2, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('y') }), z: slider({ label: 'おくゆき(Z)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('z') }), - rotY: slider({ label: '回転', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotY') }), + rotX: slider({ label: '前後の傾き', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotX') }), + rotY: slider({ label: '回転(よこ)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotY') }), + rotZ: slider({ label: '左右の傾き', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotZ') }), scale: slider({ label: '大きさ', min: 0.4, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: patch('scale') }), }; // The numbers are only a fallback for the drag, so they stay folded away. @@ -2971,7 +2973,9 @@ export function buildPanel(app, root) { entry.sliders.x.set(item.x ?? 0); entry.sliders.y.set(item.y ?? 0); entry.sliders.z.set(item.z ?? 0); + entry.sliders.rotX.set(item.rotX ?? 0); entry.sliders.rotY.set(item.rotY ?? 0); + entry.sliders.rotZ.set(item.rotZ ?? 0); entry.sliders.scale.set(item.scale ?? 1); entry.faceScale?.set(item.faceScale ?? 1); applyPropFaceScale(groups[entry.index], item.faceScale); diff --git a/public/bluebey-studio/src/props.js b/public/bluebey-studio/src/props.js index 70a258d..9fb3e49 100644 --- a/public/bluebey-studio/src/props.js +++ b/public/bluebey-studio/src/props.js @@ -360,7 +360,9 @@ const buildPencil = prop((group, material) => { addCylinder(group, material('metal', 0.9), r, r, 0.12, [0, 0.22, 0], 6); addCylinder(group, material('accent'), r, r, 1.30, [0, 0.93, 0], 6); addCylinder(group, material('wood'), 0, r, 0.30, [0, 1.73, 0], 6); - addCylinder(group, material('metal', 0.3), 0, r * 0.3, 0.12, [0, 1.94, 0], 6); + // The lead's wide base sinks into the cone (whose tip is at y = 1.88), so the + // two look joined, while its point still pokes out past the wood. + addCylinder(group, material('metal', 0.3), 0, r * 0.3, 0.12, [0, 1.86, 0], 6); }); /** コップ: a mug with a rim, a handle and a little drink inside. */ @@ -371,6 +373,32 @@ const buildCup = prop((group, material) => { addMesh(group, new THREE.TorusGeometry(0.16, 0.045, 8, 16), material('paper'), 0.36, 0.36, 0); }); +/** ノート: a fat pad of pages under a thin cover, bound along one long edge. */ +const buildNotebook = prop((group, material) => { + const width = 1.5; + const depth = 2.0; + const pages = 0.18; + const cover = 0.04; + // The cover pokes out past the pages, so the notebook reads from above. + addBox(group, material('wood', 0.6), [width + 0.08, cover, depth + 0.08], [0, cover / 2, 0]); + addBox(group, material('paper'), [width, pages, depth], [0, cover + pages / 2, 0]); + addBox( + group, + material('accent'), + [0.16, pages + 0.04, depth + 0.02], + [-width / 2 + 0.08, cover + pages / 2, 0], + ); +}); + +/** 消しゴム: a chunky two-tone block, a hard accent top on a soft white base. */ +const buildEraser = prop((group, material) => { + const width = 0.9; + const depth = 0.5; + const half = 0.15; + addBox(group, material('paper'), [width, half, depth], [0, half / 2, 0]); + addBox(group, material('accent', 0.9), [width, half, depth], [0, half + half / 2, 0]); +}); + export const PROP_LIBRARY = [ { id: 'podium', label: '演台', height: 2.2, build: buildPodium }, { id: 'desk', label: '机', height: 1.5, build: buildDesk }, @@ -383,6 +411,8 @@ export const PROP_LIBRARY = [ { id: 'futon', label: '布団', height: 0.65, build: buildFuton }, { id: 'pencil', label: '鉛筆', height: 2.0, build: buildPencil }, { id: 'cup', label: 'コップ', height: 0.66, build: buildCup }, + { id: 'notebook', label: 'ノート', height: 0.22, build: buildNotebook }, + { id: 'eraser', label: '消しゴム', height: 0.3, build: buildEraser }, ]; /** @@ -407,6 +437,8 @@ export const PROP_DEFAULTS = { futon: { x: 4.2, y: 0, z: -0.6, rotY: -0.35, scale: 1 }, pencil: { x: -1.4, y: 0, z: 2.6, rotY: 0.3, scale: 1 }, cup: { x: 1.5, y: 0, z: 2.5, rotY: -0.4, scale: 1 }, + notebook: { x: -3.2, y: 0, z: -2.7, rotY: 0.35, scale: 1 }, + eraser: { x: 3.6, y: 0, z: 2.6, rotY: -0.45, scale: 1 }, }; /** The palette entry each part reads, derived from the app's colours. */ diff --git a/public/bluebey-studio/src/styles.js b/public/bluebey-studio/src/styles.js index 1b6a08d..8efdf71 100644 --- a/public/bluebey-studio/src/styles.js +++ b/public/bluebey-studio/src/styles.js @@ -110,6 +110,11 @@ export class Styles { this.gradientMap = makeGradientMap(); this.toonMaterials = new Map(); this.paperMaterial = new THREE.MeshBasicMaterial({ color: paper, toneMapped: false }); + // A hat is not flat: in a line drawing a white hat on a white head merges + // into one shape, so hats get a light tone instead of paper (see addMesh). + this.toneMaterial = new THREE.MeshBasicMaterial({ color: toneOf(paper), toneMapped: false }); + /** Runtime meshes that want the tone fill rather than paper in 線画. */ + this.toneMeshes = new Set(); this.invisibleMaterial = new THREE.MeshBasicMaterial({ colorWrite: false, depthWrite: true }); this.outlineWidth = outlineWidth; @@ -186,11 +191,15 @@ export class Styles { /** * Register a mesh that is built at runtime (a hat), so it follows the render * styles and gets an outline hull like the GLB parts. `removeMesh` undoes it. + * + * `options.tone` gives the mesh the light tone fill in 線画 instead of paper, + * for parts whose shape would otherwise merge with the body (the hats). */ - addMesh(mesh) { + addMesh(mesh, options = {}) { if (!mesh || this.originals.has(mesh)) return; this.originals.set(mesh, mesh.material); this.meshes.push(mesh); + if (options.tone) this.toneMeshes.add(mesh); const kind = this.kindOf(mesh); if (!OUTLINE_SKIP.has(kind)) { const overlay = OUTLINE_SILHOUETTE_ONLY.has(kind); @@ -217,6 +226,7 @@ export class Styles { /** Take a runtime mesh (and its outline hull) back out. */ removeMesh(mesh) { if (!mesh || !this.originals.has(mesh)) return; + const original = this.originals.get(mesh); const hull = mesh.userData.hull; if (hull) { hull.removeFromParent(); @@ -224,7 +234,12 @@ export class Styles { if (i >= 0) this.outlineMeshes.splice(i, 1); delete mesh.userData.hull; } + // Put the mesh's own material back before dropping it: while a line style is + // on it is wearing the *shared* paper/tone material, and the caller is about + // to dispose it - which would blank every part using it. + if (original) mesh.material = original; this.originals.delete(mesh); + this.toneMeshes.delete(mesh); const j = this.meshes.indexOf(mesh); if (j >= 0) this.meshes.splice(j, 1); } @@ -243,11 +258,14 @@ export class Styles { const original = this.originals.get(mesh); let material; if (line) { - // Paper on paper in `lineart`; invisible in `outline`, which is the pass + // Paper on paper in `lineart`; a light tone for hats, which would + // otherwise merge into the head; invisible in `outline`, which is the pass // the SVG trace reads. Either way every edge - the body, the nose, and // the leaves - is drawn by the screen-space outline, so no part needs a // material trick of its own any more. - material = value === 'lineart' ? this.paperMaterial : this.invisibleMaterial; + material = value === 'lineart' + ? (this.toneMeshes.has(mesh) ? this.toneMaterial : this.paperMaterial) + : this.invisibleMaterial; } else if (value === 'flat') { material = this.toonFor(original); } else { @@ -307,6 +325,7 @@ export class Styles { setPaper(color) { this.paper = color; this.paperMaterial.color.set(color); + this.toneMaterial.color.copy(toneOf(color)); } setOutlineVisible(visible) { @@ -360,6 +379,7 @@ export class Styles { for (const entry of this.hullMaterials.values()) entry.material.dispose(); this.hullMaterials.clear(); this.paperMaterial.dispose(); + this.toneMaterial.dispose(); this.invisibleMaterial.dispose(); this.gradientMap.dispose(); for (const material of this.toonMaterials.values()) material.dispose(); @@ -367,6 +387,11 @@ export class Styles { } } +/** A step towards the ink, for the light fill a hat gets in 線画. */ +function toneOf(paper) { + return new THREE.Color(paper).lerp(new THREE.Color('#2a1e33'), 0.14); +} + /** A 3-step ramp gives crisper cartoon bands than the shader's default. */ function makeGradientMap() { const steps = new Uint8Array([90, 165, 255]); diff --git a/public/bluebey-studio/src/urlState.js b/public/bluebey-studio/src/urlState.js index a3a7e48..d6fb6bd 100644 --- a/public/bluebey-studio/src/urlState.js +++ b/public/bluebey-studio/src/urlState.js @@ -24,8 +24,16 @@ * `decodeState` is deliberately total: any malformed, truncated or hostile input * returns `null` rather than throwing, because it is fed whatever came out of * the address bar. + * + * Most of a session is still at its default, and a default costs bytes on every + * link. `pruneDefaults` drops exactly the fields that equal `defaultState()`, and + * `restoreDefaults` merges what is left back onto a fresh default, so the link + * carries only what the user actually changed. The pruning is lossless and the + * decoder still accepts the older, full-state links. */ +import { applyPatch, defaultState } from './presets.js'; + /** Above this length a `data:` background image is a photo, not a link. */ const MAX_INLINE_IMAGE = 2048; @@ -53,6 +61,73 @@ export function stripForUrl(state) { return copy; } +/** Plain objects only: not `null`, not an array. */ +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Structural equality for the JSON-shaped values this module deals in. */ +function deepEqual(a, b) { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (typeof a !== 'object') return Number.isNaN(a) && Number.isNaN(b); + if (Array.isArray(a) !== Array.isArray(b)) return false; + if (Array.isArray(a)) { + return a.length === b.length && a.every((item, i) => deepEqual(item, b[i])); + } + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key])); +} + +/** A detached copy of a JSON-shaped value. */ +function cloneValue(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); +} + +/** Marks a value that equals its default, so its key can be left out entirely. */ +const OMIT = Symbol('urlState.omit'); + +/** + * `value` with every sub-tree that deep-equals the matching `def` removed. + * Arrays are kept whole - never pruned element by element - because that is how + * `applyPatch` replaces them; objects are pruned key by key. `OMIT` means the + * value matched its default and can be dropped from the parent. + */ +function pruneValue(value, def) { + if (deepEqual(value, def)) return OMIT; + if (isPlainObject(value)) { + if (!isPlainObject(def)) return cloneValue(value); + const out = {}; + for (const [key, child] of Object.entries(value)) { + const pruned = pruneValue(child, def[key]); + if (pruned !== OMIT) out[key] = pruned; + } + return Object.keys(out).length === 0 ? OMIT : out; + } + return Array.isArray(value) ? cloneValue(value) : value; +} + +/** + * A copy of `state` with every value equal to `defaultState()` omitted. Lossless: + * `restoreDefaults` merges the result onto a fresh default. Arrays that differ + * from the default are carried whole so `applyPatch` can replace them. + */ +export function pruneDefaults(state) { + const pruned = pruneValue(state, defaultState()); + return pruned === OMIT ? {} : pruned; +} + +/** + * The inverse of `pruneDefaults`: a full state, with every field the partial does + * not mention left at its default. Merging a previously full state is idempotent, + * so links made by the older encoder still decode. + */ +export function restoreDefaults(partial) { + return applyPatch(defaultState(), partial); +} + /** True when the encoded fragment is bigger than a URL can comfortably hold. */ export function isTooLong(text, limit = 1800) { return typeof text === 'string' && text.length > limit; @@ -117,7 +192,7 @@ async function inflateRaw(bytes) { * @returns {Promise<string>} a URL-safe string (tag + base64url) */ export async function encodeState(state) { - const json = JSON.stringify(stripForUrl(state) ?? null); + const json = JSON.stringify(pruneDefaults(stripForUrl(state))); const bytes = new TextEncoder().encode(json); if (typeof CompressionStream === 'function') { @@ -155,7 +230,7 @@ export async function decodeState(text) { const parsed = JSON.parse(new TextDecoder().decode(bytes)); if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null; - return { state: parsed }; + return { state: restoreDefaults(parsed) }; } catch { return null; } |
