| 1 | package layout |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "slices" |
| 6 | "strings" |
| 7 | |
| 8 | "amadan.net/oficina/slides/internal/slides/deck" |
| 9 | "amadan.net/oficina/slides/internal/slides/fonts" |
| 10 | "amadan.net/oficina/slides/internal/slides/render" |
| 11 | ) |
| 12 | |
| 13 | // Layout draws d's slide at index into a paint list. |
| 14 | // |
| 15 | // It fails only on a caller mistake — no deck, or an index that is not |
| 16 | // a slide. Everything a deck can contain lays out as something: an |
| 17 | // unknown theme falls back to the default, an unknown block type draws |
| 18 | // nothing rather than breaking the slide around it, and a prop with |
| 19 | // the wrong type takes its default. A half-written document that |
| 20 | // arrived from a peer mid-edit is the ordinary case, not an error. |
| 21 | // |
| 22 | // The warnings are the things worth saying out loud about the result: |
| 23 | // a block that outgrew its box, a rune no embedded face can draw, an |
| 24 | // image the deck never registered. None of them stops the drawing. |
| 25 | func Layout(d *deck.Deck, index int) (Paint, []Warning, error) { |
| 26 | if d == nil { |
| 27 | return Paint{}, nil, fmt.Errorf("layout: no deck") |
| 28 | } |
| 29 | if index < 0 || index >= len(d.Slides) { |
| 30 | return Paint{}, nil, fmt.Errorf("layout: slide %d is outside a deck of %d", index, len(d.Slides)) |
| 31 | } |
| 32 | e := newEngine(d, index) |
| 33 | e.slide(d.Slides[index]) |
| 34 | return Paint{W: FrameW, H: FrameH, Background: e.bg, Ops: e.ops}, e.warnings, nil |
| 35 | } |
| 36 | |
| 37 | // engine is one Layout call: the palette it paints in, the faces it |
| 38 | // measures with, and what it has drawn and noticed so far. |
| 39 | type engine struct { |
| 40 | deck *deck.Deck |
| 41 | |
| 42 | bg, fg Fill |
| 43 | accent, accent2 Fill |
| 44 | highlight Fill |
| 45 | head, body, code fonts.Face |
| 46 | |
| 47 | // sc is the type and spacing scale this slide is drawn at, which |
| 48 | // depends on whether its section has padding (sizes.go). |
| 49 | sc scale |
| 50 | |
| 51 | ops []Op |
| 52 | warnings []Warning |
| 53 | |
| 54 | // slideID is the slide being drawn. Every warning this engine |
| 55 | // makes carries it (see warn), so a caller that laid out a whole |
| 56 | // deck can say which slide each line is about. |
| 57 | slideID string |
| 58 | |
| 59 | // block is the id warnings are attributed to while a block is |
| 60 | // being measured, and seen keeps a missing glyph from being |
| 61 | // reported once per line it appears on. |
| 62 | block string |
| 63 | seen map[rune]bool |
| 64 | } |
| 65 | |
| 66 | func newEngine(d *deck.Deck, index int) *engine { |
| 67 | // The same palette the section carries, overrides and contrast |
| 68 | // judgements included — render.SlideVars is the seam, so the paint |
| 69 | // list cannot form a second opinion about what colour a slide is. |
| 70 | vars, _ := render.SlideVars(d, index) |
| 71 | theme := render.ThemeID(d.Meta.Theme) |
| 72 | |
| 73 | e := &engine{ |
| 74 | deck: d, |
| 75 | slideID: d.Slides[index].ID, |
| 76 | bg: Fill(vars["--slides-bg"]), |
| 77 | fg: Fill(vars["--slides-fg"]), |
| 78 | code: fonts.Get(theme, fonts.Code, false, false), |
| 79 | seen: map[rune]bool{}, |
| 80 | } |
| 81 | e.accent = Fill(vars["--slides-accent"]) |
| 82 | e.accent2 = Fill(vars["--slides-accent-2"]) |
| 83 | e.highlight = mix(e.accent, e.bg, mixHighlight) |
| 84 | e.head = e.faceFor(theme, fonts.Heading, vars["--slides-font-heading"]) |
| 85 | e.body = e.faceFor(theme, fonts.Body, vars["--slides-font-body"]) |
| 86 | return e |
| 87 | } |
| 88 | |
| 89 | // faceFor is the face a stack names, or the theme's own when the deck |
| 90 | // named a family this binary does not carry (meta.fonts lets it name |
| 91 | // any at all). The substitution is reported rather than made quietly: |
| 92 | // it is the one case where the browser and this engine are knowingly |
| 93 | // setting a slide in two different faces, and an export nobody warned |
| 94 | // about is an export nobody checks. |
| 95 | func (e *engine) faceFor(theme string, role fonts.Role, stack string) fonts.Face { |
| 96 | if fam, ok := fonts.FamilyForStack(stack); ok { |
| 97 | return fonts.Face{Family: fam} |
| 98 | } |
| 99 | sub := fonts.Get(theme, role, false, false) |
| 100 | e.warn(Warning{Kind: WarnFont, Detail: fmt.Sprintf( |
| 101 | "this deck asks for %q, which is not a face this build carries: measured in %s instead", |
| 102 | strings.TrimSpace(strings.Split(stack, ",")[0]), sub.Family.CSSName())}) |
| 103 | return sub |
| 104 | } |
| 105 | |
| 106 | // warn records a warning, stamping the slide being drawn onto it so no |
| 107 | // call site has to remember to. A warning that already names a slide |
| 108 | // keeps the one it names. |
| 109 | func (e *engine) warn(w Warning) { |
| 110 | if w.Slide == "" { |
| 111 | w.Slide = e.slideID |
| 112 | } |
| 113 | e.warnings = append(e.warnings, w) |
| 114 | } |
| 115 | |
| 116 | // noteGlyphs reports runes no face could draw, once each per slide. |
| 117 | func (e *engine) noteGlyphs(rs []rune) { |
| 118 | var fresh []string |
| 119 | for _, r := range rs { |
| 120 | if e.seen[r] { |
| 121 | continue |
| 122 | } |
| 123 | e.seen[r] = true |
| 124 | fresh = append(fresh, fmt.Sprintf("%q", r)) |
| 125 | } |
| 126 | if len(fresh) == 0 { |
| 127 | return |
| 128 | } |
| 129 | e.warn(Warning{Kind: WarnGlyph, Block: e.block, Detail: fmt.Sprintf( |
| 130 | "no embedded face has a glyph for %s; drawn at the width of a box", |
| 131 | strings.Join(fresh, ", "))}) |
| 132 | } |
| 133 | |
| 134 | func (e *engine) draw(ops ...Op) { e.ops = append(e.ops, ops...) } |
| 135 | |
| 136 | // ── One slide ──────────────────────────────────────────────────────── |
| 137 | |
| 138 | // slide paints the background and then the blocks, in the order they |
| 139 | // are read: regions in reading order on a flow slide, z order on a |
| 140 | // canvas one. |
| 141 | func (e *engine) slide(s deck.Slide) { |
| 142 | // A picture behind the slide (§2.2) is an image op under |
| 143 | // everything else, covering the frame — `background-size: cover`. |
| 144 | if s.Background.Kind == "image" { |
| 145 | if id := s.Background.Value; id != "" { |
| 146 | if _, ok := e.deck.Assets[id]; ok { |
| 147 | e.draw(Image{X: 0, Y: 0, W: FrameW, H: FrameH, Asset: id, Fit: "cover"}) |
| 148 | } else { |
| 149 | e.warn(Warning{Kind: WarnAsset, Detail: fmt.Sprintf( |
| 150 | "this slide's background names asset %q, which this deck never registered", id)}) |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | tmpl := s.Template |
| 156 | if !slices.Contains(Templates(), tmpl) { |
| 157 | tmpl = "blank" |
| 158 | } |
| 159 | e.sc = scaleFor(s.Layout, tmpl) |
| 160 | |
| 161 | // A columns block's children are laid out inside it and nowhere |
| 162 | // else (§2.4) — render.body's rule, and it has to be the same rule |
| 163 | // or a child would be drawn twice. |
| 164 | byID := make(map[string]deck.Block, len(s.Blocks)) |
| 165 | claimed := map[string]bool{} |
| 166 | for _, b := range s.Blocks { |
| 167 | byID[b.ID] = b |
| 168 | } |
| 169 | for _, b := range s.Blocks { |
| 170 | if b.Type != "columns" { |
| 171 | continue |
| 172 | } |
| 173 | for _, id := range childIDs(b) { |
| 174 | if _, ok := byID[id]; ok { |
| 175 | claimed[id] = true |
| 176 | } |
| 177 | } |
| 178 | } |
| 179 | // done is shared across the whole slide, so a block is drawn at |
| 180 | // most once however many columns blocks name it. |
| 181 | done := map[string]bool{} |
| 182 | |
| 183 | // What no root reaches is drawn last and reported (codex 7): a |
| 184 | // block named as a child by a columns block that nothing arrives |
| 185 | // at — two peers' assignments merged into a cycle, or a document |
| 186 | // written past the ops, which refuse the shape. render.body does |
| 187 | // the same, so the page and the export agree on what is there. |
| 188 | late := unreached(s.Blocks, byID, claimed) |
| 189 | for _, b := range late { |
| 190 | e.warn(Warning{Kind: WarnUnreached, Block: b.ID, Detail: fmt.Sprintf( |
| 191 | "a %s block is named as a child by a columns block nothing reaches (a cycle); it is drawn after everything else", |
| 192 | b.Type)}) |
| 193 | } |
| 194 | |
| 195 | if s.Layout == "canvas" { |
| 196 | e.canvas(s, late, byID, claimed, done) |
| 197 | return |
| 198 | } |
| 199 | |
| 200 | // Flow: group into regions, canonical regions first, then any |
| 201 | // region name the core let through, in first-appearance order; the |
| 202 | // unreached blocks go to the end of the body, which is where the |
| 203 | // browser's unreached region auto-places on every template. |
| 204 | grouped := map[string][]deck.Block{} |
| 205 | var extra []string |
| 206 | for _, b := range s.Blocks { |
| 207 | if claimed[b.ID] { |
| 208 | continue |
| 209 | } |
| 210 | name := b.Region |
| 211 | switch { |
| 212 | case name == "": |
| 213 | name = "body" |
| 214 | case !slices.Contains(regionOrder, name) && !slices.Contains(extra, name): |
| 215 | extra = append(extra, name) |
| 216 | } |
| 217 | grouped[name] = append(grouped[name], b) |
| 218 | } |
| 219 | grouped["body"] = append(grouped["body"], late...) |
| 220 | // Measure every region, in reading order, then place them, then |
| 221 | // draw them in paint order. The measuring has to come first |
| 222 | // because two of the grids depend on what is in them: a |
| 223 | // `grid-template-rows: auto 1fr` heading row is as tall as the |
| 224 | // heading, and `title` centres its two rows as a pair. |
| 225 | var laid []*region |
| 226 | for _, name := range append(slices.Clone(regionOrder), extra...) { |
| 227 | if len(grouped[name]) == 0 { |
| 228 | continue |
| 229 | } |
| 230 | laid = append(laid, e.measureRegion(tmpl, name, grouped[name], byID, done)) |
| 231 | } |
| 232 | e.place(tmpl, laid) |
| 233 | for _, name := range append(paintOrder(tmpl), extra...) { |
| 234 | for _, rg := range laid { |
| 235 | if rg.name == name { |
| 236 | e.drawRegion(tmpl, rg) |
| 237 | } |
| 238 | } |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | // paintOrder is the order the regions are DRAWN in, which is reading |
| 243 | // order everywhere but one place: image-full puts the heading over the |
| 244 | // picture with a z-index, so the picture has to be painted first. A |
| 245 | // paint list has no z-index, only order, and getting this wrong is not |
| 246 | // subtle — the photograph covers the title. |
| 247 | func paintOrder(tmpl string) []string { |
| 248 | if tmpl == "image-full" { |
| 249 | return []string{"media", "body", "aside", "heading"} |
| 250 | } |
| 251 | return slices.Clone(regionOrder) |
| 252 | } |
| 253 | |
| 254 | // region is one measured region: where the table put it, what is in |
| 255 | // it, how tall that came out, and where the top of it ended up. |
| 256 | type region struct { |
| 257 | name string |
| 258 | box Region |
| 259 | plans []plan |
| 260 | total float64 |
| 261 | y float64 |
| 262 | first string // the id of the first block in it, for a warning |
| 263 | } |
| 264 | |
| 265 | // measure lays one region's blocks out at its width without deciding |
| 266 | // where the top of it goes. |
| 267 | func (e *engine) measureRegion(tmpl, name string, blocks []deck.Block, byID map[string]deck.Block, done map[string]bool) *region { |
| 268 | rg := ®ion{name: name, box: regionFor(tmpl, name), first: firstID(blocks)} |
| 269 | |
| 270 | // image-full's media region is the whole frame and the picture |
| 271 | // fills it (`block-size: 100%`); every other region is as tall as |
| 272 | // what is in it. |
| 273 | fill := 0.0 |
| 274 | if tmpl == "image-full" && name == "media" { |
| 275 | fill = rg.box.H |
| 276 | } |
| 277 | |
| 278 | for _, b := range blocks { |
| 279 | if done[b.ID] { |
| 280 | continue |
| 281 | } |
| 282 | done[b.ID] = true |
| 283 | p := e.plan(b, slot{w: rg.box.W, h: fill, align: rg.box.AlignX}, byID, done) |
| 284 | if p.h == 0 && p.paint == nil { |
| 285 | continue |
| 286 | } |
| 287 | rg.plans = append(rg.plans, p) |
| 288 | } |
| 289 | // The accent bar under a section's heading: slides.css draws it |
| 290 | // with a ::after on the region, so it is the region's and not any |
| 291 | // block's, and it is part of what gets centred with it. |
| 292 | if tmpl == "section" && name == "heading" && len(rg.plans) > 0 { |
| 293 | rg.plans = append(rg.plans, e.sectionRule()) |
| 294 | } |
| 295 | rg.total = e.shrink(rg.plans, rg.box.H) |
| 296 | return rg |
| 297 | } |
| 298 | |
| 299 | // place decides where each region's content starts, and is where the |
| 300 | // two grids that depend on their own contents are handled. |
| 301 | // |
| 302 | // - `title` centres its two auto rows as a PAIR (`align-content: |
| 303 | // center` over `auto auto`), so the heading and the body straddle |
| 304 | // the middle with one gap between them, whatever height each is. |
| 305 | // - `title-body` and `two-column` have `grid-template-rows: auto 1fr`, |
| 306 | // so a heading that runs to two lines pushes the row below it down |
| 307 | // instead of overlapping it. The table's fixed heading row is the |
| 308 | // one-line case; this is every other case. |
| 309 | func (e *engine) place(tmpl string, laid []*region) { |
| 310 | for _, rg := range laid { |
| 311 | rg.y = rg.box.Y |
| 312 | switch rg.box.AlignY { |
| 313 | case Center: |
| 314 | rg.y += (rg.box.H - rg.total) / 2 |
| 315 | case End: |
| 316 | rg.y = rg.box.Bottom() - rg.total |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | byName := map[string]*region{} |
| 321 | for _, rg := range laid { |
| 322 | byName[rg.name] = rg |
| 323 | } |
| 324 | head := byName["heading"] |
| 325 | |
| 326 | switch tmpl { |
| 327 | case "title": |
| 328 | body := byName["body"] |
| 329 | if head == nil || body == nil { |
| 330 | return |
| 331 | } |
| 332 | top := contentY + (contentH-(head.total+sectionGap+body.total))/2 |
| 333 | head.y = top |
| 334 | body.y = top + head.total + sectionGap |
| 335 | case "title-body", "two-column": |
| 336 | if head == nil { |
| 337 | return |
| 338 | } |
| 339 | below := head.y + head.total + sectionGap |
| 340 | for _, name := range []string{"body", "aside"} { |
| 341 | rg := byName[name] |
| 342 | if rg == nil || below <= rg.box.Y { |
| 343 | continue |
| 344 | } |
| 345 | // The row below grew downwards, so it starts lower and has |
| 346 | // less room; a Start-aligned region simply moves. A |
| 347 | // heading long enough to leave it no room at all leaves it |
| 348 | // none — not a negative amount, which would read as |
| 349 | // nonsense in the overflow warning. |
| 350 | rg.box.H = max(0, rg.box.H-(below-rg.box.Y)) |
| 351 | rg.box.Y = below |
| 352 | rg.y = below |
| 353 | } |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | // drawRegion puts one measured region's ops in the paint list. |
| 358 | func (e *engine) drawRegion(tmpl string, rg *region) { |
| 359 | if len(rg.plans) == 0 { |
| 360 | return |
| 361 | } |
| 362 | // image-full's heading sits on a scrim, so the words survive a |
| 363 | // light picture. Drawn from the measured content rather than from |
| 364 | // the region box, because the stylesheet sizes that box to fit |
| 365 | // (`justify-self: start` inside `max-inline-size: 80%`). |
| 366 | if tmpl == "image-full" && rg.name == "heading" { |
| 367 | e.draw(e.scrim(rg.plans, rg.box, rg.y, rg.total)) |
| 368 | } |
| 369 | |
| 370 | top := rg.y |
| 371 | for i, p := range rg.plans { |
| 372 | if i > 0 { |
| 373 | top += e.sc.gap() |
| 374 | } |
| 375 | // A picture or a shape squeezed to nothing draws nothing — |
| 376 | // which is what the browser shows too, and a zero-sized op in |
| 377 | // an export is noise nobody can act on. The overflow warning |
| 378 | // below is what says the region was over-full. |
| 379 | if p.paint != nil && !(p.flex && p.h <= 0) { |
| 380 | e.draw(p.paint(rg.box.X, top, rg.box.W, p.h)...) |
| 381 | } |
| 382 | top += p.h |
| 383 | } |
| 384 | if rg.total > rg.box.H+0.001 { |
| 385 | e.warn(Warning{Kind: WarnOverflow, Block: rg.first, Detail: fmt.Sprintf( |
| 386 | "the %s region of a %s slide holds %.0fpx of content in %.0fpx; it is drawn past the box rather than cut", |
| 387 | rg.name, tmpl, rg.total, rg.box.H)}) |
| 388 | } |
| 389 | } |
| 390 | |
| 391 | // shrink is the one piece of flexbox this engine implements, and it is |
| 392 | // here because leaving it out is visible from across a room. |
| 393 | // |
| 394 | // A region is a column flex container with a definite height. Text |
| 395 | // blocks cannot shrink — their minimum content size is the wrapped |
| 396 | // text — but a picture and a shape can, and they do: an image block in |
| 397 | // a body region is `display: flex` over an `<img>` at 100%, so its |
| 398 | // hypothetical size is the picture's own aspect ratio at the column |
| 399 | // width (1680 tall for a square picture in a 1680 column), and the |
| 400 | // browser shrinks it to whatever the region has left. Without this a |
| 401 | // slide with one heading and one photograph puts the photograph three |
| 402 | // slides below the bottom of the frame. |
| 403 | // |
| 404 | // The CSS algorithm with equal shrink factors: distribute the excess |
| 405 | // in proportion to each flexible item's base size, freeze anything |
| 406 | // that hits its floor, and go round again. It returns the height the |
| 407 | // region's contents ended up as, gaps included. |
| 408 | func (e *engine) shrink(plans []plan, avail float64) float64 { |
| 409 | total := e.sc.gap() * float64(len(plans)-1) |
| 410 | var flexible []int |
| 411 | for i, p := range plans { |
| 412 | total += p.h |
| 413 | if p.flex { |
| 414 | flexible = append(flexible, i) |
| 415 | } |
| 416 | } |
| 417 | for total > avail+0.001 && len(flexible) > 0 { |
| 418 | excess := total - avail |
| 419 | var base float64 |
| 420 | for _, i := range flexible { |
| 421 | base += plans[i].h |
| 422 | } |
| 423 | if base <= 0 { |
| 424 | break |
| 425 | } |
| 426 | var frozen []int |
| 427 | var shrunk float64 |
| 428 | for _, i := range flexible { |
| 429 | want := plans[i].h - excess*plans[i].h/base |
| 430 | if want <= plans[i].minH { |
| 431 | want = plans[i].minH |
| 432 | frozen = append(frozen, i) |
| 433 | } |
| 434 | shrunk += plans[i].h - want |
| 435 | plans[i].h = want |
| 436 | } |
| 437 | total -= shrunk |
| 438 | if len(frozen) == 0 { |
| 439 | break |
| 440 | } |
| 441 | // Drop the frozen ones and, if the excess is not used up, |
| 442 | // take it out of the rest. |
| 443 | var rest []int |
| 444 | for _, i := range flexible { |
| 445 | if !slices.Contains(frozen, i) { |
| 446 | rest = append(rest, i) |
| 447 | } |
| 448 | } |
| 449 | flexible = rest |
| 450 | } |
| 451 | return total |
| 452 | } |
| 453 | |
| 454 | // scrim is the panel behind image-full's heading: the region's own |
| 455 | // background, its padding, and its corner radius. |
| 456 | // |
| 457 | // The stylesheet mixes it 82% towards transparent over the picture. |
| 458 | // This draws it opaque, because a paint list has no compositing — an |
| 459 | // SVG and a PDF would blend it differently, and the scrim exists to |
| 460 | // make words readable over an unknown photograph, which an opaque |
| 461 | // panel does and a wrongly-composited one might not. |
| 462 | func (e *engine) scrim(plans []plan, r Region, y, total float64) Rect { |
| 463 | w := 0.0 |
| 464 | for _, p := range plans { |
| 465 | w = max(w, p.w) |
| 466 | } |
| 467 | w = min(w+2*scrimPadX, scrimW) |
| 468 | return Rect{ |
| 469 | X: r.X - scrimPadX, Y: y - scrimPadY, |
| 470 | W: w, H: total + 2*scrimPadY, |
| 471 | Fill: e.bg, Radius: 1 * e.sc.cqw, |
| 472 | } |
| 473 | } |
| 474 | |
| 475 | // sectionRule is the accent bar under a section slide's heading — |
| 476 | // `[data-template="section"] [data-region="heading"]::after`, 18cqw of |
| 477 | // the accent colour, and the only decoration in the whole stylesheet. |
| 478 | func (e *engine) sectionRule() plan { |
| 479 | w := 18 * e.sc.cqw |
| 480 | rule := e.sc.rule() |
| 481 | return plan{h: rule, w: w, paint: func(x, y, colW, _ float64) []Op { |
| 482 | return []Op{Rect{X: x + (colW-w)/2, Y: y, W: w, H: rule, Fill: e.accent, Radius: rule / 2}} |
| 483 | }} |
| 484 | } |
| 485 | |
| 486 | // canvas draws blocks where they were put (§2.3): each box is a |
| 487 | // fraction of the frame, and z is paint order. late is what nothing |
| 488 | // reaches (see slide), drawn after the rest at its own box. |
| 489 | func (e *engine) canvas(s deck.Slide, late []deck.Block, byID map[string]deck.Block, claimed, done map[string]bool) { |
| 490 | blocks := slices.Clone(s.Blocks) |
| 491 | // Stable, so two blocks at the same z keep the order the array |
| 492 | // had — the same tie-break render's reflow order takes. |
| 493 | slices.SortStableFunc(blocks, func(a, b deck.Block) int { return a.Z - b.Z }) |
| 494 | roots := make([]deck.Block, 0, len(blocks)+len(late)) |
| 495 | for _, b := range blocks { |
| 496 | if !claimed[b.ID] { |
| 497 | roots = append(roots, b) |
| 498 | } |
| 499 | } |
| 500 | roots = append(roots, late...) |
| 501 | |
| 502 | for _, b := range roots { |
| 503 | if done[b.ID] { |
| 504 | continue |
| 505 | } |
| 506 | done[b.ID] = true |
| 507 | box := canvasBox(b.Box) |
| 508 | p := e.plan(b, slot{w: box.W, h: box.H, align: Start}, byID, done) |
| 509 | if p.paint != nil { |
| 510 | e.draw(p.paint(box.X, box.Y, box.W, p.h)...) |
| 511 | } |
| 512 | if p.h > box.H { |
| 513 | e.warn(Warning{Kind: WarnOverflow, Block: b.ID, Detail: fmt.Sprintf( |
| 514 | "a %s block holds %.0fpx of content in a %.0fpx box; it is drawn past the box rather than cut", |
| 515 | b.Type, p.h, box.H)}) |
| 516 | } |
| 517 | } |
| 518 | } |
| 519 | |
| 520 | // canvasBox turns §2.3's fractions into frame coordinates. A block |
| 521 | // with no box at all — which the core does not produce and a peer |
| 522 | // mid-edit might — gets the content box, which is where a browser's |
| 523 | // static position would put it. |
| 524 | func canvasBox(b *deck.Box) Box { |
| 525 | if b == nil { |
| 526 | return Box{contentX, contentY, contentW, contentH} |
| 527 | } |
| 528 | return Box{X: b.X * FrameW, Y: b.Y * FrameH, W: b.W * FrameW, H: b.H * FrameH} |
| 529 | } |
| 530 | |
| 531 | // unreached is every block no traversal from the slide's roots — the |
| 532 | // blocks no columns block claims — arrives at: the members of a cycle |
| 533 | // of columns blocks and whatever hangs under them. Array order. The |
| 534 | // same walk as render's, because the two have to agree on what is on |
| 535 | // the slide. |
| 536 | func unreached(blocks []deck.Block, byID map[string]deck.Block, claimed map[string]bool) []deck.Block { |
| 537 | reached := map[string]bool{} |
| 538 | var visit func(b deck.Block) |
| 539 | visit = func(b deck.Block) { |
| 540 | if reached[b.ID] { |
| 541 | return |
| 542 | } |
| 543 | reached[b.ID] = true |
| 544 | if b.Type != "columns" { |
| 545 | return |
| 546 | } |
| 547 | for _, id := range childIDs(b) { |
| 548 | if child, ok := byID[id]; ok { |
| 549 | visit(child) |
| 550 | } |
| 551 | } |
| 552 | } |
| 553 | for _, b := range blocks { |
| 554 | if !claimed[b.ID] { |
| 555 | visit(b) |
| 556 | } |
| 557 | } |
| 558 | var out []deck.Block |
| 559 | for _, b := range blocks { |
| 560 | if !reached[b.ID] { |
| 561 | out = append(out, b) |
| 562 | } |
| 563 | } |
| 564 | return out |
| 565 | } |
| 566 | |
| 567 | func firstID(blocks []deck.Block) string { |
| 568 | if len(blocks) == 0 { |
| 569 | return "" |
| 570 | } |
| 571 | return blocks[0].ID |
| 572 | } |
| 573 | |