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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +206 -0
- data/exe/mp +6 -0
- data/lib/mp/app.rb +158 -0
- data/lib/mp/browser.rb +119 -0
- data/lib/mp/cdp.rb +119 -0
- data/lib/mp/cli.rb +36 -0
- data/lib/mp/deck.rb +268 -0
- data/lib/mp/frame_unblocker.rb +77 -0
- data/lib/mp/server.rb +224 -0
- data/lib/mp/slide_renderer.rb +184 -0
- data/lib/mp/terminal.rb +41 -0
- data/lib/mp/version.rb +5 -0
- data/lib/mp/web_socket.rb +49 -0
- data/lib/mp.rb +12 -0
- data/public/app.js +306 -0
- data/public/index.html +37 -0
- data/public/presenter.css +33 -0
- data/public/presenter.html +41 -0
- data/public/presenter.js +168 -0
- data/public/slides.js +35 -0
- data/public/style.css +93 -0
- data/themes/dark.css +26 -0
- data/themes/default.css +27 -0
- data/vendor/assets/katex/LICENSE +21 -0
- data/vendor/assets/katex/auto-render.min.js +1 -0
- data/vendor/assets/katex/fonts/KaTeX_AMS-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_AMS-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-Bold.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-Bold.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-Italic.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-Italic.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Math-Italic.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Math-Italic.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Script-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Script-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size1-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size1-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size2-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size2-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size3-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size3-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size4-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size4-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Typewriter-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- data/vendor/assets/katex/katex.min.css +1 -0
- data/vendor/assets/katex/katex.min.js +1 -0
- data/vendor/assets/xterm/LICENSE +21 -0
- data/vendor/assets/xterm/addon-fit.js +2 -0
- data/vendor/assets/xterm/xterm.css +285 -0
- data/vendor/assets/xterm/xterm.js +2 -0
- 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
|
data/lib/mp/terminal.rb
ADDED
|
@@ -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,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; }
|