smeditor 0.2.2 → 0.2.4

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: deea7d855b9ea5c7d61a51d82adeb656e3583db0dc9ed8d677345fe2d1bac550
4
- data.tar.gz: 99a68a8823548919c2acb1aaea114822b677e33d464cd37a66f42fe4cc802e73
3
+ metadata.gz: 3edf61aad6834f2726ef15563ef798a7d71ae7758868f016fcd82af1b3521e39
4
+ data.tar.gz: 4ef021f5c72395a43f3f6dac47e5694c425bb4051691830f12ff83425f7e40e6
5
5
  SHA512:
6
- metadata.gz: 6cf34629807ab5b326dff8ddaa85e80bc2f4b5c7de86a233e4d2a9c1196a3151df18af818f658f6826e0f21c7d90317c8f190df1697f5ecc8501c4f24511c9b7
7
- data.tar.gz: 3f72f9d9fc819294563dd2519eb07e84dd159432fd2bc8ad6111b0ed125c46790163ef7caae49ae1b76ea7ced5406baf65b1dc9a2350e6115edc15b34c3d57bc
6
+ metadata.gz: dbf99306fc6c0e519bc46f3de5910d7508f24d4016afde2aea1c713380db598e7145338b778d44c284f94902a1a5f31ca35e95e099355c8563e29a6f842930b3
7
+ data.tar.gz: fda2e73e1c3df37b94c77911b0cf63c27d1d2d9bfa3488553a58b45f356601e3d7f015e524a847acc5878535ff5a5af33518310a4e52b3f583cc14fdb378c475
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog — smeditor
2
2
 
3
+ ## 0.2.4 — 2026-09-09
4
+
5
+ - Added the selected-text bubble formatting toolbar to the self-contained Rails adapter, matching the playground behavior.
6
+ - Bubble toolbar positioning and selection tracking now work inside the Rails Shadow DOM and stay visible across scroll/resize.
7
+ - Core selection syncing now also listens on the editor ShadowRoot when available.
8
+
9
+ ## 0.2.3
10
+
11
+ - Added configurable responsive minimum editor heights for desktop, tablet, and mobile.
12
+ - Added per-field `min_height`, `tablet_min_height`, and `mobile_min_height` Rails helper options.
13
+ - Added matching global defaults in `SMEditor.configure`.
14
+
3
15
  ## 0.2.2 — 2026-09-09
4
16
 
5
17
  - Fixed caret jumps and reordered typing inside the Rails Shadow DOM editor. The core now resolves the live DOM Selection from the editor's own root instead of always using `document.getSelection()`.
data/README.md CHANGED
@@ -42,6 +42,9 @@ SMEditor.configure do |config|
42
42
  config.uploads = :none # or :active_storage
43
43
  config.upload_path = "/smeditor/uploads"
44
44
  config.default_kit = "starter" # or "full"
45
+ config.min_height = 320 # desktop, px when numeric
46
+ config.tablet_min_height = 280 # <= 1024px
47
+ config.mobile_min_height = 220 # <= 640px
45
48
  config.sanitize_output = true
46
49
 
47
50
  # true by default: the first editor field includes smeditor.js/css.
@@ -61,7 +64,10 @@ end
61
64
  <%= form_with model: @article do |form| %>
62
65
  <%= form.smeditor_editor :content,
63
66
  kit: "full",
64
- placeholder: "Write…" %>
67
+ placeholder: "Write…",
68
+ min_height: 420,
69
+ tablet_min_height: 340,
70
+ mobile_min_height: 260 %>
65
71
 
66
72
  <%= form.submit %>
67
73
  <% end %>
@@ -79,8 +85,14 @@ Available options:
79
85
  - `class:`
80
86
  - `label:` — editor ARIA label
81
87
  - `upload_url:` — custom image upload endpoint
88
+ - `min_height:` — minimum height on desktop (> 1024px)
89
+ - `tablet_min_height:` — minimum height on tablet widths (641-1024px)
90
+ - `mobile_min_height:` — minimum height on phone widths (<= 640px)
82
91
  - `include_assets: false` — skip automatic asset tags for this field
83
92
 
93
+ Height options accept numbers (treated as pixels) or CSS lengths such as `24rem`,
94
+ `45vh`, or `60dvh`. Per-field values override the initializer defaults.
95
+
84
96
  ### Assets in the layout (optional)
85
97
 
86
98
  By default the first `smeditor_editor` call includes the packaged assets once.
@@ -446,6 +446,120 @@ function buildToolbar(editor, kit, uploadUrl) {
446
446
  };
447
447
  return toolbar;
448
448
  }
449
+ function selectionForSurface(surface) {
450
+ var _a, _b, _c, _d;
451
+ const root = (_a = surface.getRootNode) === null || _a === void 0 ? void 0 : _a.call(surface);
452
+ const getSelection = root === null || root === void 0 ? void 0 : root.getSelection;
453
+ if (root && typeof getSelection === "function") {
454
+ const selection = getSelection.call(root);
455
+ if (selection)
456
+ return selection;
457
+ }
458
+ return (_d = (_c = (_b = surface.ownerDocument) === null || _b === void 0 ? void 0 : _b.getSelection) === null || _c === void 0 ? void 0 : _c.call(_b)) !== null && _d !== void 0 ? _d : null;
459
+ }
460
+ /**
461
+ * Rails uses a dependency-free browser adapter rather than the React package,
462
+ * so contextual UI that exists in the playground must be mounted explicitly.
463
+ * This mirrors React's <BubbleMenu />: selecting non-empty text opens a small
464
+ * formatting toolbar above the live DOM range.
465
+ */
466
+ function buildBubbleMenu(editor, surface) {
467
+ var _a;
468
+ const menu = document.createElement("div");
469
+ menu.className = "smeditor-floating smeditor-bubble-menu";
470
+ menu.setAttribute("role", "toolbar");
471
+ menu.setAttribute("aria-label", "Selected text formatting");
472
+ menu.style.position = "fixed";
473
+ menu.style.display = "none";
474
+ menu.style.zIndex = "2147483000";
475
+ const buttons = [
476
+ commandButton(editor, "bold", "Bold", () => run(editor, "toggleBold"), () => editor.isActive("bold")),
477
+ commandButton(editor, "italic", "Italic", () => run(editor, "toggleItalic"), () => editor.isActive("italic")),
478
+ commandButton(editor, "underline", "Underline", () => run(editor, "toggleUnderline"), () => editor.isActive("underline")),
479
+ commandButton(editor, "strike", "Strike-through", () => run(editor, "toggleStrike"), () => editor.isActive("strike")),
480
+ commandButton(editor, "link", "Link", () => {
481
+ if (editor.isActive("link"))
482
+ return run(editor, "unsetLink");
483
+ const href = window.prompt("Link URL", "https://");
484
+ return href ? run(editor, "setLink", { href }) : false;
485
+ }, () => editor.isActive("link")),
486
+ ];
487
+ menu.append(...buttons);
488
+ // Keeping pointer-down inside the menu from focusing a button preserves the
489
+ // editor's text selection, which the formatting command needs.
490
+ menu.addEventListener("mousedown", (event) => event.preventDefault());
491
+ let frame = 0;
492
+ const hide = () => {
493
+ menu.style.display = "none";
494
+ menu.style.visibility = "hidden";
495
+ };
496
+ const updateNow = () => {
497
+ var _a, _b;
498
+ frame = 0;
499
+ if (surface.getAttribute("contenteditable") === "false")
500
+ return hide();
501
+ const selection = selectionForSurface(surface);
502
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed)
503
+ return hide();
504
+ const range = selection.getRangeAt(0);
505
+ if (!surface.contains(range.commonAncestorContainer))
506
+ return hide();
507
+ const anchor = range.getBoundingClientRect();
508
+ if (anchor.width === 0 && anchor.height === 0)
509
+ return hide();
510
+ for (const button of buttons)
511
+ (_b = (_a = button).__smeditorRefresh) === null || _b === void 0 ? void 0 : _b.call(_a);
512
+ // Make it measurable off-screen, then place it before the next paint.
513
+ menu.style.display = "inline-flex";
514
+ menu.style.visibility = "hidden";
515
+ menu.style.left = "-9999px";
516
+ menu.style.top = "-9999px";
517
+ const rect = menu.getBoundingClientRect();
518
+ const margin = 8;
519
+ const offset = 8;
520
+ const viewportWidth = window.innerWidth;
521
+ const viewportHeight = window.innerHeight;
522
+ let left = anchor.left + anchor.width / 2 - rect.width / 2;
523
+ left = Math.max(margin, Math.min(left, viewportWidth - rect.width - margin));
524
+ let top = anchor.top - rect.height - offset;
525
+ if (top < margin)
526
+ top = anchor.bottom + offset;
527
+ if (top + rect.height > viewportHeight - margin) {
528
+ top = Math.max(margin, anchor.top - rect.height - offset);
529
+ }
530
+ menu.style.left = `${Math.round(left)}px`;
531
+ menu.style.top = `${Math.round(top)}px`;
532
+ menu.style.visibility = "visible";
533
+ };
534
+ const scheduleUpdate = () => {
535
+ if (frame)
536
+ cancelAnimationFrame(frame);
537
+ frame = requestAnimationFrame(updateNow);
538
+ };
539
+ const selectionRoot = (_a = surface.getRootNode) === null || _a === void 0 ? void 0 : _a.call(surface);
540
+ const selectionTarget = selectionRoot && selectionRoot !== document
541
+ ? selectionRoot
542
+ : null;
543
+ const disposeSelection = editor.on("selectionUpdate", scheduleUpdate);
544
+ document.addEventListener("selectionchange", scheduleUpdate);
545
+ selectionTarget === null || selectionTarget === void 0 ? void 0 : selectionTarget.addEventListener("selectionchange", scheduleUpdate);
546
+ surface.addEventListener("pointerup", scheduleUpdate);
547
+ surface.addEventListener("keyup", scheduleUpdate);
548
+ window.addEventListener("resize", scheduleUpdate);
549
+ window.addEventListener("scroll", scheduleUpdate, true);
550
+ menu.__smeditorDispose = () => {
551
+ if (frame)
552
+ cancelAnimationFrame(frame);
553
+ disposeSelection();
554
+ document.removeEventListener("selectionchange", scheduleUpdate);
555
+ selectionTarget === null || selectionTarget === void 0 ? void 0 : selectionTarget.removeEventListener("selectionchange", scheduleUpdate);
556
+ surface.removeEventListener("pointerup", scheduleUpdate);
557
+ surface.removeEventListener("keyup", scheduleUpdate);
558
+ window.removeEventListener("resize", scheduleUpdate);
559
+ window.removeEventListener("scroll", scheduleUpdate, true);
560
+ };
561
+ return menu;
562
+ }
449
563
  function shadowRootFor(mount) {
450
564
  const root = mount.shadowRoot || mount.attachShadow({ mode: "open" });
451
565
  root.innerHTML = "";
@@ -476,6 +590,15 @@ function bootMount(mount) {
476
590
  const root = shadowRootFor(mount);
477
591
  const shell = document.createElement("div");
478
592
  shell.className = "smeditor";
593
+ const responsiveHeights = [
594
+ ["--sme-editor-min-height", mount.dataset.smeditorMinHeight],
595
+ ["--sme-editor-min-height-tablet", mount.dataset.smeditorTabletMinHeight],
596
+ ["--sme-editor-min-height-mobile", mount.dataset.smeditorMobileMinHeight],
597
+ ];
598
+ for (const [property, value] of responsiveHeights) {
599
+ if (value)
600
+ shell.style.setProperty(property, value);
601
+ }
479
602
  const surface = document.createElement("div");
480
603
  surface.className = "smeditor-editor";
481
604
  surface.setAttribute("aria-label", mount.dataset.smeditorLabel || "Rich text editor");
@@ -495,11 +618,16 @@ function bootMount(mount) {
495
618
  });
496
619
  const toolbar = buildToolbar(editor, kit, uploadUrl);
497
620
  shell.insertBefore(toolbar, surface);
621
+ const bubbleMenu = buildBubbleMenu(editor, surface);
622
+ root.appendChild(bubbleMenu);
498
623
  input.smeditorInstance = editor;
499
624
  mount.smeditorInstance = editor;
500
625
  mount.dataset.smeditorBooted = "1";
501
626
  instances.set(mount, editor);
502
- disposers.set(mount, [() => { var _a, _b; return (_b = (_a = toolbar).__smeditorDispose) === null || _b === void 0 ? void 0 : _b.call(_a); }]);
627
+ disposers.set(mount, [
628
+ () => { var _a, _b; return (_b = (_a = toolbar).__smeditorDispose) === null || _b === void 0 ? void 0 : _b.call(_a); },
629
+ () => { var _a, _b; return (_b = (_a = bubbleMenu).__smeditorDispose) === null || _b === void 0 ? void 0 : _b.call(_a); },
630
+ ]);
503
631
  }
504
632
  function boot(root = document) {
505
633
  root.querySelectorAll("[data-smeditor]").forEach(bootMount);
@@ -681,6 +809,7 @@ class Editor {
681
809
  this.onDomFocus = () => this.emit("focus", { editor: this });
682
810
  this.onDomBlur = () => this.emit("blur", { editor: this });
683
811
  this.onSelectionChange = () => this.syncSelectionFromDOM();
812
+ this.selectionChangeTargets = [];
684
813
  this.onCompositionStart = () => {
685
814
  this.composing = true;
686
815
  };
@@ -988,6 +1117,7 @@ class Editor {
988
1117
  this.attachToElement();
989
1118
  }
990
1119
  attachToElement() {
1120
+ var _a;
991
1121
  const el = this.element;
992
1122
  el.setAttribute("contenteditable", String(this.options.editable !== false));
993
1123
  el.classList.add("smeditor-editor");
@@ -1000,7 +1130,17 @@ class Editor {
1000
1130
  el.addEventListener("compositionstart", this.onCompositionStart);
1001
1131
  el.addEventListener("compositionend", this.onCompositionEnd);
1002
1132
  if (typeof document !== "undefined") {
1003
- document.addEventListener("selectionchange", this.onSelectionChange);
1133
+ // Keep the model selection in sync in both regular DOM and Shadow DOM.
1134
+ // Chromium exposes the live shadow selection on ShadowRoot; listening to
1135
+ // that root as well as Document avoids missing drag-selection events.
1136
+ const root = (_a = el.getRootNode) === null || _a === void 0 ? void 0 : _a.call(el);
1137
+ const targets = [document];
1138
+ if (root && root !== document && "addEventListener" in root)
1139
+ targets.push(root);
1140
+ for (const target of targets) {
1141
+ target.addEventListener("selectionchange", this.onSelectionChange);
1142
+ this.selectionChangeTargets.push(target);
1143
+ }
1004
1144
  }
1005
1145
  this.renderToDOM();
1006
1146
  }
@@ -1337,9 +1477,10 @@ class Editor {
1337
1477
  this.element.removeEventListener("compositionstart", this.onCompositionStart);
1338
1478
  this.element.removeEventListener("compositionend", this.onCompositionEnd);
1339
1479
  }
1340
- if (typeof document !== "undefined") {
1341
- document.removeEventListener("selectionchange", this.onSelectionChange);
1480
+ for (const target of this.selectionChangeTargets) {
1481
+ target.removeEventListener("selectionchange", this.onSelectionChange);
1342
1482
  }
1483
+ this.selectionChangeTargets.length = 0;
1343
1484
  for (const ext of this.extensions) {
1344
1485
  (_a = ext.onDestroy) === null || _a === void 0 ? void 0 : _a.call(ext, this);
1345
1486
  }
@@ -79,6 +79,11 @@
79
79
 
80
80
  /* Indent step (block-level indentation) */
81
81
  --sme-indent-step: 24px;
82
+
83
+ /* Responsive editor surface height. Rails can override these per field. */
84
+ --sme-editor-min-height: 320px;
85
+ --sme-editor-min-height-tablet: 280px;
86
+ --sme-editor-min-height-mobile: 220px;
82
87
  }
83
88
 
84
89
  /* ---------------------------------------------------------------------------
@@ -366,7 +371,7 @@
366
371
  padding: var(--sme-space-4);
367
372
  font-size: var(--sme-text-base);
368
373
  line-height: var(--sme-leading);
369
- min-height: 8em;
374
+ min-height: var(--sme-editor-min-height, 320px);
370
375
  outline: none;
371
376
  }
372
377
 
@@ -1186,7 +1191,19 @@
1186
1191
  font-size: 11px;
1187
1192
  }
1188
1193
 
1194
+ @media (max-width: 1024px) {
1195
+ .smeditor-content,
1196
+ .smeditor-editor {
1197
+ min-height: var(--sme-editor-min-height-tablet, 280px);
1198
+ }
1199
+ }
1200
+
1189
1201
  @media (max-width: 640px) {
1202
+ .smeditor-content,
1203
+ .smeditor-editor {
1204
+ min-height: var(--sme-editor-min-height-mobile, 220px);
1205
+ }
1206
+
1190
1207
  .smeditor-toolbar {
1191
1208
  gap: 4px;
1192
1209
  padding: var(--sme-space-2);
@@ -14,6 +14,12 @@ SMEditor.configure do |config|
14
14
  # "starter" gives the MVP toolbar; "full" enables the larger extension set.
15
15
  config.default_kit = "starter"
16
16
 
17
+ # Responsive minimum editor height. Numeric values are pixels. You can also
18
+ # use CSS lengths such as "24rem" or "45vh".
19
+ config.min_height = 320
20
+ config.tablet_min_height = 280
21
+ config.mobile_min_height = 220
22
+
17
23
  # Stored editor HTML is sanitized before public rendering.
18
24
  config.sanitize_output = true
19
25
 
@@ -23,6 +23,13 @@ module SMEditor
23
23
  # Include the packaged JS/CSS automatically with the first editor field.
24
24
  # Set false when the host app calls `smeditor_assets` in its layout.
25
25
  attr_accessor :auto_include_assets
26
+ # Default minimum editor heights. They are responsive by viewport width:
27
+ # desktop (> 1024px), tablet (641-1024px), and mobile (<= 640px).
28
+ # Numeric values are interpreted as pixels; CSS lengths such as "24rem"
29
+ # and "45vh" are accepted too.
30
+ attr_accessor :min_height
31
+ attr_accessor :tablet_min_height
32
+ attr_accessor :mobile_min_height
26
33
  # Hard ceiling on an uploaded file, in bytes. The endpoint is reachable
27
34
  # by anyone who can reach the host app unless the controller is
28
35
  # subclassed with authentication, so it refuses anything larger.
@@ -42,6 +49,9 @@ module SMEditor
42
49
  @default_kit = "starter"
43
50
  @upload_path = "/smeditor/uploads"
44
51
  @auto_include_assets = true
52
+ @min_height = "320px"
53
+ @tablet_min_height = "280px"
54
+ @mobile_min_height = "220px"
45
55
  @max_upload_size = 10 * 1024 * 1024
46
56
  @allowed_upload_types = %w[
47
57
  image/png image/jpeg image/gif image/webp image/avif image/bmp
@@ -23,7 +23,8 @@ module SMEditor
23
23
  # form - a Rails FormBuilder
24
24
  # method - the model attribute (stored as an HTML string)
25
25
  # options - :kit ("starter" | "full"), :class, :placeholder,
26
- # :upload_url, :label, :include_assets
26
+ # :upload_url, :label, :include_assets, :min_height,
27
+ # :tablet_min_height, :mobile_min_height
27
28
  def smeditor_editor(form, method, options = {})
28
29
  object = form.object
29
30
  current = object.respond_to?(method) ? object.public_send(method) : nil
@@ -31,6 +32,9 @@ module SMEditor
31
32
  upload_url = options.fetch(:upload_url, smeditor_upload_url)
32
33
  include_assets = options.fetch(:include_assets, SMEditor.config.auto_include_assets)
33
34
  stylesheet_url = asset_path("smeditor.css")
35
+ min_height = smeditor_css_length(options.fetch(:min_height, SMEditor.config.min_height), :min_height)
36
+ tablet_min_height = smeditor_css_length(options.fetch(:tablet_min_height, SMEditor.config.tablet_min_height), :tablet_min_height)
37
+ mobile_min_height = smeditor_css_length(options.fetch(:mobile_min_height, SMEditor.config.mobile_min_height), :mobile_min_height)
34
38
 
35
39
  hidden = form.hidden_field(
36
40
  method,
@@ -49,6 +53,9 @@ module SMEditor
49
53
  smeditor_upload_url: upload_url,
50
54
  smeditor_label: options[:label],
51
55
  smeditor_stylesheet: stylesheet_url,
56
+ smeditor_min_height: min_height,
57
+ smeditor_tablet_min_height: tablet_min_height,
58
+ smeditor_mobile_min_height: mobile_min_height,
52
59
  }.compact,
53
60
  )
54
61
 
@@ -60,6 +67,15 @@ module SMEditor
60
67
 
61
68
  private
62
69
 
70
+ CSS_LENGTH = %r{\A(?:0|(?:\d+(?:\.\d+)?)(?:px|rem|em|vh|dvh|svh|vw|%))\z}.freeze
71
+
72
+ def smeditor_css_length(value, option_name)
73
+ normalized = value.is_a?(Numeric) ? "#{value}px" : value.to_s.strip
74
+ return normalized if normalized.match?(CSS_LENGTH)
75
+
76
+ raise ArgumentError, "#{option_name} must be a number (pixels) or a CSS length such as 320px, 24rem, or 45vh"
77
+ end
78
+
63
79
  def smeditor_upload_url
64
80
  SMEditor.config.upload_path if SMEditor.config.uploads == :active_storage
65
81
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module SMEditor
4
4
  module Rails
5
- VERSION = "0.2.2"
5
+ VERSION = "0.2.4"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smeditor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - SMEditor contributors
@@ -96,7 +96,7 @@ metadata:
96
96
  source_code_uri: https://github.com/sCruze/smeditor/tree/main/gems/smeditor
97
97
  changelog_uri: https://github.com/sCruze/smeditor/blob/main/gems/smeditor/CHANGELOG.md
98
98
  bug_tracker_uri: https://github.com/sCruze/smeditor/issues
99
- documentation_uri: https://rubydoc.info/gems/smeditor/0.2.2
99
+ documentation_uri: https://rubydoc.info/gems/smeditor/0.2.4
100
100
  rubygems_mfa_required: 'true'
101
101
  rdoc_options: []
102
102
  require_paths: