Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions kernel/src/save.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ const TRANSIENT_SELECTOR = '[data-bento-transient]'

let pristine: Document | null = null

/** App-owned save-time preparation. The default is identity. Apps use this to
* compact a serialization copy without teaching the kernel their document
* shape or mutating the live Store. */
let prepareDocument: (doc: KernelDoc) => KernelDoc = (doc) => doc

export function registerSerializePrepare(fn: (doc: KernelDoc) => KernelDoc): void {
prepareDocument = fn
}

/** Call first thing at boot, before any DOM mutation. */
export function capturePristine() {
pristine = document.cloneNode(true) as Document
Expand Down Expand Up @@ -355,7 +364,8 @@ function writePreview(clone: Document, body: string, doc: KernelDoc): void {
* PLAIN output — encryption-aware callers use serializeDocInto/serializeAuto.
*/
export function serializeWith(shell: Document, doc: KernelDoc): string {
return serializeBody(shell, JSON.stringify(doc), doc)
const prepared = prepareDocument(doc)
return serializeBody(shell, JSON.stringify(prepared), prepared)
}

/** The full .bento.html file content with `doc` embedded (plain). */
Expand Down Expand Up @@ -475,10 +485,11 @@ export async function decryptEnvelope(env: EncEnvelope, password: string): Promi
* saves and self-updates. Plain when no password is active.
*/
export async function serializeDocInto(shell: Document, doc: KernelDoc): Promise<string> {
const prepared = prepareDocument(doc)
const body = encPassword
? await encryptBody(JSON.stringify(doc), encPassword)
: JSON.stringify(doc)
return serializeBody(shell, body, doc)
? await encryptBody(JSON.stringify(prepared), encPassword)
: JSON.stringify(prepared)
return serializeBody(shell, body, prepared)
}

/** Encryption-aware serializeFile. */
Expand Down
141 changes: 141 additions & 0 deletions slides/src/compact-assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 The Bento authors
// Save-time asset compaction for bento/slides.

import type { BentoDoc, Slide, SlideElement } from './model'

export interface AssetCompactionStats {
before: number
after: number
removed: number
deduplicated: number
interned: number
}

export interface CompactedDocument {
doc: BentoDoc
stats: AssetCompactionStats
}

const cloneDoc = (doc: BentoDoc): BentoDoc =>
typeof structuredClone === 'function'
? structuredClone(doc)
: JSON.parse(JSON.stringify(doc)) as BentoDoc

/** Compact a serialization copy. The caller's live document is never mutated. */
export function compactDocumentAssets(input: BentoDoc): CompactedDocument {
const doc = cloneDoc(input)
const original = doc.assets ?? {}
const assets: Record<string, string> = {}
const canonicalByValue = new Map<string, string>()
const alias = new Map<string, string>()
let deduplicated = 0
let interned = 0
let seq = 0

for (const [key, value] of Object.entries(original)) {
const existing = canonicalByValue.get(value)
if (existing) {
alias.set(key, existing)
deduplicated++
} else {
assets[key] = value
canonicalByValue.set(value, key)
alias.set(key, key)
}
}

const freshKey = () => {
let key: string
do key = `a-compact-${++seq}`
while (key in assets || key in original)
return key
}

const intern = (value: string): string => {
const existing = canonicalByValue.get(value)
if (existing) return existing
const key = freshKey()
assets[key] = value
canonicalByValue.set(value, key)
interned++
return key
}

const assetRef = (value: string | undefined): string | undefined => {
if (!value) return value
if (value.startsWith('data:')) return `asset:${intern(value)}`
if (!value.startsWith('asset:')) return value
const key = value.slice(6)
return `asset:${alias.get(key) ?? key}`
}

const bareRef = (value: string | undefined): string | undefined =>
value ? (alias.get(value) ?? value) : value

const rewriteElement = (el: SlideElement) => {
if (el.type === 'image' || el.type === 'media') el.src = assetRef(el.src) ?? ''
if (el.type === 'media') el.poster = assetRef(el.poster)
if (el.type === 'svg') el.asset = bareRef(el.asset)
if (el.type === 'code') {
el.grammarAssetId = bareRef(el.grammarAssetId)
el.themeAssetId = bareRef(el.themeAssetId)
}
}
const rewriteSlide = (slide: Slide) => slide.elements.forEach(rewriteElement)
doc.slides.forEach(rewriteSlide)
doc.layouts?.forEach(rewriteSlide)
doc.fonts?.forEach((font) => { font.asset = bareRef(font.asset)! })

const used = new Set<string>()
const useBare = (key: string | undefined) => { if (key) used.add(key) }
const useElement = (el: SlideElement) => {
const useRef = (ref: string | undefined) => {
if (ref?.startsWith('asset:')) used.add(ref.slice(6))
}
if (el.type === 'image' || el.type === 'media') useRef(el.src)
if (el.type === 'media') useRef(el.poster)
if (el.type === 'svg') useBare(el.asset)
if (el.type === 'code') {
useBare(el.grammarAssetId)
useBare(el.themeAssetId)
}
}
const useSlide = (slide: Slide) => slide.elements.forEach(useElement)
doc.slides.forEach(useSlide)
doc.layouts?.forEach(useSlide)
doc.fonts?.forEach((font) => useBare(font.asset))

// Preserve additive future fields that use the explicit asset:<key> form.
// Skip the asset table itself, otherwise every entry would keep itself live.
const scan = (value: unknown) => {
if (typeof value === 'string') {
if (value.startsWith('asset:')) used.add(alias.get(value.slice(6)) ?? value.slice(6))
return
}
if (Array.isArray(value)) { value.forEach(scan); return }
if (!value || typeof value !== 'object') return
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
if (key !== 'assets') scan(child)
}
}
scan(doc)

const compacted: Record<string, string> = {}
for (const key of used) if (assets[key] !== undefined) compacted[key] = assets[key]
if (Object.keys(compacted).length) doc.assets = compacted
else delete doc.assets

if (doc.blobs) {
const blobs = Object.fromEntries(Object.entries(doc.blobs).filter(([key]) => used.has(key)))
if (Object.keys(blobs).length) doc.blobs = blobs
else delete doc.blobs
}

const before = Object.keys(original).length
const after = Object.keys(compacted).length
return {
doc,
stats: { before, after, removed: Math.max(0, before + interned - after), deduplicated, interned },
}
}
14 changes: 9 additions & 5 deletions slides/src/editor/canvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export class SlideCanvas {
this.selecto = new Selecto({
container: this.scroller,
dragContainer: this.scroller,
selectableTargets: ['.bento-el'],
selectableTargets: ['.bento-el:not(.bento-template-locked)'],
selectByClick: true,
selectFromInside: false,
toggleContinueSelect: 'shift',
Expand Down Expand Up @@ -552,6 +552,7 @@ export class SlideCanvas {
private deepSelect(px: number, py: number) {
const stack: string[] = []
for (const el of this.store.slide.elements) {
if (el.templateLocked) continue
if (px < el.x || px > el.x + el.w || py < el.y || py > el.y + el.h) continue
const node = this.scaleHost.querySelector<HTMLElement>(`[data-el-id="${CSS.escape(el.id)}"]`)
if (!node || node.style.display === 'none') continue // hidden hover set
Expand Down Expand Up @@ -717,6 +718,7 @@ export class SlideCanvas {
private selectedNodes(): HTMLElement[] {
if (!this.surface) return []
return this.store.selection
.filter((id) => !this.store.element(id)?.templateLocked)
.map((id) => this.surface!.querySelector<HTMLElement>(`[data-el-id="${CSS.escape(id)}"]`))
.filter((n): n is HTMLElement => !!n)
}
Expand Down Expand Up @@ -900,16 +902,18 @@ export class SlideCanvas {
target.style.top = `${top}px`
}
}
const syncKeepRatio = (inputEvent: MouseEvent | undefined) => {
const want = !!inputEvent?.shiftKey
const syncKeepRatio = (inputEvent: MouseEvent | undefined, target: HTMLElement) => {
const el = target.dataset.elId ? this.store.element(target.dataset.elId) : undefined
const configured = el?.type === 'image' && el.keepAspectRatio !== false
const want = inputEvent?.shiftKey ? !configured : configured
if (mv.keepRatio !== want) mv.keepRatio = want
}
mv.on('resizeStart', (e) => {
syncKeepRatio(e.inputEvent as MouseEvent)
syncKeepRatio(e.inputEvent as MouseEvent, e.target as HTMLElement)
noteResizeStart(e.target as HTMLElement)
})
mv.on('resize', (e) => {
syncKeepRatio(e.inputEvent as MouseEvent)
syncKeepRatio(e.inputEvent as MouseEvent, e.target as HTMLElement)
applyResize(e.target as HTMLElement, e.width, e.height, e.drag.left, e.drag.top, e.inputEvent as MouseEvent)
})
mv.on('resizeGroupStart', (e) => e.events.forEach((ev) => noteResizeStart(ev.target as HTMLElement)))
Expand Down
89 changes: 88 additions & 1 deletion slides/src/editor/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ export class Editor {
private lastAutoCheck: import('../update').UpdateCheck | null = null
/** side panel widths (px) — user-resizable, persisted per browser */
private panelW = { left: 188, right: 236 }
private templateReturnSlideId: string | null = null
private templateManagerAbort: AbortController | null = null

constructor(
private root: HTMLElement,
Expand All @@ -105,6 +107,8 @@ export class Editor {
document.addEventListener('bento:apply-layout', ((ev: CustomEvent) => {
this.openLayoutPicker(ev.detail.anchor as HTMLElement, { kind: 'apply' })
}) as EventListener)
document.addEventListener('bento:save-template-edit', () => this.finishTemplateEdit(true))
document.addEventListener('bento:cancel-template-edit', () => this.finishTemplateEdit(false))
this.rebuildSidebar()
}

Expand Down Expand Up @@ -1607,10 +1611,89 @@ export class Editor {
add.classList.add('ed-add-slide')
add.title = t('New slide from a layout')
this.sidebar.appendChild(add)
const templates = btn(ICONS.template, t('Templates'), () => this.openTemplateManager(templates))
templates.classList.add('ed-add-slide', 'ed-template-button')
templates.title = t('Create, edit, duplicate, and delete template pages')
this.sidebar.appendChild(templates)
this.sidebar.scrollTop = scroll
this.highlightSidebar()
}

// --- template pages ---------------------------------------------------------

private openTemplateManager(anchor: HTMLElement) {
// Abort before removing the old node: a removed popover's document-level
// outside/Escape listeners must not survive and close a later instance.
this.templateManagerAbort?.abort()
document.querySelector('.ed-template-manager')?.remove()
const abort = new AbortController()
this.templateManagerAbort = abort
const pop = div('ed-layoutpick ed-template-manager')
const head = div('ed-template-manager-head')
const title = div('ed-layoutpick-title'); title.textContent = t('Template pages')
const close = document.createElement('button'); close.className = 'ed-template-close'; close.type = 'button'; close.textContent = '×'; close.title = t('Close')
head.append(title, close); pop.appendChild(head)
const cleanup = () => {
if (this.templateManagerAbort === abort) this.templateManagerAbort = null
abort.abort()
pop.remove()
}
const outside = (ev: PointerEvent) => { if (!pop.contains(ev.target as Node)) cleanup() }
const escape = (ev: KeyboardEvent) => { if (ev.key === 'Escape') cleanup() }
close.addEventListener('click', cleanup, { signal: abort.signal })
const hint = div('ed-hint'); hint.textContent = t('Non-text template objects become protected background furniture; template text boxes stay editable on normal slides.'); pop.appendChild(hint)
const actions = div('ed-ops')
const blank = btn(ICONS.plus, t('Blank template'), () => {
const name = window.prompt(t('Template name'), t('Blank template')); if (!name) return
const layout: Slide = { id: uid('layout'), name, background: this.store.slide.background, transition: 'fade', elements: [], notes: '' }
this.store.commit(() => { this.store.doc.layouts = [...(this.store.doc.layouts ?? []), layout] }, 'slides'); cleanup(); this.beginTemplateEdit(layout.id)
})
const fromSlide = btn(ICONS.copy, t('From current slide'), () => {
const name = window.prompt(t('Template name'), this.store.slide.name ?? t('My template')); if (!name) return
const layout = JSON.parse(JSON.stringify(this.store.slide)) as Slide
layout.id = uid('layout'); layout.name = name; layout.notes = ''; delete layout.stateOf; delete layout.hidden; delete layout.templateId; delete layout.templateEditOf
for (const el of layout.elements) delete el.templateLocked
this.store.commit(() => { this.store.doc.layouts = [...(this.store.doc.layouts ?? []), layout] }, 'slides'); cleanup(); this.beginTemplateEdit(layout.id)
})
actions.append(blank, fromSlide); pop.appendChild(actions)
const grid = div('ed-layoutpick-grid')
for (const layout of this.store.doc.layouts ?? []) {
const item = div('ed-layoutpick-item'); item.appendChild(renderThumbnail(layout, this.store.doc, 104))
const name = div('ed-layoutpick-name'); name.textContent = layout.name ?? t('Untitled'); item.appendChild(name)
const tools = div('ed-ops')
const edit = btn('', t('Edit'), (ev) => { ev.stopPropagation(); cleanup(); this.beginTemplateEdit(layout.id) })
const duplicate = btn('', t('Duplicate'), (ev) => { ev.stopPropagation(); const copy = JSON.parse(JSON.stringify(layout)) as Slide; copy.id = uid('layout'); copy.name = t('{name} copy', { name: layout.name ?? t('Template') }); this.store.commit(() => { this.store.doc.layouts = [...(this.store.doc.layouts ?? []), copy] }, 'slides'); cleanup(); this.openTemplateManager(anchor) })
const remove = btn('', t('Delete'), (ev) => { ev.stopPropagation(); if (!window.confirm(t('Delete this template?'))) return; this.store.commit(() => { this.store.doc.layouts = this.store.doc.layouts?.filter((x) => x.id !== layout.id); if (!this.store.doc.layouts?.length) delete this.store.doc.layouts }, 'slides'); cleanup(); this.openTemplateManager(anchor) })
tools.append(edit, duplicate, remove); item.appendChild(tools); grid.appendChild(item)
}
pop.appendChild(grid)
const r = anchor.getBoundingClientRect(); pop.style.left = `${Math.max(8, r.left)}px`; pop.style.bottom = `${window.innerHeight - r.top + 8}px`; document.body.appendChild(pop)
// Delay outside-click registration so the click that opened the manager
// cannot immediately close it. If it was closed in that turn, do nothing.
setTimeout(() => {
if (!abort.signal.aborted) document.addEventListener('pointerdown', outside, { capture: true, signal: abort.signal })
})
document.addEventListener('keydown', escape, { signal: abort.signal })
}

private beginTemplateEdit(layoutId: string) {
const layout = this.store.doc.layouts?.find((x) => x.id === layoutId); if (!layout) return
this.finishTemplateEdit(false); this.templateReturnSlideId = this.store.slide.id
const draft = JSON.parse(JSON.stringify(layout)) as Slide; draft.id = uid('slide'); draft.name = t('Template: {name}', { name: layout.name ?? t('Untitled') }); draft.templateEditOf = layoutId; draft.hidden = true; delete draft.templateId
for (const el of draft.elements) delete el.templateLocked
const at = this.store.currentIndex + 1; this.store.commit(() => this.store.doc.slides.splice(at, 0, draft), 'slides'); this.store.goTo(at)
}

private finishTemplateEdit(save: boolean) {
const draft = this.store.slide; if (!draft?.templateEditOf) return
const layoutId = draft.templateEditOf, returnId = this.templateReturnSlideId, index = this.store.currentIndex
this.store.commit(() => {
if (save) { const next = JSON.parse(JSON.stringify(draft)) as Slide; next.id = layoutId; next.notes = ''; next.name = this.store.doc.layouts?.find((x) => x.id === layoutId)?.name ?? next.name?.replace(/^Template: /, ''); delete next.hidden; delete next.stateOf; delete next.templateId; delete next.templateEditOf; for (const el of next.elements) delete el.templateLocked; const at = this.store.doc.layouts?.findIndex((x) => x.id === layoutId) ?? -1; if (at >= 0) this.store.doc.layouts![at] = next; else this.store.doc.layouts = [...(this.store.doc.layouts ?? []), next] }
this.store.doc.slides.splice(index, 1)
}, 'slides')
const returnIndex = Math.max(0, this.store.doc.slides.findIndex((x) => x.id === returnId)); this.store.goTo(returnIndex); this.templateReturnSlideId = null; if (save) this.toast(t('Template saved'))
}

// --- layouts ---------------------------------------------------------------

/** Layout popover. Serves three flows: the New-slide button, the
Expand Down Expand Up @@ -1699,7 +1782,10 @@ export class Editor {
this.store.commit(() => {
const s = this.store.slide
s.elements = applyLayout(s, layout, known)
const layoutIds = new Set(layout.elements.map((el) => el.id))
for (const el of s.elements) { if (layoutIds.has(el.id) && el.type !== 'text') el.templateLocked = true; else delete el.templateLocked }
s.background = layout.background
s.templateId = layout.id
})
this.store.select([])
}
Expand Down Expand Up @@ -2168,7 +2254,7 @@ export class Editor {

private async runAutosave() {
const doc = this.store.doc
if (doc.readonly) return
if (doc.readonly || doc.slides.some((slide) => !!slide.templateEditOf)) return
// Never write an encrypted deck's plaintext to IndexedDB; its file
// write-back below stays encrypted via serializeAuto.
let snapshotted = false
Expand Down Expand Up @@ -2583,6 +2669,7 @@ export class Editor {

async save(forcePicker: boolean) {
this.canvas.commitTextEdit()
if (this.store.doc.slides.some((slide) => !!slide.templateEditOf)) { this.toast(t('Save or cancel template editing first')); return }
// shared docs persist their CRDT state so the saved copy can rejoin
// as a true fork later (offline edits merge both ways)
this.session?.stampInto(this.store.doc)
Expand Down
Loading
Loading