oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// editor.go is the browser editor's own surface (spec §3.1): the page
2// the bundle enhances, and the slide HTML its stage and strip fetch.
3//
4// The page is server-rendered whole — the strip with every thumbnail,
5// the stage with the current slide, the notes under it — so the first
6// paint is right before a byte of script has run, and so the one thing
7// the bundle adds is editing. What the bundle needs to know (the deck,
8// the log's seq, the current slide, who this person is) rides on data
9// attributes; every visible string it will ever show is rendered into
10// one too, from the catalog, so the bundle carries no English.
11package slides
12
13import (
14 "fmt"
15 "html/template"
16 "net/http"
17 "strconv"
18 "strings"
19
20 "github.com/go-chi/chi/v5"
21
22 "amadan.net/rastrillo/rastrillo"
23
24 slidehtml "amadan.net/oficina/slides/internal/slides/render"
25)
26
27// stripItem is one slide in the strip: the address of the server's
28// picture of it, its label, and whether it is the one on stage.
29//
30// A URL and not markup. The strip used to carry nine whole renderings
31// of the deck scaled down by CSS; each is now one <img> of the SVG
32// thumbs.go draws, which is smaller to send, cheaper to paint, and the
33// same picture the export is (thumbs.go says why at length).
34type stripItem struct {
35 ID string
36 Label string
37 Thumb string
38 Current bool
39}
40
41// editorPage is the whole editor screen. The Text* fields are the
42// strings the bundle shows at run time — the status line's three
43// states, the link dialog — rendered into data attributes rather than
44// into the DOM, because the bundle decides WHEN to show them and the
45// catalog decides WHAT they say.
46type editorPage struct {
47 shell
48 Title string
49 DeckSqid string
50 Seq int64
51 SlideID string
52 ClientName string
53 ClientColour string
54 BundleSrc string
55
56 BackHref string
57 BackLabel string
58
59 MenuLabel string
60 NewSlideLabel string
61 DuplicateLabel string
62 UndoLabel string
63 RedoLabel string
64 PresentAction string
65 PresentLabel string
66
67 StripLabel string
68 CurrentLabel string
69 // SlideLabel is the "Slide {n}" template itself, placeholder and
70 // all, for the strip to label a slide with no heading by its
71 // number after the order changes (strip.mjs's labelFor).
72 SlideLabel string
73 Strip []stripItem
74
75 StageLabel string
76 HasSlides bool
77 Stage template.HTML
78 EmptyTitle string
79 EmptyBody string
80
81 NotesLabel string
82 Notes template.HTML
83
84 InspectorLabel string
85 InspectorEmpty string
86
87 StatusLabel string
88 TextSaved string
89 TextSaving string
90 TextOffline string
91 TextFailed string
92 LinkTitle string
93 LinkURLLabel string
94 LinkInsert string
95 LinkRemove string
96 LinkCancel string
97 NoScriptBody string
98 NoScriptLink string
99
100 // Themes is the deck section's theme picker, the settings screen's
101 // own list with the deck's theme selected.
102 Themes []selectOption
103 // HasStore says whether this deployment has an object store bound
104 // (Config.Blobs). False disables the image block's file input and
105 // puts the "no store" line in its place — the page says so rather
106 // than the bundle discovering it from a 503 after somebody has
107 // already chosen a file.
108 HasStore bool
109 // AcceptTypes is the file input's accept attribute: §2.7's four
110 // types, from the same map the upload endpoint validates against
111 // (assets.go), so the picker and the gate cannot drift.
112 AcceptTypes string
113 // S holds every other string the inspector, the strip's menu, the
114 // switcher and the canvas overlay show, keyed by the tail of its
115 // catalog key (slides.editor.<key>). The inspector has some ninety
116 // labels; one named field each would be a page type nobody could
117 // read, and the template reads them through L.
118 S map[string]string
119}
120
121// L is the template's way to a string in S: {{.L "deck.title"}}.
122func (p editorPage) L(key string) string { return p.S[key] }
123
124// editorStrings is every key the template reads through L. The list
125// is here, once, so a missing key is a missing label on the page
126// (rastrillo.T's own fallback) rather than a template failure.
127var editorStrings = []string{
128 "strip.actions", "strip.reorder",
129 "view", "view.desktop", "view.phone", "view.both", "view.phone.readonly",
130 "inspector.deck", "inspector.slide", "inspector.block", "inspector.blocks", "inspector.blocks.none",
131 "deck.title", "deck.theme", "deck.accent", "deck.accent.reset", "deck.font.heading", "deck.font.body",
132 "font.theme", "font.system", "font.serif", "font.mono",
133 "slide.layout", "layout.flow", "layout.flow.desc", "layout.canvas", "layout.canvas.desc",
134 "slide.template", "template.title", "template.title-body", "template.two-column", "template.section",
135 "template.image-full", "template.quote", "template.blank",
136 "slide.background", "background.theme", "background.color", "slide.background.color",
137 "slide.transition", "transition.none", "transition.fade",
138 "slide.phone", "slide.phone.inherit", "slide.phone.reflow", "slide.notes.show",
139 "slide.add", "slide.move.up", "slide.move.down", "slide.delete", "slide.delete.confirm", "slide.delete.cancel",
140 "block.add", "type.heading", "type.text", "type.list", "type.quote", "type.image", "type.columns",
141 "type.code", "type.shape", "type.table", "type.divider", "type.spacer",
142 "block.level", "block.size", "size.s", "size.m", "size.l",
143 "block.align", "align.start", "align.center", "align.end",
144 "block.style", "style.bullet", "style.number", "block.cite",
145 "block.fit", "fit.cover", "fit.contain", "block.alt", "block.decorative", "block.radius",
146 "block.image.file", "block.image.hint", "block.image.nostore", "block.image.uploading",
147 "block.image.checking", "block.image.done", "block.image.failed", "block.image.toolarge",
148 "block.image.type", "block.image.altfirst",
149 "assets.unused", "assets.unused.none", "assets.remove",
150 "block.count", "block.language",
151 "block.kind", "kind.rect", "kind.ellipse", "kind.line", "kind.arrow",
152 "block.fill", "block.stroke", "block.strokeWidth", "block.label",
153 "block.rows", "block.cols", "block.header",
154 "block.region", "region.heading", "region.body", "region.aside", "region.media",
155 "block.box", "box.x", "box.y", "box.w", "box.h",
156 "block.forward", "block.back", "block.move.up", "block.move.down", "block.delete",
157 "block.phone", "block.phone.hidden", "block.phone.order", "block.phone.size", "block.phone.same",
158 "block.phone.override", "block.phone.reset",
159 "canvas.selected",
160 "presence.slide", "presence.block", "presence.more", "presence.joined", "presence.left",
161 "status.error",
162}
163
164// blockTypes is §2.4's vocabulary in the order the add menu lists it.
165var blockTypes = []string{"heading", "text", "list", "quote", "image", "columns", "code", "shape", "table", "divider", "spacer"}
166
167// BlockTypes is the add menu's list, for the template's range.
168func (p editorPage) BlockTypes() []string { return blockTypes }
169
170// --- GET /d/{sqid}/edit ---
171
172// deckEditor serves the editor page to deckFor's audience — the owner
173// and, on a team deck, every member. A team deck is edited by the team
174// (live.go's rule for the write endpoints, which this page is the
175// front of), so there is no owner-only branch here.
176func (a *app) deckEditor(w http.ResponseWriter, r *http.Request) {
177 u, ok := a.viewer(w, r)
178 if !ok {
179 return
180 }
181 d, ok := a.deckFor(w, r, u)
182 if !ok {
183 return
184 }
185 h, view, err := a.openDeck(r.Context(), d)
186 if err != nil {
187 a.fail(w, r, "open a deck to edit", err)
188 return
189 }
190 defer h.Close(r.Context())
191
192 base := "/d/" + d.Sqid
193 person := u.Name
194 if person == "" {
195 person = u.Email
196 }
197 page := editorPage{
198 shell: a.shellFor(r, u, titled(view.Meta.Title)),
199 Title: view.Meta.Title,
200 DeckSqid: d.Sqid,
201 Seq: d.Seq,
202 ClientName: person,
203 ClientColour: u.Colour,
204 BundleSrc: editorBundlePath(),
205
206 BackHref: base,
207 BackLabel: rastrillo.T(r, "slides.deck.back"),
208
209 MenuLabel: rastrillo.T(r, "slides.editor.menu"),
210 NewSlideLabel: rastrillo.T(r, "slides.editor.slide.new"),
211 DuplicateLabel: rastrillo.T(r, "slides.editor.slide.duplicate"),
212 UndoLabel: rastrillo.T(r, "slides.editor.undo"),
213 RedoLabel: rastrillo.T(r, "slides.editor.redo"),
214 PresentAction: base + "/present",
215 PresentLabel: rastrillo.T(r, "slides.viewer.present"),
216
217 StripLabel: rastrillo.T(r, "slides.editor.strip"),
218 CurrentLabel: rastrillo.T(r, "slides.editor.strip.current"),
219 SlideLabel: rastrillo.T(r, "slides.deck.slide"),
220 StageLabel: rastrillo.T(r, "slides.editor.stage"),
221 EmptyTitle: rastrillo.T(r, "slides.viewer.empty.title"),
222 EmptyBody: rastrillo.T(r, "slides.editor.empty.body"),
223 NotesLabel: rastrillo.T(r, "slides.viewer.notes"),
224
225 InspectorLabel: rastrillo.T(r, "slides.editor.inspector"),
226 InspectorEmpty: rastrillo.T(r, "slides.editor.inspector.empty"),
227
228 StatusLabel: rastrillo.T(r, "slides.editor.status"),
229 TextSaved: rastrillo.T(r, "slides.editor.status.saved"),
230 TextSaving: rastrillo.T(r, "slides.editor.status.saving"),
231 TextOffline: rastrillo.T(r, "slides.editor.status.offline"),
232 TextFailed: rastrillo.T(r, "slides.editor.status.failed"),
233 LinkTitle: rastrillo.T(r, "slides.editor.link.title"),
234 LinkURLLabel: rastrillo.T(r, "slides.editor.link.url"),
235 LinkInsert: rastrillo.T(r, "slides.editor.link.insert"),
236 LinkRemove: rastrillo.T(r, "slides.editor.link.remove"),
237 LinkCancel: rastrillo.T(r, "slides.editor.link.cancel"),
238 NoScriptBody: rastrillo.T(r, "slides.editor.noscript"),
239 NoScriptLink: rastrillo.T(r, "slides.editor.noscript.link"),
240
241 Themes: themeOptions(r, view.Meta.Theme),
242 HasStore: a.cfg.Blobs != nil,
243 AcceptTypes: acceptTypes(),
244 S: make(map[string]string, len(editorStrings)),
245 }
246 for _, k := range editorStrings {
247 page.S[k] = rastrillo.T(r, "slides.editor."+k)
248 }
249
250 if len(view.Slides) == 0 {
251 render(w, "editor", page)
252 return
253 }
254
255 // The page opens on the first slide. Which slide the URL hash
256 // names is the bundle's business — the hash never reaches the
257 // server — and the bundle moves the stage if it says otherwise.
258 page.HasSlides = true
259 page.SlideID = view.Slides[0].ID
260 opt := slidehtml.Options{AssetBase: assetBaseFor(base)}
261 stage, err := slidehtml.SlideWith(view, 0, slidehtml.Desktop, opt)
262 if err != nil {
263 a.fail(w, r, "render the stage", err)
264 return
265 }
266 page.Stage = stage
267 page.Notes = slidehtml.Notes(view.Slides[0].Notes)
268
269 page.Strip = make([]stripItem, 0, len(view.Slides))
270 for i, s := range view.Slides {
271 // The deck's seq is on the URL so the browser asks again when
272 // the deck has changed and reads its own cache when it has
273 // not. The route revalidates on an ETag either way; this is
274 // what keeps a returning tab from showing yesterday's slide
275 // out of the disk cache before the first flush.
276 page.Strip = append(page.Strip, stripItem{
277 ID: s.ID,
278 Label: slideLabel(r, s, i+1),
279 Thumb: fmt.Sprintf("%s/slides/%s.svg?seq=%d", base, s.ID, d.Seq),
280 Current: i == 0,
281 })
282 }
283 render(w, "editor", page)
284}
285
286// --- GET /d/{sqid}/slides/{id}.html ---
287
288// deckSlide renders one slide as the section the viewer would show it
289// in — the session-cookie twin of the API's apiSlide, for the editor's
290// stage and its thumbnail strip.
291//
292// Two surfaces rather than one shared route because the two are
293// authenticated differently and nothing else about them differs: the
294// API's caller carries a bearer token and the API's own 404 shape, this
295// one carries a session cookie and this app's. The rendering is
296// literally the same call into the same package, which is the invariant
297// that matters (AGENTS.md: render.Slide is the only place slide HTML is
298// produced).
299//
300// It is the SECTION, never a page: the stage swaps this into the DOM,
301// so a layout around it would be a page nested inside a page.
302//
303// vp names the viewport: `phone` reflows (spec §2.5), `desktop` and
304// `thumb` both render desktop. A thumbnail is a desktop slide scaled by
305// CSS rather than a third rendering — the one rule this app has is that
306// a slide is one block tree rendered many ways, and "many ways" is two
307// viewports, not one per place a slide is drawn.
308func (a *app) deckSlide(w http.ResponseWriter, r *http.Request) {
309 u, ok := a.viewer(w, r)
310 if !ok {
311 return
312 }
313 d, ok := a.deckFor(w, r, u)
314 if !ok {
315 return
316 }
317 // The picture twin, on the same route because the two are the same
318 // slide behind the same capability and differ only in what a
319 // caller wants back (thumbs.go).
320 if strings.HasSuffix(chi.URLParam(r, "file"), ".svg") {
321 a.serveThumb(w, r, d, "/d/"+d.Sqid, func() { a.notFound(w, r) })
322 return
323 }
324
325 slideID, ok := strings.CutSuffix(chi.URLParam(r, "file"), ".html")
326 if !ok || slideID == "" {
327 a.notFound(w, r)
328 return
329 }
330 vp := editorViewport(r.URL.Query().Get("vp"))
331
332 // Seq, slide and viewport: the three things that change what these
333 // bytes are. The stage re-fetches after every structural change,
334 // and the browser sends the tag back; a match answers 304 before
335 // the document is opened, which is what makes the fetch that
336 // changed nothing cheap. The slide id is in the tag so a stale
337 // slide's tag never matches a live one at the same seq — though a
338 // removed slide is a 404 below either way.
339 //
340 // The header goes on the two responses that ARE this
341 // representation — the 304 here and the 200 below — and never on
342 // the 404: a tag on "there is no such slide" invites a cache to
343 // treat the absence as the thing.
344 etag := strconv.Quote(strconv.FormatInt(d.Seq, 10) + "-" + slideID + "-" + vp.String())
345 if matchesETag(r.Header.Get("If-None-Match"), etag) {
346 w.Header().Set("ETag", etag)
347 w.WriteHeader(http.StatusNotModified)
348 return
349 }
350
351 h, view, err := a.openDeck(r.Context(), d)
352 if err != nil {
353 a.fail(w, r, "open a deck to render a slide", err)
354 return
355 }
356 defer h.Close(r.Context())
357
358 index := -1
359 for i, s := range view.Slides {
360 if s.ID == slideID {
361 index = i
362 break
363 }
364 }
365 if index < 0 {
366 a.notFound(w, r)
367 return
368 }
369
370 section, err := slidehtml.SlideWith(view, index, vp,
371 slidehtml.Options{AssetBase: assetBaseFor("/d/" + d.Sqid)})
372 if err != nil {
373 a.fail(w, r, "render a slide for the editor", err)
374 return
375 }
376 w.Header().Set("ETag", etag)
377 w.Header().Set("Content-Type", "text/html; charset=utf-8")
378 w.WriteHeader(http.StatusOK)
379 fmt.Fprint(w, section)
380}
381
382// editorViewport maps the ?vp= value onto a rendering. Anything
383// unrecognised is desktop rather than a 400: this is a rendering hint
384// on a read, and a stage that got a typo should show the slide.
385func editorViewport(vp string) slidehtml.Viewport {
386 if vp == "phone" {
387 return slidehtml.Phone
388 }
389 return slidehtml.Desktop
390}
391