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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +262 -0
- data/assets/bootstrap.rb +9 -0
- data/assets/build.json +6 -0
- data/assets/fonts/IBMPlexSansJP-Bold.woff2 +0 -0
- data/assets/fonts/IBMPlexSansJP-Regular.woff2 +0 -0
- data/assets/highlight.css +117 -0
- data/assets/index.html +39 -0
- data/assets/rabbit.svg +1 -0
- data/assets/rouge.LICENSE +187 -0
- data/assets/runtime.LICENSE +21 -0
- data/assets/runtime.NOTICE +1292 -0
- data/assets/runtime.pr672.js +3143 -0
- data/assets/shark.svg +1 -0
- data/assets/speaker.html +2 -0
- data/assets/style.css +103 -0
- data/assets/time-expired.png +0 -0
- data/assets/wani.wasm +4 -0
- data/exe/wani +3 -0
- data/lib/wani/browser/application.rb +189 -0
- data/lib/wani/browser/bridge_progress.rb +19 -0
- data/lib/wani/browser/fonts.rb +60 -0
- data/lib/wani/browser/layout.rb +107 -0
- data/lib/wani/browser/presentation_timer.rb +66 -0
- data/lib/wani/browser/renderer.rb +80 -0
- data/lib/wani/cli.rb +134 -0
- data/lib/wani/core/fonts.rb +49 -0
- data/lib/wani/core/parser.rb +127 -0
- data/lib/wani/core/presentation.rb +15 -0
- data/lib/wani/core/safe_html.rb +74 -0
- data/lib/wani/core/validator.rb +31 -0
- data/lib/wani/fonts/http.rb +45 -0
- data/lib/wani/fonts/manager.rb +190 -0
- data/lib/wani/server/application.rb +92 -0
- data/lib/wani/server/http_server.rb +34 -0
- data/lib/wani/version.rb +3 -0
- data/lib/wani.rb +4 -0
- data/templates/images/workflow.svg +36 -0
- data/templates/slides.md +182 -0
- data/third_party/gems/kramdown-LICENSE +30 -0
- data/third_party/gems/kramdown-parser-gfm-LICENSE +21 -0
- data/third_party/gems/rexml-LICENSE +22 -0
- data/third_party/ibm-plex-sans-jp/LICENSE.txt +93 -0
- data/third_party/ibm-plex-sans-jp/README.md +39 -0
- data/third_party/ibm-plex-sans-jp/provenance.json +20 -0
- data/third_party/ruby-wasm-loader/LICENSE +21 -0
- data/third_party/ruby-wasm-loader/NOTICE +1292 -0
- data/third_party/ruby-wasm-loader/README.md +35 -0
- data/third_party/ruby-wasm-loader/browser.script.iife.js +3143 -0
- data/third_party/ruby-wasm-loader/provenance.json +10 -0
- 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>
|
data/assets/speaker.html
ADDED
|
@@ -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
data/exe/wani
ADDED
|
@@ -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
|