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-image.mjs — the two pieces of the inspector that
2// are about pictures (spec §2.7): the image block's file input, and the
3// Deck section's list of images no slide uses any more.
4//
5// The upload itself is uploads.mjs's state machine; this file is the
6// DOM around it. It draws nothing it was not given: every string
7// arrives from the page's data attributes (the catalog), and the status
8// line is text with role="status" on it in the template, so progress
9// and every refusal are read out rather than shown as a colour.
10//
11// One rule worth stating: the file input is never the thing that
12// changes the document. It hands the finished record to commit() as
13// register_asset plus a set_block naming it, both in one batch, so the
14// upload lands in the deck through the same op path — and one undo —
15// as everything else.
16import { registerAssetOps, removeAssetOp, unusedAssets } from './inspector-ops.mjs'
17
18// The upload states that map onto a line of text, in the order they
19// happen. Anything not here (idle, error) is handled at the call site.
20const PROGRESS = { sign: 'uploading', put: 'uploading', register: 'checking', op: 'checking', done: 'done' }
21
22/**
23 * The words for one refusal. The server's own message wins when it sent
24 * one — it knows why better than this does — and the client's codes
25 * fall back to the catalog string for each.
26 */
27export function uploadMessage (err, labels) {
28 if (!err) return ''
29 if (err.serverMessage) return err.serverMessage
30 switch (err.code) {
31 case 'type': return labels.type
32 case 'toolarge': case 'too_large': return labels.toolarge
33 case 'altfirst': return labels.altfirst
34 default: return labels.failed
35 }
36}
37
38/**
39 * Reconciles the Unused images list by asset id, the same way the
40 * Blocks list is reconciled and for the same reason: fill() runs on
41 * every document change, a colleague's included, and rebuilding would
42 * take a focused Remove button out of the document under somebody's
43 * finger.
44 *
45 * `list` is the <ul>, `none` the "every image is on a slide" line,
46 * `assets` the result of unusedAssets(), `removeLabel` the button's
47 * word.
48 */
49export function fillUnused ({ list, none, assets, removeLabel }) {
50 if (!list) return
51 if (none) none.hidden = assets.length > 0
52 const have = new Map()
53 for (const li of list.querySelectorAll('[data-asset]')) have.set(li.dataset.asset, li)
54
55 const want = []
56 for (const a of assets) {
57 let li = have.get(a.id)
58 if (li) have.delete(a.id)
59 else {
60 li = document.createElement('li')
61 li.dataset.asset = a.id
62 const name = document.createElement('span')
63 name.setAttribute('slides-asset-name', '')
64 const btn = document.createElement('button')
65 btn.type = 'button'
66 btn.setAttribute('rst-btn', 'sm')
67 btn.dataset.removeAsset = a.id
68 li.append(name, btn)
69 }
70 // An asset uploaded without a filename is named by its address,
71 // shortened: it is the only thing there is to call it, and the
72 // whole hash tells a person nothing the first eight characters do
73 // not.
74 const words = a.name || String(a.ref || '').slice(0, 8)
75 const nameEl = li.querySelector('[slides-asset-name]')
76 if (nameEl && nameEl.textContent !== words) nameEl.textContent = words
77 const btn = li.querySelector('[data-remove-asset]')
78 if (btn) {
79 if (btn.textContent !== removeLabel) btn.textContent = removeLabel
80 // Two buttons called "Remove" in a row are one button as far as a
81 // screen reader's element list is concerned; the accessible name
82 // says which picture this one drops.
83 btn.setAttribute('aria-label', `${removeLabel}: ${words}`)
84 }
85 want.push(li)
86 }
87 for (const li of have.values()) li.remove()
88
89 let at = list.firstElementChild
90 for (const li of want) {
91 if (at === li) {
92 at = li.nextElementSibling
93 continue
94 }
95 list.insertBefore(li, at)
96 }
97}
98
99/**
100 * Mounts both pieces.
101 *
102 * root the inspector element (both forms are inside it)
103 * getView () => the deck's JSON view
104 * getSlideId, getSelected the current slide and block
105 * getBlock () => the selected block's JSON, or null
106 * commit (ops) => applies a batch
107 * upload (file, {alt, decorative}) => the record (uploads.mjs)
108 * labels the strings, from the page's data attributes
109 *
110 * Returns {fill, destroy}. fill() is called by the inspector's own
111 * fill, so the unused list follows a peer's edits too.
112 */
113export function mountAssets ({ root, getView, getSlideId, getSelected, getBlock, commit, upload, labels }) {
114 const input = root.querySelector('[slides-asset-input]')
115 const status = root.querySelector('[slides-asset-status]')
116 const list = root.querySelector('[slides-unused-list]')
117 const none = root.querySelector('[slides-unused-none]')
118
119 function say (state, words) {
120 if (!status) return
121 status.dataset.state = state
122 status.textContent = words || ''
123 }
124
125 async function chose (file) {
126 if (!file || !upload) return
127 // The slide and block as they are NOW: a 20 MiB upload is long
128 // enough for somebody to have selected another block by the time it
129 // lands, and it belongs to the one they were looking at when they
130 // picked the file.
131 const block = getBlock()
132 const slideId = getSlideId()
133 const props = (block && block.props) || {}
134 try {
135 await upload(file, {
136 alt: typeof props.alt === 'string' ? props.alt : '',
137 decorative: props.decorative === true,
138 applyOp: record => commit(registerAssetOps(record, slideId, block ? block.id : ''))
139 })
140 } catch (err) {
141 say('error', uploadMessage(err, labels))
142 return
143 } finally {
144 // Always: choosing the same file twice in a row fires no change
145 // event unless the value is cleared, which is exactly what
146 // somebody does after a failure.
147 if (input) input.value = ''
148 }
149 say('done', labels.done)
150 }
151
152 function onChange (e) {
153 if (e.target !== input) return
154 const file = input.files && input.files[0]
155 if (file) chose(file)
156 }
157
158 function onClick (e) {
159 const btn = e.target.closest('[data-remove-asset]')
160 if (!btn || !root.contains(btn)) return
161 commit([removeAssetOp(btn.dataset.removeAsset)])
162 }
163
164 function fill () {
165 fillUnused({ list, none, assets: unusedAssets(getView()), removeLabel: labels.remove })
166 }
167
168 const abort = new AbortController()
169 const { signal } = abort
170 root.addEventListener('change', onChange, { signal })
171 root.addEventListener('click', onClick, { signal })
172
173 return {
174 fill,
175 // onState is handed to createUploader so the status line follows
176 // the machine without this file knowing its steps twice.
177 onState (state, detail) {
178 if (state === 'error') return say('error', uploadMessage(detail, labels))
179 const key = PROGRESS[state]
180 if (key) say(state, labels[key])
181 },
182 destroy () { abort.abort() },
183 // Named so a caller (and a test) can reach the selection rule
184 // without going through a change event.
185 chose,
186 get selected () { return getSelected() }
187 }
188}
189