hadar 0.2.0 → 0.3.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
- data/CHANGELOG.md +4 -0
- data/README.md +23 -0
- data/docs/adr/001-template-layouts.md +2 -0
- data/docs/adr/002-opt-in-freeform-layout.md +23 -0
- data/lib/hadar/application.rb +19 -6
- data/lib/hadar/layout.rb +2 -1
- data/lib/hadar/renderer.rb +27 -2
- data/lib/hadar/slide.rb +53 -2
- data/lib/hadar/slot.rb +18 -2
- data/lib/hadar/version.rb +1 -1
- data/sig/hadar.rbs +2 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b0bc8dd031f42ad45785dbcf360c9f075e8d71758be0a5e2df0e158abc77ef8d
|
|
4
|
+
data.tar.gz: 0702a59cfdcfd8987311f08e178a9390ec31b3442de35b4414fb1b3dc5c301d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a234432c82f1b580bc19b3f4c29211a98556f277f28f5cdbd9da7c1f08172b9fcddea6f64c9a9dba1bcbe3204e071391f20ad6c48a8aa87ec04f19643f59fc83
|
|
7
|
+
data.tar.gz: ca5aadb8917b2aa2761592ecec9a7adc98e0d22b24fe1e30f20d398afac625c6ba5b10e5c60f13b84ceeff3e2a44769e732eea2fa55d8f060705ceb90bc0c0c0
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
|
@@ -222,6 +222,29 @@ the referenced assets are not copied. If an absolute selected asset lives
|
|
|
222
222
|
outside the deck directory, its saved relative reference points outside that
|
|
223
223
|
directory rather than copying the file.
|
|
224
224
|
|
|
225
|
+
Freeform placement is an explicit per-slide opt-in. Add `<!-- layout: freeform -->`
|
|
226
|
+
and one `<!-- place: x,y,width,height -->` immediately before each Markdown
|
|
227
|
+
block. Coordinates are percentages of the slide's inner canvas (after theme
|
|
228
|
+
margins); every rectangle must fit inside 0–100%. An image-only paragraph is
|
|
229
|
+
placed as an image. For example:
|
|
230
|
+
|
|
231
|
+
```markdown
|
|
232
|
+
<!-- layout: freeform -->
|
|
233
|
+
|
|
234
|
+
<!-- place: 5,8,90,20 -->
|
|
235
|
+
# Quarterly report
|
|
236
|
+
|
|
237
|
+
<!-- place: 10,35,80,50 -->
|
|
238
|
+
Revenue increased **18%**.
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
The content stays readable in a plain Markdown viewer, but its placement does
|
|
242
|
+
not. Hadar marks freeform slides in the editor with a compatibility warning.
|
|
243
|
+
Missing, malformed, or overflowing positions are errors; Hadar never silently
|
|
244
|
+
drops a block. Edit the directives in the Markdown source to reposition items.
|
|
245
|
+
After an external edit reloads a freeform slide, select its block again before
|
|
246
|
+
editing; source-order item numbers may have changed.
|
|
247
|
+
|
|
225
248
|
`SlideList` creates thumbnail rows only for the visible viewport, using
|
|
226
249
|
`Zaniah::UniformList`; `build(width:, height:)` returns the Zaniah element for
|
|
227
250
|
embedding in an application layout. Its `select(index)` method updates the
|
|
@@ -20,3 +20,5 @@ one from the parsed Markdown structure. A free-form canvas is out of scope.
|
|
|
20
20
|
Template content can remain a projection of the source AST and can be edited
|
|
21
21
|
without duplicating slide state. Users do not get arbitrary element placement;
|
|
22
22
|
reconsider only for a later format that can represent positions explicitly.
|
|
23
|
+
|
|
24
|
+
The later opt-in format is specified in [ADR 002](002-opt-in-freeform-layout.md).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# ADR 002: Opt-in freeform placement in Markdown
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-09-24
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
ADR 001 kept the initial release template-only. Later freeform slides still
|
|
9
|
+
need a source-backed position format and a visible compatibility warning.
|
|
10
|
+
|
|
11
|
+
## Decision
|
|
12
|
+
|
|
13
|
+
`<!-- layout: freeform -->` opts in one slide. Each Markdown block has a
|
|
14
|
+
preceding `<!-- place: x,y,width,height -->` comment, measured as percentages
|
|
15
|
+
of the inner slide canvas. Positions must be finite, positive-sized rectangles
|
|
16
|
+
entirely within the canvas. Missing or malformed positions are rejected rather
|
|
17
|
+
than ignored. Hadar's editor warns that a plain Markdown viewer loses layout.
|
|
18
|
+
|
|
19
|
+
## Consequences
|
|
20
|
+
|
|
21
|
+
Content remains ordinary Markdown and edits still pass through Beid. Plain
|
|
22
|
+
viewers display the content in source order without the Hadar layout. Positions
|
|
23
|
+
are edited in Markdown; drag-and-drop or independent canvas state is not added.
|
data/lib/hadar/application.rb
CHANGED
|
@@ -100,6 +100,9 @@ module Hadar
|
|
|
100
100
|
editor_pane = Zaniah::Div.new.w(editor_width).h_full.p(12).gap(8)
|
|
101
101
|
.style(flex_direction: :column, border: 1, border_color: deck.theme.colors.fetch("muted"))
|
|
102
102
|
editor_pane.child(Zaniah::UI::Label.new(slot_title, size: :sm))
|
|
103
|
+
if selected_index && deck.slide(selected_index).layout == :freeform
|
|
104
|
+
editor_pane.child(Zaniah::UI::Label.new("Freeform — このスライドは素の Markdown ビューアでは崩れます", tone: :muted))
|
|
105
|
+
end
|
|
103
106
|
editor_pane.child(slot_selector) if selected_index && deck.slide(selected_index).slots.any?
|
|
104
107
|
editor_pane.child(editor.w_full.flex_1) if editor
|
|
105
108
|
editor_pane.child(Zaniah::UI::Label.new("Editing unavailable: #{error}", tone: :muted)) if error
|
|
@@ -166,7 +169,7 @@ module Hadar
|
|
|
166
169
|
|
|
167
170
|
def insert_or_replace_selected_image(path)
|
|
168
171
|
slot = selected_slot || raise(Error, "there is no selected slot")
|
|
169
|
-
raise Error, "select the image slot first" unless slot
|
|
172
|
+
raise Error, "select the image slot first" unless image_slot?(slot)
|
|
170
173
|
|
|
171
174
|
updated = slot.empty? ? slot.insert_image(path) : slot.replace_image(path)
|
|
172
175
|
finish_slot_edit
|
|
@@ -427,12 +430,13 @@ module Hadar
|
|
|
427
430
|
def slot_editor_for_selected_slide
|
|
428
431
|
return [nil, nil] unless selected_index
|
|
429
432
|
unless selected_slot_name
|
|
430
|
-
@body_editor_error ||=
|
|
433
|
+
@body_editor_error ||= deck.slide(selected_index).layout == :freeform ?
|
|
434
|
+
"select a block to edit after reloading this freeform slide" : "this slide has no editable slots"
|
|
431
435
|
return [nil, @body_editor_error]
|
|
432
436
|
end
|
|
433
437
|
|
|
434
438
|
slot = selected_slot
|
|
435
|
-
if slot
|
|
439
|
+
if image_slot?(slot)
|
|
436
440
|
pending_body_editor_focus(:clear) if @pending_body_editor_reload && @pending_body_editor_reload[:slot] == slot.name
|
|
437
441
|
[image_slot_editor(slot), @body_editor_error]
|
|
438
442
|
elsif slot.nodes.any? { |node| %i[table code_block].include?(node.type) }
|
|
@@ -480,7 +484,7 @@ module Hadar
|
|
|
480
484
|
end
|
|
481
485
|
|
|
482
486
|
def image_slot_editor(slot)
|
|
483
|
-
return Zaniah::UI::Label.new("Select an image slot to insert or replace an image.") unless slot
|
|
487
|
+
return Zaniah::UI::Label.new("Select an image slot to insert or replace an image.") unless image_slot?(slot)
|
|
484
488
|
if !slot.empty? && !(slot.nodes.one? && slot.nodes.first.type == :image)
|
|
485
489
|
return Zaniah::UI::Label.new("This image slot contains multiple images and cannot be edited safely.", tone: :muted)
|
|
486
490
|
end
|
|
@@ -606,6 +610,10 @@ module Hadar
|
|
|
606
610
|
slide.slots.key?(:body) ? :body : slide.slots.keys.first
|
|
607
611
|
end
|
|
608
612
|
|
|
613
|
+
def image_slot?(slot)
|
|
614
|
+
slot.name == :image || (slot.nodes.one? && slot.nodes.first.type == :image)
|
|
615
|
+
end
|
|
616
|
+
|
|
609
617
|
def key_context(window)
|
|
610
618
|
(window.dispatcher.focused&.ancestors || []).reverse.each_with_object({}) do |handle, context|
|
|
611
619
|
context.merge!(handle.context)
|
|
@@ -681,8 +689,13 @@ module Hadar
|
|
|
681
689
|
[[selected_index || 0, 0].max, deck.length - 1].min
|
|
682
690
|
end
|
|
683
691
|
@pending_body_editor_reload = nil if @selected_index != previous_index
|
|
684
|
-
@selected_slot_name = @selected_index && deck.slide(@selected_index).
|
|
685
|
-
|
|
692
|
+
@selected_slot_name = if @selected_index && deck.slide(@selected_index).layout == :freeform
|
|
693
|
+
nil # Positional item IDs may now refer to different blocks; require explicit reselection.
|
|
694
|
+
elsif @selected_index && deck.slide(@selected_index).slots.key?(previous_slot)
|
|
695
|
+
previous_slot
|
|
696
|
+
else
|
|
697
|
+
@selected_index && default_slot_name(deck.slide(@selected_index))
|
|
698
|
+
end
|
|
686
699
|
reset_slot_editor_state_after_reload
|
|
687
700
|
presenter.reconcile!
|
|
688
701
|
rebuild_slide_list
|
data/lib/hadar/layout.rb
CHANGED
|
@@ -12,7 +12,8 @@ module Hadar
|
|
|
12
12
|
Definition.new(:full_bleed_image, %i[image]),
|
|
13
13
|
Definition.new(:quote, %i[quote attribution]),
|
|
14
14
|
Definition.new(:code, %i[title code]),
|
|
15
|
-
Definition.new(:blank, [])
|
|
15
|
+
Definition.new(:blank, []),
|
|
16
|
+
Definition.new(:freeform, [])
|
|
16
17
|
].freeze
|
|
17
18
|
private_constant :Definition
|
|
18
19
|
|
data/lib/hadar/renderer.rb
CHANGED
|
@@ -41,6 +41,12 @@ module Hadar
|
|
|
41
41
|
node :stack, props: {gap: :number} do |props, children|
|
|
42
42
|
Zaniah::Element.new.flex_col.style(gap: props.fetch(:gap)).children(children)
|
|
43
43
|
end
|
|
44
|
+
node :placed, props: {x: :number, y: :number, width: :number, height: :number} do |props, children|
|
|
45
|
+
Zaniah::Element.new.flex_col.style(position: :absolute,
|
|
46
|
+
left: Zaniah.percent(props.fetch(:x)), top: Zaniah.percent(props.fetch(:y)),
|
|
47
|
+
width: Zaniah.percent(props.fetch(:width)), height: Zaniah.percent(props.fetch(:height)))
|
|
48
|
+
.children(children)
|
|
49
|
+
end
|
|
44
50
|
node :columns, props: {gap: :number} do |props, children|
|
|
45
51
|
Zaniah::Element.new.flex_row.style(gap: props.fetch(:gap)).children(children)
|
|
46
52
|
end
|
|
@@ -96,6 +102,21 @@ module Hadar
|
|
|
96
102
|
theme = slide_theme(slide)
|
|
97
103
|
gap = theme.spacing.fetch("gap")
|
|
98
104
|
case slide.layout
|
|
105
|
+
when :freeform
|
|
106
|
+
slide.placements.map do |name, (x, y, width, height)|
|
|
107
|
+
slot = slide.slot(name)
|
|
108
|
+
entry = slot.nodes.first
|
|
109
|
+
children = if entry.type == :image
|
|
110
|
+
[image_node(slot)]
|
|
111
|
+
elsif entry.type == :heading
|
|
112
|
+
[text_node(slot.text, theme.font.fetch("title_size"), theme),
|
|
113
|
+
*images_in(entry).map { |image| image_node(slot, image: image) }]
|
|
114
|
+
else
|
|
115
|
+
[*render_blocks(slot, theme), *images_in(entry).map { |image| image_node(slot, image: image) }]
|
|
116
|
+
end
|
|
117
|
+
node(:placed, {x: x, y: y, width: width, height: height}, children.compact,
|
|
118
|
+
"slide-#{slide.index}-#{name}")
|
|
119
|
+
end
|
|
99
120
|
when :title
|
|
100
121
|
stack([
|
|
101
122
|
text_node(slide.slot(:title).text, theme.font.fetch("title_size"), theme),
|
|
@@ -226,10 +247,14 @@ module Hadar
|
|
|
226
247
|
Zaniah::Describe::Node.new(type, props, children, key)
|
|
227
248
|
end
|
|
228
249
|
|
|
229
|
-
def
|
|
250
|
+
def images_in(entry)
|
|
251
|
+
[entry, *entry.children.flat_map { |child| images_in(child) }].select { |node| node.type == :image }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def image_node(slot, image: nil)
|
|
230
255
|
return if slot.empty?
|
|
231
256
|
|
|
232
|
-
path = slot.resolved_image_path
|
|
257
|
+
path = image ? slot.resolved_image_path_for(image) : slot.resolved_image_path
|
|
233
258
|
node(:image, {path: path}, [], "slide-#{slot.slide_index}-#{slot.name}-image")
|
|
234
259
|
end
|
|
235
260
|
|
data/lib/hadar/slide.rb
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module Hadar
|
|
4
4
|
class Slide
|
|
5
|
-
attr_reader :index, :document, :nodes, :layout, :slots, :theme, :notes
|
|
5
|
+
attr_reader :index, :document, :nodes, :layout, :slots, :theme, :notes, :placements
|
|
6
6
|
|
|
7
7
|
def initialize(index:, document:, nodes:, layout: nil, theme:, deck:)
|
|
8
8
|
@index, @document, @nodes, @theme, @deck = index, document, nodes.freeze, theme, deck
|
|
9
9
|
@notes, @notes_ranges = extract_notes
|
|
10
10
|
@layout = Layout.select(content_nodes, requested: layout)
|
|
11
|
+
@placements = {}
|
|
11
12
|
@slots = build_slots.freeze
|
|
13
|
+
@placements.freeze
|
|
12
14
|
freeze
|
|
13
15
|
end
|
|
14
16
|
|
|
@@ -18,7 +20,11 @@ module Hadar
|
|
|
18
20
|
end
|
|
19
21
|
end
|
|
20
22
|
|
|
21
|
-
def title
|
|
23
|
+
def title
|
|
24
|
+
return slot(:title).text unless layout == :freeform
|
|
25
|
+
|
|
26
|
+
slots.values.find { |entry| entry.nodes.first&.type == :heading }&.text.to_s
|
|
27
|
+
end
|
|
22
28
|
|
|
23
29
|
private
|
|
24
30
|
|
|
@@ -75,6 +81,8 @@ module Hadar
|
|
|
75
81
|
|
|
76
82
|
def build_slots
|
|
77
83
|
content = content_nodes
|
|
84
|
+
return build_freeform_slots(content) if layout == :freeform
|
|
85
|
+
|
|
78
86
|
definition = Layout.fetch(layout)
|
|
79
87
|
mapping = definition.slots.to_h { |name| [name, []] }
|
|
80
88
|
case layout
|
|
@@ -114,6 +122,49 @@ module Hadar
|
|
|
114
122
|
end
|
|
115
123
|
end
|
|
116
124
|
|
|
125
|
+
def build_freeform_slots(content)
|
|
126
|
+
pending = nil
|
|
127
|
+
slots = {}
|
|
128
|
+
content.each do |node|
|
|
129
|
+
next if node.type == :link_definition
|
|
130
|
+
|
|
131
|
+
if placement_directive?(node)
|
|
132
|
+
raise Error, "freeform placement must be followed by one content block" if pending
|
|
133
|
+
|
|
134
|
+
pending = parse_placement(node.attributes.dig(:values, "place"))
|
|
135
|
+
next
|
|
136
|
+
end
|
|
137
|
+
raise Error, "freeform content requires a preceding place directive" unless pending
|
|
138
|
+
|
|
139
|
+
name = :"item_#{slots.length + 1}"
|
|
140
|
+
body = node.type == :paragraph && node.children.one? && node.children.first.type == :image ? node.children.first : node
|
|
141
|
+
slots[name] = Slot.new(name: name, nodes: [body], document: document,
|
|
142
|
+
deck: @deck, slide_index: index)
|
|
143
|
+
@placements[name] = pending.freeze
|
|
144
|
+
pending = nil
|
|
145
|
+
end
|
|
146
|
+
raise Error, "freeform placement must be followed by one content block" if pending
|
|
147
|
+
|
|
148
|
+
slots
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def placement_directive?(node)
|
|
152
|
+
node.type == :directive && node.attributes[:kind] == :html_comment &&
|
|
153
|
+
node.attributes[:values]&.key?("place")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def parse_placement(value)
|
|
157
|
+
unless value.to_s.match?(/\A\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*\z/)
|
|
158
|
+
raise Error, "place must contain x,y,width,height percentages"
|
|
159
|
+
end
|
|
160
|
+
x, y, width, height = value.split(",").map { |part| Float(part) }
|
|
161
|
+
unless width.positive? && height.positive? && x + width <= 100 && y + height <= 100
|
|
162
|
+
raise Error, "freeform placement must fit within the slide (0–100%)"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
[x, y, width, height]
|
|
166
|
+
end
|
|
167
|
+
|
|
117
168
|
def layout_directive?(node)
|
|
118
169
|
node.type == :directive && node.attributes[:kind] == :html_comment &&
|
|
119
170
|
node.attributes[:values]&.key?("layout")
|
data/lib/hadar/slot.rb
CHANGED
|
@@ -44,8 +44,7 @@ module Hadar
|
|
|
44
44
|
return if nodes.empty?
|
|
45
45
|
raise Error, "image_path requires a slot containing exactly one image" unless nodes.one? && nodes.first.type == :image
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
destination.match?(/\Ahttps?:\/\//i) ? destination : URI::DEFAULT_PARSER.unescape(destination)
|
|
47
|
+
destination_path(nodes.first)
|
|
49
48
|
end
|
|
50
49
|
|
|
51
50
|
def resolved_image_path
|
|
@@ -53,6 +52,14 @@ module Hadar
|
|
|
53
52
|
path && @deck.resolve_image_path(path)
|
|
54
53
|
end
|
|
55
54
|
|
|
55
|
+
def resolved_image_path_for(node)
|
|
56
|
+
unless node.is_a?(Beid::Node) && node.type == :image && nodes.any? { |entry| contains_node?(entry, node) }
|
|
57
|
+
raise Error, "image is not part of this slot"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@deck.resolve_image_path(destination_path(node))
|
|
61
|
+
end
|
|
62
|
+
|
|
56
63
|
def replace_text(text)
|
|
57
64
|
@deck.replace_text(self, text)
|
|
58
65
|
end
|
|
@@ -95,6 +102,15 @@ module Hadar
|
|
|
95
102
|
|
|
96
103
|
private
|
|
97
104
|
|
|
105
|
+
def contains_node?(entry, target)
|
|
106
|
+
entry.equal?(target) || entry.children.any? { |child| contains_node?(child, target) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def destination_path(node)
|
|
110
|
+
destination = node.attributes.fetch(:destination)
|
|
111
|
+
destination.match?(/\Ahttps?:\/\//i) ? destination : URI::DEFAULT_PARSER.unescape(destination)
|
|
112
|
+
end
|
|
113
|
+
|
|
98
114
|
def table_nodes = nodes.select { |node| node.type == :table }
|
|
99
115
|
|
|
100
116
|
def valid_index!(index, name)
|
data/lib/hadar/version.rb
CHANGED
data/sig/hadar.rbs
CHANGED
|
@@ -30,6 +30,7 @@ module Hadar
|
|
|
30
30
|
def text_for: (Beid::Node node) -> String
|
|
31
31
|
def image_path: () -> String?
|
|
32
32
|
def resolved_image_path: () -> String?
|
|
33
|
+
def resolved_image_path_for: (Beid::Node node) -> String
|
|
33
34
|
def table_rows: (?table: Integer) -> Array[Array[String]]
|
|
34
35
|
def replace_text: (String text) -> Slot
|
|
35
36
|
def rich_text: (?editable: bool) -> Zaniah::UI::RichText
|
|
@@ -49,6 +50,7 @@ module Hadar
|
|
|
49
50
|
attr_reader slots: Hash[Symbol, Slot]
|
|
50
51
|
attr_reader theme: Theme
|
|
51
52
|
attr_reader notes: String?
|
|
53
|
+
attr_reader placements: Hash[Symbol, Array[Float]]
|
|
52
54
|
|
|
53
55
|
def initialize: (index: Integer, document: Beid::Document, nodes: Array[Beid::Node],
|
|
54
56
|
?layout: String | Symbol | nil, theme: Theme, deck: Deck) -> void
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hadar
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
@@ -136,6 +136,7 @@ files:
|
|
|
136
136
|
- assets/themes/minimal.jsonc
|
|
137
137
|
- assets/themes/warm.jsonc
|
|
138
138
|
- docs/adr/001-template-layouts.md
|
|
139
|
+
- docs/adr/002-opt-in-freeform-layout.md
|
|
139
140
|
- lib/hadar.rb
|
|
140
141
|
- lib/hadar/application.rb
|
|
141
142
|
- lib/hadar/command_palette.rb
|