diff --git a/kernel/src/save.ts b/kernel/src/save.ts index 1c53568c..5ac32d1f 100644 --- a/kernel/src/save.ts +++ b/kernel/src/save.ts @@ -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 @@ -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). */ @@ -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 { + 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. */ diff --git a/slides/src/compact-assets.ts b/slides/src/compact-assets.ts new file mode 100644 index 00000000..c5922717 --- /dev/null +++ b/slides/src/compact-assets.ts @@ -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 = {} + const canonicalByValue = new Map() + const alias = new Map() + 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() + 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: 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)) { + if (key !== 'assets') scan(child) + } + } + scan(doc) + + const compacted: Record = {} + 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 }, + } +} diff --git a/slides/src/editor/canvas.ts b/slides/src/editor/canvas.ts index 6f6d65c5..65d69fe3 100644 --- a/slides/src/editor/canvas.ts +++ b/slides/src/editor/canvas.ts @@ -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', @@ -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(`[data-el-id="${CSS.escape(el.id)}"]`) if (!node || node.style.display === 'none') continue // hidden hover set @@ -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(`[data-el-id="${CSS.escape(id)}"]`)) .filter((n): n is HTMLElement => !!n) } @@ -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))) diff --git a/slides/src/editor/editor.ts b/slides/src/editor/editor.ts index 58876e4a..8257e598 100644 --- a/slides/src/editor/editor.ts +++ b/slides/src/editor/editor.ts @@ -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, @@ -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() } @@ -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 @@ -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([]) } @@ -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 @@ -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) diff --git a/slides/src/editor/panels.ts b/slides/src/editor/panels.ts index b423e4a7..2fd751ef 100644 --- a/slides/src/editor/panels.ts +++ b/slides/src/editor/panels.ts @@ -162,6 +162,11 @@ export class PropsPanel { this.stale = true // don't rip the field out from under the user; catch up on focusout return } + // A doc mutation rebuilds the same inspector in place. Preserve its scroll + // position so changing a control near the bottom does not jump the panel to + // the top. Selection/slide switches pass force=true and intentionally start + // the newly relevant inspector at the top. + const scrollTop = force ? 0 : this.host.scrollTop this.stale = false this.burst = false this.host.innerHTML = '' @@ -170,6 +175,7 @@ export class PropsPanel { else if (els.length === 1) this.buildElementPanel(els[0]) else this.buildMultiPanel(els) this.applyAccordion() + this.host.scrollTop = scrollTop } /** Collapsed by default until the user opens them (persisted per title). */ @@ -225,6 +231,18 @@ export class PropsPanel { private buildSlidePanel() { const slide = this.store.slide + if (slide.templateEditOf) { + this.section(t('Template editing')) + const hint = document.createElement('p'); hint.className = 'ed-hint' + hint.textContent = t('Edit every object here. Save the template when finished, or cancel to discard the draft.') + this.host.appendChild(hint) + const actions = document.createElement('div'); actions.className = 'ed-ops' + const save = document.createElement('button'); save.className = 'ed-btn ed-primary'; save.textContent = t('Save template') + save.addEventListener('click', () => document.dispatchEvent(new CustomEvent('bento:save-template-edit'))) + const cancel = document.createElement('button'); cancel.className = 'ed-btn'; cancel.textContent = t('Cancel') + cancel.addEventListener('click', () => document.dispatchEvent(new CustomEvent('bento:cancel-template-edit'))) + actions.append(save, cancel); this.host.appendChild(actions) + } this.section(t('Slide')) // deck-wide page size: presets + custom. Elements keep their absolute // positions — a size change reframes the canvas, it never rescales art. @@ -462,6 +480,24 @@ export class PropsPanel { this.setNum(el.id, 'opacity', Math.min(Math.max(v / 100, 0), 1))), ) this.host.appendChild(geo) + if (el.type === 'image') { + this.row('Keep aspect ratio', this.toggle(el.keepAspectRatio !== false, (on) => + this.mutate(el.id, (e) => { + if (e.type !== 'image') return + // Re-enabling means "lock the shape as it is now". The current w/h + // already defines the new ratio, so do not move or resize the frame. + // Moveable reads that current ratio at the next resize start. + if (on) { + delete e.keepAspectRatio + } else { + e.keepAspectRatio = false + // From this point the image itself follows independent width and + // height changes. Keep fill after re-locking so toggling the lock + // never restores contain/cover or changes the visible shape. + e.fit = 'fill' + } + }, true))) + } this.row('Role', this.select( ['none', 'title', 'subtitle', 'body', 'kicker'], @@ -1823,11 +1859,11 @@ export class PropsPanel { textBtn('↔', 'Match widths — first selected sets the size (2+)', () => this.matchSize(els, 'w'), els.length >= 2), textBtn('↕', 'Match heights — first selected sets the size (2+)', () => this.matchSize(els, 'h'), els.length >= 2), ]) - group('Order', [ - textBtn('⇈', 'Bring to front', () => this.reorder(els, 'front')), - textBtn('↑', 'Bring forward one step', () => this.step(els, +1)), - textBtn('↓', 'Send backward one step', () => this.step(els, -1)), - textBtn('⇊', 'Send to back', () => this.reorder(els, 'back')), + group(t('Order'), [ + textBtn('⇈', t('Bring to front'), () => this.reorder(els, 'front')), + textBtn('↑', t('Bring forward one step'), () => this.step(els, +1)), + textBtn('↓', t('Send backward one step'), () => this.step(els, -1)), + textBtn('⇊', t('Send to back'), () => this.reorder(els, 'back')), ]) const grouped = els.some((e) => e.groupId) @@ -1899,7 +1935,7 @@ export class PropsPanel { /** Move the selection one step up/down the paint order (adjacent swap). */ private step(els: SlideElement[], dir: 1 | -1) { - const ids = new Set(els.map((e) => e.id)) + const ids = new Set(els.filter((e) => !e.templateLocked).map((e) => e.id)) this.store.commit(() => { const arr = this.store.slide.elements const idxs = arr.map((e, i) => (ids.has(e.id) ? i : -1)).filter((i) => i >= 0) @@ -1953,7 +1989,7 @@ export class PropsPanel { } private reorder(els: SlideElement[], where: 'front' | 'back') { - const ids = new Set(els.map((e) => e.id)) + const ids = new Set(els.filter((e) => !e.templateLocked).map((e) => e.id)) this.store.commit(() => { const slide = this.store.slide const picked = slide.elements.filter((e) => ids.has(e.id)) diff --git a/slides/src/i18n/de.ts b/slides/src/i18n/de.ts index 6497f646..fce5329d 100644 --- a/slides/src/i18n/de.ts +++ b/slides/src/i18n/de.ts @@ -752,4 +752,25 @@ export const de: Catalog = { "The bento/tray app can keep your files on this device.": "Die bento/tray-App kann Ihre Dateien auf diesem Gerät behalten.", "This browser cannot reopen a file in place — Chrome or Edge can.": "Dieser Browser kann eine Datei nicht direkt überschreiben — Chrome oder Edge schon.", "Keep the file somewhere you can find it again.": "Bewahren Sie die Datei an einem Ort auf, den Sie wiederfinden.", + 'Keep aspect ratio': 'Seitenverhältnis beibehalten', + 'Template editing': 'Vorlage bearbeiten', + 'Edit every object here. Save the template when finished, or cancel to discard the draft.': 'Bearbeiten Sie hier alle Objekte. Speichern Sie anschließend die Vorlage oder brechen Sie ab, um den Entwurf zu verwerfen.', + 'Save template': 'Vorlage speichern', + 'Templates': 'Vorlagen', + 'Create, edit, duplicate, and delete template pages': 'Vorlagenseiten erstellen, bearbeiten, duplizieren und löschen', + 'Template pages': 'Vorlagenseiten', + 'Non-text template objects become protected background furniture; template text boxes stay editable on normal slides.': 'Nicht-Textobjekte der Vorlage werden zu geschützten Hintergrundelementen; Textfelder bleiben auf normalen Folien bearbeitbar.', + 'Blank template': 'Leere Vorlage', + 'Template name': 'Vorlagenname', + 'From current slide': 'Aus aktueller Folie', + 'My template': 'Meine Vorlage', + '{name} copy': 'Kopie von {name}', + 'Template': 'Vorlage', + 'Delete this template?': 'Diese Vorlage löschen?', + 'Template: {name}': 'Vorlage: {name}', + 'Template saved': 'Vorlage gespeichert', + 'Save or cancel template editing first': 'Speichern oder verwerfen Sie zuerst die Vorlagenbearbeitung', + 'Edit': 'Bearbeiten', + 'Close': 'Schließen', + } diff --git a/slides/src/i18n/es.ts b/slides/src/i18n/es.ts index 393266e9..6bf7465a 100644 --- a/slides/src/i18n/es.ts +++ b/slides/src/i18n/es.ts @@ -752,4 +752,25 @@ export const es: Catalog = { "The bento/tray app can keep your files on this device.": "La app bento/tray puede guardar tus archivos en este dispositivo.", "This browser cannot reopen a file in place — Chrome or Edge can.": "Este navegador no puede reescribir un archivo en su sitio — Chrome o Edge sí.", "Keep the file somewhere you can find it again.": "Guarda el archivo en un lugar donde puedas encontrarlo.", + 'Keep aspect ratio': 'Mantener relación de aspecto', + 'Template editing': 'Edición de plantilla', + 'Edit every object here. Save the template when finished, or cancel to discard the draft.': 'Edita aquí todos los objetos. Guarda la plantilla al terminar o cancela para descartar el borrador.', + 'Save template': 'Guardar plantilla', + 'Templates': 'Plantillas', + 'Create, edit, duplicate, and delete template pages': 'Crear, editar, duplicar y eliminar páginas de plantilla', + 'Template pages': 'Páginas de plantilla', + 'Non-text template objects become protected background furniture; template text boxes stay editable on normal slides.': 'Los objetos no textuales de la plantilla se convierten en elementos de fondo protegidos; los cuadros de texto siguen siendo editables en las diapositivas normales.', + 'Blank template': 'Plantilla en blanco', + 'Template name': 'Nombre de la plantilla', + 'From current slide': 'Desde la diapositiva actual', + 'My template': 'Mi plantilla', + '{name} copy': 'Copia de {name}', + 'Template': 'Plantilla', + 'Delete this template?': '¿Eliminar esta plantilla?', + 'Template: {name}': 'Plantilla: {name}', + 'Template saved': 'Plantilla guardada', + 'Save or cancel template editing first': 'Guarda o cancela primero la edición de la plantilla', + 'Edit': 'Editar', + 'Close': 'Cerrar', + } diff --git a/slides/src/i18n/fr.ts b/slides/src/i18n/fr.ts index 18d1eca2..5b6bacdc 100644 --- a/slides/src/i18n/fr.ts +++ b/slides/src/i18n/fr.ts @@ -752,4 +752,25 @@ export const fr: Catalog = { "The bento/tray app can keep your files on this device.": "L'application bento/tray peut conserver vos fichiers sur cet appareil.", "This browser cannot reopen a file in place — Chrome or Edge can.": "Ce navigateur ne peut pas réécrire un fichier sur place — Chrome ou Edge le peuvent.", "Keep the file somewhere you can find it again.": "Conservez le fichier à un endroit où vous le retrouverez.", + 'Keep aspect ratio': 'Conserver les proportions', + 'Template editing': 'Modification du modèle', + 'Edit every object here. Save the template when finished, or cancel to discard the draft.': 'Modifiez ici tous les objets. Enregistrez le modèle une fois terminé ou annulez pour supprimer le brouillon.', + 'Save template': 'Enregistrer le modèle', + 'Templates': 'Modèles', + 'Create, edit, duplicate, and delete template pages': 'Créer, modifier, dupliquer et supprimer des pages de modèle', + 'Template pages': 'Pages de modèle', + 'Non-text template objects become protected background furniture; template text boxes stay editable on normal slides.': 'Les objets non textuels du modèle deviennent des éléments d’arrière-plan protégés ; les zones de texte restent modifiables sur les diapositives normales.', + 'Blank template': 'Modèle vierge', + 'Template name': 'Nom du modèle', + 'From current slide': 'À partir de la diapositive actuelle', + 'My template': 'Mon modèle', + '{name} copy': 'Copie de {name}', + 'Template': 'Modèle', + 'Delete this template?': 'Supprimer ce modèle ?', + 'Template: {name}': 'Modèle : {name}', + 'Template saved': 'Modèle enregistré', + 'Save or cancel template editing first': 'Enregistrez ou annulez d’abord la modification du modèle', + 'Edit': 'Modifier', + 'Close': 'Fermer', + } diff --git a/slides/src/i18n/it.ts b/slides/src/i18n/it.ts index a6d4de47..37daafe5 100644 --- a/slides/src/i18n/it.ts +++ b/slides/src/i18n/it.ts @@ -752,4 +752,25 @@ export const it: Catalog = { "The bento/tray app can keep your files on this device.": "L'app bento/tray può conservare i tuoi file su questo dispositivo.", "This browser cannot reopen a file in place — Chrome or Edge can.": "Questo browser non può riscrivere un file al suo posto — Chrome o Edge sì.", "Keep the file somewhere you can find it again.": "Conserva il file in un posto dove potrai ritrovarlo.", + 'Keep aspect ratio': 'Mantieni proporzioni', + 'Template editing': 'Modifica modello', + 'Edit every object here. Save the template when finished, or cancel to discard the draft.': 'Modifica qui tutti gli oggetti. Al termine salva il modello oppure annulla per eliminare la bozza.', + 'Save template': 'Salva modello', + 'Templates': 'Modelli', + 'Create, edit, duplicate, and delete template pages': 'Crea, modifica, duplica ed elimina pagine modello', + 'Template pages': 'Pagine modello', + 'Non-text template objects become protected background furniture; template text boxes stay editable on normal slides.': 'Gli oggetti non testuali del modello diventano elementi di sfondo protetti; le caselle di testo restano modificabili nelle diapositive normali.', + 'Blank template': 'Modello vuoto', + 'Template name': 'Nome del modello', + 'From current slide': 'Dalla diapositiva corrente', + 'My template': 'Il mio modello', + '{name} copy': 'Copia di {name}', + 'Template': 'Modello', + 'Delete this template?': 'Eliminare questo modello?', + 'Template: {name}': 'Modello: {name}', + 'Template saved': 'Modello salvato', + 'Save or cancel template editing first': 'Salva o annulla prima la modifica del modello', + 'Edit': 'Modifica', + 'Close': 'Chiudi', + } diff --git a/slides/src/i18n/ja.ts b/slides/src/i18n/ja.ts index b5e976bd..2392c4d2 100644 --- a/slides/src/i18n/ja.ts +++ b/slides/src/i18n/ja.ts @@ -752,4 +752,25 @@ export const ja: Catalog = { "The bento/tray app can keep your files on this device.": "bento/tray アプリはこの端末にファイルを保存できます。", "This browser cannot reopen a file in place — Chrome or Edge can.": "このブラウザーはファイルを直接上書きできません — Chrome または Edge なら可能です。", "Keep the file somewhere you can find it again.": "ファイルは後で見つけられる場所に保存しておいてください。", + 'Keep aspect ratio': '縦横比を固定', + 'Template editing': 'テンプレートを編集中', + 'Edit every object here. Save the template when finished, or cancel to discard the draft.': 'ここではすべてのオブジェクトを編集できます。完了したらテンプレートを保存するか、キャンセルして下書きを破棄してください。', + 'Save template': 'テンプレートを保存', + 'Templates': 'テンプレート', + 'Create, edit, duplicate, and delete template pages': 'テンプレートページを作成、編集、複製、削除します', + 'Template pages': 'テンプレートページ', + 'Non-text template objects become protected background furniture; template text boxes stay editable on normal slides.': 'テキスト以外のテンプレートオブジェクトは保護された背景要素になり、テンプレートのテキストボックスは通常のスライドでも編集できます。', + 'Blank template': '空のテンプレート', + 'Template name': 'テンプレート名', + 'From current slide': '現在のスライドから作成', + 'My template': 'マイテンプレート', + '{name} copy': '{name} のコピー', + 'Template': 'テンプレート', + 'Delete this template?': 'このテンプレートを削除しますか?', + 'Template: {name}': 'テンプレート:{name}', + 'Template saved': 'テンプレートを保存しました', + 'Save or cancel template editing first': '先にテンプレート編集を保存またはキャンセルしてください', + 'Edit': '編集', + 'Close': '閉じる', + } diff --git a/slides/src/i18n/packed.ts b/slides/src/i18n/packed.ts index c3f65b0b..d9164937 100644 --- a/slides/src/i18n/packed.ts +++ b/slides/src/i18n/packed.ts @@ -758,4 +758,24 @@ export const PACKED: Record> = { "The bento/tray app can keep your files on this device.": ["bento/tray アプリはこの端末にファイルを保存できます。","bento/tray 应用可以把文件保存在此设备上。","bento/tray 應用程式可以將檔案保存在此裝置上。","La app bento/tray puede guardar tus archivos en este dispositivo.","L'application bento/tray peut conserver vos fichiers sur cet appareil.","Die bento/tray-App kann Ihre Dateien auf diesem Gerät behalten.","L'app bento/tray può conservare i tuoi file su questo dispositivo.","A aplicação bento/tray pode guardar os seus ficheiros neste dispositivo."], "This browser cannot reopen a file in place — Chrome or Edge can.": ["このブラウザーはファイルを直接上書きできません — Chrome または Edge なら可能です。","此浏览器无法就地重写文件 — Chrome 或 Edge 可以。","此瀏覽器無法就地覆寫檔案 — Chrome 或 Edge 可以。","Este navegador no puede reescribir un archivo en su sitio — Chrome o Edge sí.","Ce navigateur ne peut pas réécrire un fichier sur place — Chrome ou Edge le peuvent.","Dieser Browser kann eine Datei nicht direkt überschreiben — Chrome oder Edge schon.","Questo browser non può riscrivere un file al suo posto — Chrome o Edge sì.","Este navegador não consegue reescrever um ficheiro no lugar — o Chrome ou o Edge conseguem."], "Keep the file somewhere you can find it again.": ["ファイルは後で見つけられる場所に保存しておいてください。","把文件保存在你以后能找到的位置。","請將檔案保存在你日後找得到的位置。","Guarda el archivo en un lugar donde puedas encontrarlo.","Conservez le fichier à un endroit où vous le retrouverez.","Bewahren Sie die Datei an einem Ort auf, den Sie wiederfinden.","Conserva il file in un posto dove potrai ritrovarlo.","Guarde o ficheiro num sítio onde o consiga encontrar."], + "Keep aspect ratio": ["縦横比を固定","保持宽高比","鎖定長寬比","Mantener relación de aspecto","Conserver les proportions","Seitenverhältnis beibehalten","Mantieni proporzioni","Manter proporção"], + "Template editing": ["テンプレートを編集中","模板编辑","編輯範本","Edición de plantilla","Modification du modèle","Vorlage bearbeiten","Modifica modello","Edição de modelo"], + "Edit every object here. Save the template when finished, or cancel to discard the draft.": ["ここではすべてのオブジェクトを編集できます。完了したらテンプレートを保存するか、キャンセルして下書きを破棄してください。","可在此编辑所有对象。完成后保存模板,或取消以放弃草稿。","可在此編輯所有物件。完成後儲存範本,或取消以捨棄草稿。","Edita aquí todos los objetos. Guarda la plantilla al terminar o cancela para descartar el borrador.","Modifiez ici tous les objets. Enregistrez le modèle une fois terminé ou annulez pour supprimer le brouillon.","Bearbeiten Sie hier alle Objekte. Speichern Sie anschließend die Vorlage oder brechen Sie ab, um den Entwurf zu verwerfen.","Modifica qui tutti gli oggetti. Al termine salva il modello oppure annulla per eliminare la bozza.","Edite aqui todos os objetos. Ao terminar, guarde o modelo ou cancele para descartar o rascunho."], + "Save template": ["テンプレートを保存","保存模板","儲存範本","Guardar plantilla","Enregistrer le modèle","Vorlage speichern","Salva modello","Guardar modelo"], + "Templates": ["テンプレート","模板","範本","Plantillas","Modèles","Vorlagen","Modelli","Modelos"], + "Create, edit, duplicate, and delete template pages": ["テンプレートページを作成、編集、複製、削除します","创建、编辑、复制和删除模板页面","建立、編輯、複製及刪除範本頁面","Crear, editar, duplicar y eliminar páginas de plantilla","Créer, modifier, dupliquer et supprimer des pages de modèle","Vorlagenseiten erstellen, bearbeiten, duplizieren und löschen","Crea, modifica, duplica ed elimina pagine modello","Criar, editar, duplicar e eliminar páginas de modelo"], + "Template pages": ["テンプレートページ","模板页面","範本頁面","Páginas de plantilla","Pages de modèle","Vorlagenseiten","Pagine modello","Páginas de modelo"], + "Non-text template objects become protected background furniture; template text boxes stay editable on normal slides.": ["テキスト以外のテンプレートオブジェクトは保護された背景要素になり、テンプレートのテキストボックスは通常のスライドでも編集できます。","非文本模板对象将成为受保护的背景元素;模板文本框在普通幻灯片中仍可编辑。","非文字範本物件會成為受保護的背景元素;範本文字方塊在一般投影片中仍可編輯。","Los objetos no textuales de la plantilla se convierten en elementos de fondo protegidos; los cuadros de texto siguen siendo editables en las diapositivas normales.","Les objets non textuels du modèle deviennent des éléments d’arrière-plan protégés ; les zones de texte restent modifiables sur les diapositives normales.","Nicht-Textobjekte der Vorlage werden zu geschützten Hintergrundelementen; Textfelder bleiben auf normalen Folien bearbeitbar.","Gli oggetti non testuali del modello diventano elementi di sfondo protetti; le caselle di testo restano modificabili nelle diapositive normali.","Os objetos não textuais do modelo tornam-se elementos de fundo protegidos; as caixas de texto continuam editáveis nos diapositivos normais."], + "Blank template": ["空のテンプレート","空白模板","空白範本","Plantilla en blanco","Modèle vierge","Leere Vorlage","Modello vuoto","Modelo em branco"], + "Template name": ["テンプレート名","模板名称","範本名稱","Nombre de la plantilla","Nom du modèle","Vorlagenname","Nome del modello","Nome do modelo"], + "From current slide": ["現在のスライドから作成","从当前幻灯片创建","從目前投影片建立","Desde la diapositiva actual","À partir de la diapositive actuelle","Aus aktueller Folie","Dalla diapositiva corrente","A partir do diapositivo atual"], + "My template": ["マイテンプレート","我的模板","我的範本","Mi plantilla","Mon modèle","Meine Vorlage","Il mio modello","O meu modelo"], + "{name} copy": ["{name} のコピー","{name} 副本","{name} 副本","Copia de {name}","Copie de {name}","Kopie von {name}","Copia di {name}","Cópia de {name}"], + "Template": ["テンプレート","模板","範本","Plantilla","Modèle","Vorlage","Modello","Modelo"], + "Delete this template?": ["このテンプレートを削除しますか?","删除此模板?","要刪除此範本嗎?","¿Eliminar esta plantilla?","Supprimer ce modèle ?","Diese Vorlage löschen?","Eliminare questo modello?","Eliminar este modelo?"], + "Template: {name}": ["テンプレート:{name}","模板:{name}","範本:{name}","Plantilla: {name}","Modèle : {name}","Vorlage: {name}","Modello: {name}","Modelo: {name}"], + "Template saved": ["テンプレートを保存しました","模板已保存","範本已儲存","Plantilla guardada","Modèle enregistré","Vorlage gespeichert","Modello salvato","Modelo guardado"], + "Save or cancel template editing first": ["先にテンプレート編集を保存またはキャンセルしてください","请先保存或取消模板编辑","請先儲存或取消範本編輯","Guarda o cancela primero la edición de la plantilla","Enregistrez ou annulez d’abord la modification du modèle","Speichern oder verwerfen Sie zuerst die Vorlagenbearbeitung","Salva o annulla prima la modifica del modello","Guarde ou cancele primeiro a edição do modelo"], + "Edit": ["編集","编辑","編輯","Editar","Modifier","Bearbeiten","Modifica","Editar"], + "Close": ["閉じる","关闭","關閉","Cerrar","Fermer","Schließen","Chiudi","Fechar"], } diff --git a/slides/src/i18n/pt.ts b/slides/src/i18n/pt.ts index ddbcf4c6..0abd025e 100644 --- a/slides/src/i18n/pt.ts +++ b/slides/src/i18n/pt.ts @@ -750,4 +750,25 @@ export const pt: Catalog = { "The bento/tray app can keep your files on this device.": "A aplicação bento/tray pode guardar os seus ficheiros neste dispositivo.", "This browser cannot reopen a file in place — Chrome or Edge can.": "Este navegador não consegue reescrever um ficheiro no lugar — o Chrome ou o Edge conseguem.", "Keep the file somewhere you can find it again.": "Guarde o ficheiro num sítio onde o consiga encontrar.", + 'Keep aspect ratio': 'Manter proporção', + 'Template editing': 'Edição de modelo', + 'Edit every object here. Save the template when finished, or cancel to discard the draft.': 'Edite aqui todos os objetos. Ao terminar, guarde o modelo ou cancele para descartar o rascunho.', + 'Save template': 'Guardar modelo', + 'Templates': 'Modelos', + 'Create, edit, duplicate, and delete template pages': 'Criar, editar, duplicar e eliminar páginas de modelo', + 'Template pages': 'Páginas de modelo', + 'Non-text template objects become protected background furniture; template text boxes stay editable on normal slides.': 'Os objetos não textuais do modelo tornam-se elementos de fundo protegidos; as caixas de texto continuam editáveis nos diapositivos normais.', + 'Blank template': 'Modelo em branco', + 'Template name': 'Nome do modelo', + 'From current slide': 'A partir do diapositivo atual', + 'My template': 'O meu modelo', + '{name} copy': 'Cópia de {name}', + 'Template': 'Modelo', + 'Delete this template?': 'Eliminar este modelo?', + 'Template: {name}': 'Modelo: {name}', + 'Template saved': 'Modelo guardado', + 'Save or cancel template editing first': 'Guarde ou cancele primeiro a edição do modelo', + 'Edit': 'Editar', + 'Close': 'Fechar', + } diff --git a/slides/src/i18n/zh-Hans.ts b/slides/src/i18n/zh-Hans.ts index 9731c833..7551242c 100644 --- a/slides/src/i18n/zh-Hans.ts +++ b/slides/src/i18n/zh-Hans.ts @@ -752,4 +752,25 @@ export const zhHans: Catalog = { "The bento/tray app can keep your files on this device.": "bento/tray 应用可以把文件保存在此设备上。", "This browser cannot reopen a file in place — Chrome or Edge can.": "此浏览器无法就地重写文件 — Chrome 或 Edge 可以。", "Keep the file somewhere you can find it again.": "把文件保存在你以后能找到的位置。", + 'Keep aspect ratio': '保持宽高比', + 'Template editing': '模板编辑', + 'Edit every object here. Save the template when finished, or cancel to discard the draft.': '可在此编辑所有对象。完成后保存模板,或取消以放弃草稿。', + 'Save template': '保存模板', + 'Templates': '模板', + 'Create, edit, duplicate, and delete template pages': '创建、编辑、复制和删除模板页面', + 'Template pages': '模板页面', + 'Non-text template objects become protected background furniture; template text boxes stay editable on normal slides.': '非文本模板对象将成为受保护的背景元素;模板文本框在普通幻灯片中仍可编辑。', + 'Blank template': '空白模板', + 'Template name': '模板名称', + 'From current slide': '从当前幻灯片创建', + 'My template': '我的模板', + '{name} copy': '{name} 副本', + 'Template': '模板', + 'Delete this template?': '删除此模板?', + 'Template: {name}': '模板:{name}', + 'Template saved': '模板已保存', + 'Save or cancel template editing first': '请先保存或取消模板编辑', + 'Edit': '编辑', + 'Close': '关闭', + } diff --git a/slides/src/i18n/zh-Hant.ts b/slides/src/i18n/zh-Hant.ts index bc893719..492d46b6 100644 --- a/slides/src/i18n/zh-Hant.ts +++ b/slides/src/i18n/zh-Hant.ts @@ -752,4 +752,25 @@ export const zhHant: Catalog = { "The bento/tray app can keep your files on this device.": "bento/tray 應用程式可以將檔案保存在此裝置上。", "This browser cannot reopen a file in place — Chrome or Edge can.": "此瀏覽器無法就地覆寫檔案 — Chrome 或 Edge 可以。", "Keep the file somewhere you can find it again.": "請將檔案保存在你日後找得到的位置。", + 'Keep aspect ratio': '鎖定長寬比', + 'Template editing': '編輯範本', + 'Edit every object here. Save the template when finished, or cancel to discard the draft.': '可在此編輯所有物件。完成後儲存範本,或取消以捨棄草稿。', + 'Save template': '儲存範本', + 'Templates': '範本', + 'Create, edit, duplicate, and delete template pages': '建立、編輯、複製及刪除範本頁面', + 'Template pages': '範本頁面', + 'Non-text template objects become protected background furniture; template text boxes stay editable on normal slides.': '非文字範本物件會成為受保護的背景元素;範本文字方塊在一般投影片中仍可編輯。', + 'Blank template': '空白範本', + 'Template name': '範本名稱', + 'From current slide': '從目前投影片建立', + 'My template': '我的範本', + '{name} copy': '{name} 副本', + 'Template': '範本', + 'Delete this template?': '要刪除此範本嗎?', + 'Template: {name}': '範本:{name}', + 'Template saved': '範本已儲存', + 'Save or cancel template editing first': '請先儲存或取消範本編輯', + 'Edit': '編輯', + 'Close': '關閉', + } diff --git a/slides/src/main.ts b/slides/src/main.ts index 2cda9bf2..7906e054 100644 --- a/slides/src/main.ts +++ b/slides/src/main.ts @@ -11,10 +11,11 @@ import { startNetGuard } from '../../kernel/src/net.ts' import { capturePristine, readEmbeddedDoc, serializeFile, serializeAuto, downloadFile, suggestedFileName, parseEnvelope, decryptEnvelope, setEncryptionPassword, - registerPreview, canWriteInPlace, hostCan, + registerPreview, registerSerializePrepare, canWriteInPlace, hostCan, } from './save' import { maybeShowReturnGate } from './editor/returngate' import { buildSlidePreview } from './preview' +import { compactDocumentAssets } from './compact-assets' import { APP_VERSION, checkForUpdates, buildUpdatedFile, applyUpdate } from './update' import { i18nApi, t, applyDirection } from './i18n' import { parseDoc, type BentoDoc, type TextElement } from './model' @@ -41,6 +42,7 @@ configureApp({ // Registered before capturePristine only for tidiness — nothing serializes // this early — but it must be registered before the first save. registerPreview((doc) => buildSlidePreview(doc as BentoDoc)) +registerSerializePrepare((doc) => compactDocumentAssets(doc as BentoDoc).doc) capturePristine() diff --git a/slides/src/model.ts b/slides/src/model.ts index 1f5f473f..e73eeaa4 100644 --- a/slides/src/model.ts +++ b/slides/src/model.ts @@ -105,6 +105,8 @@ export interface ElementBase { * those four are the conventions the built-in layouts use. */ role?: string + /** Protected non-text furniture inherited from a template. */ + templateLocked?: boolean } export interface ShadowSpec { @@ -220,6 +222,9 @@ export interface ImageElement extends ElementBase { src: string fit: 'contain' | 'cover' | 'fill' radius: number + /** Undefined/true keeps proportions; false applies to this image only. + * Re-enabling locks the image at its CURRENT frame ratio. */ + keepAspectRatio?: boolean } export interface SvgElement extends ElementBase { @@ -385,6 +390,10 @@ export interface Slide { hover?: { type: 'focus-group' | 'reveal'; dim?: number; default?: string } /** review comment threads (editor-only; see Comment) */ comments?: Comment[] + /** Template/layout lineage for an instantiated slide. */ + templateId?: string + /** Temporary editor-only template draft marker. */ + templateEditOf?: string } export interface BentoDoc { @@ -1010,7 +1019,12 @@ export function builtinLayouts(size?: { width: number; height: number }): Slide[ /** A fresh slide from a layout — new slide id, element ids KEPT (lineage). */ export function instantiateLayout(layout: Slide): Slide { const copy: Slide = JSON.parse(JSON.stringify(layout)) - return { ...copy, id: uid('slide'), name: undefined, stateOf: undefined, notes: '' } + copy.elements = copy.elements.map((el) => { + if (el.type === 'text') { delete el.templateLocked; return el } + el.templateLocked = true + return el + }) + return { ...copy, id: uid('slide'), name: undefined, stateOf: undefined, notes: '', templateId: layout.id } } const textHasContent = (e: SlideElement) => diff --git a/slides/src/render.ts b/slides/src/render.ts index 10f2af20..d416de0c 100644 --- a/slides/src/render.ts +++ b/slides/src/render.ts @@ -962,6 +962,11 @@ export function renderTableHtml(el: TableElement, doc: BentoDoc): string { export function renderElement(el: SlideElement, doc: BentoDoc, opts: RenderOpts = {}): HTMLElement { const node = document.createElement('div') node.className = `bento-el bento-el-${el.type}` + if (el.templateLocked) { + node.classList.add('bento-template-locked') + node.style.pointerEvents = 'none' + node.setAttribute('aria-hidden', 'true') + } node.dataset.elId = el.id node.dataset.flipId = morphKey(el) if (el.link) node.dataset.link = el.link @@ -1033,6 +1038,10 @@ export function renderElement(el: SlideElement, doc: BentoDoc, opts: RenderOpts if (imgSrc) img.src = imgSrc else img.dataset.bentoOffline = '1' img.draggable = false + // Ratio locking controls resize geometry only. It must never switch the + // bitmap between contain/cover/fill, because doing so makes the visible + // image jump when the lock is toggled. Freeform image distortion is made + // persistent by setting fit='fill' when the lock is turned off. img.style.cssText = `width:100%;height:100%;object-fit:${el.fit};border-radius:${el.radius}px;display:block` node.appendChild(img) break diff --git a/slides/src/styles.css b/slides/src/styles.css index b2f63566..2c5c18b6 100644 --- a/slides/src/styles.css +++ b/slides/src/styles.css @@ -2483,3 +2483,10 @@ input.ed-toggle { width: 16px; height: 16px; accent-color: #5E7699; cursor: poin } .ed-gate-offer { margin: 0; font-size: 12.5px; line-height: 1.5; color: var(--muted); } .ed-gate-acts { display: flex; justify-content: flex-end; margin-top: 4px; } + +.bento-template-locked { pointer-events: none !important; } +.ed-template-button { margin-top: 4px; } +.ed-template-manager .ed-hint { margin: 8px 12px; max-width: 390px; } +.ed-template-manager-head { display:flex; align-items:center; justify-content:space-between; gap:12px; padding-right:8px; } +.ed-template-close { border:0; background:transparent; color:inherit; font-size:24px; line-height:1; cursor:pointer; border-radius:6px; padding:2px 7px; } +.ed-template-close:hover { background:rgba(94,118,153,.14); }