oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1// fixtures/inspector-ops.mjs — every inspector control as the one
2// drive operation it is (spec §3.1: "every inspector control is one
3// drive operation, and the editor calls the same function the API
4// handler calls"). Pure: a field name and a value in, an op out, over
5// the deck's JSON view (view.mjs), so inspector.test.mjs can hold each
6// control to its op with no DOM.
7//
8// The view-dependent block fields — box, size, hidden, order — go
9// through switcher.mjs's routeEdit, which is where the desktop/phone
10// rule lives; everything here is the rest.
11import { routeEdit } from './switcher.mjs'
12import { defaultRegion } from './layout.mjs'
13
14export const FONT_KEYS = ['heading', 'body']
15export const DEFAULT_BOX = { x: 0.1, y: 0.1, w: 0.5, h: 0.3 }
16export const TEXT_TYPES = ['heading', 'text', 'list', 'quote', 'code', 'shape']
17
18// --- deck ---
19
20/** field: title | theme | accent | accent-reset | font-heading | font-body. */
21export function deckOp (field, value, meta) {
22 switch (field) {
23 case 'title': return { op: 'set_meta', title: value }
24 case 'theme': return { op: 'set_meta', theme: value }
25 case 'accent': return { op: 'set_meta', accent: value }
26 case 'accent-reset': return { op: 'set_meta', accent: null }
27 case 'font-heading':
28 case 'font-body': {
29 const fonts = { heading: '', body: '', ...(meta && meta.fonts ? meta.fonts : {}) }
30 fonts[field.slice(5)] = value
31 if (!fonts.heading && !fonts.body) return { op: 'set_meta', fonts: null }
32 return { op: 'set_meta', fonts: { heading: fonts.heading || '', body: fonts.body || '' } }
33 }
34 }
35 throw new Error(`deckOp: unknown field ${field}`)
36}
37
38// --- slide ---
39
40const DEFAULT_COLOR = '#ffffff'
41
42/** field: layout | template | background | background-color | transition | phone. */
43export function slideOp (slide, field, value) {
44 const id = slide.id
45 switch (field) {
46 case 'layout': return { op: 'set_slide', id, layout: value }
47 case 'template': return { op: 'set_slide', id, template: value }
48 case 'transition': return { op: 'set_slide', id, transition: value }
49 case 'phone': return { op: 'set_slide', id, phone: value }
50 case 'background': {
51 if (value === 'color') {
52 const was = slide.background && slide.background.kind === 'color' ? slide.background.value : ''
53 return { op: 'set_slide', id, background: { kind: 'color', value: was || DEFAULT_COLOR } }
54 }
55 return { op: 'set_slide', id, background: { kind: 'theme', value: '' } }
56 }
57 case 'background-color': return { op: 'set_slide', id, background: { kind: 'color', value } }
58 }
59 throw new Error(`slideOp: unknown field ${field}`)
60}
61
62/** move_slide one step: after the previous-but-one going up, after the next going down; null when already at the end. */
63export function moveSlideOp (ids, id, dir) {
64 const i = ids.indexOf(id)
65 if (i < 0) return null
66 if (dir === 'up') {
67 if (i === 0) return null
68 return i === 1 ? { op: 'move_slide', id } : { op: 'move_slide', id, after: ids[i - 2] }
69 }
70 if (i >= ids.length - 1) return null
71 return { op: 'move_slide', id, after: ids[i + 1] }
72}
73
74// --- block ---
75
76/** A prop from a control: kind 'int' parses a number, 'bool' is a checkbox, '' clears the prop. */
77export function propValue (raw, kind) {
78 if (kind === 'bool') return raw === true || raw === 'true' || raw === 'on'
79 if (raw === '' || raw === null || raw === undefined) return null
80 if (kind === 'int') {
81 const n = Number(raw)
82 return Number.isFinite(n) ? Math.trunc(n) : null
83 }
84 return String(raw)
85}
86
87/** set_block on one prop. */
88export function propOp (slideId, block, prop, value) {
89 return { op: 'set_block', slide: slideId, id: block.id, props: { [prop]: value } }
90}
91
92/** set_block region on a flow slide. */
93export function regionOp (slideId, block, region) {
94 return { op: 'set_block', slide: slideId, id: block.id, region }
95}
96
97/** A shape's label: set_text over the block's one paragraph. */
98export function labelOp (slideId, block, text) {
99 return { op: 'set_text', slide: slideId, id: block.id, markdown: text }
100}
101
102/** One box field (x, y, w, h) typed as a percentage, routed by view. */
103export function boxFieldOp ({ view, slideId, block, key, percent }) {
104 const base = view === 'phone' && block.phone && block.phone.box ? block.phone.box : block.box
105 if (!base) return null
106 const n = Number(percent)
107 if (!Number.isFinite(n)) return null
108 const box = { ...base, [key]: n / 100 }
109 return routeEdit({ view, slide: slideId, block, field: 'box', value: box })
110}
111
112/** move_block one step within the slide's array order; null at the end. */
113export function moveBlockOp (slideId, ids, id, dir) {
114 const i = ids.indexOf(id)
115 if (i < 0) return null
116 if (dir === 'up') {
117 if (i === 0) return null
118 return i === 1 ? { op: 'move_block', slide: slideId, id } : { op: 'move_block', slide: slideId, id, after: ids[i - 2] }
119 }
120 if (i >= ids.length - 1) return null
121 return { op: 'move_block', slide: slideId, id, after: ids[i + 1] }
122}
123
124/**
125 * Bring forward / send back: the blocks sorted by z (array order for
126 * ties), the block swapped with its neighbour, and every block whose
127 * z then differs from its position written — so z is a permutation
128 * again however the slide arrived. [] when nothing changes.
129 */
130export function zOps (slideId, blocks, id, dir) {
131 const order = blocks.map((b, i) => ({ id: b.id, z: Number.isFinite(b.z) ? b.z : 0, i }))
132 .sort((a, b) => (a.z - b.z) || (a.i - b.i))
133 const at = order.findIndex(b => b.id === id)
134 if (at < 0) return []
135 const to = dir === 'forward' ? at + 1 : at - 1
136 if (to < 0 || to >= order.length) return []
137 const [moved] = order.splice(at, 1)
138 order.splice(to, 0, moved)
139 return order.map((b, z) => ({ ...b, z })).filter(b => b.z !== blocks[b.i].z)
140 .map(b => ({ op: 'set_block', slide: slideId, id: b.id, z: b.z }))
141}
142
143/**
144 * The op adding a block of kind to slide (the view's slide JSON) after
145 * the selected block, or last: on a flow slide in the body region (a
146 * heading in the heading region, an image in media), on a canvas
147 * slide in the default box at the top z. A heading is level 2 unless
148 * the slide has none; text-bearing types get an empty paragraph to
149 * type into.
150 */
151export function addBlockOp (slide, kind, id, after) {
152 const op = { op: 'add_block', slide: slide.id, id, type: kind, props: {} }
153 if (after && slide.blocks.some(b => b.id === after)) op.after = after
154 if (kind === 'heading') op.props.level = slide.blocks.some(b => b.type === 'heading') ? 2 : 1
155 if (kind === 'table') { op.props.rows = 2; op.props.cols = 2 }
156 if (kind === 'columns') op.props.count = 2
157 if (TEXT_TYPES.includes(kind)) op.markdown = ''
158 if (slide.layout === 'canvas') {
159 op.box = { ...DEFAULT_BOX }
160 op.z = slide.blocks.reduce((m, b) => Math.max(m, Number.isFinite(b.z) ? b.z + 1 : 0), 0)
161 } else {
162 op.region = defaultRegion(kind)
163 }
164 return op
165}
166
167/** A block's name in the Blocks list: its type's label, then its first words. */
168export function blockLabel (block, typeLabel, max = 40) {
169 const paras = Array.isArray(block.text) ? block.text : []
170 const words = paras.map(p => p.map(r => r.t || '').join('')).join(' ').replace(/\s+/g, ' ').trim()
171 if (!words) return typeLabel
172 const cut = words.length > max ? words.slice(0, max - 1).trimEnd() + '…' : words
173 return `${typeLabel}: ${cut}`
174}
175
176// --- assets (§2.7) ---
177
178/**
179 * The two ops one finished upload is: the registration itself, and the
180 * selected image block pointing at it.
181 *
182 * `record` is what POST /d/{sqid}/assets answered — the id the server
183 * minted, the ref it verified, and the name, size and alt it validated.
184 * The client writes them rather than the server, so an upload arrives
185 * in the document through the same path, the same update log and the
186 * same undo step as every other change (uploads.mjs's header).
187 *
188 * One batch, so undo takes both back: an asset registered with no block
189 * using it would be an entry in the Unused images list nobody asked
190 * for.
191 */
192export function registerAssetOps (record, slideId, blockId) {
193 const ops = [{
194 op: 'register_asset',
195 id: record.id,
196 ref: record.ref,
197 name: record.name || '',
198 width: Number.isInteger(record.width) ? record.width : 0,
199 height: Number.isInteger(record.height) ? record.height : 0,
200 alt: record.alt || ''
201 }]
202 if (slideId && blockId) {
203 ops.push({ op: 'set_block', slide: slideId, id: blockId, props: { asset: record.id } })
204 }
205 return ops
206}
207
208/** Dropping a registration. The bytes stay in the store, which is shared and content-addressed. */
209export function removeAssetOp (id) {
210 return { op: 'remove_asset', id }
211}
212
213/**
214 * The assets no slide in the deck refers to any more, as [{id, ...asset}]
215 * in the document's own order.
216 *
217 * Two things refer to one: an image block's props.asset, and a slide
218 * background of kind "image". Deleting the last block that used an
219 * asset deliberately leaves the registration behind — an undo has to be
220 * able to put the block back — so this list is how a person clears them
221 * out, and it is derived from the document every time rather than
222 * tracked.
223 */
224export function unusedAssets (deckView) {
225 const assets = (deckView && deckView.assets) || {}
226 const used = new Set()
227 for (const slide of (deckView && deckView.slides) || []) {
228 const bg = slide.background
229 if (bg && bg.kind === 'image' && bg.value) used.add(bg.value)
230 for (const block of slide.blocks || []) {
231 const id = block.props && block.props.asset
232 if (typeof id === 'string' && id) used.add(id)
233 }
234 }
235 return Object.keys(assets)
236 .filter(id => !used.has(id))
237 .map(id => ({ id, ...assets[id] }))
238}
239