wani 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +262 -0
  4. data/assets/bootstrap.rb +9 -0
  5. data/assets/build.json +6 -0
  6. data/assets/fonts/IBMPlexSansJP-Bold.woff2 +0 -0
  7. data/assets/fonts/IBMPlexSansJP-Regular.woff2 +0 -0
  8. data/assets/highlight.css +117 -0
  9. data/assets/index.html +39 -0
  10. data/assets/rabbit.svg +1 -0
  11. data/assets/rouge.LICENSE +187 -0
  12. data/assets/runtime.LICENSE +21 -0
  13. data/assets/runtime.NOTICE +1292 -0
  14. data/assets/runtime.pr672.js +3143 -0
  15. data/assets/shark.svg +1 -0
  16. data/assets/speaker.html +2 -0
  17. data/assets/style.css +103 -0
  18. data/assets/time-expired.png +0 -0
  19. data/assets/wani.wasm +4 -0
  20. data/exe/wani +3 -0
  21. data/lib/wani/browser/application.rb +189 -0
  22. data/lib/wani/browser/bridge_progress.rb +19 -0
  23. data/lib/wani/browser/fonts.rb +60 -0
  24. data/lib/wani/browser/layout.rb +107 -0
  25. data/lib/wani/browser/presentation_timer.rb +66 -0
  26. data/lib/wani/browser/renderer.rb +80 -0
  27. data/lib/wani/cli.rb +134 -0
  28. data/lib/wani/core/fonts.rb +49 -0
  29. data/lib/wani/core/parser.rb +127 -0
  30. data/lib/wani/core/presentation.rb +15 -0
  31. data/lib/wani/core/safe_html.rb +74 -0
  32. data/lib/wani/core/validator.rb +31 -0
  33. data/lib/wani/fonts/http.rb +45 -0
  34. data/lib/wani/fonts/manager.rb +190 -0
  35. data/lib/wani/server/application.rb +92 -0
  36. data/lib/wani/server/http_server.rb +34 -0
  37. data/lib/wani/version.rb +3 -0
  38. data/lib/wani.rb +4 -0
  39. data/templates/images/workflow.svg +36 -0
  40. data/templates/slides.md +182 -0
  41. data/third_party/gems/kramdown-LICENSE +30 -0
  42. data/third_party/gems/kramdown-parser-gfm-LICENSE +21 -0
  43. data/third_party/gems/rexml-LICENSE +22 -0
  44. data/third_party/ibm-plex-sans-jp/LICENSE.txt +93 -0
  45. data/third_party/ibm-plex-sans-jp/README.md +39 -0
  46. data/third_party/ibm-plex-sans-jp/provenance.json +20 -0
  47. data/third_party/ruby-wasm-loader/LICENSE +21 -0
  48. data/third_party/ruby-wasm-loader/NOTICE +1292 -0
  49. data/third_party/ruby-wasm-loader/README.md +35 -0
  50. data/third_party/ruby-wasm-loader/browser.script.iife.js +3143 -0
  51. data/third_party/ruby-wasm-loader/provenance.json +10 -0
  52. metadata +159 -0
data/assets/shark.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="100" height="48" viewBox="0 0 100 48"><g transform="translate(100 0) scale(-1 1)"><path d="M9 27 0 13v28l13-11q31 18 76-4-22-17-44-15L35 0l-7 17z" fill="#71887e"/><path d="m42 31 7 14 10-17" fill="#71887e"/><circle cx="74" cy="23" r="2" fill="#fff"/></g></svg>
@@ -0,0 +1,2 @@
1
+ <!doctype html><html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Wani — 発表者ノート</title><link rel="stylesheet" href="/assets/style.css"><link rel="stylesheet" href="/assets/highlight.css"></head>
2
+ <body class="speaker-view"><header class="speaker-heading">Wani · 発表者ノート <output id="position"></output></header><main class="speaker-columns"><article><h1 id="current"></h1><div id="body"></div><p id="page-footnote"></p></article><aside><h2>トークノート</h2><div id="notes"></div></aside></main><p class="speaker-help">メイン画面のページ移動に同期します。</p></body></html>
data/assets/style.css ADDED
@@ -0,0 +1,103 @@
1
+ @font-face {
2
+ font-family: "IBM Plex Sans JP";
3
+ src: url("/assets/fonts/IBMPlexSansJP-Regular.woff2") format("woff2");
4
+ font-weight: 400;
5
+ font-style: normal;
6
+ font-display: block;
7
+ }
8
+ @font-face {
9
+ font-family: "IBM Plex Sans JP";
10
+ src: url("/assets/fonts/IBMPlexSansJP-Bold.woff2") format("woff2");
11
+ font-weight: 700;
12
+ font-style: normal;
13
+ font-display: block;
14
+ }
15
+ :root { font-family: "IBM Plex Sans JP", sans-serif; color: #20352e; background: #e9eee9; }
16
+ * { box-sizing: border-box; }
17
+ body { margin: 0; }
18
+ .presentation-view { height: 100dvh; display: flex; flex-direction: column; }
19
+ .presentation-view > * { flex-shrink: 0; }
20
+ .toolbar { min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 24px; border-bottom: 1px solid #d4ddd5; background: #f8faf7; }
21
+ .brand { font-family: Georgia, serif; font-size: 28px; font-weight: bold; text-decoration: none; color: #294d3e; }
22
+ .brand span { margin-left: 14px; font: 11px system-ui, sans-serif; color: #75867c; letter-spacing: .06em; }
23
+ nav { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
24
+ button { color: #294d3e; border: 1px solid #ccd8cc; background: #fff; border-radius: 6px; padding: 8px 12px; cursor: pointer; font-family: inherit; font-size: 13px; }
25
+ button:hover { background: #edf3eb; }
26
+ button:disabled { cursor: default; opacity: .4; }
27
+ button:focus-visible, a:focus-visible { outline: 2px solid #557e64; outline-offset: 3px; }
28
+ #position { min-width: 66px; text-align: center; font-variant-numeric: tabular-nums; font-size: 13px; }
29
+ .toolbar-divider { border-left: 1px solid #ccd8cc; height: 20px; margin: 0 7px; }
30
+ #status { font-size: 12px; margin: 0; padding: 8px 24px; color: #607569; min-height: 30px; }
31
+ #viewport { width: 100%; flex: 1 1 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: hidden; padding: 20px; }
32
+ #stage { flex: 0 0 1920px; transform-origin: center; box-shadow: 0 8px 48px #20352e18; }
33
+ .slide { width: 1920px; height: 1080px; padding: 64px 80px 48px; display: flex; flex-direction: column; overflow: hidden; background: white; color: #172b24; }
34
+ .title { margin: 0 0 36px; white-space: nowrap; flex: 0 0 160px; font-weight: 700; line-height: 1.12; overflow: hidden; letter-spacing: -.025em; }
35
+ .body { flex: 1 1 0; min-height: 0; overflow: hidden; line-height: 1.3; }
36
+ .body p { margin: 0 0 .35em; }
37
+ .body ul, .body ol { margin: .2em 0 .4em; padding-left: 1.35em; }
38
+ .body pre { margin: .3em 0; padding: .4em .55em; background: #f3f6f2; white-space: pre-wrap; tab-size: 2; }
39
+ .body code { font-family: ui-monospace, "IBM Plex Sans JP", monospace; font-size: .8em; }
40
+ .body blockquote { border-left: .08em solid #b6cbb9; margin: .4em 0; padding-left: .6em; }
41
+ .body table { border-collapse: collapse; width: 100%; }
42
+ .body th, .body td { border: 1px solid #bac8bd; padding: .15em .3em; }
43
+ .body img { display: block; object-fit: contain; max-width: 100%; }
44
+ .body a { color: #32634b; }
45
+ .slide-footer { flex: 0 0 128px; position: relative; display: flex; align-items: start; font-size: 24px; color: #71887e; padding-top: 12px; }
46
+ .decorations { position: absolute; left: 0; right: 132px; bottom: 0; height: 80px; }
47
+ .slide-number { position: absolute; right: 0; bottom: 8px; width: 112px; text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
48
+ .decorations::after { content: ""; position: absolute; left: 0; right: 0; bottom: 8px; border-top: 2px solid #71887e25; }
49
+ .bridge { position: absolute; left: 84px; right: 84px; bottom: 0; height: 44px; display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); }
50
+ .bridge-segment { display: block; }
51
+ .bridge-segment[data-present="false"] { visibility: hidden; }
52
+ .bridge-wani { display: block; width: 100%; height: 44px; object-fit: contain; }
53
+ .rabbit-position { position: absolute; bottom: 30px; width: 84px; height: 40px; left: calc((100% - 84px) * var(--rabbit-progress)); }
54
+ .rabbit-image { display: block; width: 84px; height: 40px; }
55
+ .slide.active .bridge-segment[data-appearing="true"] { animation: wani-arrive .35s ease-out; }
56
+ .slide.active .decorations[data-moving="true"] .rabbit-position { animation: rabbit-advance .45s ease-in-out; }
57
+ .slide.active .decorations[data-moving="true"] .rabbit-image { animation: rabbit-hop .45s ease-in-out; }
58
+ @keyframes wani-arrive { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
59
+ @keyframes rabbit-advance { from { left: calc((100% - 84px) * var(--rabbit-from)); } to { left: calc((100% - 84px) * var(--rabbit-progress)); } }
60
+ @keyframes rabbit-hop { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-12px); } }
61
+ @media (prefers-reduced-motion: reduce) { .decorations * { animation: none !important; } }
62
+ #stage .slide:not(.active) { display: none; }
63
+ .help { padding: 8px 24px; font-size: 11px; color: #718578; display: flex; gap: 22px; }
64
+ #warnings { margin: 8px 24px; padding: 12px; border-left: 3px solid #ac792c; background: #fff6e7; color: #6c4818; font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 130px; overflow: auto; }
65
+ #print-pages { display: none; }
66
+ :fullscreen .toolbar, :fullscreen .help, :fullscreen #status { display: none; }
67
+ :fullscreen #viewport { padding: 0; }
68
+ :fullscreen { background: #182a22; }
69
+ .speaker-heading { display: flex; justify-content: space-between; padding: 24px; font-weight: bold; }
70
+ .speaker-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; padding: 0 24px; }
71
+ .speaker-columns > * { background: white; padding: 28px; border-radius: 8px; min-width: 0; }
72
+ .speaker-columns img { max-width: 100%; max-height: 35vh; object-fit: contain; }
73
+ .speaker-help { margin: 24px; color: #718578; font-size: 12px; }
74
+ @media (max-width: 700px) { .brand span, .toolbar-divider { display: none; } .toolbar { padding: 8px; } .help { gap: 8px; } .speaker-columns { grid-template-columns: 1fr; } }
75
+ @page { size: 320mm 180mm; margin: 0; }
76
+ @media print {
77
+ .decorations { display: none !important; }
78
+ body { margin: 0; background: white; }
79
+ .presentation-view { display: block; height: auto; }
80
+ body > :not(#print-pages) { display: none !important; }
81
+ #print-pages { display: block; }
82
+ .print-sheet { width: 320mm; height: 180mm; overflow: hidden; break-after: page; position: relative; }
83
+ .print-sheet:last-child { break-after: auto; }
84
+ .print-sheet .slide { position: absolute; left: 0; top: 0; zoom: .6299212598; transform: none; break-inside: avoid; }
85
+ }
86
+
87
+ #countdown { position: fixed; top: 18px; right: 24px; z-index: 20; padding: 12px 20px; border: 2px solid #9c452c; border-radius: 8px; background: #fff6e7; color: #84341f; font-size: clamp(24px, 4vw, 48px); font-weight: 700; font-variant-numeric: tabular-nums; pointer-events: none; }
88
+ #time-expired { position: fixed; inset: 0; z-index: 30; display: flex; flex-direction: column; align-items: center; justify-content: center; background: white; color: #20352e; padding: 16px; overflow: auto; }
89
+ #time-expired[hidden], #countdown[hidden] { display: none; }
90
+ #time-expired h1 { margin: 0; font-size: clamp(24px, 4vw, 56px); }
91
+ .time-expired-image { width: min(90vw, calc(65vh * 1024 / 683)); aspect-ratio: 1024 / 683; flex-shrink: 0; background: url('/assets/time-expired.png') right center / 200% 100% no-repeat; }
92
+ #time-expired p { margin: 8px 0; text-align: center; }
93
+ @media print { #countdown, #time-expired { display: none !important; } }
94
+
95
+ #countdown.urgent:not([hidden]) { inset: 0; display: flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 0; background: transparent; color: #b32d1e; line-height: 1; }
96
+ #countdown.urgent span { display: block; font-size: clamp(24px, 4vw, 48px); text-shadow: 0 0 12px white, 0 0 3px white; animation: countdown-shrink .8s ease-out both; }
97
+ @keyframes countdown-shrink { from { font-size: var(--countdown-peak); } to { font-size: clamp(24px, 4vw, 48px); } }
98
+ @media (prefers-reduced-motion: reduce) { #countdown.urgent span { animation: none; } }
99
+
100
+ .island { position: absolute; bottom: 8px; width: 84px; height: 26px; background: #dfcea6; border-top: 7px solid #71887e; border-bottom: 4px solid #c4b18a; border-radius: 50% 50% 35% 35%; z-index: 1; }
101
+ .island-left { left: 0; }
102
+ .island-right { right: 0; }
103
+ .rabbit-position { z-index: 2; }
Binary file
data/assets/wani.wasm ADDED
@@ -0,0 +1,4 @@
1
+ [diffend] Oversized file quarantined before diffing.
2
+ name: data/assets/wani.wasm
3
+ size: 35166158 bytes
4
+ sha256: bfb964e6d30c8045bcf2e742dde56f046b1968f9465457130c008e9cdb037600
data/exe/wani ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env ruby
2
+ require "wani/cli"
3
+ exit Wani::CLI.new.run(ARGV)
@@ -0,0 +1,189 @@
1
+ require "js"
2
+ require "json"
3
+ require_relative "../core/parser"
4
+ require_relative "layout"
5
+ require_relative "renderer"
6
+ require_relative "fonts"
7
+ require_relative "presentation_timer"
8
+
9
+ module Wani
10
+ module Browser
11
+ class Application
12
+ def start
13
+ @document = JS.global[:document]
14
+ @index = 0
15
+ @warnings = []
16
+ @layout = Layout.new(@document) { |message| warn(message) }
17
+ response = JS.global.fetch("/talk/slides.md").await
18
+ raise "Markdown を読み込めません (HTTP #{response[:status].to_i})" unless response[:ok] == JS::True
19
+ @presentation = Core::Parser.new.parse(response.text.await.to_s)
20
+ @presentation.diagnostics.each { |item| warn("#{item.slide ? "スライド #{item.slide}: " : ""}#{item.message}") }
21
+ @renderer = Renderer.new(@document, @presentation)
22
+ @sections = @presentation.slides.map { |slide| @renderer.slide(slide) }
23
+ @sections.each { |section| element("stage").appendChild(section) }
24
+ font_sections = @sections + @presentation.slides.map { |slide| @renderer.slide(slide, notes: true) }
25
+ Fonts.new(@document) { |message| warn(message) }.apply(@presentation.fonts, font_sections)
26
+ @layout.wait_assets(element("stage"))
27
+ @sections.each_with_index { |section, index| @layout.fit_slide(section, index + 1) }
28
+ prepare_pages(false)
29
+ bind_events
30
+ go(hash_index)
31
+ %w[previous next fullscreen speaker print print-notes].each { |id| element(id)[:disabled] = false }
32
+ update_buttons
33
+ start_timer
34
+ element("status")[:textContent] = "準備できました"
35
+ end
36
+
37
+ private
38
+
39
+ def element(id)
40
+ @document.getElementById(id)
41
+ end
42
+
43
+ def warn(message)
44
+ @warnings << message unless @warnings.include?(message)
45
+ element("warnings")[:hidden] = false
46
+ element("warnings")[:textContent] = @warnings.join("\n")
47
+ end
48
+
49
+ def async(&block)
50
+ Fiber.new do
51
+ block.call
52
+ rescue => error
53
+ warn(error.message)
54
+ end.transfer
55
+ end
56
+
57
+ def bind_events
58
+ element("previous").addEventListener("click") { go(@index - 1) }
59
+ element("next").addEventListener("click") { go(@index + 1) }
60
+ element("fullscreen").addEventListener("click") { fullscreen }
61
+ element("speaker").addEventListener("click") { speaker }
62
+ element("print").addEventListener("click") { async { print_pages(false) } }
63
+ element("print-notes").addEventListener("click") { async { print_pages(true) } }
64
+ @document.addEventListener("keydown") do |event|
65
+ target = event[:target]
66
+ next if event[:ctrlKey] == JS::True || event[:altKey] == JS::True || event[:metaKey] == JS::True
67
+ next if %w[INPUT TEXTAREA SELECT].include?(target[:tagName].to_s) || target[:isContentEditable] == JS::True
68
+ handled = true
69
+ case event[:key].to_s
70
+ when "ArrowRight", " ", "PageDown" then go(@index + 1)
71
+ when "ArrowLeft", "PageUp" then go(@index - 1)
72
+ when "Home" then go(0)
73
+ when "End" then go(@sections.length - 1)
74
+ when "f", "F" then fullscreen
75
+ when "Escape" then exit_fullscreen
76
+ when "n", "N" then speaker
77
+ else handled = false
78
+ end
79
+ event.preventDefault if handled
80
+ end
81
+ JS.global.addEventListener("resize") { @layout.scale }
82
+ JS.global.addEventListener("hashchange") { go(hash_index) }
83
+ @document.addEventListener("fullscreenchange") { @layout.scale }
84
+ end
85
+
86
+ def hash_index
87
+ match = JS.global[:location][:hash].to_s.match(/\A#slide=(\d+)\z/)
88
+ match ? match[1].to_i - 1 : 0
89
+ end
90
+
91
+ def go(index)
92
+ if @time_expired
93
+ @time_expired = false
94
+ element("time-expired")[:hidden] = true
95
+ element("viewport")[:inert] = false
96
+ index = @index
97
+ end
98
+ previous_number = @active_number || 0
99
+ @index = index.clamp(0, @sections.length - 1)
100
+ @renderer.transition(@sections[@index], previous_number)
101
+ @sections.each_with_index { |section, number| section[:classList].toggle("active", number == @index) }
102
+ @active_number = @index + 1
103
+ element("position")[:textContent] = "#{@index + 1} / #{@sections.length}"
104
+ desired = "#slide=#{@index + 1}"
105
+ JS.global[:history].replaceState(JS::Null, "", desired) unless JS.global[:location][:hash].to_s == desired
106
+ update_buttons
107
+ @layout.scale
108
+ sync_speaker
109
+ update_timer_for_slide
110
+ end
111
+
112
+ def update_buttons
113
+ element("previous")[:disabled] = @index.zero? && !@time_expired
114
+ element("next")[:disabled] = @index == @sections.length - 1 && !@time_expired
115
+ end
116
+
117
+ def fullscreen
118
+ return exit_fullscreen unless @document[:fullscreenElement] == JS::Null
119
+ @document[:documentElement].requestFullscreen.then(->(_value) { nil }, ->(_error) { warn("全画面表示が許可されませんでした。F またはボタンから再試行してください。"); nil })
120
+ end
121
+
122
+ def exit_fullscreen
123
+ return if @document[:fullscreenElement] == JS::Null
124
+ @document.exitFullscreen.then(->(_value) { nil }, ->(_error) { warn("全画面表示を終了できませんでした"); nil })
125
+ end
126
+
127
+ def speaker
128
+ if @speaker && @speaker[:closed] == JS::False
129
+ @speaker.focus
130
+ sync_speaker
131
+ return
132
+ end
133
+ @speaker = JS.global.open("/assets/speaker.html", "wani-speaker")
134
+ if @speaker == JS::Null
135
+ warn("ノート画面を開けません。ポップアップを許可して再試行してください。")
136
+ return
137
+ end
138
+ @speaker.addEventListener("load") do |_event|
139
+ @speaker.addEventListener("message") do |event|
140
+ next unless event[:origin].to_s == JS.global[:location][:origin].to_s && event[:source] == JS.global
141
+ state = JSON.parse(event[:data].to_s)
142
+ document = @speaker[:document]
143
+ document.getElementById("position")[:textContent] = state.fetch("position")
144
+ document.getElementById("current")[:textContent] = state.fetch("title")
145
+ document.getElementById("body")[:innerHTML] = state.fetch("html")
146
+ document.getElementById("notes")[:innerHTML] = state.fetch("notes_html")
147
+ document.getElementById("page-footnote")[:textContent] = state.fetch("page_footnote")
148
+ @renderer.resolve_links(document.getElementById("body"))
149
+ @renderer.resolve_links(document.getElementById("notes"))
150
+ end
151
+ sync_speaker
152
+ end
153
+ end
154
+
155
+ def sync_speaker
156
+ return unless @speaker && @speaker[:closed] == JS::False
157
+ slide = @presentation.slides[@index]
158
+ @speaker.postMessage(JSON.generate({ position: "#{@index + 1} / #{@sections.length}", title: slide.title, html: slide.html, notes_html: slide.notes_html, page_footnote: slide.page_footnote }), JS.global[:location][:origin])
159
+ end
160
+
161
+ def prepare_pages(notes)
162
+ pages = element("print-pages")
163
+ pages.replaceChildren
164
+ @presentation.slides.each_with_index do |slide, index|
165
+ [false, *(notes ? [true] : [])].each do |is_note|
166
+ section = is_note ? @renderer.slide(slide, notes: true) : @sections[index].cloneNode(true)
167
+ if is_note
168
+ element("stage").appendChild(section)
169
+ @layout.wait_assets(section)
170
+ @layout.fit_slide(section, slide.number)
171
+ end
172
+ sheet = @document.createElement("div")
173
+ sheet[:className] = "print-sheet"
174
+ sheet.appendChild(section)
175
+ pages.appendChild(sheet)
176
+ end
177
+ end
178
+ @layout.wait_assets(pages)
179
+ pages.setAttribute("aria-hidden", "false")
180
+ element("status")[:textContent] = "印刷準備完了 (#{pages[:childElementCount].to_i} ページ)"
181
+ end
182
+
183
+ def print_pages(notes)
184
+ prepare_pages(notes)
185
+ JS.global.print
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,19 @@
1
+ module Wani
2
+ module Browser
3
+ # Page progress builds a bridge in the first half and crosses it in the second.
4
+ class BridgeProgress
5
+ SEGMENTS = 20
6
+ attr_reader :sharks, :rabbit_fraction
7
+
8
+ def initialize(number, total)
9
+ raise ArgumentError, "Slide count must be positive" unless total.positive?
10
+ number = number.clamp(0, total)
11
+ # Anchor the first page at one wani, reaching the full bridge halfway.
12
+ # A two-page deck completes the bridge on page two; a single page stays at one.
13
+ steps = [(total + 1) / 2, 2].max - 1
14
+ @sharks = number.zero? ? 0 : [1 + ((SEGMENTS - 1) * (number - 1) + steps - 1) / steps, SEGMENTS].min
15
+ @rabbit_fraction = (2.0 * number / total - 1).clamp(0, 1)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,60 @@
1
+ require "json"
2
+ require_relative "../core/fonts"
3
+
4
+ module Wani
5
+ module Browser
6
+ class Fonts
7
+ def initialize(document, &warn)
8
+ @document, @warn = document, warn
9
+ end
10
+
11
+ def apply(settings, sections)
12
+ weights = [settings.fetch("title-weight", 700), settings.fetch("body-weight", 400)]
13
+ weight_style = @document.createElement("style")
14
+ weight_style[:textContent] = ".slide .title { font-weight: #{weights[0]}; } .slide .body { font-weight: #{weights[1]}; }"
15
+ @document[:head].appendChild(weight_style)
16
+ name = settings["default"]
17
+ return unless name
18
+ response = JS.global.fetch("/talk/fonts.json").await
19
+ raise "フォント登録情報を読み込めません" unless response[:ok] == JS::True
20
+ registry = JSON.parse(response.text.await.to_s)
21
+ entry = Core::Fonts.resolve(registry, name)
22
+ unless entry
23
+ @warn.call("未登録のフォント: #{name}。代替フォントを使用します")
24
+ return
25
+ end
26
+ link = @document.createElement("link")
27
+ link[:rel] = "stylesheet"
28
+ link[:href] = "/talk/#{entry.fetch('stylesheet')}"
29
+ loaded = JS.global[:Promise].new do |resolve, _reject|
30
+ link.addEventListener("load", ->(_) { resolve.apply(true); nil }, { once: true }.to_js)
31
+ link.addEventListener("error", ->(_) { resolve.apply(false); nil }, { once: true }.to_js)
32
+ JS.global.setTimeout(-> { resolve.apply(false); nil }, 20_000)
33
+ @document[:head].appendChild(link)
34
+ end.await
35
+ raise "CSSを読み込めません" unless loaded == JS::True
36
+ family = JSON.generate(entry.fetch("css_family"))
37
+ # All slide and note characters are supplied, including characters in hidden slides.
38
+ text = sections.map { |section| section[:textContent].to_s }.join
39
+ entry.fetch("styles", ["normal"]).each do |style|
40
+ ([400, 700] + weights).uniq.each do |weight|
41
+ loading = @document[:fonts].load("#{style} #{weight} 16px #{family}", text)
42
+ timeout = JS.global[:Promise].new do |resolve, _reject|
43
+ JS.global.setTimeout(-> { resolve.apply(false); nil }, 20_000)
44
+ end
45
+ faces = JS.global[:Promise].race([loading, timeout].to_js).await
46
+ raise "フォント読み込みがタイムアウトしました" if faces == JS::False
47
+ raise "対応するフォントがありません" if faces[:length].to_i.zero? && !text.empty?
48
+ end
49
+ end
50
+ @document[:fonts][:ready].await
51
+ style = @document.createElement("style")
52
+ style[:textContent] = %(.slide .title, .slide .body { font-family: #{family}, "IBM Plex Sans JP", sans-serif; })
53
+ @document[:head].appendChild(style)
54
+ rescue => error
55
+ link.remove if link
56
+ @warn.call("フォントを読み込めませんでした: #{name} (#{error.message})。代替フォントを使用します")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,107 @@
1
+ module Wani
2
+ module Browser
3
+ class Layout
4
+ WIDTH = 1920
5
+ HEIGHT = 1080
6
+ BODY_MIN = 16
7
+ BODY_MAX = 160
8
+ TITLE_MIN = 24
9
+ TITLE_MAX = 220
10
+
11
+ def initialize(document, &warn)
12
+ @document, @warn = document, warn
13
+ end
14
+
15
+ def wait_assets(root)
16
+ unless @fonts_loaded
17
+ # Hidden slide titles do not trigger font downloads. Load both weights
18
+ # explicitly before fitting, including Japanese glyphs in the request.
19
+ family = JS.global.getComputedStyle(@document[:documentElement])[:fontFamily].to_s
20
+ [400, 700].each do |weight|
21
+ begin
22
+ @document[:fonts].load("#{weight} 16px #{family}", "日本語 ABC").await
23
+ rescue JS::Error
24
+ @warn.call("フォントを読み込めませんでした。代替フォントを使用します (#{weight})")
25
+ end
26
+ end
27
+ @fonts_loaded = true
28
+ end
29
+ @document[:fonts][:ready].await
30
+ root.querySelectorAll("img").to_a.each do |image|
31
+ unless image[:complete] == JS::True
32
+ JS.global[:Promise].new do |resolve, _reject|
33
+ settled = false
34
+ finish = -> do
35
+ unless settled
36
+ settled = true
37
+ resolve.apply(nil)
38
+ end
39
+ nil
40
+ end
41
+ image.addEventListener("load", ->(_event) { finish.call }, { once: true }.to_js)
42
+ image.addEventListener("error", ->(_event) { finish.call }, { once: true }.to_js)
43
+ JS.global.setTimeout(-> { finish.call }, 15_000)
44
+ end.await
45
+ end
46
+ @warn.call("画像の読み込みに失敗しました: #{image.getAttribute('src')}") if image[:naturalWidth].to_i.zero?
47
+ end
48
+ end
49
+
50
+ def fit_slide(section, number)
51
+ section[:style][:display] = "flex"
52
+ title = section.querySelector(".title")
53
+ unless title[:style][:display].to_s == "none"
54
+ fit_font(title, TITLE_MIN, TITLE_MAX)
55
+ @warn.call("スライド #{number}: タイトルが収まりません: #{title[:textContent]}") unless fits?(title)
56
+ end
57
+ body = section.querySelector(".body")
58
+ fit_images(body)
59
+ fit_font(body, BODY_MIN, BODY_MAX)
60
+ @warn.call("スライド #{number}: 本文が収まりません: #{body[:textContent].to_s[0, 120]}") unless fits?(body)
61
+ section[:style][:display] = ""
62
+ end
63
+
64
+ def scale
65
+ viewport = @document.getElementById("viewport")
66
+ style = JS.global.getComputedStyle(viewport)
67
+ width = viewport[:clientWidth].to_f - style[:paddingLeft].to_s.to_f - style[:paddingRight].to_s.to_f
68
+ height = viewport[:clientHeight].to_f - style[:paddingTop].to_s.to_f - style[:paddingBottom].to_s.to_f
69
+ ratio = [width / WIDTH, height / HEIGHT].min
70
+ @document.getElementById("stage")[:style][:transform] = "scale(#{[ratio, 0.01].max})"
71
+ end
72
+
73
+ private
74
+
75
+ def fits?(node)
76
+ node[:scrollWidth].to_i <= node[:clientWidth].to_i + 1 && node[:scrollHeight].to_i <= node[:clientHeight].to_i + 1
77
+ end
78
+
79
+ def fit_font(node, minimum, maximum)
80
+ low, high, best = minimum, maximum, minimum
81
+ while low <= high
82
+ middle = (low + high) / 2
83
+ node[:style][:fontSize] = "#{middle}px"
84
+ if fits?(node)
85
+ best, low = middle, middle + 1
86
+ else
87
+ high = middle - 1
88
+ end
89
+ end
90
+ node[:style][:fontSize] = "#{best}px"
91
+ end
92
+
93
+ def fit_images(body)
94
+ images = body.querySelectorAll("img").to_a
95
+ return if images.empty?
96
+ mixed = !body[:textContent].to_s.strip.empty?
97
+ budget = body[:clientHeight].to_f * (mixed ? 0.45 : 0.85) / images.length
98
+ images.each do |image|
99
+ next if image[:naturalWidth].to_i.zero? || image[:naturalHeight].to_i.zero?
100
+ ratio = [body[:clientWidth].to_f / image[:naturalWidth].to_f, budget / image[:naturalHeight].to_f].min
101
+ image[:style][:width] = "#{image[:naturalWidth].to_f * ratio}px"
102
+ image[:style][:height] = "#{image[:naturalHeight].to_f * ratio}px"
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,66 @@
1
+ module Wani
2
+ module Browser
3
+ class Application
4
+ private
5
+
6
+ def start_timer
7
+ return if @presentation.time_limit.zero?
8
+
9
+ @deadline = JS.global[:Date].now.to_f + @presentation.time_limit * 60_000
10
+ element("resume-slides").addEventListener("click") { go(@index) }
11
+ @timer_callback = -> { tick_timer }
12
+ @timer_id = JS.global.setInterval(@timer_callback, 200)
13
+ # Check immediately when returning from a background tab, whose timers may be throttled.
14
+ @document.addEventListener("visibilitychange") { tick_timer unless @timer_finished }
15
+ update_timer_for_slide
16
+ end
17
+
18
+ def update_timer_for_slide
19
+ return unless @deadline && !@timer_finished
20
+
21
+ now = JS.global[:Date].now.to_f
22
+ if @index == @sections.length - 1
23
+ @timer_paused_at ||= now
24
+ element("countdown")[:hidden] = true
25
+ else
26
+ if @timer_paused_at
27
+ @deadline += now - @timer_paused_at
28
+ @timer_paused_at = nil
29
+ end
30
+ tick_timer
31
+ end
32
+ end
33
+
34
+ def tick_timer
35
+ return if @timer_paused_at || @timer_finished
36
+
37
+ remaining = ((@deadline - JS.global[:Date].now.to_f) / 1000).ceil
38
+ countdown = element("countdown")
39
+ countdown[:hidden] = remaining > 30 || remaining <= 0
40
+ if remaining.between?(1, 30) && remaining != @last_countdown
41
+ @last_countdown = remaining
42
+ urgent = remaining <= 5
43
+ countdown[:classList].toggle("urgent", urgent)
44
+ countdown.setAttribute("aria-label", "発表の残り時間 #{remaining} 秒")
45
+ if urgent
46
+ number = @document.createElement("span")
47
+ number[:textContent] = remaining.to_s
48
+ number[:style].setProperty("--countdown-peak", "#{(6 - remaining) * 20}vmin")
49
+ countdown.replaceChildren(number)
50
+ else
51
+ countdown[:textContent] = "残り #{remaining} 秒"
52
+ end
53
+ end
54
+ return if remaining > 0
55
+
56
+ JS.global.clearInterval(@timer_id)
57
+ @timer_finished = true
58
+ @time_expired = true
59
+ element("time-expired")[:hidden] = false
60
+ element("viewport")[:inert] = true
61
+ element("resume-slides").focus
62
+ update_buttons
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,80 @@
1
+ require_relative "bridge_progress"
2
+
3
+ module Wani
4
+ module Browser
5
+ class Renderer
6
+ def initialize(document, presentation)
7
+ @document, @presentation = document, presentation
8
+ end
9
+
10
+ def slide(slide, notes: false)
11
+ section = @document.createElement("section")
12
+ section[:className] = "slide"
13
+ section.setAttribute("aria-label", "スライド #{slide.number}")
14
+ title = child(section, "h1", "title")
15
+ title[:textContent] = notes ? "ノート #{slide.number}" : slide.title
16
+ title[:style][:display] = "none" if title[:textContent].to_s.empty?
17
+ body = child(section, "div", "body")
18
+ body[:innerHTML] = notes ? slide.notes_html : slide.html
19
+ resolve_links(body)
20
+ footer = child(section, "footer", "slide-footer")
21
+ child(footer, "span", "page-footnote")[:textContent] = slide.page_footnote
22
+ number = child(footer, "span", "slide-number")
23
+ number[:textContent] = "#{slide.number}/#{@presentation.slides.length}"
24
+ number.setAttribute("aria-label", "#{@presentation.slides.length} ページ中 #{slide.number} ページ")
25
+ progress = BridgeProgress.new(slide.number, @presentation.slides.length)
26
+ decorations = child(footer, "span", "decorations")
27
+ decorations.setAttribute("aria-hidden", "true")
28
+ decorations.setAttribute("data-slide", slide.number.to_s)
29
+ decorations.setAttribute("data-sharks", progress.sharks.to_s)
30
+ decorations.setAttribute("data-rabbit", progress.rabbit_fraction.to_s)
31
+ decorations[:style].setProperty("--rabbit-progress", progress.rabbit_fraction.to_s)
32
+ decorations[:style].setProperty("--rabbit-from", progress.rabbit_fraction.to_s)
33
+ child(decorations, "span", "island island-left")
34
+ child(decorations, "span", "island island-right")
35
+ bridge = child(decorations, "span", "bridge")
36
+ BridgeProgress::SEGMENTS.times do |index|
37
+ segment = child(bridge, "span", "bridge-segment")
38
+ segment.setAttribute("data-present", (index >= BridgeProgress::SEGMENTS - progress.sharks).to_s)
39
+ image = child(segment, "img", "bridge-wani")
40
+ image[:src] = "/assets/shark.svg"
41
+ image[:alt] = ""
42
+ end
43
+ rabbit = child(child(decorations, "span", "rabbit-position"), "img", "rabbit-image")
44
+ rabbit[:src] = "/assets/rabbit.svg"
45
+ rabbit[:alt] = ""
46
+ section
47
+ end
48
+
49
+ def transition(section, from_number)
50
+ scene = section.querySelector(".decorations")
51
+ return if scene == JS::Null
52
+ previous = BridgeProgress.new(from_number, @presentation.slides.length)
53
+ current = BridgeProgress.new(scene.getAttribute("data-slide").to_s.to_i, @presentation.slides.length)
54
+ scene[:style].setProperty("--rabbit-from", previous.rabbit_fraction.to_s)
55
+ scene.setAttribute("data-moving", (previous.rabbit_fraction != current.rabbit_fraction).to_s)
56
+ scene.querySelectorAll(".bridge-segment").to_a.each_with_index do |segment, index|
57
+ appearing = index >= BridgeProgress::SEGMENTS - current.sharks && index < BridgeProgress::SEGMENTS - previous.sharks
58
+ segment.setAttribute("data-appearing", appearing.to_s)
59
+ end
60
+ end
61
+
62
+ def resolve_links(root)
63
+ root.querySelectorAll("img, a").to_a.each do |node|
64
+ attribute = node[:tagName].to_s == "IMG" ? "src" : "href"
65
+ value = node.getAttribute(attribute).to_s
66
+ node.setAttribute(attribute, JS.global[:URL].new(value, "#{JS.global[:location][:origin]}/talk/")[:href])
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def child(parent, tag, class_name)
73
+ element = @document.createElement(tag)
74
+ element[:className] = class_name
75
+ parent.appendChild(element)
76
+ element
77
+ end
78
+ end
79
+ end
80
+ end