playbook_ui 16.5.0.pre.alpha.RTEPOC15708 → 16.5.0.pre.alpha.RTEPOC15742

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: 007e13930c03383e3d8d12e920f115ba93f90a42a7209e1892e5573a035ac924
4
- data.tar.gz: db7d6dcd4bf18b7a56d864b5a9e8dd7e95110ba894ff68ece84bf67518a6d69d
3
+ metadata.gz: 0ae730fde19e05cbbd23bebf541db944a7e7e92ede9564b91886c776e27d930a
4
+ data.tar.gz: bfc177ae612fe3e2032a4959b3d949f064c478455be2c502fd335ec4147574f6
5
5
  SHA512:
6
- metadata.gz: c3d8760e03b24889ae15e84e210eb86d600423ae7935ac2a29a23e010bfba4c9510d31a13d55c4fd527f5d64204c16cd0183336f561a9d65840eb3e8ed7714be
7
- data.tar.gz: 04b2d00ab21c6bc07c1e2c19d580948b4898cd010138dcf1260fdebfc3081848b5ab6b2da5d7649825390839fff108f07537159d72e131ca08f78760a9d30036
6
+ metadata.gz: f5e1b1ef50ebee06c617e1dbe5078f95d2770fc216a399398805db6cd6e4fb7795a0d0efe5db34275c361891a49117f50e2dc5659483aecb3a5ca20d03220a81
7
+ data.tar.gz: d2056b8d90992bbb246ee13c7617100e30bf484450b2180d4e1bcc188d2f6b2ecd13d3efcb48f43f91f4d12287d3c13a10bb1654b43def8faa64747a8fe6c37f
@@ -8,7 +8,7 @@
8
8
  @import "../tokens/transition";
9
9
  @import "previewer_mixin";
10
10
 
11
- [class^="pb_rich_text_editor_kit"] {
11
+ .pb_rich_text_editor_kit {
12
12
  &.inline {
13
13
  .toolbar {
14
14
  opacity: 0;
@@ -94,18 +94,112 @@
94
94
  align-items: center;
95
95
  gap: $space_xs;
96
96
  }
97
+
98
+ // Vertical section separators use ::before/::after with height: 100%. With
99
+ // align-items: center on .toolbar_block the kit’s cross size was 0, so the
100
+ // lines disappeared (master only had gap on .toolbar_block, default stretch).
101
+ .pb_section_separator_kit.pb_section_separator_vertical {
102
+ align-self: center;
103
+ flex-shrink: 0;
104
+ height: $space_xl;
105
+ }
106
+
107
+ // Match React ToolbarDropdown: secondary trigger flattened to text-style control.
97
108
  .editor-dropdown-button {
98
109
  background: transparent;
99
110
  border: none;
100
111
  color: $text_lt_light;
101
112
  cursor: pointer;
102
113
  font-weight: $light;
103
- padding: ($space_xs - 1) 0px;
104
- width: $space_xl * 3;
114
+ letter-spacing: normal;
115
+ line-height: 1;
116
+ min-height: unset;
117
+ min-width: $space_xl * 5;
118
+ padding: ($space_xs - 1) $space_xs;
119
+ width: auto;
120
+
121
+ // Undo Playbook .pb_button_kit defaults that throw off icon vs label (line-height 1.5, min-height 40px).
122
+ .pb_button_content {
123
+ align-items: center;
124
+ display: inline-flex;
125
+ line-height: 1;
126
+ }
127
+
128
+ // React: single Flex row inside the button.
129
+ .pb_button_content > .pb_flex_kit {
130
+ align-items: center;
131
+ }
132
+
133
+ // Rails: block-style trigger row (spans + icons).
134
+ .rte-block-style-trigger-inner {
135
+ align-items: center;
136
+ }
137
+
138
+ .rte-block-style-trigger-icon,
139
+ .rte-block-style-chevron {
140
+ display: inline-flex;
141
+ flex-shrink: 0;
142
+ line-height: 0;
143
+
144
+ .pb_icon_kit {
145
+ align-items: center;
146
+ display: flex;
147
+ line-height: 0;
148
+ }
149
+
150
+ svg {
151
+ display: block;
152
+ }
153
+ }
154
+
155
+ .rte-block-style-trigger-label {
156
+ align-items: center;
157
+ display: inline-flex;
158
+ line-height: 1.2;
159
+ }
160
+
105
161
  &:focus-visible {
106
162
  box-shadow: unset;
107
163
  }
108
164
  }
165
+
166
+ // Rails TipTap toolbar: mirror React Toolbar.tsx — <Flex paddingX="sm" paddingY="xxs" justify="between">.
167
+ &.rte-rails-toolbar-layout {
168
+ .rte-rails-toolbar-row {
169
+ align-items: center;
170
+ box-sizing: border-box;
171
+ column-gap: 0;
172
+ display: flex;
173
+ flex-wrap: wrap;
174
+ justify-content: flex-start;
175
+ padding: $space_xxs $space_sm;
176
+ row-gap: $space_xs;
177
+ width: 100%;
178
+ }
179
+
180
+ .rte-toolbar-left {
181
+ align-items: center;
182
+ display: flex;
183
+ flex: 1 1 auto;
184
+ flex-wrap: wrap;
185
+ gap: $space_xs;
186
+ min-width: 0;
187
+ }
188
+
189
+ .rte-toolbar-right {
190
+ align-items: center;
191
+ display: flex;
192
+ flex-shrink: 0;
193
+ gap: $space_xs;
194
+ margin-left: auto;
195
+ }
196
+
197
+ // Align dropdown trigger with icon row (React wraps Popover + SectionSeparator in one flex line).
198
+ .pb_popover_reference_wrapper {
199
+ align-items: center;
200
+ display: inline-flex;
201
+ }
202
+ }
109
203
  }
110
204
 
111
205
  .ProseMirror {
@@ -171,6 +265,17 @@
171
265
  @include preview_tiptap_ul;
172
266
  }
173
267
  }
268
+
269
+ // Toolbar + editor stack: toolbar keeps its border; editor has no top stroke (classic layout).
270
+ // Avoid relying on a wrapper-only frame — partial deploys then looked “borderless” because
271
+ // ProseMirror had been forced to border: none.
272
+ .pb_rich_text_editor_advanced_container.toolbar-active {
273
+ .ProseMirror {
274
+ border-top: none;
275
+ border-top-left-radius: initial;
276
+ border-top-right-radius: initial;
277
+ }
278
+ }
174
279
  }
175
280
 
176
281
  .pb_tiptap_toolbar_dropdown_list_item {
@@ -197,42 +302,51 @@
197
302
  }
198
303
  }
199
304
  }
200
- .pb_rich_text_editor_advanced_container {
201
- transition: box-shadow 0.3s ease-in-out, border-radius 0.3s ease-in-out,
202
- border-color 0.3s ease-in-out;
305
+
306
+ // Rails RTE: block-style menu uses Nav (popover) instead of React NavItem class hook.
307
+ .pb_rich_text_editor_kit .pb_popover_tooltip .pb_nav_list_item_link.is-active {
308
+ background-color: $bg_light;
309
+ border-radius: unset !important;
310
+ color: $primary;
311
+
312
+ .pb_nav_list_item_text,
313
+ .pb_nav_list_item_icon_left {
314
+ color: $primary !important;
315
+ }
316
+ }
317
+
318
+ .pb_rich_text_editor_kit .pb_popover_tooltip .pb_nav_list_kit_item:hover .pb_nav_list_item_link:not(.is-active) {
319
+ background-color: $neutral_subtle;
320
+ border-radius: unset !important;
321
+
322
+ .pb_nav_list_item_text,
323
+ .pb_nav_list_item_icon_left {
324
+ background-color: unset;
325
+ color: $text_lt_light !important;
326
+ }
327
+ }
328
+
329
+ // No toolbar: ring the whole control.
330
+ .pb_rich_text_editor_advanced_container:not(.toolbar-active) {
331
+ transition: box-shadow 0.3s ease-in-out, border-radius 0.3s ease-in-out;
203
332
  &:focus-visible,
204
333
  &:focus-within {
205
- outline: unset;
206
- box-shadow: 0 0 0 1px $input_border_state;
207
334
  border-radius: $border_rad_heaviest;
335
+ box-shadow: 0 0 0 1px $input_border_state;
336
+ outline: unset;
208
337
  transition: box-shadow 0.3s ease-in-out, border-radius 0.3s ease-in-out;
209
338
  }
210
- // Single outer frame (toolbar + editor): avoids inner ProseMirror border + focus ring looking like two bottoms.
211
- &.toolbar-active {
212
- border: 1px solid $input_border_default;
213
- border-radius: $border_rad_heaviest;
214
- overflow: hidden;
215
-
216
- &:focus-visible,
217
- &:focus-within {
218
- outline: unset;
219
- box-shadow: none;
220
- border-color: $input_border_state;
221
- }
339
+ }
222
340
 
341
+ // Toolbar + editor: use border color (not an outer box-shadow) so the bottom isn’t doubled.
342
+ .pb_rich_text_editor_advanced_container.toolbar-active {
343
+ &:focus-within {
223
344
  .toolbar {
224
- border: none;
225
- border-bottom: 1px solid $input_border_default;
226
- border-radius: 0;
227
- }
228
-
229
- &:focus-within .toolbar {
230
- border-bottom-color: $input_border_state;
345
+ border-color: $input_border_state;
231
346
  }
232
347
 
233
348
  .ProseMirror {
234
- border: none;
235
- border-radius: 0;
349
+ border-color: $input_border_state;
236
350
  }
237
351
  }
238
352
  }
@@ -1 +1,12 @@
1
- TipTap (vanilla JS) the Playbook **Rails** rich text editor. No React; same editor core as the React TipTap variant. Content is synced to a hidden input for Rails form submission. Use `pb_rails("rich_text_editor", props: { input_options: { id: "...", name: "..." }, value: "..." })`.
1
+ The Rails rich text editor is a TipTap surface with no React. The UI (toolbar, block-style menu, formatting actions) is rendered with Playbook Rails kits (`pb_rails`). The editor document is a vanilla TipTap `Editor` instance; HTML is synced to a hidden `<input>` so standard Rails forms can submit the value.
2
+
3
+ ### How TipTap is loaded (Rails)
4
+
5
+ - The kit ships an `importmap` in the ERB template that maps `@tiptap/*` and ProseMirror packages to ES module URLs on a CDN (see `rich_text_editor.html.erb`). A small `type="module"` script `import()`s `@tiptap/core`, `@tiptap/starter-kit`, `@tiptap/extension-link`, and related modules at runtime.
6
+ - You do not need to add TipTap to your app’s npm dependencies or Gemfile for this kit to work out of the box—the browser loads those modules from the CDN when the page runs.
7
+ - Your app must support import maps and ES modules in the browser (modern browsers; ensure CSP allows the CDN if you lock scripts down).
8
+
9
+ ### Relation to the React implementation
10
+
11
+ - Same core: both use TipTap v2 on top of ProseMirror; styling lives in Playbook SCSS (`_tiptap_styles.scss`) so the editor chrome lines up between platforms.
12
+ - Different shell: Rails uses ERB + Playbook Rails components + inline module script. React uses `RichTextEditor` / `_tiptap_editor.tsx` and TipTap wired through the bundled Playbook React package—see Advanced Default for that stack and when you need TipTap installed in your JavaScript bundle.
@@ -69,140 +69,111 @@
69
69
  <% end %>
70
70
  <input type="hidden" name="<%= object.input_name %>" id="<%= object.input_id %>" value="" />
71
71
  <div class="pb_rich_text_editor_advanced_container toolbar-active">
72
- <div class="pb_background_kit pb_background_color_white toolbar" id="<%= object.toolbar_id %>">
73
- <div class="toolbar_block">
74
- <%= pb_rails("button", props: {
75
- icon: "bold",
76
- size: "sm",
77
- variant: "link",
78
- html_options: {
79
- type: "button",
80
- data: { action: "bold" },
81
- title: "Bold",
82
- role: "button",
83
- tabindex: 0,
84
- class: "toolbar_button"
85
- }
86
- }) %>
87
- <%= pb_rails("button", props: {
88
- icon: "italic",
89
- size: "sm",
90
- variant: "link",
91
- html_options: {
92
- type: "button",
93
- data: { action: "italic" },
94
- title: "Italic",
95
- role: "button",
96
- tabindex: 0,
97
- class: "toolbar_button"
98
- }
99
- }) %>
100
- <%= pb_rails("button", props: {
101
- icon: "strikethrough",
102
- size: "sm",
103
- variant: "link",
104
- html_options: {
105
- type: "button",
106
- data: { action: "strike" },
107
- title: "Strikethrough",
108
- role: "button",
109
- tabindex: 0,
110
- class: "toolbar_button"
111
- }
112
- }) %>
113
- <%= pb_rails("button", props: {
114
- icon: "h1",
115
- size: "sm",
116
- variant: "link",
117
- html_options: {
118
- type: "button",
119
- data: { action: "heading", level: "1" },
120
- title: "Heading 1",
121
- role: "button",
122
- tabindex: 0,
123
- class: "toolbar_button"
124
- }
125
- }) %>
126
- <%= pb_rails("button", props: {
127
- icon: "h2",
128
- size: "sm",
129
- variant: "link",
130
- html_options: {
131
- type: "button",
132
- data: { action: "heading", level: "2" },
133
- title: "Heading 2",
134
- role: "button",
135
- tabindex: 0,
136
- class: "toolbar_button"
137
- }
138
- }) %>
139
- <%= pb_rails("button", props: {
140
- icon: "list",
141
- size: "sm",
142
- variant: "link",
143
- html_options: {
144
- type: "button",
145
- data: { action: "bulletList" },
146
- title: "Bullet list",
147
- role: "button",
148
- tabindex: 0,
149
- class: "toolbar_button"
150
- }
151
- }) %>
152
- <%= pb_rails("button", props: {
153
- icon: "list-ol",
154
- size: "sm",
155
- variant: "link",
156
- html_options: {
157
- type: "button",
158
- data: { action: "orderedList" },
159
- title: "Ordered list",
160
- role: "button",
161
- tabindex: 0,
162
- class: "toolbar_button"
163
- }
164
- }) %>
165
- <%= pb_rails("button", props: {
166
- icon: "quote-left",
167
- size: "sm",
168
- variant: "link",
169
- html_options: {
170
- type: "button",
171
- data: { action: "blockquote" },
172
- title: "Quote",
173
- role: "button",
174
- tabindex: 0,
175
- class: "toolbar_button"
176
- }
177
- }) %>
178
- <%= pb_rails("button", props: {
179
- icon: "code",
180
- size: "sm",
181
- variant: "link",
182
- html_options: {
183
- type: "button",
184
- data: { action: "codeBlock" },
185
- title: "Code block",
186
- role: "button",
187
- tabindex: 0,
188
- class: "toolbar_button"
189
- }
190
- }) %>
191
- <%= pb_rails("button", props: {
192
- icon: "link",
193
- size: "sm",
194
- variant: "link",
195
- html_options: {
196
- type: "button",
197
- data: { action: "link" },
198
- title: "Link",
199
- role: "button",
200
- tabindex: 0,
201
- class: "toolbar_button"
202
- }
203
- }) %>
72
+ <% block_style_options = [
73
+ { value: "paragraph", text: "Paragraph", icon: "paragraph" },
74
+ { value: "heading-1", text: "Heading 1", icon: "h1" },
75
+ { value: "heading-2", text: "Heading 2", icon: "h2" },
76
+ { value: "heading-3", text: "Heading 3", icon: "h3" },
77
+ { value: "bulletList", text: "Bullet List", icon: "list" },
78
+ { value: "orderedList", text: "Ordered List", icon: "list-ol" },
79
+ { value: "blockquote", text: "Block Quote", icon: "block-quote" },
80
+ ] %>
81
+ <div class="pb_background_kit pb_background_color_white toolbar rte-rails-toolbar-layout" id="<%= object.toolbar_id %>">
82
+ <div class="rte-rails-toolbar-row">
83
+ <div class="toolbar_block rte-toolbar-left">
84
+ <span class="pb_popover_reference_wrapper">
85
+ <%# Button kit wraps content in <span class="pb_button_content"> — block <div>s inside are invalid and break layout. Single <span> row + JS sync from templates. %>
86
+ <%= pb_rails("button", props: {
87
+ id: object.rte_block_style_trigger_id,
88
+ variant: "secondary",
89
+ classname: "editor-dropdown-button",
90
+ html_options: {
91
+ type: "button",
92
+ "aria-label": "Text style",
93
+ "aria-haspopup": "true",
94
+ },
95
+ }) do %>
96
+ <span class="pb_flex_kit pb_flex_kit_orientation_row pb_flex_kit_justify_content_left pb_flex_kit_align_items_center pb_flex_kit_spacing_none pb_flex_kit_gap_xs gap_xs rte-block-style-trigger-inner" data-rte-block-trigger>
97
+ <span class="rte-block-style-trigger-icon">
98
+ <%= pb_rails("icon", props: { icon: "paragraph", size: "lg" }) %>
99
+ </span>
100
+ <span class="rte-block-style-trigger-label">Paragraph</span>
101
+ <span class="display_inline_flex rte-block-style-chevron">
102
+ <%= pb_rails("icon", props: { icon: "chevron-down", fixed_width: true }) %>
103
+ </span>
104
+ </span>
105
+ <% end %>
106
+ </span>
107
+ <%= pb_rails("popover", props: {
108
+ trigger_element_id: object.rte_block_style_trigger_id,
109
+ tooltip_id: object.rte_block_style_tooltip_id,
110
+ position: "bottom",
111
+ padding: "none",
112
+ close_on_click: "any",
113
+ offset: true,
114
+ }) do %>
115
+ <%= pb_rails("nav", props: { variant: "subtle", padding_top: "xs", padding_bottom: "xs" }) do %>
116
+ <% block_style_options.each do |opt| %>
117
+ <%= pb_rails("nav/item", props: {
118
+ link: "##{opt[:value]}",
119
+ text: opt[:text],
120
+ icon_left: opt[:icon],
121
+ margin: "none",
122
+ padding_top: "xxs",
123
+ padding_bottom: "xxs",
124
+ }) %>
125
+ <% end %>
126
+ <% end %>
127
+ <% end %>
128
+ <%= pb_rails("section_separator", props: { orientation: "vertical" }) %>
129
+ <button type="button" class="toolbar_button" data-action="bold" title="Bold" role="button" tabindex="0">
130
+ <%= pb_rails("flex", props: { align: "center", justify: "center", classname: "toolbar_button_icon" }) do %>
131
+ <%= pb_rails("icon", props: { icon: "bold", size: "lg" }) %>
132
+ <% end %>
133
+ </button>
134
+ <button type="button" class="toolbar_button" data-action="italic" title="Italic" role="button" tabindex="0">
135
+ <%= pb_rails("flex", props: { align: "center", justify: "center", classname: "toolbar_button_icon" }) do %>
136
+ <%= pb_rails("icon", props: { icon: "italic", size: "lg" }) %>
137
+ <% end %>
138
+ </button>
139
+ <button type="button" class="toolbar_button" data-action="strike" title="Strikethrough" role="button" tabindex="0">
140
+ <%= pb_rails("flex", props: { align: "center", justify: "center", classname: "toolbar_button_icon" }) do %>
141
+ <%= pb_rails("icon", props: { icon: "strikethrough", size: "lg" }) %>
142
+ <% end %>
143
+ </button>
144
+ <%= pb_rails("section_separator", props: { orientation: "vertical" }) %>
145
+ <button type="button" class="toolbar_button" data-action="codeBlock" title="Code block" role="button" tabindex="0">
146
+ <%= pb_rails("flex", props: { align: "center", justify: "center", classname: "toolbar_button_icon" }) do %>
147
+ <%= pb_rails("icon", props: { icon: "code", size: "lg" }) %>
148
+ <% end %>
149
+ </button>
150
+ <button type="button" class="toolbar_button" data-action="link" title="Link" role="button" tabindex="0">
151
+ <%= pb_rails("flex", props: { align: "center", justify: "center", classname: "toolbar_button_icon" }) do %>
152
+ <%= pb_rails("icon", props: { icon: "link", size: "lg" }) %>
153
+ <% end %>
154
+ </button>
155
+ </div>
156
+ <div class="toolbar_block rte-toolbar-right">
157
+ <button type="button" class="toolbar_button" data-action="undo" title="Undo" role="button" tabindex="0">
158
+ <%= pb_rails("flex", props: { align: "center", justify: "center", classname: "toolbar_button_icon" }) do %>
159
+ <%= pb_rails("icon", props: { icon: "undo", size: "lg" }) %>
160
+ <% end %>
161
+ </button>
162
+ <button type="button" class="toolbar_button" data-action="redo" title="Redo" role="button" tabindex="0">
163
+ <%= pb_rails("flex", props: { align: "center", justify: "center", classname: "toolbar_button_icon" }) do %>
164
+ <%= pb_rails("icon", props: { icon: "redo", size: "lg" }) %>
165
+ <% end %>
166
+ </button>
167
+ </div>
204
168
  </div>
205
169
  </div>
170
+ <div id="<%= object.container_id %>-block-icon-templates" hidden aria-hidden="true">
171
+ <% block_style_options.each do |opt| %>
172
+ <span data-block-template-for="<%= opt[:value] %>" data-label="<%= opt[:text] %>">
173
+ <%= pb_rails("icon", props: { icon: opt[:icon], size: "lg" }) %>
174
+ </span>
175
+ <% end %>
176
+ </div>
206
177
  <div class="rte-editor-wrap">
207
178
  <div id="<%= object.editor_node_id %>"></div>
208
179
  </div>
@@ -222,6 +193,8 @@
222
193
  const hiddenInput = document.getElementById(inputId);
223
194
  const editorNode = document.getElementById("<%= object.editor_node_id %>");
224
195
  const toolbar = document.getElementById("<%= object.toolbar_id %>");
196
+ const blockTooltipId = "<%= object.rte_block_style_tooltip_id %>";
197
+ const iconTemplatesRoot = document.getElementById("<%= object.container_id %>-block-icon-templates");
225
198
  if (!editorNode || !hiddenInput || !toolbar) return;
226
199
 
227
200
  function syncToHiddenInput(editor) {
@@ -251,28 +224,90 @@
251
224
  bold: "toggleBold",
252
225
  italic: "toggleItalic",
253
226
  strike: "toggleStrike",
254
- bulletList: "toggleBulletList",
255
- orderedList: "toggleOrderedList",
256
- blockquote: "toggleBlockquote",
257
227
  codeBlock: "toggleCodeBlock",
258
228
  };
259
229
 
230
+ function getCurrentBlockValue() {
231
+ let value = "paragraph";
232
+ if (editor.isActive("heading", { level: 1 })) value = "heading-1";
233
+ else if (editor.isActive("heading", { level: 2 })) value = "heading-2";
234
+ else if (editor.isActive("heading", { level: 3 })) value = "heading-3";
235
+ else if (editor.isActive("bulletList")) value = "bulletList";
236
+ else if (editor.isActive("orderedList")) value = "orderedList";
237
+ else if (editor.isActive("blockquote")) value = "blockquote";
238
+ return value;
239
+ }
240
+
241
+ function syncBlockTrigger() {
242
+ const current = getCurrentBlockValue();
243
+ const triggerRoot = toolbar.querySelector("[data-rte-block-trigger]");
244
+ let tpl = iconTemplatesRoot && [...iconTemplatesRoot.children].find(
245
+ (el) => el.getAttribute("data-block-template-for") === current
246
+ );
247
+ if (!tpl && iconTemplatesRoot) {
248
+ tpl = [...iconTemplatesRoot.children].find(
249
+ (el) => el.getAttribute("data-block-template-for") === "paragraph"
250
+ );
251
+ }
252
+ if (triggerRoot && tpl) {
253
+ const iconWrap = triggerRoot.querySelector(".rte-block-style-trigger-icon");
254
+ const labelEl = triggerRoot.querySelector(".rte-block-style-trigger-label");
255
+ if (iconWrap) iconWrap.innerHTML = tpl.innerHTML;
256
+ if (labelEl) labelEl.textContent = tpl.getAttribute("data-label") || "";
257
+ }
258
+ const tooltip = document.getElementById(blockTooltipId);
259
+ if (tooltip) {
260
+ tooltip.querySelectorAll("a.pb_nav_list_item_link").forEach((a) => {
261
+ const href = a.getAttribute("href") || "";
262
+ const v = href.startsWith("#") ? href.slice(1) : "";
263
+ a.classList.toggle("is-active", v === current);
264
+ });
265
+ }
266
+ }
267
+
268
+ function applyBlockType(value) {
269
+ const chain = editor.chain().focus();
270
+ if (value === "paragraph") chain.setParagraph().run();
271
+ else if (value === "heading-1") chain.toggleHeading({ level: 1 }).run();
272
+ else if (value === "heading-2") chain.toggleHeading({ level: 2 }).run();
273
+ else if (value === "heading-3") chain.toggleHeading({ level: 3 }).run();
274
+ else if (value === "bulletList") chain.toggleBulletList().run();
275
+ else if (value === "orderedList") chain.toggleOrderedList().run();
276
+ else if (value === "blockquote") chain.toggleBlockquote().run();
277
+ }
278
+
279
+ const blockStyleTooltip = document.getElementById(blockTooltipId);
280
+ if (blockStyleTooltip) {
281
+ blockStyleTooltip.addEventListener("click", (e) => {
282
+ const a = e.target.closest("a[href^='#']");
283
+ if (!a || !blockStyleTooltip.contains(a)) return;
284
+ e.preventDefault();
285
+ const href = a.getAttribute("href") || "";
286
+ const v = href.startsWith("#") ? href.slice(1) : "";
287
+ if (!v) return;
288
+ applyBlockType(v);
289
+ updateActiveStates();
290
+ });
291
+ }
292
+
260
293
  function updateActiveStates() {
294
+ syncBlockTrigger();
261
295
  toolbar.querySelectorAll("button[data-action]").forEach((btn) => {
262
296
  const action = btn.dataset.action;
263
- const level = btn.dataset.level ? parseInt(btn.dataset.level, 10) : null;
264
297
  let active = false;
265
298
  if (action === "bold") active = editor.isActive("bold");
266
299
  else if (action === "italic") active = editor.isActive("italic");
267
300
  else if (action === "strike") active = editor.isActive("strike");
268
- else if (action === "blockquote") active = editor.isActive("blockquote");
269
- else if (action === "bulletList") active = editor.isActive("bulletList");
270
- else if (action === "orderedList") active = editor.isActive("orderedList");
271
301
  else if (action === "codeBlock") active = editor.isActive("codeBlock");
272
302
  else if (action === "link") active = editor.isActive("link");
273
- else if (action === "heading" && level != null) active = editor.isActive("heading", { level });
274
303
  btn.classList.toggle("is-active", active);
275
304
  });
305
+ toolbar.querySelectorAll("button[data-action='undo']").forEach((btn) => {
306
+ btn.disabled = !editor.can().undo();
307
+ });
308
+ toolbar.querySelectorAll("button[data-action='redo']").forEach((btn) => {
309
+ btn.disabled = !editor.can().redo();
310
+ });
276
311
  }
277
312
 
278
313
  toolbar.addEventListener("click", (e) => {
@@ -280,13 +315,20 @@
280
315
  if (!btn) return;
281
316
  e.preventDefault();
282
317
  const action = btn.dataset.action;
283
- const level = btn.dataset.level ? parseInt(btn.dataset.level, 10) : null;
284
318
 
285
- if (action === "heading" && level) {
286
- editor.chain().focus().toggleHeading({ level }).run();
319
+ if (action === "undo") {
320
+ editor.chain().focus().undo().run();
321
+ } else if (action === "redo") {
322
+ editor.chain().focus().redo().run();
287
323
  } else if (action === "link") {
288
- const url = window.prompt("URL:");
289
- if (url) editor.chain().focus().setLink({ href: url }).run();
324
+ const previousUrl = editor.getAttributes("link").href || "";
325
+ const url = window.prompt("URL", previousUrl);
326
+ if (url === null) return;
327
+ if (url === "") {
328
+ editor.chain().focus().extendMarkRange("link").unsetLink().run();
329
+ } else {
330
+ editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
331
+ }
290
332
  } else {
291
333
  const chainMethod = actionToChain[action];
292
334
  if (chainMethod && typeof editor.chain().focus()[chainMethod] === "function") {
@@ -10,6 +10,12 @@ module Playbook
10
10
  prop :label
11
11
  prop :required_indicator, type: Playbook::Props::Boolean, default: false
12
12
 
13
+ # Match React default (globalProps maxWidth "md").
14
+ def max_width
15
+ v = values[:max_width] || values["max_width"]
16
+ v.nil? || v == "" ? "md" : v
17
+ end
18
+
13
19
  def classname
14
20
  generate_classname("pb_rich_text_editor_kit", "rte-container")
15
21
  end
@@ -40,6 +46,15 @@ module Playbook
40
46
  def toolbar_id
41
47
  "#{container_id}-toolbar"
42
48
  end
49
+
50
+ # Stable DOM ids for TipTap toolbar popover (used in ERB + module script; must be kit methods — not ERB locals).
51
+ def rte_block_style_trigger_id
52
+ "#{toolbar_id}-block-trigger"
53
+ end
54
+
55
+ def rte_block_style_tooltip_id
56
+ "#{toolbar_id}-block-tooltip"
57
+ end
43
58
  end
44
59
  end
45
60
  end