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.
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ module Hadar
7
+ module Export
8
+ class PNGSequence
9
+ MAX_PIXELS = 32_000_000
10
+
11
+ def self.write(deck, directory, renderer: Renderer.new, width: 1280, height: 720)
12
+ raise TypeError, "deck must be a Hadar::Deck" unless deck.is_a?(Deck)
13
+ raise Error, "cannot export an empty deck" if deck.empty?
14
+ raise TypeError, "renderer must respond to build" unless renderer.respond_to?(:build)
15
+ raise ArgumentError, "directory must be a nonempty path" unless directory.is_a?(String) && !directory.empty?
16
+ unless [width, height].all? { |dimension| dimension.is_a?(Integer) && dimension.positive? } && width * height <= MAX_PIXELS
17
+ raise ArgumentError, "PNG dimensions must be positive integers totaling at most #{MAX_PIXELS} pixels"
18
+ end
19
+
20
+ output = File.expand_path(directory)
21
+ FileUtils.mkdir_p(output)
22
+ digits = [3, deck.length.to_s.length].max
23
+ names = deck.length.times.map { |index| format("slide-%0#{digits}d.png", index + 1) }
24
+ if (existing = names.find { |name| File.exist?(File.join(output, name)) || File.symlink?(File.join(output, name)) })
25
+ raise Error, "PNG sequence output already exists: #{existing}"
26
+ end
27
+
28
+ Dir.mktmpdir(".hadar-png-", output) do |staging|
29
+ write_frames(deck, renderer, width, height, staging, names)
30
+ commit_frames(output, staging, names)
31
+ end
32
+ names.map { |name| File.join(output, name) }.freeze
33
+ rescue SystemCallError => error
34
+ raise Error, "cannot write PNG sequence: #{error.message}"
35
+ end
36
+
37
+ def self.write_frames(deck, renderer, width, height, staging, names)
38
+ window = Zaniah::Platform.open_window(backend: :headless, width: width, height: height)
39
+ window.text_system = Zaniah::TextSystem::Renderer.new
40
+ deck.slides.each_with_index do |slide, index|
41
+ window.render(renderer.build(slide), clear: slide.theme.colors.fetch("background"))
42
+ window.write_png(File.join(staging, names[index]))
43
+ end
44
+ ensure
45
+ window&.close
46
+ end
47
+ private_class_method :write_frames
48
+
49
+ def self.commit_frames(output, staging, names)
50
+ created = []
51
+ names.each do |name|
52
+ target = File.join(output, name)
53
+ File.link(File.join(staging, name), target)
54
+ created << target
55
+ end
56
+ rescue SystemCallError
57
+ created.each { |path| File.unlink(path) if File.file?(path) }
58
+ raise
59
+ end
60
+ private_class_method :commit_frames
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hadar
4
+ module Layout
5
+ Definition = Data.define(:name, :slots)
6
+
7
+ DEFINITIONS = [
8
+ Definition.new(:title, %i[title subtitle]),
9
+ Definition.new(:title_body, %i[title body]),
10
+ Definition.new(:two_column, %i[title left right]),
11
+ Definition.new(:image_text, %i[title text image]),
12
+ Definition.new(:full_bleed_image, %i[image]),
13
+ Definition.new(:quote, %i[quote attribution]),
14
+ Definition.new(:code, %i[title code]),
15
+ Definition.new(:blank, [])
16
+ ].freeze
17
+ private_constant :Definition
18
+
19
+ NAMES = DEFINITIONS.map(&:name).freeze
20
+ ALIASES = {
21
+ "title+body" => :title_body,
22
+ "title-body" => :title_body,
23
+ "two-column" => :two_column,
24
+ "image+text" => :image_text,
25
+ "image-text" => :image_text,
26
+ "full-bleed-image" => :full_bleed_image
27
+ }.freeze
28
+
29
+ def self.fetch(name)
30
+ normalized = normalize(name)
31
+ DEFINITIONS.find { |definition| definition.name == normalized } ||
32
+ raise(ArgumentError, "unknown slide layout: #{name.inspect}")
33
+ end
34
+
35
+ def self.select(nodes, requested: nil)
36
+ return fetch(requested).name if requested && !requested.to_s.empty?
37
+
38
+ types = nodes.map(&:type)
39
+ div_names = nodes.select { |node| node.type == :directive && node.attributes[:kind] == :div }
40
+ .map { |node| node.attributes[:name] }
41
+ return :two_column if div_names.include?("left") || div_names.include?("right")
42
+ return :quote if types.include?(:block_quote)
43
+ return :code if types.include?(:code_block) && (types - %i[heading code_block]).empty?
44
+
45
+ images = nodes.any? { |node| contains_type?(node, :image) }
46
+ text = nodes.any? { |node| text?(node) }
47
+ return :image_text if images && text
48
+ return :full_bleed_image if images
49
+ return :blank if nodes.empty?
50
+
51
+ headings = nodes.count { |node| node.type == :heading }
52
+ return :title if headings.between?(1, 2) && nodes.all? { |node| node.type == :heading }
53
+
54
+ :title_body
55
+ end
56
+
57
+ def self.normalize(name)
58
+ key = name.to_s.strip.downcase
59
+ ALIASES.fetch(key) { key.tr("-", "_").to_sym }
60
+ end
61
+ private_class_method :normalize
62
+
63
+ def self.contains_type?(node, type)
64
+ node.type == type || node.children.any? { |child| contains_type?(child, type) }
65
+ end
66
+ private_class_method :contains_type?
67
+
68
+ def self.text?(node)
69
+ return false if node.type == :image
70
+ if node.type == :text
71
+ text = node.attributes.fetch(:text, "")
72
+ return !text.strip.empty?
73
+ end
74
+ return true if %i[code_block html_block].include?(node.type)
75
+
76
+ node.children.any? { |child| text?(child) }
77
+ end
78
+ private_class_method :text?
79
+ end
80
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hadar
4
+ class Presenter
5
+ attr_reader :deck, :current_index, :started_at
6
+
7
+ def initialize(deck, renderer: Renderer.new, clock: Zaniah::MONOTONIC_CLOCK)
8
+ raise TypeError, "deck must be a Hadar::Deck" unless deck.is_a?(Deck)
9
+ raise TypeError, "renderer must respond to build" unless renderer.respond_to?(:build)
10
+ raise TypeError, "clock must be callable" unless clock.respond_to?(:call)
11
+
12
+ @deck, @renderer, @clock = deck, renderer, clock
13
+ @current_index = deck.empty? ? nil : 0
14
+ end
15
+
16
+ def start(index: current_index)
17
+ validate_index!(index)
18
+ @current_index = index
19
+ @started_at = @clock.call
20
+ self
21
+ end
22
+
23
+ def go_to(index)
24
+ validate_index!(index)
25
+ @current_index = index
26
+ self
27
+ end
28
+
29
+ def stop
30
+ @started_at = nil
31
+ self
32
+ end
33
+
34
+ def started? = !started_at.nil?
35
+
36
+ def current_slide
37
+ current_index && deck.slide(current_index)
38
+ end
39
+
40
+ def next_slide
41
+ return if current_index.nil? || current_index + 1 >= deck.length
42
+
43
+ deck.slide(current_index + 1)
44
+ end
45
+
46
+ def notes = current_slide&.notes
47
+
48
+ def elapsed_seconds
49
+ return 0.0 unless started?
50
+
51
+ [@clock.call - started_at, 0.0].max
52
+ end
53
+
54
+ def elapsed_text
55
+ seconds = elapsed_seconds.floor
56
+ format("%d:%02d", seconds / 60, seconds % 60)
57
+ end
58
+
59
+ def slide_view
60
+ return Zaniah::UI::Label.new("No slides") unless current_slide
61
+
62
+ @renderer.build(current_slide)
63
+ end
64
+
65
+ def presenter_view
66
+ following = next_slide
67
+ Zaniah::Div.new.flex_col.gap(12)
68
+ .child(Zaniah::UI::Label.new("Next slide", size: :lg))
69
+ .child(following ? @renderer.build(following) : Zaniah::UI::Label.new("End of deck", tone: :muted))
70
+ .child(Zaniah::UI::Label.new("Speaker notes", size: :lg))
71
+ .child(Zaniah::UI::Label.new(notes || "No speaker notes", wrap: :word))
72
+ .child(Zaniah::UI::Label.new("Elapsed: #{elapsed_text}", tone: :muted))
73
+ end
74
+
75
+ def reconcile!
76
+ if deck.empty?
77
+ @current_index = nil
78
+ @started_at = nil
79
+ else
80
+ @current_index = [[current_index || 0, 0].max, deck.length - 1].min
81
+ end
82
+ self
83
+ end
84
+
85
+ private
86
+
87
+ def validate_index!(index)
88
+ raise IndexError, "cannot start a presentation without slides" if deck.empty?
89
+ raise IndexError, "slide index is outside the deck" unless index.is_a?(Integer) && (0...deck.length).cover?(index)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hadar
4
+ class Renderer
5
+ def initialize
6
+ @font_db = nil
7
+ @fonts = {}
8
+ @vocabulary = build_vocabulary
9
+ end
10
+
11
+ def describe(slide)
12
+ raise TypeError, "slide must be a Hadar::Slide" unless slide.is_a?(Slide)
13
+
14
+ children = Array(content_for(slide)).compact
15
+ Zaniah::Describe::Node.new(:slide, {
16
+ background: slide_theme(slide).colors.fetch("background"),
17
+ margin: slide_theme(slide).spacing.fetch("margin"),
18
+ gap: slide_theme(slide).spacing.fetch("gap")
19
+ }, children, "slide-#{slide.index}")
20
+ end
21
+
22
+ def build(slide)
23
+ Zaniah::Describe.build(describe(slide), vocabulary: @vocabulary,
24
+ on_event: ->(_id, _payload) {})
25
+ end
26
+
27
+ def surface(slide)
28
+ Zaniah::Describe::Surface.new(vocabulary: @vocabulary,
29
+ on_event: ->(_id, _payload) {}).replace(describe(slide))
30
+ end
31
+
32
+ private
33
+
34
+ def build_vocabulary
35
+ db = self
36
+ Zaniah::Describe::Vocabulary.build do
37
+ node :slide, props: {background: :string, margin: :number, gap: :number} do |props, children|
38
+ Zaniah::Element.new.flex_col.w_full.h_full.bg(props.fetch(:background))
39
+ .p(props.fetch(:margin)).style(gap: props.fetch(:gap)).children(children)
40
+ end
41
+ node :stack, props: {gap: :number} do |props, children|
42
+ Zaniah::Element.new.flex_col.style(gap: props.fetch(:gap)).children(children)
43
+ end
44
+ node :columns, props: {gap: :number} do |props, children|
45
+ Zaniah::Element.new.flex_row.style(gap: props.fetch(:gap)).children(children)
46
+ end
47
+ node :text, props: {text: :string, size: :number, color: :string,
48
+ family: :string}, children: :none do |props, _children|
49
+ font = db.send(:font, props.fetch(:family))
50
+ Zaniah::Text.new(props.fetch(:text), size: props.fetch(:size),
51
+ color: props.fetch(:color), font: font, wrap: :word)
52
+ end
53
+ node :image, props: {path: :string}, children: :none do |props, _children|
54
+ path = props.fetch(:path)
55
+ begin
56
+ Zaniah::Image.new(path).w_full
57
+ rescue StandardError => error
58
+ raise Error, "cannot render image #{path.inspect}: #{error.message}"
59
+ end
60
+ end
61
+ node :table, props: {rows: :array, alignments: :array, size: :number,
62
+ family: :string, text_color: :string, header_color: :string, border_color: :string}, children: :none do |props, _children|
63
+ rows = props.fetch(:rows).each_with_index.map do |row, row_index|
64
+ cells = row.each_with_index.map do |value, column|
65
+ alignment = {left: :start, center: :center, right: :end}[props.fetch(:alignments)[column]] || :start
66
+ color = row_index.zero? ? props.fetch(:header_color) : props.fetch(:text_color)
67
+ Zaniah::Text.new(value, size: props.fetch(:size), color: color,
68
+ font: db.send(:font, props.fetch(:family)), wrap: :word, align: alignment)
69
+ .flex_1.p(6).border(1).border_color(props.fetch(:border_color))
70
+ end
71
+ Zaniah::Element.new.flex_row.w_full.children(cells)
72
+ end
73
+ Zaniah::Element.new.flex_col.w_full.children(rows)
74
+ end
75
+ node :code_block, props: {language: :string} do |_props, children|
76
+ Zaniah::Element.new.flex_col.w_full.children(children)
77
+ end
78
+ node :code_line, props: {} do |_props, children|
79
+ Zaniah::Element.new.flex_row.children(children)
80
+ end
81
+ node :code_token, props: {text: :string, color: :string, size: :number}, children: :none do |props, _children|
82
+ Zaniah::Text.new(props.fetch(:text), size: props.fetch(:size), color: props.fetch(:color),
83
+ font: db.send(:font, "monospace"), wrap: :none)
84
+ end
85
+ end
86
+ end
87
+
88
+ def font(family)
89
+ @fonts[family] ||= begin
90
+ @font_db ||= Zaniah::TextSystem::FontDB.new
91
+ @font_db.find(family: family == "sans-serif" ? nil : family)
92
+ end
93
+ end
94
+
95
+ def content_for(slide)
96
+ theme = slide_theme(slide)
97
+ gap = theme.spacing.fetch("gap")
98
+ case slide.layout
99
+ when :title
100
+ stack([
101
+ text_node(slide.slot(:title).text, theme.font.fetch("title_size"), theme),
102
+ text_node(slide.slot(:subtitle).text, theme.font.fetch("body_size"), theme, color: theme.colors.fetch("muted"))
103
+ ].reject(&:nil?), gap)
104
+ when :two_column
105
+ heading = text_node(slide.slot(:title).text, theme.font.fetch("title_size"), theme)
106
+ columns = node(:columns, {gap: gap}, [
107
+ stack(render_blocks(slide.slot(:left), theme), gap),
108
+ stack(render_blocks(slide.slot(:right), theme), gap)
109
+ ])
110
+ [heading, columns].compact
111
+ when :image_text
112
+ [text_node(slide.slot(:title).text, theme.font.fetch("title_size"), theme),
113
+ *render_blocks(slide.slot(:text), theme),
114
+ image_node(slide.slot(:image))].compact
115
+ when :full_bleed_image
116
+ [image_node(slide.slot(:image))].compact
117
+ when :quote
118
+ [*text_nodes(slide.slot(:quote).text, theme, size: theme.font.fetch("title_size")),
119
+ *text_nodes(slide.slot(:attribution).text, theme, size: theme.font.fetch("body_size"), color: theme.colors.fetch("muted"))]
120
+ when :code
121
+ [text_node(slide.slot(:title).text, theme.font.fetch("title_size"), theme),
122
+ *render_blocks(slide.slot(:code), theme)].compact
123
+ when :blank
124
+ []
125
+ else
126
+ [text_node(slide.slot(:title).text, theme.font.fetch("title_size"), theme),
127
+ *render_blocks(slide.slot(:body), theme)].compact
128
+ end
129
+ end
130
+
131
+ def render_blocks(slot, theme)
132
+ table_index = code_index = 0
133
+ slot.nodes.flat_map do |source_node|
134
+ case source_node.type
135
+ when :table
136
+ result = table_node(slot, source_node, table_index, theme)
137
+ table_index += 1
138
+ [result]
139
+ when :code_block
140
+ result = code_node(slot, source_node, code_index, theme)
141
+ code_index += 1
142
+ [result]
143
+ else
144
+ text_nodes(slot.text_for(source_node), theme)
145
+ end
146
+ end
147
+ end
148
+
149
+ def table_node(slot, source_node, index, theme)
150
+ node(:table, {
151
+ rows: slot.table_rows(table: index),
152
+ alignments: source_node.attributes.fetch(:alignments, []),
153
+ size: theme.font.fetch("body_size"),
154
+ family: theme.font.fetch("family"),
155
+ text_color: theme.colors.fetch("text"),
156
+ header_color: theme.colors.fetch("accent"),
157
+ border_color: theme.colors.fetch("muted")
158
+ }, [], "slide-#{slot.slide_index}-table-#{index}")
159
+ end
160
+
161
+ def code_node(slot, source_node, index, theme)
162
+ source = slot.text_for(source_node)
163
+ language = source_node.attributes.fetch(:info, "").split.first.to_s
164
+ lines = source.scan(/.*?(?:\r\n|\r|\n|\z)/m).reject(&:empty?)
165
+ lines = [""] if lines.empty?
166
+ lexer = language.empty? ? nil : Rouge::Lexer.find(language)
167
+ rows = code_tokens(lines, lexer)
168
+ line_nodes = rows.each_with_index.map do |tokens, line_index|
169
+ line = lines.fetch(line_index)
170
+ body = line.sub(/\r\n\z|\r\z|\n\z/, "")
171
+ visible_bytes = body.bytesize
172
+ offset = 0
173
+ token_nodes = tokens.filter_map do |kind, text|
174
+ size = [text.bytesize, visible_bytes - offset].min
175
+ offset += text.bytesize
176
+ next if size <= 0
177
+
178
+ node(:code_token, {text: text.byteslice(0, size), color: token_color(kind, theme),
179
+ size: theme.font.fetch("body_size")}, [])
180
+ end
181
+ token_nodes << node(:code_token, {text: " ", color: theme.colors.fetch("text"),
182
+ size: theme.font.fetch("body_size")}, []) if token_nodes.empty?
183
+ node(:code_line, {}, token_nodes, "line-#{line_index}")
184
+ end
185
+ node(:code_block, {language: language}, line_nodes, "slide-#{slot.slide_index}-code-#{index}")
186
+ end
187
+
188
+ def code_tokens(lines, lexer)
189
+ return lines.map { |line| [[nil, line]] } unless lexer
190
+
191
+ lexer_lines = lines.map do |line|
192
+ line.match?(/(?:\r\n|\r|\n)\z/) ? line.sub(/\r\n\z|\r\z|\n\z/, "\n") : line
193
+ end
194
+ highlighter = Antares::Highlighter.new(lexer: lexer.new,
195
+ lines: ->(index) { lexer_lines.fetch(index) }, line_count: -> { lexer_lines.length })
196
+ lexer_lines.each_index.map { |index| highlighter.tokens_for(index) }
197
+ end
198
+
199
+ def token_color(kind, theme)
200
+ name = kind&.qualname.to_s
201
+ return theme.colors.fetch("muted") if name.start_with?("Comment")
202
+ return theme.colors.fetch("accent") if name.start_with?("Keyword", "Literal.Number", "Literal.String", "Name.Builtin")
203
+
204
+ theme.colors.fetch("text")
205
+ end
206
+
207
+ def text_nodes(text, theme, size: theme.font.fetch("body_size"), color: theme.colors.fetch("text"), family: theme.font.fetch("family"))
208
+ return [] if text.nil? || text.empty?
209
+
210
+ text.split(/\n+/).reject(&:empty?).map do |part|
211
+ text_node(part, size, theme, color: color, family: family)
212
+ end
213
+ end
214
+
215
+ def text_node(text, size, theme, color: theme.colors.fetch("text"), family: theme.font.fetch("family"))
216
+ return if text.nil? || text.empty?
217
+
218
+ node(:text, {text: text, size: size, color: color, family: family}, [])
219
+ end
220
+
221
+ def stack(children, gap)
222
+ node(:stack, {gap: gap}, children)
223
+ end
224
+
225
+ def node(type, props, children, key = nil)
226
+ Zaniah::Describe::Node.new(type, props, children, key)
227
+ end
228
+
229
+ def image_node(slot)
230
+ return if slot.empty?
231
+
232
+ path = slot.resolved_image_path
233
+ node(:image, {path: path}, [], "slide-#{slot.slide_index}-#{slot.name}-image")
234
+ end
235
+
236
+ def slide_theme(slide) = slide.theme
237
+ end
238
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hadar
4
+ class RichTextProjection
5
+ Segment = Data.define(:start, :finish, :text, :node, :style, :wrappers, :list_item, :list_level)
6
+ attr_reader :document, :text, :segments, :list_context, :paragraph_styles
7
+
8
+ def initialize(slot)
9
+ @document = slot.document
10
+ @list_context = {}
11
+ @parts = join(slot.nodes.map { |node| project(node, {}, [], -1, nil) }, "\n")
12
+ @text = @parts.map(&:first).join.freeze
13
+ offset = 0
14
+ @segments = @parts.map do |part, node, style, wrappers, list_item, list_level|
15
+ segment = Segment.new(offset, offset + part.bytesize, part, node, style.freeze, wrappers.freeze, list_item, list_level)
16
+ offset += part.bytesize
17
+ segment
18
+ end.freeze
19
+ @paragraph_styles = Array.new(@text.count("\n") + 1) { {} }
20
+ @segments.each do |segment|
21
+ next unless segment.list_level
22
+
23
+ first = @text.byteslice(0...segment.start).to_s.count("\n")
24
+ last = @text.byteslice(0...segment.finish).to_s.count("\n")
25
+ (first..last).each { |line| @paragraph_styles[line] = {level: segment.list_level} }
26
+ end
27
+ raise Error, "slot content is not representable as rich text" unless @text == slot.text
28
+ end
29
+
30
+ def build(editable: true)
31
+ runs = @parts.map { |part, _node, style, _wrappers, _item, _level| {text: part, **style} }
32
+ value = Zaniah::UI::RichText.new(runs, selectable: true, editable: editable)
33
+ line_start = 0
34
+ @paragraph_styles.each_with_index do |style, index|
35
+ line_end = @text.b.index("\n".b, line_start) || @text.bytesize
36
+ value.paragraph_style(line_start...line_end, level: style[:level]) if style.key?(:level)
37
+ line_start = line_end + 1
38
+ end
39
+ value
40
+ end
41
+
42
+ def replace(value)
43
+ RichTextWriteback.new(self, value).replace
44
+ end
45
+
46
+ private
47
+
48
+ def project(node, style, wrappers, list_level, parent_item)
49
+ case node.type
50
+ when :text
51
+ leaf(node.attributes.fetch(:text, ""), node, style, wrappers, parent_item, list_level)
52
+ when :code_span
53
+ leaf(node.attributes.fetch(:text, ""), node, style.merge(font: "monospace"), wrappers, parent_item, list_level)
54
+ when :image
55
+ leaf(node.attributes.fetch(:label, ""), node, style, wrappers, parent_item, list_level)
56
+ when :break, :softbreak
57
+ leaf("\n", node, style, wrappers, parent_item, list_level)
58
+ when :strong
59
+ children(node.children, style.merge(bold: true), wrappers + [[:bold, node]], list_level, parent_item)
60
+ when :emphasis
61
+ children(node.children, style.merge(italic: true), wrappers + [[:italic, node]], list_level, parent_item)
62
+ when :link
63
+ children(node.children, style.merge(link: node.attributes.fetch(:destination)), wrappers, list_level, parent_item)
64
+ when :paragraph, :heading
65
+ children(node.children, style, wrappers, list_level, parent_item)
66
+ when :block_quote
67
+ join(node.children.map { |child| project(child, style, wrappers, list_level, parent_item) }, "\n")
68
+ when :list, :ordered_list
69
+ level = list_level + 1
70
+ items = node.children.each_with_index.map do |item, index|
71
+ @list_context[item.object_id] = {level: level, list: node, previous: node.children[index - 1],
72
+ parent: parent_item}
73
+ marker = if item.attributes[:task]
74
+ item.attributes[:checked] ? "☑ " : "☐ "
75
+ elsif item.attributes[:ordered]
76
+ "#{item.attributes[:start] || index + 1}. "
77
+ else
78
+ "• "
79
+ end
80
+ [[marker, item, {}, [], item, level], *project(item, style, wrappers, level, item)]
81
+ end
82
+ join(items, "\n")
83
+ when :list_item
84
+ join(node.children.map { |child| project(child, style, wrappers, list_level, node) }, "\n")
85
+ when :footnote_reference, :task_checkbox, :front_matter, :directive, :thematic_break
86
+ []
87
+ when :strikethrough
88
+ raise Error, "strikethrough has no Zaniah rich-text style"
89
+ else
90
+ raise Error, "#{node.type} slots are not supported by rich-text editing"
91
+ end
92
+ end
93
+
94
+ def children(nodes, style, wrappers, list_level, parent_item)
95
+ nodes.flat_map { |node| project(node, style, wrappers, list_level, parent_item) }
96
+ end
97
+
98
+ def leaf(text, node, style, wrappers, list_item, list_level)
99
+ text.empty? ? [] : [[text, node, style, wrappers, list_item, list_item ? list_level : nil]]
100
+ end
101
+
102
+ def join(groups, separator)
103
+ groups.reject { |parts| parts.empty? || parts.all? { |part, _node, _style, _wrappers, _item, _level| part.empty? } }
104
+ .each_with_object([]) do |parts, result|
105
+ result << [separator, nil, {}, [], nil, nil] unless result.empty?
106
+ result.concat(parts)
107
+ end
108
+ end
109
+ end
110
+ end