mp 0.1.0

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 (94) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +206 -0
  4. data/exe/mp +6 -0
  5. data/lib/mp/app.rb +158 -0
  6. data/lib/mp/browser.rb +119 -0
  7. data/lib/mp/cdp.rb +119 -0
  8. data/lib/mp/cli.rb +36 -0
  9. data/lib/mp/deck.rb +268 -0
  10. data/lib/mp/frame_unblocker.rb +77 -0
  11. data/lib/mp/server.rb +224 -0
  12. data/lib/mp/slide_renderer.rb +184 -0
  13. data/lib/mp/terminal.rb +41 -0
  14. data/lib/mp/version.rb +5 -0
  15. data/lib/mp/web_socket.rb +49 -0
  16. data/lib/mp.rb +12 -0
  17. data/public/app.js +306 -0
  18. data/public/index.html +37 -0
  19. data/public/presenter.css +33 -0
  20. data/public/presenter.html +41 -0
  21. data/public/presenter.js +168 -0
  22. data/public/slides.js +35 -0
  23. data/public/style.css +93 -0
  24. data/themes/dark.css +26 -0
  25. data/themes/default.css +27 -0
  26. data/vendor/assets/katex/LICENSE +21 -0
  27. data/vendor/assets/katex/auto-render.min.js +1 -0
  28. data/vendor/assets/katex/fonts/KaTeX_AMS-Regular.ttf +0 -0
  29. data/vendor/assets/katex/fonts/KaTeX_AMS-Regular.woff +0 -0
  30. data/vendor/assets/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  31. data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  32. data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  33. data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  34. data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  35. data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  36. data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  37. data/vendor/assets/katex/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  38. data/vendor/assets/katex/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  39. data/vendor/assets/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  40. data/vendor/assets/katex/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  41. data/vendor/assets/katex/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  42. data/vendor/assets/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  43. data/vendor/assets/katex/fonts/KaTeX_Main-Bold.ttf +0 -0
  44. data/vendor/assets/katex/fonts/KaTeX_Main-Bold.woff +0 -0
  45. data/vendor/assets/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  46. data/vendor/assets/katex/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  47. data/vendor/assets/katex/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  48. data/vendor/assets/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  49. data/vendor/assets/katex/fonts/KaTeX_Main-Italic.ttf +0 -0
  50. data/vendor/assets/katex/fonts/KaTeX_Main-Italic.woff +0 -0
  51. data/vendor/assets/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  52. data/vendor/assets/katex/fonts/KaTeX_Main-Regular.ttf +0 -0
  53. data/vendor/assets/katex/fonts/KaTeX_Main-Regular.woff +0 -0
  54. data/vendor/assets/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  55. data/vendor/assets/katex/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  56. data/vendor/assets/katex/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  57. data/vendor/assets/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  58. data/vendor/assets/katex/fonts/KaTeX_Math-Italic.ttf +0 -0
  59. data/vendor/assets/katex/fonts/KaTeX_Math-Italic.woff +0 -0
  60. data/vendor/assets/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  61. data/vendor/assets/katex/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  62. data/vendor/assets/katex/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  63. data/vendor/assets/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  64. data/vendor/assets/katex/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  65. data/vendor/assets/katex/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  66. data/vendor/assets/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  67. data/vendor/assets/katex/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  68. data/vendor/assets/katex/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  69. data/vendor/assets/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  70. data/vendor/assets/katex/fonts/KaTeX_Script-Regular.ttf +0 -0
  71. data/vendor/assets/katex/fonts/KaTeX_Script-Regular.woff +0 -0
  72. data/vendor/assets/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  73. data/vendor/assets/katex/fonts/KaTeX_Size1-Regular.ttf +0 -0
  74. data/vendor/assets/katex/fonts/KaTeX_Size1-Regular.woff +0 -0
  75. data/vendor/assets/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  76. data/vendor/assets/katex/fonts/KaTeX_Size2-Regular.ttf +0 -0
  77. data/vendor/assets/katex/fonts/KaTeX_Size2-Regular.woff +0 -0
  78. data/vendor/assets/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  79. data/vendor/assets/katex/fonts/KaTeX_Size3-Regular.ttf +0 -0
  80. data/vendor/assets/katex/fonts/KaTeX_Size3-Regular.woff +0 -0
  81. data/vendor/assets/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  82. data/vendor/assets/katex/fonts/KaTeX_Size4-Regular.ttf +0 -0
  83. data/vendor/assets/katex/fonts/KaTeX_Size4-Regular.woff +0 -0
  84. data/vendor/assets/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  85. data/vendor/assets/katex/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  86. data/vendor/assets/katex/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  87. data/vendor/assets/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  88. data/vendor/assets/katex/katex.min.css +1 -0
  89. data/vendor/assets/katex/katex.min.js +1 -0
  90. data/vendor/assets/xterm/LICENSE +21 -0
  91. data/vendor/assets/xterm/addon-fit.js +2 -0
  92. data/vendor/assets/xterm/xterm.css +285 -0
  93. data/vendor/assets/xterm/xterm.js +2 -0
  94. metadata +230 -0
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "kramdown"
5
+ require "kramdown-parser-gfm"
6
+ require "rouge"
7
+
8
+ module Mp
9
+ # Rouge の HTML フォーマッタは <span> しか出さないので、<pre> で包む
10
+ class CodeFormatter < Rouge::Formatters::HTML
11
+ def stream(tokens, &block)
12
+ yield %(<pre class="highlight"><code>)
13
+ super
14
+ yield "</code></pre>"
15
+ end
16
+ end
17
+
18
+ # 1 スライド分の Markdown を <section> にする。
19
+ class SlideRenderer
20
+ FILTERS = %w[blur brightness contrast drop-shadow grayscale hue-rotate invert opacity saturate sepia].freeze
21
+ FILTER_DEFAULTS = {
22
+ "blur" => "10px", "brightness" => "1.5", "contrast" => "2", "drop-shadow" => "0 5px 10px rgba(0,0,0,.4)",
23
+ "grayscale" => "1", "hue-rotate" => "180deg", "invert" => "1", "opacity" => ".5",
24
+ "saturate" => "2", "sepia" => "1"
25
+ }.freeze
26
+ KRAMDOWN = {
27
+ input: "GFM", auto_ids: false, smart_quotes: "apos,apos,quot,quot",
28
+ syntax_highlighter: "rouge", syntax_highlighter_opts: { formatter: CodeFormatter }
29
+ }.freeze
30
+
31
+ def initialize(chunk, index:, total:, directives:, tokens:)
32
+ @chunk = chunk
33
+ @index = index
34
+ @total = total
35
+ @directives = directives
36
+ @tokens = tokens
37
+ @backgrounds = []
38
+ @split = nil
39
+ end
40
+
41
+ def render
42
+ source = @chunk.lines.join("\n")
43
+ document = Kramdown::Document.new(source, KRAMDOWN)
44
+ @source_lines = source.split("\n", -1)
45
+ transform(document.root)
46
+ html = scope_styles(restore_tokens(document.to_html))
47
+ html = html.sub(/<h([12])>/, '<h\1 class="fit">') if @chunk.fit
48
+
49
+ { html: %(<section class="#{classes}" data-index="#{@index}" style="#{CGI.escapeHTML(style)}">) \
50
+ "#{background_html}#{furniture(:header)}#{html}#{furniture(:footer)}#{page_number}</section>" }
51
+ end
52
+
53
+ private
54
+
55
+ def transform(element)
56
+ element.children.each { transform(_1) }
57
+ element.children.reject! { |child| background_only?(child) }
58
+ mark_fragments(element) if element.type == :ul
59
+ element.children.each { convert_image(_1) if _1.type == :img }
60
+ end
61
+
62
+ def background_only?(element)
63
+ return false unless element.type == :p
64
+
65
+ images, rest = element.children.partition { _1.type == :img && keywords(_1).include?("bg") }
66
+ images.each { add_background(_1) }
67
+ element.children.replace(rest)
68
+ !images.empty? && rest.all? { _1.type == :text && _1.value.strip.empty? }
69
+ end
70
+
71
+ def keywords(image)
72
+ image.attr["alt"].to_s.split
73
+ end
74
+
75
+ def add_background(image)
76
+ size = "cover"
77
+ filters = []
78
+ keywords(image).each do |word|
79
+ name, value = word.split(":", 2)
80
+ case name
81
+ when "left", "right" then @split = [name, value || "50%"]
82
+ when "cover", "contain", "auto" then size = name
83
+ when "fit" then size = "contain"
84
+ when "vertical" then @vertical = true
85
+ when /\A\d+(\.\d+)?%\z/ then size = name
86
+ when *FILTERS then filters << "#{name}(#{value || FILTER_DEFAULTS[name]})"
87
+ end
88
+ end
89
+ @backgrounds << { src: image.attr["src"], size: size, filter: filters.join(" ") }
90
+ end
91
+
92
+ # Marp と同じく `*` のリストは 1 項目ずつ表示する
93
+ def mark_fragments(list)
94
+ list.children.each do |item|
95
+ line = @source_lines[item.options[:location].to_i - 1].to_s
96
+ item.attr["class"] = "fragment" if line.lstrip.start_with?("*")
97
+ end
98
+ end
99
+
100
+ def convert_image(image)
101
+ return if keywords(image).include?("bg") # 親の段落を処理するときに背景へ移す
102
+
103
+ styles = []
104
+ alt = keywords(image).reject do |word|
105
+ name, value = word.split(":", 2)
106
+ case name
107
+ when "w", "width" then styles << "width:#{css_length(value)}"
108
+ when "h", "height" then styles << "height:#{css_length(value)}"
109
+ when *FILTERS then (@image_filters ||= []) << "#{name}(#{value || FILTER_DEFAULTS[name]})"
110
+ else next false
111
+ end
112
+ true
113
+ end
114
+ styles << "filter:#{@image_filters.join(" ")}" if @image_filters
115
+ @image_filters = nil
116
+ image.attr["alt"] = alt.join(" ")
117
+ image.attr["style"] = styles.join(";") unless styles.empty?
118
+ end
119
+
120
+ def css_length(value)
121
+ value.to_s.match?(/\A\d+(\.\d+)?\z/) ? "#{value}px" : value.to_s
122
+ end
123
+
124
+ def restore_tokens(html)
125
+ html.gsub(%r{<p>(MPTOKEN\d+X)</p>}) { @tokens.fetch(Regexp.last_match(1)) }
126
+ .gsub(/MPTOKEN\d+X/) { |key| @tokens.fetch(key) }
127
+ end
128
+
129
+ # <style scoped> はそのスライドだけに効かせる
130
+ def scope_styles(html)
131
+ html.gsub(%r{<style\s+scoped[^>]*>(.*?)</style>}m) do
132
+ %(<style>@scope (section[data-index="#{@index}"]) { #{Regexp.last_match(1)} }</style>)
133
+ end
134
+ end
135
+
136
+ def classes
137
+ kind = { cover: "cover", continuation: "continuation" }[@chunk.kind]
138
+ ["slide", kind, *Array(@directives["class"]).flat_map { _1.to_s.split }].compact.join(" ")
139
+ end
140
+
141
+ def style
142
+ map = {
143
+ "backgroundColor" => "background-color", "backgroundImage" => "background-image",
144
+ "backgroundPosition" => "background-position", "backgroundRepeat" => "background-repeat",
145
+ "backgroundSize" => "background-size", "color" => "color"
146
+ }
147
+ styles = map.filter_map { |key, css| "#{css}:#{@directives[key]}" if @directives[key] }
148
+ styles << "--split-#{@split[0]}:#{@split[1]}" if @split
149
+ styles.join(";")
150
+ end
151
+
152
+ def background_html
153
+ return "" if @backgrounds.empty?
154
+
155
+ position = case @split&.first
156
+ when "left" then "left:0;width:#{@split[1]}"
157
+ when "right" then "right:0;width:#{@split[1]}"
158
+ else "left:0;width:100%"
159
+ end
160
+ images = @backgrounds.map do |bg|
161
+ css = %(background-image:url("#{bg[:src].gsub('"', "%22")}");background-size:#{bg[:size]})
162
+ css += ";filter:#{bg[:filter]}" unless bg[:filter].empty?
163
+ %(<div class="bg-image" style="#{CGI.escapeHTML(css)}"></div>)
164
+ end
165
+ direction = @vertical ? ";flex-direction:column" : ""
166
+ %(<div class="bg-layer" style="#{position}#{direction}">#{images.join}</div>)
167
+ end
168
+
169
+ def furniture(name)
170
+ text = @directives[name.to_s]
171
+ return "" if text.to_s.empty?
172
+
173
+ inline = Kramdown::Document.new(text.to_s, input: "GFM").to_html.strip.sub(%r{\A<p>(.*)</p>\z}m, '\1')
174
+ "<#{name}>#{inline}</#{name}>"
175
+ end
176
+
177
+ def page_number
178
+ show = @directives["paginate"]
179
+ return "" unless show == true && @chunk.kind != :cover
180
+
181
+ %(<div class="page-number">#{@index + 1} / #{@total}</div>)
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "pty"
5
+
6
+ module Mp
7
+ # 1 つの埋め込みに対応するシェル。
8
+ class Terminal
9
+ def initialize(cwd:, cols:, rows:)
10
+ env = { "TERM" => "xterm-256color", "COLORTERM" => "truecolor" }
11
+ @output, @input, @pid = PTY.spawn(env, ENV.fetch("SHELL", "/bin/sh"), "-l", chdir: cwd)
12
+ resize(cols, rows)
13
+ end
14
+
15
+ def read_loop
16
+ loop { yield @output.readpartial(16_384) }
17
+ rescue EOFError, Errno::EIO, IOError
18
+ nil # シェル終了時、Linux では EIO になる
19
+ end
20
+
21
+ def write(data)
22
+ @input.write(data)
23
+ rescue IOError, Errno::EIO
24
+ nil
25
+ end
26
+
27
+ def resize(cols, rows)
28
+ @output.winsize = [rows.clamp(2, 500), cols.clamp(2, 500)]
29
+ rescue IOError, Errno::EIO
30
+ nil
31
+ end
32
+
33
+ def close
34
+ Process.kill("HUP", @pid)
35
+ Process.detach(@pid)
36
+ [@input, @output].each(&:close)
37
+ rescue Errno::ESRCH, IOError
38
+ nil
39
+ end
40
+ end
41
+ end
data/lib/mp/version.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mp
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require "websocket/driver"
5
+
6
+ module Mp
7
+ # Rack hijack したソケットを WebSocket として扱う。
8
+ class WebSocketConnection
9
+ attr_reader :env
10
+
11
+ def initialize(env)
12
+ @env = env
13
+ @lock = Monitor.new
14
+ @socket = env["rack.hijack"].call
15
+ @driver = WebSocket::Driver.rack(self, max_length: 16 * 1024 * 1024)
16
+ end
17
+
18
+ def on_message(&block) = @driver.on(:message) { |event| block.call(event.data) }
19
+ def on_close(&block) = (@on_close = block)
20
+
21
+ def run
22
+ @driver.start
23
+ Thread.new do
24
+ loop { @driver.parse(@socket.readpartial(16_384)) }
25
+ rescue IOError, SystemCallError
26
+ nil
27
+ ensure
28
+ @socket.close unless @socket.closed?
29
+ @on_close&.call
30
+ end
31
+ end
32
+
33
+ # websocket-driver が呼ぶ
34
+ def write(data)
35
+ @lock.synchronize { @socket.write(data) }
36
+ rescue IOError, SystemCallError
37
+ nil
38
+ end
39
+
40
+ def send_text(text) = @lock.synchronize { @driver.text(text) }
41
+ def send_binary(data) = @lock.synchronize { @driver.binary(data.bytes) }
42
+
43
+ def close
44
+ @socket.close unless @socket.closed?
45
+ rescue IOError
46
+ nil
47
+ end
48
+ end
49
+ end
data/lib/mp.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mp/version"
4
+ require_relative "mp/deck"
5
+ require_relative "mp/browser"
6
+ require_relative "mp/frame_unblocker"
7
+ require_relative "mp/server"
8
+ require_relative "mp/app"
9
+
10
+ module Mp
11
+ ROOT = File.expand_path("..", __dir__)
12
+ end
data/public/app.js ADDED
@@ -0,0 +1,306 @@
1
+ "use strict";
2
+
3
+ const stage = document.getElementById("stage");
4
+ const notesPanel = document.getElementById("notes");
5
+ const helpPanel = document.getElementById("help");
6
+ const wsBase = `ws://${location.host}${location.pathname}_mp/`;
7
+
8
+ let deck = null;
9
+ let slides = [];
10
+ let current = 0;
11
+ let terminals = new Map(); // embed id -> { term, socket, fit, host, slot, font }
12
+ let scale = 1;
13
+ let activeTerminal = null;
14
+ let digits = "";
15
+
16
+ async function loadDeck() {
17
+ for (const { socket, term, host } of terminals.values()) {
18
+ socket.onclose = null;
19
+ socket.close();
20
+ term.dispose();
21
+ host.remove();
22
+ }
23
+ terminals = new Map();
24
+ activeTerminal = null;
25
+
26
+ deck = await MpSlides.load(stage);
27
+ slides = [...stage.querySelectorAll(":scope > section.slide")];
28
+ layout();
29
+ show(Math.min(Number(location.hash.slice(1) || 1) - 1, slides.length - 1), { fragments: "none" });
30
+ }
31
+
32
+ function layout() {
33
+ if (!deck) return;
34
+ scale = Math.min(innerWidth / deck.size[0], innerHeight / deck.size[1]);
35
+ stage.style.transform = `translate(-50%, -50%) scale(${scale})`;
36
+ placeTerminals();
37
+ }
38
+
39
+ // xterm.js は文字幅を getBoundingClientRect で測るので、transform の中に置くと
40
+ // 拡大率が二重に効いて桁数や行数が狂う。ステージの外に置き、埋め込み位置へ重ねる
41
+ function placeTerminals() {
42
+ const padding = 12 * scale;
43
+ for (const { term, fit, host, slot, font } of terminals.values()) {
44
+ host.hidden = !slot.closest("section").classList.contains("active");
45
+ if (host.hidden) continue;
46
+ const rect = slot.getBoundingClientRect();
47
+ Object.assign(host.style, {
48
+ left: `${rect.left + padding}px`,
49
+ top: `${rect.top + padding}px`,
50
+ width: `${rect.width - padding * 2}px`,
51
+ height: `${rect.height - padding * 2}px`,
52
+ });
53
+ term.options.fontSize = Math.round(font * scale);
54
+ fit.fit();
55
+ }
56
+ }
57
+
58
+ function fragmentsOf(slide) {
59
+ return [...slide.querySelectorAll(".fragment")];
60
+ }
61
+
62
+ function show(index, { fragments }) {
63
+ if (!slides[index]) index = 0;
64
+ releaseFocus();
65
+ slides[current]?.classList.remove("active");
66
+ slides[current]?.querySelectorAll("iframe").forEach((frame) => frame.blur());
67
+ current = index;
68
+ const slide = slides[current];
69
+ slide.classList.add("active");
70
+ fragmentsOf(slide).forEach((el) => el.classList.toggle("shown", fragments === "all"));
71
+ history.replaceState(null, "", `#${current + 1}`);
72
+ mountEmbeds(slide);
73
+ renderNotes();
74
+ sendState();
75
+ }
76
+
77
+ // 発表者ビューへ今の表示を伝える
78
+ function sendState() {
79
+ if (events.readyState !== WebSocket.OPEN || !slides[current]) return;
80
+ const step = fragmentsOf(slides[current]).filter((el) => el.classList.contains("shown")).length;
81
+ const terminal = currentTerminal();
82
+ events.send(JSON.stringify({
83
+ type: "state", slide: current, step, script: terminal?.script() ?? null, ready: terminal?.ready() ?? false,
84
+ }));
85
+ }
86
+
87
+ function currentTerminal() {
88
+ const entries = [...slides[current].querySelectorAll(".embed-terminal")].map((el) => terminals.get(el.dataset.embed));
89
+ return entries.find((entry) => entry?.script().length) ?? entries[0];
90
+ }
91
+
92
+ // 発表者ビューの Enter。ターミナルにフォーカスがなくても実行できるようにする
93
+ function runTerminal() {
94
+ const terminal = currentTerminal();
95
+ // コマンドを打ち込む前の Enter は空行になるだけなので送らない
96
+ if (terminal && (terminal.ready() || !terminal.script().length)) terminal.term.input("\r");
97
+ }
98
+
99
+ function next() {
100
+ const hidden = fragmentsOf(slides[current]).find((el) => !el.classList.contains("shown"));
101
+ if (hidden) {
102
+ hidden.classList.add("shown");
103
+ sendState();
104
+ } else if (current < slides.length - 1) {
105
+ show(current + 1, { fragments: "none" });
106
+ }
107
+ }
108
+
109
+ function prev() {
110
+ const shown = fragmentsOf(slides[current]).filter((el) => el.classList.contains("shown")).pop();
111
+ if (shown) {
112
+ shown.classList.remove("shown");
113
+ sendState();
114
+ } else if (current > 0) {
115
+ show(current - 1, { fragments: "all" });
116
+ }
117
+ }
118
+
119
+ function mountEmbeds(slide) {
120
+ slide.querySelectorAll(".embed:not(.mounted)").forEach((el) => {
121
+ el.classList.add("mounted");
122
+ const embed = deck.embeds[el.dataset.embed];
123
+ if (embed.type === "terminal") mountTerminal(el, el.dataset.embed, embed);
124
+ else mountWeb(el, embed);
125
+ });
126
+ placeTerminals();
127
+ }
128
+
129
+ function mountTerminal(slot, id, embed) {
130
+ const host = document.createElement("div");
131
+ host.className = "terminal-host";
132
+ document.body.append(host);
133
+ const term = new Terminal({
134
+ fontFamily: 'Menlo, "Hiragino Sans", monospace',
135
+ fontSize: Math.round(embed.font * scale),
136
+ cursorBlink: true,
137
+ theme: { background: "#14161b", foreground: "#e6e6e6" },
138
+ });
139
+ const fit = new FitAddon.FitAddon();
140
+ term.loadAddon(fit);
141
+ term.open(host);
142
+ const script = () => (typed ? [typed, ...queue] : [...queue]);
143
+ const ready = () => typed !== null;
144
+ terminals.set(id, { term, fit, host, slot, font: embed.font, script, ready });
145
+ placeTerminals();
146
+
147
+ const socket = new WebSocket(`${wsBase}pty?id=${id}&cols=${term.cols}&rows=${term.rows}`);
148
+ socket.binaryType = "arraybuffer";
149
+ const queue = [...embed.commands];
150
+ let idleTimer = null;
151
+ let waitingToType = queue.length > 0;
152
+ let typed = null; // 入力欄にあって実行待ちのコマンド
153
+
154
+ // 台本のコマンドは入力欄に打つだけで実行しない。実行は発表者の Enter に任せる
155
+ const typeNextWhenIdle = () => {
156
+ clearTimeout(idleTimer);
157
+ if (!waitingToType) return;
158
+ idleTimer = setTimeout(() => {
159
+ waitingToType = false;
160
+ if (queue.length) {
161
+ typed = queue.shift();
162
+ send({ input: typed });
163
+ sendState();
164
+ }
165
+ }, 400);
166
+ };
167
+ const send = (message) => {
168
+ if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
169
+ };
170
+
171
+ socket.onmessage = (event) => {
172
+ term.write(new Uint8Array(event.data));
173
+ typeNextWhenIdle();
174
+ };
175
+ socket.onclose = () => term.write("\r\n\x1b[2m[セッション終了]\x1b[0m\r\n");
176
+ term.onData((data) => {
177
+ send({ input: data });
178
+ if (data.includes("\r")) {
179
+ typed = null;
180
+ if (queue.length) waitingToType = true;
181
+ sendState();
182
+ }
183
+ });
184
+ term.onResize(({ cols, rows }) => send({ resize: [cols, rows] }));
185
+ term.attachCustomKeyEventHandler(() => true);
186
+ term.textarea.addEventListener("focus", () => (activeTerminal = term));
187
+ term.textarea.addEventListener("blur", () => {
188
+ if (activeTerminal === term) activeTerminal = null;
189
+ });
190
+
191
+ terminals.get(id).socket = socket;
192
+ }
193
+
194
+ function mountWeb(el, embed) {
195
+ if (!embed.src) {
196
+ el.textContent = "URL が指定されていません";
197
+ return;
198
+ }
199
+ const frame = document.createElement("iframe");
200
+ frame.src = embed.src;
201
+ // サイトが親ページを遷移させる処理(フレーム破壊)を防ぐため top navigation は許可しない
202
+ frame.sandbox = "allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads";
203
+ frame.allow = "fullscreen";
204
+ if (embed.zoom !== 1) {
205
+ frame.style.width = `${100 / embed.zoom}%`;
206
+ frame.style.height = `${100 / embed.zoom}%`;
207
+ frame.style.transform = `scale(${embed.zoom})`;
208
+ frame.style.transformOrigin = "0 0";
209
+ }
210
+ // iframe にフォーカスが移るとキー操作が届かなくなるので、クリックするまで覆っておく
211
+ const shield = document.createElement("button");
212
+ shield.className = "shield";
213
+ shield.title = "クリックでページを操作";
214
+ shield.addEventListener("click", () => {
215
+ shield.hidden = true;
216
+ frame.focus();
217
+ });
218
+ el.addEventListener("mouseleave", () => {
219
+ shield.hidden = false;
220
+ frame.blur();
221
+ window.focus();
222
+ });
223
+ el.append(frame, shield);
224
+ }
225
+
226
+ function releaseFocus() {
227
+ activeTerminal?.blur();
228
+ activeTerminal = null;
229
+ }
230
+
231
+ function focusTerminal() {
232
+ const el = slides[current].querySelector(".embed-terminal");
233
+ if (el) terminals.get(el.dataset.embed)?.term.focus();
234
+ }
235
+
236
+ function renderNotes() {
237
+ notesPanel.textContent = deck.slides[current].notes || "(ノートなし)";
238
+ }
239
+
240
+ function toggleFullscreen() {
241
+ if (document.fullscreenElement) document.exitFullscreen();
242
+ else document.documentElement.requestFullscreen();
243
+ }
244
+
245
+ window.addEventListener("keydown", (event) => {
246
+ const command = event.metaKey || event.ctrlKey;
247
+ // ターミナル操作中でも効くキー。xterm より先に受けるためキャプチャ段階で処理する
248
+ if (command && (event.key === "ArrowRight" || event.key === "ArrowLeft")) {
249
+ event.preventDefault();
250
+ event.stopPropagation();
251
+ event.key === "ArrowRight" ? next() : prev();
252
+ return;
253
+ }
254
+ if (command && event.key === ".") {
255
+ event.preventDefault();
256
+ event.stopPropagation();
257
+ releaseFocus();
258
+ return;
259
+ }
260
+ if (activeTerminal || command || event.altKey) return;
261
+
262
+ const actions = {
263
+ ArrowRight: next, ArrowDown: next, PageDown: next, " ": next,
264
+ ArrowLeft: prev, ArrowUp: prev, PageUp: prev,
265
+ Home: () => show(0, { fragments: "none" }),
266
+ End: () => show(slides.length - 1, { fragments: "all" }),
267
+ f: toggleFullscreen,
268
+ n: () => (notesPanel.hidden = !notesPanel.hidden),
269
+ t: focusTerminal,
270
+ "?": () => (helpPanel.hidden = !helpPanel.hidden),
271
+ p: () => events.send(JSON.stringify({ type: "open-presenter" })),
272
+ q: () => events.send(JSON.stringify({ type: "quit" })),
273
+ };
274
+ if (/^\d$/.test(event.key)) {
275
+ digits += event.key;
276
+ } else if (event.key === "Enter" && digits) {
277
+ show(Math.min(Number(digits), slides.length) - 1, { fragments: "all" });
278
+ digits = "";
279
+ } else if (actions[event.key]) {
280
+ event.preventDefault();
281
+ digits = "";
282
+ actions[event.key]();
283
+ }
284
+ }, true);
285
+
286
+ window.addEventListener("resize", layout);
287
+
288
+ const commands = {
289
+ next,
290
+ prev,
291
+ run: runTerminal,
292
+ first: () => show(0, { fragments: "none" }),
293
+ last: () => show(slides.length - 1, { fragments: "all" }),
294
+ goto: ({ slide }) => show(Math.min(Math.max(slide, 0), slides.length - 1), { fragments: "all" }),
295
+ };
296
+
297
+ const events = new WebSocket(`${wsBase}events?role=audience`);
298
+ events.onopen = sendState;
299
+ events.onmessage = (event) => {
300
+ const message = JSON.parse(event.data);
301
+ if (message.type === "reload") loadDeck();
302
+ else if (message.type === "hello") sendState();
303
+ else if (message.type === "command") commands[message.cmd]?.(message);
304
+ };
305
+
306
+ loadDeck();
data/public/index.html ADDED
@@ -0,0 +1,37 @@
1
+ <!doctype html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>mp</title>
6
+ <link rel="stylesheet" href="_mp/vendor/xterm/xterm.css">
7
+ <link rel="stylesheet" href="_mp/vendor/katex/katex.min.css">
8
+ <link rel="stylesheet" href="_mp/style.css">
9
+ <style id="theme"></style>
10
+ <style id="deck-style"></style>
11
+ </head>
12
+ <body>
13
+ <div id="stage"></div>
14
+ <aside id="notes" hidden></aside>
15
+ <aside id="help" hidden>
16
+ <h3>キー操作</h3>
17
+ <dl>
18
+ <dt>→ ↓ Space PageDown</dt><dd>次へ</dd>
19
+ <dt>← ↑ PageUp</dt><dd>前へ</dd>
20
+ <dt>Home / End</dt><dd>最初 / 最後</dd>
21
+ <dt>数字 + Enter</dt><dd>そのページへ</dd>
22
+ <dt>t またはクリック</dt><dd>ターミナルを操作</dd>
23
+ <dt>⌘. </dt><dd>ターミナルから抜ける</dd>
24
+ <dt>⌘← / ⌘→</dt><dd>ターミナル操作中でもページ移動</dd>
25
+ <dt>f</dt><dd>全画面の切り替え</dd>
26
+ <dt>n</dt><dd>発表者ノート</dd>
27
+ <dt>p</dt><dd>発表者ビュー</dd>
28
+ <dt>q</dt><dd>終了</dd>
29
+ </dl>
30
+ </aside>
31
+ <script src="_mp/vendor/xterm/xterm.js"></script>
32
+ <script src="_mp/vendor/xterm/addon-fit.js"></script>
33
+ <script src="_mp/vendor/katex/katex.min.js"></script>
34
+ <script src="_mp/slides.js"></script>
35
+ <script src="_mp/app.js"></script>
36
+ </body>
37
+ </html>
@@ -0,0 +1,33 @@
1
+ body.presenter {
2
+ display: grid;
3
+ grid-template-columns: 3fr 2fr;
4
+ grid-template-rows: 2fr 3fr auto;
5
+ gap: 12px;
6
+ box-sizing: border-box;
7
+ height: 100vh;
8
+ padding: 14px 16px 8px;
9
+ background: #101218;
10
+ color: #e9ecf2;
11
+ font-family: "Hiragino Sans", system-ui, sans-serif;
12
+ }
13
+ .pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
14
+ .current-pane { grid-row: 1 / 3; }
15
+ .status { grid-column: 1 / -1; display: flex; align-items: baseline; gap: 24px; }
16
+
17
+ .label { font-size: 14px; color: #98a1b3; margin-bottom: 6px; }
18
+ .label span { color: #e9ecf2; font-weight: 600; margin-left: 8px; }
19
+
20
+ .frame { position: relative; flex: 1; min-height: 0; overflow: hidden; background: #000; }
21
+ .stage { position: absolute; left: 0; top: 0; overflow: hidden; transform-origin: 0 0; }
22
+ #mirror { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; }
23
+ #end { position: absolute; inset: 0; display: grid; place-items: center; color: #98a1b3; font-size: 18px; }
24
+
25
+ .embed-summary { margin: 0; padding: 20px; background: none; color: #e6e6e6; font: 22px/1.5 Menlo, monospace; white-space: pre-wrap; word-break: break-all; }
26
+ .embed-web .embed-summary { color: #333; }
27
+
28
+ #script { margin-top: 10px; padding: 10px 14px; border-radius: 8px; background: #1d2736; color: #9fc4ff; font: 20px/1.4 Menlo, "Hiragino Sans", monospace; }
29
+ #speaker-notes { flex: 1; min-height: 0; overflow: auto; line-height: 1.6; white-space: pre-wrap; }
30
+
31
+ #elapsed { font: 600 34px Menlo, monospace; }
32
+ #clock { font: 20px Menlo, monospace; color: #98a1b3; }
33
+ .hints { margin-left: auto; font-size: 13px; color: #6f7889; }