| 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. |
| 16 | import { 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. |
| 20 | const 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 | */ |
| 27 | export 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 | */ |
| 49 | export 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 | */ |
| 113 | export 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 | |