presently 0.22.0 → 0.24.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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/context/animating-slides.md +14 -0
- data/lib/presently/application.rb +39 -8
- data/lib/presently/home_view.rb +1 -1
- data/lib/presently/page.rb +1 -0
- data/lib/presently/playback.xrb +9 -3
- data/lib/presently/presentation_controller.rb +10 -1
- data/lib/presently/{recording_view.rb → recorder_view.rb} +4 -19
- data/lib/presently/recording_controls_view.rb +44 -0
- data/lib/presently/slide.rb +42 -0
- data/lib/presently/slide_renderer.rb +3 -1
- data/lib/presently/version.rb +1 -1
- data/lib/presently.rb +1 -1
- data/public/_components/@socketry/presently/Presently/Scripts.js +3 -0
- data/public/_components/@socketry/presently/Presently/SlideRendering.js +20 -15
- data/public/_static/recorder.css +48 -25
- data/public/_static/slides.css +5 -0
- data/public/application.js +9 -8
- data/public/playback.js +34 -35
- data/public/recording_controls.js +522 -0
- data/readme.md +14 -14
- data/releases.md +13 -0
- data.tar.gz.sig +0 -0
- metadata +4 -3
- metadata.gz.sig +2 -2
- data/public/recorder.js +0 -282
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ff7fb5b5f79f21fe8d6ebae384ee3e8454fe85012c209568d244b40840898774
|
|
4
|
+
data.tar.gz: ba3b8d4703af77058b4abfc2343032cc778ce61487f52b70ef6cb7643547001a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d75218dd736c68eb3022142ea6bb3305a0db487f823a915175f11f8774b01ef5a6cba0b2cfa4cabb2f881367896d5c9d331a944a1d5e09295d86c5237066f918
|
|
7
|
+
data.tar.gz: 13c3042a25f7f664efec66a0be216e38edd3d8a087248d3236727d35e509c9d3787e50698b06984e6cde3a3ec52e876c894a1a6110e5cca8d48aed812a096270
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
data/context/animating-slides.md
CHANGED
|
@@ -29,6 +29,20 @@ The script receives a `slide` object — an instance of the `Slide` class export
|
|
|
29
29
|
|
|
30
30
|
If the script contains a syntax error or throws an exception, the error is logged to the browser console and the presentation continues unaffected.
|
|
31
31
|
|
|
32
|
+
## Flicker-Free Initial State
|
|
33
|
+
|
|
34
|
+
Presently keeps a scripted slide hidden until its scripts have run. Any visibility or styling that defines the slide's initial state must therefore be applied synchronously at the top level of the script:
|
|
35
|
+
|
|
36
|
+
``` javascript
|
|
37
|
+
const bullets = slide.find("li").builder({effect: "fade"})
|
|
38
|
+
bullets.show(0)
|
|
39
|
+
bullets.play(500)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Use `slide.after()`, `setTimeout()`, promises, and `requestAnimationFrame()` only for changes that occur after the initial state is established. Deferring the initial `show()` call allows the slide to become visible before the callback runs and can expose its uninitialized content for a frame.
|
|
43
|
+
|
|
44
|
+
Integrations using `SlideRendering#render(update)` have the same requirement: `update` must mutate the supplied view synchronously. Presently runs the new slide's scripts immediately after that update, before yielding to asynchronous syntax highlighting or code-focus preparation.
|
|
45
|
+
|
|
32
46
|
## The Slide API
|
|
33
47
|
|
|
34
48
|
### `slide.find(selector)`
|
|
@@ -11,7 +11,7 @@ require_relative "presentation_controller"
|
|
|
11
11
|
require_relative "home_view"
|
|
12
12
|
require_relative "display_view"
|
|
13
13
|
require_relative "presenter_view"
|
|
14
|
-
require_relative "
|
|
14
|
+
require_relative "recorder_view"
|
|
15
15
|
require_relative "recordings"
|
|
16
16
|
require_relative "slide_assets"
|
|
17
17
|
require_relative "playback"
|
|
@@ -44,7 +44,7 @@ module Presently
|
|
|
44
44
|
# The view classes that this application allows.
|
|
45
45
|
# @returns [Array(Class)] The allowed view classes.
|
|
46
46
|
def allowed_views
|
|
47
|
-
[HomeView, DisplayView, PresenterView,
|
|
47
|
+
[HomeView, DisplayView, PresenterView, RecorderView, RecordingControlsView]
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
# The shared state passed to all views via the resolver.
|
|
@@ -60,7 +60,7 @@ module Presently
|
|
|
60
60
|
templates = Templates.for(@templates_roots)
|
|
61
61
|
presentation = Presentation.load(@slides_root, templates)
|
|
62
62
|
|
|
63
|
-
PresentationController.new(presentation, state: State.new)
|
|
63
|
+
PresentationController.new(presentation, state: State.new, recordings: @recordings)
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
66
|
|
|
@@ -89,9 +89,9 @@ module Presently
|
|
|
89
89
|
|
|
90
90
|
router.get("/display"){make_page(resolver.root(DisplayView), interface: :display).call}
|
|
91
91
|
router.get("/presenter"){make_page(resolver.root(PresenterView), interface: :presenter).call}
|
|
92
|
-
router.get("/
|
|
92
|
+
router.get("/recorder"){make_page(resolver.root(RecorderView), interface: :recorder).call}
|
|
93
93
|
|
|
94
|
-
router.route("/recordings", methods: ["GET", "HEAD", "PUT"]) do |request|
|
|
94
|
+
router.route("/recordings", methods: ["GET", "HEAD", "PUT", "PATCH"]) do |request|
|
|
95
95
|
handle_recording(request, request_parameters(request))
|
|
96
96
|
end
|
|
97
97
|
|
|
@@ -159,10 +159,38 @@ module Presently
|
|
|
159
159
|
when "GET", "HEAD"
|
|
160
160
|
serve_recording(request, slide, @recordings)
|
|
161
161
|
when "PUT"
|
|
162
|
-
|
|
162
|
+
duration = recording_duration(parameters)
|
|
163
|
+
if parameters.key?("duration") && !duration
|
|
164
|
+
return invalid_recording_duration
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
store_recording(request, slide, duration: duration)
|
|
168
|
+
when "PATCH"
|
|
169
|
+
duration = recording_duration(parameters)
|
|
170
|
+
return invalid_recording_duration unless duration
|
|
171
|
+
return Protocol::HTTP::Response[404, [], ["Recording not found."]] unless @recordings.exist?(slide)
|
|
172
|
+
|
|
173
|
+
slide.update_duration!(duration)
|
|
174
|
+
Protocol::HTTP::Response[200, [["content-type", "application/json"]], ["{\"updated\":true,\"duration\":#{duration}}"]]
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Parse an optional positive recording duration.
|
|
179
|
+
# @parameter parameters [Hash] The decoded query parameters.
|
|
180
|
+
# @returns [Integer | Nil] The duration in seconds, or `nil` when absent or invalid.
|
|
181
|
+
def recording_duration(parameters)
|
|
182
|
+
if value = parameters["duration"]
|
|
183
|
+
duration = Integer(value, exception: false)
|
|
184
|
+
return duration if duration&.positive?
|
|
163
185
|
end
|
|
164
186
|
end
|
|
165
187
|
|
|
188
|
+
# Build the response for an invalid recording duration.
|
|
189
|
+
# @returns [Protocol::HTTP::Response] A bad request response.
|
|
190
|
+
def invalid_recording_duration
|
|
191
|
+
Protocol::HTTP::Response[400, [], ["Duration must be a positive number of seconds."]]
|
|
192
|
+
end
|
|
193
|
+
|
|
166
194
|
# Serve normalized narration for playback, falling back to the source take.
|
|
167
195
|
def handle_playback_recording(request, parameters)
|
|
168
196
|
index = recording_index(parameters)
|
|
@@ -206,7 +234,7 @@ module Presently
|
|
|
206
234
|
# @parameter request [Protocol::HTTP::Request] The incoming request.
|
|
207
235
|
# @parameter slide [Slide] The slide being recorded.
|
|
208
236
|
# @returns [Protocol::HTTP::Response]
|
|
209
|
-
def store_recording(request, slide)
|
|
237
|
+
def store_recording(request, slide, duration: nil)
|
|
210
238
|
content_type = request.headers["content-type"]&.split(";", 2)&.first
|
|
211
239
|
unless content_type == Recordings::CONTENT_TYPE
|
|
212
240
|
return Protocol::HTTP::Response[415, [], ["Expected #{Recordings::CONTENT_TYPE}."]]
|
|
@@ -217,7 +245,10 @@ module Presently
|
|
|
217
245
|
end
|
|
218
246
|
|
|
219
247
|
@recordings.write(slide, request.body)
|
|
220
|
-
|
|
248
|
+
slide.update_duration!(duration) if duration
|
|
249
|
+
|
|
250
|
+
result = duration ? "{\"saved\":true,\"duration\":#{duration}}" : "{\"saved\":true}"
|
|
251
|
+
Protocol::HTTP::Response[201, [["content-type", "application/json"]], [result]]
|
|
221
252
|
rescue Recordings::TooLarge => error
|
|
222
253
|
Protocol::HTTP::Response[413, [], [error.message]]
|
|
223
254
|
end
|
data/lib/presently/home_view.rb
CHANGED
|
@@ -20,7 +20,7 @@ module Presently
|
|
|
20
20
|
description: "Control the presentation with speaker notes, timing, and slide previews.",
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
|
-
href: "/
|
|
23
|
+
href: "/recorder",
|
|
24
24
|
title: "Narration Recorder",
|
|
25
25
|
description: "Record, review, and save narration for each slide.",
|
|
26
26
|
},
|
data/lib/presently/page.rb
CHANGED
|
@@ -29,6 +29,7 @@ module Presently
|
|
|
29
29
|
].freeze
|
|
30
30
|
IMPORTS = {
|
|
31
31
|
"live" => "/_components/@socketry/live/Live.js",
|
|
32
|
+
"@socketry/live" => "/_components/@socketry/live/Live.js",
|
|
32
33
|
"live-audio" => "/_components/@socketry/live-audio/Live/Audio.js",
|
|
33
34
|
"morphdom" => "/_components/morphdom/morphdom-esm.js",
|
|
34
35
|
"@socketry/presently" => "/_components/@socketry/presently/Presently.js",
|
data/lib/presently/playback.xrb
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
<script type="importmap">
|
|
20
20
|
{
|
|
21
21
|
"imports": {
|
|
22
|
+
"morphdom": "/_components/morphdom/morphdom-esm.js",
|
|
22
23
|
"@socketry/presently": "/_components/@socketry/presently/Presently.js",
|
|
23
24
|
"@socketry/syntax": "/_components/@socketry/syntax/Syntax.js",
|
|
24
25
|
"animejs": "/_components/animejs/dist/bundles/anime.esm.min.js"
|
|
@@ -31,12 +32,17 @@
|
|
|
31
32
|
|
|
32
33
|
<body class="playback" data-autoplay="#{self.autoplay}" data-controls="#{self.controls}">
|
|
33
34
|
<main class="playback-stage">
|
|
35
|
+
<div id="playback-frame" class="playback-frame slide-viewport"></div>
|
|
36
|
+
</main>
|
|
37
|
+
|
|
38
|
+
<!-- Keep inactive slides inert while retaining their rendered HTML for local navigation. -->
|
|
39
|
+
<div class="playback-slides" hidden>
|
|
34
40
|
<?r self.slides.each_with_index do |slide, index| ?>
|
|
35
|
-
<
|
|
41
|
+
<template data-index="#{index}" data-transition="#{slide.transition}" data-duration="#{slide.duration}">
|
|
36
42
|
#{self.render_slide(slide)}
|
|
37
|
-
</
|
|
43
|
+
</template>
|
|
38
44
|
<?r end ?>
|
|
39
|
-
</
|
|
45
|
+
</div>
|
|
40
46
|
|
|
41
47
|
<div class="playback-audio" hidden>
|
|
42
48
|
<?r self.slides.each_with_index do |slide, index| ?>
|
|
@@ -18,8 +18,10 @@ module Presently
|
|
|
18
18
|
# Initialize a new controller for the given presentation.
|
|
19
19
|
# @parameter presentation [Presentation] The presentation to control.
|
|
20
20
|
# @parameter state [State | Nil] The state persistence object. If provided, state is saved on changes and restored on initialization.
|
|
21
|
-
|
|
21
|
+
# @parameter recordings [Recordings | Nil] The narration recording store, when recording is enabled.
|
|
22
|
+
def initialize(presentation, state: nil, recordings: nil)
|
|
22
23
|
@presentation = presentation
|
|
24
|
+
@recordings = recordings
|
|
23
25
|
@current_index = 0
|
|
24
26
|
@clock = Clock.new
|
|
25
27
|
@listeners = []
|
|
@@ -55,6 +57,13 @@ module Presently
|
|
|
55
57
|
@presentation.slides[@current_index]
|
|
56
58
|
end
|
|
57
59
|
|
|
60
|
+
# Whether the given slide has a narration recording.
|
|
61
|
+
# @parameter slide [Slide] The slide to check.
|
|
62
|
+
# @returns [Boolean]
|
|
63
|
+
def recording_available?(slide)
|
|
64
|
+
@recordings&.exist?(slide) || false
|
|
65
|
+
end
|
|
66
|
+
|
|
58
67
|
# The slide following the current one.
|
|
59
68
|
# @returns [Slide | Nil] The next slide, or `nil` if on the last slide.
|
|
60
69
|
def next_slide
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
# Copyright, 2026, by Samuel Williams.
|
|
5
5
|
|
|
6
6
|
require_relative "editor"
|
|
7
|
+
require_relative "recording_controls_view"
|
|
7
8
|
require_relative "slide_view"
|
|
8
9
|
require_relative "slide_renderer"
|
|
9
10
|
|
|
@@ -13,7 +14,7 @@ module Presently
|
|
|
13
14
|
# Recording is intentionally separate from {PresenterView}: presenting is a
|
|
14
15
|
# live performance interface, while recording is an authoring workflow with
|
|
15
16
|
# retakes, playback, and explicit saving.
|
|
16
|
-
class
|
|
17
|
+
class RecorderView < SlideView
|
|
17
18
|
# Initialize a recording view.
|
|
18
19
|
# @parameter id [String] The unique element identifier.
|
|
19
20
|
# @parameter data [Hash] The element data attributes.
|
|
@@ -22,6 +23,7 @@ module Presently
|
|
|
22
23
|
super(id, data)
|
|
23
24
|
@controller = controller
|
|
24
25
|
@slide_renderer = SlideRenderer.new(css_class: "slide recording-slide", templates: controller.templates)
|
|
26
|
+
@recording_controls = RecordingControlsView.mount(self, "recording-controls", controller: controller)
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
# Bind this view to a page and register for slide changes.
|
|
@@ -73,9 +75,6 @@ module Presently
|
|
|
73
75
|
slide = @controller.current_slide
|
|
74
76
|
return unless slide
|
|
75
77
|
|
|
76
|
-
index = @controller.current_index
|
|
77
|
-
recording_url = "/recordings?index=#{index}"
|
|
78
|
-
|
|
79
78
|
builder.tag(:div, class: "recorder") do
|
|
80
79
|
render_navigation(builder, slide)
|
|
81
80
|
|
|
@@ -88,21 +87,7 @@ module Presently
|
|
|
88
87
|
builder.tag(:section, class: "recording-panel") do
|
|
89
88
|
builder.tag(:h2){builder.text("Narration")}
|
|
90
89
|
builder.tag(:p){builder.text("Record, review, and save one audio track for this slide.")}
|
|
91
|
-
|
|
92
|
-
builder.tag("presently-recorder",
|
|
93
|
-
id: "presently-recorder-#{index}",
|
|
94
|
-
"data-recording-url": recording_url
|
|
95
|
-
) do
|
|
96
|
-
builder.tag(:div, class: "recording-actions") do
|
|
97
|
-
builder.tag(:button, class: "recording-toggle", type: "button"){builder.text("● Record")}
|
|
98
|
-
builder.tag(:button, class: "recording-save", type: "button", disabled: true){builder.text("Save")}
|
|
99
|
-
builder.tag(:span, class: "recording-indicator", "aria-hidden": "true"){}
|
|
100
|
-
builder.tag(:span, class: "recording-time"){builder.text("0:00")}
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
builder.tag(:audio, class: "recording-playback", controls: true, preload: "metadata", hidden: true){}
|
|
104
|
-
builder.tag(:p, class: "recording-status", role: "status"){builder.text("Checking for an existing recording…")}
|
|
105
|
-
end
|
|
90
|
+
builder << @recording_controls.to_html
|
|
106
91
|
end
|
|
107
92
|
|
|
108
93
|
builder.tag(:section, class: "recording-notes") do
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "live"
|
|
7
|
+
|
|
8
|
+
module Presently
|
|
9
|
+
# The persistent narration controls nested within {RecorderView}.
|
|
10
|
+
class RecordingControlsView < Live::View
|
|
11
|
+
# Initialize the recording controls view.
|
|
12
|
+
# @parameter id [String] The stable element identifier.
|
|
13
|
+
# @parameter data [Hash] The element data attributes.
|
|
14
|
+
# @parameter controller [PresentationController] The shared presentation controller.
|
|
15
|
+
def initialize(id, data, controller:)
|
|
16
|
+
super(id, data)
|
|
17
|
+
@controller = controller
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Use the client-side recorder as this live view's custom element.
|
|
21
|
+
def tag_name
|
|
22
|
+
"presently-recording-controls"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Render the recorder host. Its shadow DOM owns the controls themselves.
|
|
26
|
+
# @parameter builder [XRB::Builder] The HTML builder.
|
|
27
|
+
def build_markup(builder)
|
|
28
|
+
slide = @controller.current_slide
|
|
29
|
+
return unless slide
|
|
30
|
+
|
|
31
|
+
index = @controller.current_index
|
|
32
|
+
recording_present = @controller.recording_available?(slide)
|
|
33
|
+
|
|
34
|
+
builder.inline_tag(tag_name,
|
|
35
|
+
id: @id,
|
|
36
|
+
data: @data,
|
|
37
|
+
"data-slide-index": index,
|
|
38
|
+
"data-recording-url": "/recordings?index=#{index}",
|
|
39
|
+
"data-slide-duration": slide.duration,
|
|
40
|
+
"data-recording-state": recording_present ? "present" : "missing"
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/presently/slide.rb
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
require "yaml"
|
|
7
7
|
require "markly"
|
|
8
8
|
require "protocol/url"
|
|
9
|
+
require "tempfile"
|
|
9
10
|
|
|
10
11
|
require "markly/renderer/html"
|
|
11
12
|
|
|
@@ -353,6 +354,47 @@ module Presently
|
|
|
353
354
|
@front_matter&.fetch("duration", 60) || 60
|
|
354
355
|
end
|
|
355
356
|
|
|
357
|
+
# Update the expected duration in the slide's YAML front matter.
|
|
358
|
+
# Preserves the remainder of the source file rather than reserializing it.
|
|
359
|
+
# @parameter duration [Integer] The new positive duration in seconds.
|
|
360
|
+
# @returns [Integer] The persisted duration.
|
|
361
|
+
def update_duration!(duration)
|
|
362
|
+
duration = Integer(duration)
|
|
363
|
+
raise ArgumentError, "Duration must be positive!" unless duration.positive?
|
|
364
|
+
|
|
365
|
+
path = source_path
|
|
366
|
+
source = File.read(path)
|
|
367
|
+
newline = source.include?("\r\n") ? "\r\n" : "\n"
|
|
368
|
+
|
|
369
|
+
front_matter_pattern = /\A---[ \t]*(?<newline>\r?\n)(?<body>.*?)(?<closing>^---[ \t]*(?:\r?\n|\z))/m
|
|
370
|
+
if match = front_matter_pattern.match(source)
|
|
371
|
+
body = match[:body]
|
|
372
|
+
|
|
373
|
+
if line = /^duration:[^#\r\n]*(?<comment>[ \t]+#[^\r\n]*)?(?<newline>\r?\n|\z)/.match(body)
|
|
374
|
+
replacement = "duration: #{duration}#{line[:comment]}#{line[:newline]}"
|
|
375
|
+
body = body[0...line.begin(0)] + replacement + body[line.end(0)..]
|
|
376
|
+
else
|
|
377
|
+
body += match[:newline] unless body.empty? || body.end_with?("\n")
|
|
378
|
+
body += "duration: #{duration}#{match[:newline]}"
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
source = source[0...match.begin(:body)] + body + source[match.end(:body)..]
|
|
382
|
+
else
|
|
383
|
+
source = "---#{newline}duration: #{duration}#{newline}---#{newline}#{source}"
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
stat = File.stat(path)
|
|
387
|
+
Tempfile.create([".presently-slide", ".md"], File.dirname(path), binmode: true) do |file|
|
|
388
|
+
file.chmod(stat.mode & 0o7777)
|
|
389
|
+
file.write(source)
|
|
390
|
+
file.flush
|
|
391
|
+
File.rename(file.path, path)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
(@front_matter ||= {})["duration"] = duration
|
|
395
|
+
return duration
|
|
396
|
+
end
|
|
397
|
+
|
|
356
398
|
# The title of this slide.
|
|
357
399
|
# @returns [String] The H1 text, front matter title, or filename without extension.
|
|
358
400
|
def title
|
|
@@ -45,7 +45,9 @@ module Presently
|
|
|
45
45
|
scope = TemplateScope.new(slide)
|
|
46
46
|
html = template.to_string(scope)
|
|
47
47
|
|
|
48
|
-
classes = [@css_class, extra_class]
|
|
48
|
+
classes = [@css_class, extra_class]
|
|
49
|
+
classes << "slide-scripted" unless slide.scripts.empty?
|
|
50
|
+
classes = classes.compact.join(" ")
|
|
49
51
|
path = Stylesheet.encode_path(slide.path)
|
|
50
52
|
|
|
51
53
|
builder.tag(:div, class: "slide-surface", data: {template: slide.template}) do
|
data/lib/presently/version.rb
CHANGED
data/lib/presently.rb
CHANGED
|
@@ -8,7 +8,7 @@ require_relative "presently/version"
|
|
|
8
8
|
require_relative "presently/recordings"
|
|
9
9
|
require_relative "presently/stylesheet"
|
|
10
10
|
require_relative "presently/recordings/normalizer"
|
|
11
|
-
require_relative "presently/
|
|
11
|
+
require_relative "presently/recorder_view"
|
|
12
12
|
require_relative "presently/playback"
|
|
13
13
|
require_relative "presently/application"
|
|
14
14
|
require_relative "presently/environment/application"
|
|
@@ -25,6 +25,9 @@ export function runScript(slideEl, {animated = true} = {}) {
|
|
|
25
25
|
}
|
|
26
26
|
} catch (error) {
|
|
27
27
|
console.error('Slide script error:', error);
|
|
28
|
+
} finally {
|
|
29
|
+
// Scripted slides remain hidden until their initial state has been applied.
|
|
30
|
+
slideEl.dataset.slideReady = '';
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
return slide;
|
|
@@ -12,15 +12,13 @@ async function prepareSlides() {
|
|
|
12
12
|
// Owns the scripts and view transition associated with one rendering of a slide view.
|
|
13
13
|
export class SlideRendering {
|
|
14
14
|
#view;
|
|
15
|
-
#html;
|
|
16
15
|
#transitionName;
|
|
17
16
|
#viewTransition = null;
|
|
18
17
|
#slides = [];
|
|
19
18
|
#disposed = false;
|
|
20
19
|
|
|
21
|
-
constructor(view, {
|
|
20
|
+
constructor(view, {transition = null} = {}) {
|
|
22
21
|
this.#view = view;
|
|
23
|
-
this.#html = html;
|
|
24
22
|
this.#transitionName = transition;
|
|
25
23
|
}
|
|
26
24
|
|
|
@@ -29,39 +27,46 @@ export class SlideRendering {
|
|
|
29
27
|
async initialize() {
|
|
30
28
|
if (this.#disposed) return false;
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
// Establish script-controlled visibility before yielding to asynchronous
|
|
31
|
+
// highlighting, so partially initialized slide content is never painted.
|
|
35
32
|
this.#view.querySelectorAll('.slide').forEach(slideElement => {
|
|
36
33
|
const slide = runScript(slideElement);
|
|
37
34
|
if (slide) this.#slides.push(slide);
|
|
38
35
|
});
|
|
39
36
|
|
|
40
|
-
|
|
37
|
+
await prepareSlides();
|
|
38
|
+
return !this.#disposed;
|
|
41
39
|
}
|
|
42
40
|
|
|
43
41
|
// Update the view, initialize its slides, and emit the change event.
|
|
44
|
-
//
|
|
42
|
+
// The update must mutate the view synchronously so slide scripts can establish
|
|
43
|
+
// their initial state before the browser can paint the updated DOM.
|
|
44
|
+
// @parameter update [Function] Updates the view's DOM synchronously.
|
|
45
45
|
// @returns [Promise(Boolean)] Whether the rendering was completed.
|
|
46
|
-
async render(
|
|
46
|
+
async render(update) {
|
|
47
47
|
if (this.#disposed) return false;
|
|
48
48
|
|
|
49
49
|
let initialized = false;
|
|
50
|
-
const
|
|
50
|
+
const render = async () => {
|
|
51
51
|
if (this.#disposed) return;
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
update(this.#view);
|
|
54
54
|
initialized = await this.initialize();
|
|
55
55
|
};
|
|
56
56
|
|
|
57
|
-
if (this.#transitionName && document.startViewTransition) {
|
|
57
|
+
if (this.#transitionName && document.startViewTransition && !document.hidden) {
|
|
58
58
|
document.documentElement.dataset.transition = this.#transitionName;
|
|
59
59
|
let viewTransition = null;
|
|
60
60
|
|
|
61
61
|
try {
|
|
62
|
-
viewTransition = document.startViewTransition(
|
|
62
|
+
viewTransition = document.startViewTransition(render);
|
|
63
63
|
this.#viewTransition = viewTransition;
|
|
64
|
-
|
|
64
|
+
|
|
65
|
+
// A hidden document may abort the visual transition, but the DOM update should still complete.
|
|
66
|
+
viewTransition.ready.catch(() => {});
|
|
67
|
+
const finished = viewTransition.finished.catch(() => {});
|
|
68
|
+
await viewTransition.updateCallbackDone;
|
|
69
|
+
await finished;
|
|
65
70
|
} finally {
|
|
66
71
|
if (this.#viewTransition === viewTransition) {
|
|
67
72
|
this.#viewTransition = null;
|
|
@@ -69,7 +74,7 @@ export class SlideRendering {
|
|
|
69
74
|
}
|
|
70
75
|
}
|
|
71
76
|
} else {
|
|
72
|
-
await
|
|
77
|
+
await render();
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
if (initialized && !this.#disposed) {
|
data/public/_static/recorder.css
CHANGED
|
@@ -55,62 +55,81 @@
|
|
|
55
55
|
opacity: 0.65;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
presently-
|
|
58
|
+
presently-recording-controls {
|
|
59
59
|
display: grid;
|
|
60
60
|
gap: 1rem;
|
|
61
|
+
/* Preserve the completed control layout while the client component upgrades. */
|
|
62
|
+
min-block-size: 12.5rem;
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
|
|
65
|
+
presently-recording-controls::part(actions) {
|
|
64
66
|
display: flex;
|
|
65
67
|
align-items: center;
|
|
66
68
|
gap: 0.6rem;
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
|
|
71
|
+
presently-recording-controls::part(toggle),
|
|
72
|
+
presently-recording-controls::part(save),
|
|
73
|
+
presently-recording-controls::part(apply-duration) {
|
|
74
|
+
box-sizing: border-box;
|
|
70
75
|
padding: 0.55rem 0.9rem;
|
|
71
76
|
background: var(--surface-light);
|
|
72
|
-
border: 1px solid rgba(255,255,255,0.12);
|
|
77
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
73
78
|
border-radius: 6px;
|
|
74
79
|
color: var(--slide-text);
|
|
75
80
|
cursor: pointer;
|
|
76
81
|
font: inherit;
|
|
82
|
+
transition:
|
|
83
|
+
background-color 120ms ease-out,
|
|
84
|
+
border-color 120ms ease-out,
|
|
85
|
+
color 120ms ease-out,
|
|
86
|
+
opacity 120ms ease-out;
|
|
77
87
|
}
|
|
78
88
|
|
|
79
|
-
|
|
89
|
+
presently-recording-controls::part(apply-duration) {
|
|
90
|
+
justify-self: start;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
presently-recording-controls::part(toggle):hover:not(:disabled),
|
|
94
|
+
presently-recording-controls::part(save):hover:not(:disabled),
|
|
95
|
+
presently-recording-controls::part(apply-duration):hover:not(:disabled) {
|
|
80
96
|
background: var(--accent);
|
|
81
97
|
}
|
|
82
98
|
|
|
83
|
-
|
|
99
|
+
presently-recording-controls::part(toggle):disabled,
|
|
100
|
+
presently-recording-controls::part(save):disabled,
|
|
101
|
+
presently-recording-controls::part(apply-duration):disabled {
|
|
84
102
|
cursor: default;
|
|
85
103
|
opacity: 0.35;
|
|
86
104
|
}
|
|
87
105
|
|
|
88
|
-
|
|
106
|
+
presently-recording-controls::part(toggle):not(:disabled) {
|
|
89
107
|
color: var(--accent-light);
|
|
90
108
|
}
|
|
91
109
|
|
|
92
|
-
presently-
|
|
110
|
+
presently-recording-controls[data-recording-state="recording"]::part(toggle) {
|
|
93
111
|
color: #ef5350;
|
|
94
112
|
}
|
|
95
113
|
|
|
96
|
-
|
|
114
|
+
presently-recording-controls::part(indicator) {
|
|
115
|
+
box-sizing: border-box;
|
|
97
116
|
width: 0.65rem;
|
|
98
117
|
height: 0.65rem;
|
|
99
118
|
margin-left: auto;
|
|
100
|
-
background: rgba(255,255,255,0.2);
|
|
119
|
+
background: rgba(255, 255, 255, 0.2);
|
|
101
120
|
border-radius: 50%;
|
|
102
121
|
}
|
|
103
122
|
|
|
104
|
-
presently-
|
|
105
|
-
presently-
|
|
123
|
+
presently-recording-controls[data-recording-state="preparing"]::part(indicator),
|
|
124
|
+
presently-recording-controls[data-recording-state="starting"]::part(indicator) {
|
|
106
125
|
background: #f5b942;
|
|
107
|
-
box-shadow: 0 0 0 0.25rem rgba(245,185,66,0.15);
|
|
126
|
+
box-shadow: 0 0 0 0.25rem rgba(245, 185, 66, 0.15);
|
|
108
127
|
animation: recording-indicator-pulse 600ms ease-in-out infinite alternate;
|
|
109
128
|
}
|
|
110
129
|
|
|
111
|
-
presently-
|
|
130
|
+
presently-recording-controls[data-recording-state="recording"]::part(indicator) {
|
|
112
131
|
background: #ef5350;
|
|
113
|
-
box-shadow: 0 0 0 0.25rem rgba(239,83,80,0.15);
|
|
132
|
+
box-shadow: 0 0 0 0.25rem rgba(239, 83, 80, 0.15);
|
|
114
133
|
}
|
|
115
134
|
|
|
116
135
|
@keyframes recording-indicator-pulse {
|
|
@@ -120,25 +139,29 @@ presently-recorder[data-state="recording"] .recording-indicator {
|
|
|
120
139
|
}
|
|
121
140
|
}
|
|
122
141
|
|
|
123
|
-
|
|
142
|
+
presently-recording-controls::part(time) {
|
|
124
143
|
font-family: var(--font-mono);
|
|
125
144
|
font-variant-numeric: tabular-nums;
|
|
126
145
|
}
|
|
127
146
|
|
|
128
|
-
|
|
147
|
+
presently-recording-controls::part(playback) {
|
|
148
|
+
display: block;
|
|
129
149
|
width: 100%;
|
|
130
150
|
}
|
|
131
151
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
152
|
+
presently-recording-controls::part(duration-option) {
|
|
153
|
+
display: flex;
|
|
154
|
+
align-items: center;
|
|
155
|
+
gap: 0.5rem;
|
|
156
|
+
cursor: pointer;
|
|
137
157
|
}
|
|
138
158
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
159
|
+
@media (prefers-reduced-motion: reduce) {
|
|
160
|
+
presently-recording-controls::part(toggle),
|
|
161
|
+
presently-recording-controls::part(save),
|
|
162
|
+
presently-recording-controls::part(apply-duration) {
|
|
163
|
+
transition: none;
|
|
164
|
+
}
|
|
142
165
|
}
|
|
143
166
|
|
|
144
167
|
.recording-notes {
|
data/public/_static/slides.css
CHANGED
|
@@ -60,6 +60,11 @@
|
|
|
60
60
|
container-type: size;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/* Never expose a scripted slide before its script establishes the initial state. */
|
|
64
|
+
.slide-scripted:not([data-slide-ready]) {
|
|
65
|
+
visibility: hidden;
|
|
66
|
+
}
|
|
67
|
+
|
|
63
68
|
@container (min-aspect-ratio: 16 / 9) {
|
|
64
69
|
.slide {
|
|
65
70
|
width: auto;
|