hadar 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/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +234 -0
- data/assets/themes/dark.jsonc +11 -0
- data/assets/themes/minimal.jsonc +12 -0
- data/assets/themes/warm.jsonc +11 -0
- data/docs/adr/001-template-layouts.md +22 -0
- data/lib/hadar/application.rb +749 -0
- data/lib/hadar/command_palette.rb +57 -0
- data/lib/hadar/deck.rb +440 -0
- data/lib/hadar/deck_watcher.rb +33 -0
- data/lib/hadar/export/pdf.rb +150 -0
- data/lib/hadar/export/png_sequence.rb +63 -0
- data/lib/hadar/layout.rb +80 -0
- data/lib/hadar/presenter.rb +92 -0
- data/lib/hadar/renderer.rb +238 -0
- data/lib/hadar/rich_text_projection.rb +110 -0
- data/lib/hadar/rich_text_writeback.rb +377 -0
- data/lib/hadar/slide.rb +130 -0
- data/lib/hadar/slide_list.rb +74 -0
- data/lib/hadar/slot.rb +149 -0
- data/lib/hadar/theme.rb +85 -0
- data/lib/hadar/version.rb +5 -0
- data/lib/hadar.rb +31 -0
- data/sig/hadar.rbs +221 -0
- metadata +168 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hadar
|
|
4
|
+
class RichTextWriteback
|
|
5
|
+
def initialize(projection, value)
|
|
6
|
+
@projection, @document, @text, @segments = projection, projection.document, projection.text, projection.segments
|
|
7
|
+
@value = value
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def replace
|
|
11
|
+
value = @value
|
|
12
|
+
change = diff(value.text)
|
|
13
|
+
level_changes = paragraph_level_changes(value, change)
|
|
14
|
+
if change[:first] == change[:last] && change[:inserted].empty?
|
|
15
|
+
raise Error, "edit one list item level at a time" if level_changes.length > 1
|
|
16
|
+
return apply_list_level_change(level_changes.first) unless level_changes.empty?
|
|
17
|
+
|
|
18
|
+
style = style_change(value)
|
|
19
|
+
return @document unless style
|
|
20
|
+
return apply_style_change(style)
|
|
21
|
+
end
|
|
22
|
+
raise Error, "list levels cannot be changed together with text" unless level_changes.empty?
|
|
23
|
+
|
|
24
|
+
segment = editable_segment(change[:first], change[:last])
|
|
25
|
+
validate_styles!(value, change, segment)
|
|
26
|
+
local_first = change[:first] - segment.start
|
|
27
|
+
local_last = change[:last] - segment.start
|
|
28
|
+
replacement = segment.text.byteslice(0...local_first).to_s + change[:inserted] +
|
|
29
|
+
segment.text.byteslice(local_last..).to_s
|
|
30
|
+
|
|
31
|
+
case segment.node.type
|
|
32
|
+
when :text
|
|
33
|
+
source_text = @document.source.byteslice(segment.node.range)
|
|
34
|
+
raise Error, "escaped Markdown text cannot be edited through rich text yet" unless source_text == segment.text
|
|
35
|
+
|
|
36
|
+
first = segment.node.range.begin + local_first
|
|
37
|
+
last = segment.node.range.begin + local_last
|
|
38
|
+
Beid::Editing.edit_range(@document, segment.node, first...last, change[:inserted])
|
|
39
|
+
when :code_span
|
|
40
|
+
Beid::Editing.replace_text(@document, segment.node, replacement)
|
|
41
|
+
when :image
|
|
42
|
+
range = segment.node.attributes.fetch(:label_range)
|
|
43
|
+
source_text = @document.source.byteslice(range)
|
|
44
|
+
raise Error, "escaped image labels cannot be edited through rich text yet" unless source_text == segment.text
|
|
45
|
+
|
|
46
|
+
first = range.begin + local_first
|
|
47
|
+
last = range.begin + local_last
|
|
48
|
+
Beid::Editing.edit_range(@document, segment.node, first...last, change[:inserted])
|
|
49
|
+
else
|
|
50
|
+
raise Error, "rich text edit crosses Markdown structure"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def paragraph_level_changes(value, change)
|
|
57
|
+
if change[:first] != change[:last] || !change[:inserted].empty?
|
|
58
|
+
first_line = @text.byteslice(0...change[:first]).to_s.count("\n")
|
|
59
|
+
last_line = @text.byteslice(0...change[:last]).to_s.count("\n")
|
|
60
|
+
inherited = @projection.paragraph_styles.fetch(first_line, {})
|
|
61
|
+
expected = @projection.paragraph_styles.take(first_line) +
|
|
62
|
+
Array.new(change[:inserted].count("\n") + 1) { inherited } +
|
|
63
|
+
@projection.paragraph_styles.drop(last_line + 1)
|
|
64
|
+
raise Error, "list levels cannot be changed together with text" unless value.paragraph_styles == expected
|
|
65
|
+
|
|
66
|
+
return []
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
line_starts = [0]
|
|
70
|
+
@text.b.each_byte.with_index { |byte, index| line_starts << index + 1 if byte == 10 }
|
|
71
|
+
changes = {}
|
|
72
|
+
value.paragraph_styles.each_with_index do |style, index|
|
|
73
|
+
raise Error, "only list levels can be written as paragraph styles" unless (style.keys - [:level]).empty?
|
|
74
|
+
|
|
75
|
+
offset = line_starts.fetch(index, @text.bytesize)
|
|
76
|
+
segment = @segments.find do |candidate|
|
|
77
|
+
candidate.list_item && candidate.start <= offset && offset < candidate.finish
|
|
78
|
+
end
|
|
79
|
+
if segment
|
|
80
|
+
current = style[:level]
|
|
81
|
+
next if current == segment.list_level
|
|
82
|
+
raise Error, "list level must be a nonnegative integer" unless current.is_a?(Integer) && current >= 0
|
|
83
|
+
|
|
84
|
+
key = segment.list_item.object_id
|
|
85
|
+
change = {item: segment.list_item, level: current}
|
|
86
|
+
if changes.key?(key) && changes[key][:level] != current
|
|
87
|
+
raise Error, "one list item cannot have multiple levels"
|
|
88
|
+
end
|
|
89
|
+
changes[key] = change
|
|
90
|
+
elsif style.key?(:level)
|
|
91
|
+
raise Error, "list level can only be changed on an existing list item"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
changes.values
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def apply_list_level_change(change)
|
|
98
|
+
item = change.fetch(:item)
|
|
99
|
+
context = @projection.list_context.fetch(item.object_id)
|
|
100
|
+
current_level = context.fetch(:level)
|
|
101
|
+
target_level = change.fetch(:level)
|
|
102
|
+
unless (target_level - current_level).abs == 1
|
|
103
|
+
raise Error, "change list level one step at a time"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
source = @document.source.b
|
|
107
|
+
line_start = physical_line_start(source, item.range.begin)
|
|
108
|
+
item_range = line_start...item.range.end
|
|
109
|
+
prefix = source.byteslice(line_start...item.range.begin).to_s
|
|
110
|
+
raise Error, "list item indentation is not whitespace" unless prefix.match?(/\A[ \t]*\z/)
|
|
111
|
+
|
|
112
|
+
target_indent = if target_level > current_level
|
|
113
|
+
previous = context[:previous] || raise(Error, "a list item needs a previous sibling before it can be indented")
|
|
114
|
+
list_item_content_indent(source, previous)
|
|
115
|
+
else
|
|
116
|
+
parent = context[:parent] || raise(Error, "a top-level list item cannot be outdented")
|
|
117
|
+
parent_start = physical_line_start(source, parent.range.begin)
|
|
118
|
+
indentation_columns(source.byteslice(parent_start...parent.range.begin).to_s)
|
|
119
|
+
end
|
|
120
|
+
target_parent_line = if target_level > current_level
|
|
121
|
+
source_line(context.fetch(:previous).range.begin, source)
|
|
122
|
+
else
|
|
123
|
+
parent_context = @projection.list_context.fetch(context.fetch(:parent).object_id)
|
|
124
|
+
parent_context[:parent] && source_line(parent_context[:parent].range.begin, source)
|
|
125
|
+
end
|
|
126
|
+
delta = target_indent - indentation_columns(prefix)
|
|
127
|
+
raise Error, "list level change has no source indentation change" if delta.zero?
|
|
128
|
+
|
|
129
|
+
replacement = shift_list_item(source.byteslice(item_range).to_s, delta)
|
|
130
|
+
updated = Beid::Editing.edit_range(@document, context.fetch(:list), item_range, replacement)
|
|
131
|
+
new_prefix = replacement[/\A[ \t]*/].to_s
|
|
132
|
+
updated_item, actual_level = find_list_item(updated.root, line_start + new_prefix.bytesize)
|
|
133
|
+
unless updated_item && actual_level == target_level && updated_item.marker == item.marker
|
|
134
|
+
raise Error, "list level change could not be represented without changing Markdown structure"
|
|
135
|
+
end
|
|
136
|
+
unless list_item_structure(updated.root, updated.source) ==
|
|
137
|
+
expected_list_item_structure(item, line_start, item_range.end, target_level - current_level, target_parent_line)
|
|
138
|
+
raise Error, "list level change would alter a neighboring list item"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
updated
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def physical_line_start(source, offset)
|
|
145
|
+
newline = source.rindex("\n".b, offset - 1)
|
|
146
|
+
newline ? newline + 1 : 0
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def source_line(offset, source)
|
|
150
|
+
source.byteslice(0...offset).count("\n")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def list_item_content_indent(source, item)
|
|
154
|
+
line_start = physical_line_start(source, item.range.begin)
|
|
155
|
+
line_end = source.index("\n".b, item.range.begin) || source.bytesize
|
|
156
|
+
line = source.byteslice(line_start...line_end).to_s
|
|
157
|
+
marker = /\A[ \t]*(?:[-+*]|\d{1,9}[.)])[ \t]+/.match(line)
|
|
158
|
+
raise Error, "list item marker cannot be indented safely" unless marker
|
|
159
|
+
|
|
160
|
+
indentation_columns(marker[0])
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def indentation_columns(prefix)
|
|
164
|
+
prefix.each_char.reduce(0) { |column, character| character == "\t" ? (column / 4 + 1) * 4 : column + 1 }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def shift_list_item(text, delta)
|
|
168
|
+
text.each_line.map do |line|
|
|
169
|
+
body = line.sub(/(?:\r\n|\r|\n)\z/, "")
|
|
170
|
+
next line if body.match?(/\A[ \t]*\z/)
|
|
171
|
+
next (" " * delta) + line if delta.positive?
|
|
172
|
+
|
|
173
|
+
remove_indent(line, -delta)
|
|
174
|
+
end.join
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def remove_indent(line, columns)
|
|
178
|
+
prefix = line[/\A[ \t]*/].to_s
|
|
179
|
+
removed_columns = 0
|
|
180
|
+
removed_bytes = 0
|
|
181
|
+
prefix.each_char do |character|
|
|
182
|
+
width = character == "\t" ? (removed_columns / 4 + 1) * 4 - removed_columns : 1
|
|
183
|
+
raise Error, "list item indentation cannot be removed evenly" if removed_columns + width > columns
|
|
184
|
+
|
|
185
|
+
removed_columns += width
|
|
186
|
+
removed_bytes += character.bytesize
|
|
187
|
+
break if removed_columns == columns
|
|
188
|
+
end
|
|
189
|
+
raise Error, "list item has a continuation line with insufficient indentation" unless removed_columns == columns
|
|
190
|
+
|
|
191
|
+
line.byteslice(removed_bytes..).to_s
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def find_list_item(node, offset, level = 0)
|
|
195
|
+
if %i[list ordered_list].include?(node.type)
|
|
196
|
+
node.children.each do |item|
|
|
197
|
+
return [item, level] if item.type == :list_item && item.range.begin == offset
|
|
198
|
+
|
|
199
|
+
item.children.each do |child|
|
|
200
|
+
found = find_list_item(child, offset, level + 1)
|
|
201
|
+
return found if found
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
else
|
|
205
|
+
node.children.each do |child|
|
|
206
|
+
found = find_list_item(child, offset, level)
|
|
207
|
+
return found if found
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def list_item_structure(root, source)
|
|
214
|
+
items = {}
|
|
215
|
+
collect_list_item_structure(root, source, 0, nil, items)
|
|
216
|
+
items.transform_values { |entry| {level: entry[:level], parent: entry[:parent]} }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def expected_list_item_structure(item, line_start, item_end, level_delta, target_parent_line)
|
|
220
|
+
source = @document.source.b
|
|
221
|
+
original = {}
|
|
222
|
+
collect_list_item_structure(@document.root, source, 0, nil, original)
|
|
223
|
+
selected_line = source_line(item.range.begin, source)
|
|
224
|
+
original.to_h do |line, entry|
|
|
225
|
+
inside_item = entry[:offset] >= line_start && entry[:offset] < item_end
|
|
226
|
+
expected = {level: entry[:level] + (inside_item ? level_delta : 0), parent: entry[:parent]}
|
|
227
|
+
expected[:parent] = target_parent_line if line == selected_line
|
|
228
|
+
[line, expected]
|
|
229
|
+
end.tap do |depths|
|
|
230
|
+
raise Error, "list item is not part of its projected source" unless depths.key?(selected_line)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def collect_list_item_structure(node, source, level, parent_line, items)
|
|
235
|
+
if %i[list ordered_list].include?(node.type)
|
|
236
|
+
node.children.each do |item|
|
|
237
|
+
line = source_line(item.range.begin, source)
|
|
238
|
+
items[line] = {level: level, parent: parent_line, offset: item.range.begin}
|
|
239
|
+
item.children.each { |child| collect_list_item_structure(child, source, level + 1, line, items) }
|
|
240
|
+
end
|
|
241
|
+
else
|
|
242
|
+
node.children.each { |child| collect_list_item_structure(child, source, level, parent_line, items) }
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def diff(value)
|
|
247
|
+
raise ArgumentError, "rich text must be valid UTF-8" unless value.is_a?(String) && value.valid_encoding?
|
|
248
|
+
|
|
249
|
+
old_clusters, new_clusters = @text.scan(/\X/), value.scan(/\X/)
|
|
250
|
+
prefix = 0
|
|
251
|
+
prefix += 1 while prefix < old_clusters.length && prefix < new_clusters.length &&
|
|
252
|
+
old_clusters[prefix] == new_clusters[prefix]
|
|
253
|
+
suffix = 0
|
|
254
|
+
suffix += 1 while suffix < old_clusters.length - prefix && suffix < new_clusters.length - prefix &&
|
|
255
|
+
old_clusters[-suffix - 1] == new_clusters[-suffix - 1]
|
|
256
|
+
first = old_clusters.take(prefix).sum(&:bytesize)
|
|
257
|
+
last = old_clusters.take(old_clusters.length - suffix).sum(&:bytesize)
|
|
258
|
+
inserted_finish = new_clusters.take(new_clusters.length - suffix).sum(&:bytesize)
|
|
259
|
+
{first: first, last: last, inserted: value.byteslice(first...inserted_finish).to_s,
|
|
260
|
+
old_clusters: old_clusters, new_clusters: new_clusters, prefix: prefix, suffix: suffix}
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def editable_segment(first, last)
|
|
264
|
+
candidates = @segments.select do |segment|
|
|
265
|
+
segment.node && if first == last
|
|
266
|
+
segment.start < first && first <= segment.finish
|
|
267
|
+
else
|
|
268
|
+
segment.start <= first && last <= segment.finish
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
candidates = @segments.select { |segment| segment.node && segment.start <= first && first < segment.finish } if candidates.empty? && first == last
|
|
272
|
+
segment = candidates.first
|
|
273
|
+
raise Error, "rich text edit must stay within one Markdown text run" unless segment
|
|
274
|
+
unless %i[text code_span image].include?(segment.node.type)
|
|
275
|
+
raise Error, "rich text edit crosses Markdown structure"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
segment
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def style_change(value)
|
|
282
|
+
clusters = @text.scan(/\X/)
|
|
283
|
+
changes = []
|
|
284
|
+
clusters.each_with_index do |cluster, index|
|
|
285
|
+
offset = cluster_offset(clusters, index)
|
|
286
|
+
original = old_style_at(offset)
|
|
287
|
+
current = rich_style_at(value, offset)
|
|
288
|
+
delta = (original.keys | current.keys).each_with_object({}) do |key, result|
|
|
289
|
+
result[key] = [original[key], current[key]] unless original[key] == current[key]
|
|
290
|
+
end
|
|
291
|
+
next if delta.empty?
|
|
292
|
+
|
|
293
|
+
finish = offset + cluster.bytesize
|
|
294
|
+
if changes.last && changes.last[:finish] == offset && changes.last[:delta] == delta
|
|
295
|
+
changes.last[:finish] = finish
|
|
296
|
+
else
|
|
297
|
+
changes << {first: offset, finish: finish, delta: delta}
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
return if changes.empty?
|
|
301
|
+
raise Error, "rich text style changes must target one source run" unless changes.one?
|
|
302
|
+
|
|
303
|
+
changes.first
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def apply_style_change(change)
|
|
307
|
+
segment = editable_segment(change[:first], change[:finish])
|
|
308
|
+
raise Error, "only Markdown text runs can be restyled" unless segment.node.type == :text
|
|
309
|
+
source_text = @document.source.byteslice(segment.node.range)
|
|
310
|
+
raise Error, "escaped Markdown text cannot be restyled through rich text yet" unless source_text == segment.text
|
|
311
|
+
|
|
312
|
+
formats = change[:delta]
|
|
313
|
+
raise Error, "only bold and italic styles can be written to Markdown" unless (formats.keys - %i[bold italic]).empty?
|
|
314
|
+
additions = formats.select { |_key, (old, new)| new == true && old != true }.keys
|
|
315
|
+
removals = formats.select { |_key, (old, new)| old == true && new.nil? }.keys
|
|
316
|
+
raise Error, "rich text style changes cannot be represented by Markdown" unless additions.length + removals.length == formats.length
|
|
317
|
+
raise Error, "combine only one Markdown style change at a time" if additions.any? && removals.any?
|
|
318
|
+
|
|
319
|
+
if additions.any?
|
|
320
|
+
first = segment.node.range.begin + change[:first] - segment.start
|
|
321
|
+
last = segment.node.range.begin + change[:finish] - segment.start
|
|
322
|
+
selected = source_text.byteslice((first - segment.node.range.begin)...(last - segment.node.range.begin))
|
|
323
|
+
opening = (additions.include?(:bold) ? "**" : "") + (additions.include?(:italic) ? "_" : "")
|
|
324
|
+
closing = (additions.include?(:italic) ? "_" : "") + (additions.include?(:bold) ? "**" : "")
|
|
325
|
+
return Beid::Editing.edit_range(@document, segment.node, first...last, opening + selected + closing)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
raise Error, "remove one Markdown style at a time" unless removals.one?
|
|
329
|
+
key = removals.first
|
|
330
|
+
wrapper = segment.wrappers.reverse.find do |style, node|
|
|
331
|
+
style == key && node.children == [segment.node] &&
|
|
332
|
+
segment.start == change[:first] && segment.finish == change[:finish]
|
|
333
|
+
end&.last
|
|
334
|
+
raise Error, "removing a style requires selecting its complete Markdown run" unless wrapper
|
|
335
|
+
|
|
336
|
+
marker_size = wrapper.marker.bytesize
|
|
337
|
+
inner = @document.source.byteslice((wrapper.range.begin + marker_size)...(wrapper.range.end - marker_size))
|
|
338
|
+
Beid::Editing.edit_range(@document, wrapper, wrapper.range, inner)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def rich_style_at(value, offset)
|
|
342
|
+
value.spans.find { |span| offset >= span.start && offset < span.finish }&.style || {}
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def validate_styles!(value, change, segment)
|
|
346
|
+
old_clusters = change[:old_clusters]
|
|
347
|
+
new_clusters = change[:new_clusters]
|
|
348
|
+
old_end = old_clusters.length - change[:suffix]
|
|
349
|
+
new_end = new_clusters.length - change[:suffix]
|
|
350
|
+
change[:prefix].times do |index|
|
|
351
|
+
check_style!(value, new_clusters, index, old_style_at(cluster_offset(old_clusters, index)))
|
|
352
|
+
end
|
|
353
|
+
change[:suffix].times do |index|
|
|
354
|
+
old_index = old_end + index
|
|
355
|
+
new_index = new_end + index
|
|
356
|
+
check_style!(value, new_clusters, new_index, old_style_at(cluster_offset(old_clusters, old_index)))
|
|
357
|
+
end
|
|
358
|
+
(change[:prefix]...new_end).each do |index|
|
|
359
|
+
check_style!(value, new_clusters, index, segment.style)
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def check_style!(value, clusters, index, expected)
|
|
364
|
+
offset = cluster_offset(clusters, index)
|
|
365
|
+
actual = rich_style_at(value, offset)
|
|
366
|
+
raise Error, "rich-text style changes cannot yet be written back to Markdown" unless actual == expected
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def old_style_at(offset)
|
|
370
|
+
@segments.find { |segment| offset >= segment.start && offset < segment.finish }&.style || {}
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def cluster_offset(clusters, index)
|
|
374
|
+
clusters.take(index).sum(&:bytesize)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
data/lib/hadar/slide.rb
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hadar
|
|
4
|
+
class Slide
|
|
5
|
+
attr_reader :index, :document, :nodes, :layout, :slots, :theme, :notes
|
|
6
|
+
|
|
7
|
+
def initialize(index:, document:, nodes:, layout: nil, theme:, deck:)
|
|
8
|
+
@index, @document, @nodes, @theme, @deck = index, document, nodes.freeze, theme, deck
|
|
9
|
+
@notes, @notes_ranges = extract_notes
|
|
10
|
+
@layout = Layout.select(content_nodes, requested: layout)
|
|
11
|
+
@slots = build_slots.freeze
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def slot(name)
|
|
16
|
+
slots.fetch(name.to_sym) do
|
|
17
|
+
Slot.new(name: name, nodes: [], document: document, deck: @deck, slide_index: index)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def title = slot(:title).text
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def content_nodes
|
|
26
|
+
nodes.reject { |node| layout_directive?(node) || speaker_note_node?(node) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def extract_notes
|
|
30
|
+
entries = nodes.filter_map do |node|
|
|
31
|
+
if note_directive?(node)
|
|
32
|
+
[node.range, normalize_notes(node.attributes.dig(:values, "notes"))]
|
|
33
|
+
elsif node.type == :html_block
|
|
34
|
+
multiline_notes(node)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
[entries.empty? ? nil : entries.map(&:last).join("\n\n").freeze,
|
|
38
|
+
entries.flat_map { |range, _| note_ranges_for(range) }.freeze]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def multiline_notes(node)
|
|
42
|
+
source = document.source
|
|
43
|
+
return unless source.byteslice(node.range).match?(/\A[ \t]{0,3}<!--[ \t]*notes[ \t]*:/)
|
|
44
|
+
|
|
45
|
+
terminator = source.b.index("-->".b, node.range.begin)
|
|
46
|
+
return unless terminator && terminator + 3 <= nodes.last.range.end
|
|
47
|
+
|
|
48
|
+
range = node.range.begin...(terminator + 3)
|
|
49
|
+
values = Beid::Directive.parse(source.byteslice(range))
|
|
50
|
+
return unless values&.key?("notes")
|
|
51
|
+
|
|
52
|
+
[range, normalize_notes(values.fetch("notes"))]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def note_ranges_for(range)
|
|
56
|
+
nodes.filter_map do |node|
|
|
57
|
+
node.range if node.range.begin < range.end && range.begin < node.range.end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def normalize_notes(value)
|
|
62
|
+
value.to_s.sub(/\A(?:\r\n|\r|\n)/, "").sub(/(?:\r\n|\r|\n)\z/, "")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def note_directive?(node)
|
|
66
|
+
node.type == :directive && node.attributes[:kind] == :html_comment &&
|
|
67
|
+
node.attributes.dig(:values, "notes")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def speaker_note_node?(node)
|
|
71
|
+
note_directive?(node) || @notes_ranges.any? do |range|
|
|
72
|
+
node.range.begin < range.end && range.begin < node.range.end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def build_slots
|
|
77
|
+
content = content_nodes
|
|
78
|
+
definition = Layout.fetch(layout)
|
|
79
|
+
mapping = definition.slots.to_h { |name| [name, []] }
|
|
80
|
+
case layout
|
|
81
|
+
when :title
|
|
82
|
+
headings = content.select { |node| node.type == :heading }
|
|
83
|
+
mapping[:title] = headings.take(1)
|
|
84
|
+
mapping[:subtitle] = headings.drop(1)
|
|
85
|
+
when :title_body
|
|
86
|
+
heading = content.find { |node| node.type == :heading }
|
|
87
|
+
mapping[:title] = [heading].compact
|
|
88
|
+
mapping[:body] = content.reject { |node| node.equal?(heading) }
|
|
89
|
+
when :two_column
|
|
90
|
+
mapping[:title] = content.find { |node| node.type == :heading }&.then { |node| [node] } || []
|
|
91
|
+
content.each do |node|
|
|
92
|
+
next unless node.type == :directive && node.attributes[:kind] == :div
|
|
93
|
+
|
|
94
|
+
target = node.attributes[:name].to_s.to_sym
|
|
95
|
+
mapping[target] = node.children if %i[left right].include?(target)
|
|
96
|
+
end
|
|
97
|
+
when :image_text
|
|
98
|
+
image = content.flat_map { |node| descendants(node, :image) }.first
|
|
99
|
+
mapping[:image] = [image].compact
|
|
100
|
+
mapping[:title] = content.find { |node| node.type == :heading }&.then { |node| [node] } || []
|
|
101
|
+
mapping[:text] = content.reject { |node| node.equal?(mapping[:title].first) || contains?(node, image) }
|
|
102
|
+
when :full_bleed_image
|
|
103
|
+
mapping[:image] = content.flat_map { |node| descendants(node, :image) }
|
|
104
|
+
when :quote
|
|
105
|
+
mapping[:quote] = content.select { |node| node.type == :block_quote }
|
|
106
|
+
mapping[:attribution] = content.reject { |node| node.type == :block_quote }
|
|
107
|
+
when :code
|
|
108
|
+
mapping[:title] = content.find { |node| node.type == :heading }&.then { |node| [node] } || []
|
|
109
|
+
mapping[:code] = content.select { |node| node.type == :code_block }
|
|
110
|
+
end
|
|
111
|
+
mapping.to_h do |name, entries|
|
|
112
|
+
[name, Slot.new(name: name, nodes: entries, document: document,
|
|
113
|
+
deck: @deck, slide_index: index)]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def layout_directive?(node)
|
|
118
|
+
node.type == :directive && node.attributes[:kind] == :html_comment &&
|
|
119
|
+
node.attributes[:values]&.key?("layout")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def descendants(node, type)
|
|
123
|
+
[node, *node.children.flat_map { |child| descendants(child, type) }].select { |entry| entry.type == type }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def contains?(node, target)
|
|
127
|
+
target && (node.equal?(target) || node.children.any? { |child| contains?(child, target) })
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hadar
|
|
4
|
+
class SlideList
|
|
5
|
+
DEFAULT_ROW_HEIGHT = 180
|
|
6
|
+
|
|
7
|
+
attr_reader :deck, :element, :selected_index
|
|
8
|
+
|
|
9
|
+
def initialize(deck, renderer: Renderer.new, selected: nil,
|
|
10
|
+
row_height: DEFAULT_ROW_HEIGHT, on_select: nil)
|
|
11
|
+
raise TypeError, "deck must be a Hadar::Deck" unless deck.is_a?(Deck)
|
|
12
|
+
raise TypeError, "renderer must respond to build" unless renderer.respond_to?(:build)
|
|
13
|
+
raise TypeError, "on_select must be callable" if on_select && !on_select.respond_to?(:call)
|
|
14
|
+
|
|
15
|
+
@deck, @renderer, @selected_index, @on_select = deck, renderer, selected, on_select
|
|
16
|
+
@row_height = Float(row_height)
|
|
17
|
+
raise ArgumentError, "row_height must be finite and positive" unless @row_height.finite? && @row_height.positive?
|
|
18
|
+
validate_selection!(selected) unless selected.nil?
|
|
19
|
+
|
|
20
|
+
@element = Zaniah::UniformList.new(count: deck.length, row_height: @row_height) do |index|
|
|
21
|
+
render_slide(index)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build(width:, height:)
|
|
26
|
+
[width, height].each do |dimension|
|
|
27
|
+
unless dimension.is_a?(Numeric) && dimension.finite? && dimension.positive?
|
|
28
|
+
raise ArgumentError, "list width and height must be finite and positive"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
element.style(width: width, height: height)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def select(index)
|
|
35
|
+
validate_selection!(index)
|
|
36
|
+
@selected_index = index
|
|
37
|
+
slide = deck.slide(index)
|
|
38
|
+
@on_select&.call(slide, index)
|
|
39
|
+
slide
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def render_slide(index)
|
|
45
|
+
slide = deck.slide(index)
|
|
46
|
+
selected = index == selected_index
|
|
47
|
+
Zaniah::Div.new.key(index).w_full.h_full.overflow_hidden
|
|
48
|
+
.border(selected ? 3 : 1)
|
|
49
|
+
.border_color(slide.theme.colors.fetch(selected ? "accent" : "muted"))
|
|
50
|
+
.on_click do |_event, context|
|
|
51
|
+
select(index)
|
|
52
|
+
context.window.request_frame
|
|
53
|
+
end
|
|
54
|
+
.child(thumbnail_for(slide))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def thumbnail_for(slide)
|
|
58
|
+
preview = @renderer.build(slide)
|
|
59
|
+
compact_text(preview)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def compact_text(element)
|
|
63
|
+
element.wrap(:none) if element.is_a?(Zaniah::Text)
|
|
64
|
+
element.children.each { |child| compact_text(child) }
|
|
65
|
+
element
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def validate_selection!(index)
|
|
69
|
+
unless index.is_a?(Integer) && (0...deck.length).cover?(index)
|
|
70
|
+
raise IndexError, "slide index is outside the deck"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|