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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7b4c769b40f5836617885858a50bc58e93ef8072b97430520ec2315ed81edbb
4
- data.tar.gz: e8379cf1cc2f8039429d0fba6a650d8d7f059801e30c0df69864bf5ed94be8b0
3
+ metadata.gz: b0bc8dd031f42ad45785dbcf360c9f075e8d71758be0a5e2df0e158abc77ef8d
4
+ data.tar.gz: 0702a59cfdcfd8987311f08e178a9390ec31b3442de35b4414fb1b3dc5c301d3
5
5
  SHA512:
6
- metadata.gz: b44313439e6fcc8a411c8b7fbc379a2050c843b8758dfa42352c4f007f5334141b45ced44d8d139a14dc030332d826744feaab621678e7e671760710f2ee1edd
7
- data.tar.gz: 46f5d21160a6b804ba95067906abcc9f0277bf263a5aa51281ebe93ae0c1712982d8ebf0f328a3ca0dc2670a3f0a7573d67509b75f1787f6f477a64de1714d61
6
+ metadata.gz: a234432c82f1b580bc19b3f4c29211a98556f277f28f5cdbd9da7c1f08172b9fcddea6f64c9a9dba1bcbe3204e071391f20ad6c48a8aa87ec04f19643f59fc83
7
+ data.tar.gz: ca5aadb8917b2aa2761592ecec9a7adc98e0d22b24fe1e30f20d398afac625c6ba5b10e5c60f13b84ceeff3e2a44769e732eea2fa55d8f060705ceb90bc0c0c0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-09-24
4
+
5
+ - Add opt-in freeform slide placement with explicit Markdown directives and an editor warning.
6
+
3
7
  ## 0.2.0 - 2026-09-24
4
8
 
5
9
  - Add APNG export for slide decks.
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.
@@ -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.name == :image
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 ||= "this slide has no editable slots"
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.name == :image
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.name == :image
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).slots.key?(previous_slot) ? previous_slot :
685
- (@selected_index && default_slot_name(deck.slide(@selected_index)))
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
 
@@ -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 image_node(slot)
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 = slot(:title).text
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
- destination = nodes.first.attributes.fetch(:destination)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hadar
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
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.2.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