smeditor 0.2.4 → 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: 3edf61aad6834f2726ef15563ef798a7d71ae7758868f016fcd82af1b3521e39
4
- data.tar.gz: 4ef021f5c72395a43f3f6dac47e5694c425bb4051691830f12ff83425f7e40e6
3
+ metadata.gz: 37d69366d661f31b412b1225d656367136e26de3ac8ad98988ceeaf251ddc942
4
+ data.tar.gz: fbc0c5332adb77f6a42b1716c403a31e3dd21d8eb79919ee214073a69caab8ed
5
5
  SHA512:
6
- metadata.gz: dbf99306fc6c0e519bc46f3de5910d7508f24d4016afde2aea1c713380db598e7145338b778d44c284f94902a1a5f31ca35e95e099355c8563e29a6f842930b3
7
- data.tar.gz: fda2e73e1c3df37b94c77911b0cf63c27d1d2d9bfa3488553a58b45f356601e3d7f015e524a847acc5878535ff5a5af33518310a4e52b3f583cc14fdb378c475
6
+ metadata.gz: 5db1e9fa62c9bf728776931b9276184d18b85c36e586efbe907fae818cd6ecf4785d8144e66b922a3e567f64db97a546d96ca3ee4ecd5b8aa3c655b7933bd496
7
+ data.tar.gz: 9426a009a1184a403397818a2f37600702dbf72c3ecaa73bd7c0ffff44ab0f638416b033cc8e3208bbce664fba1fca97182bb7410b61e5b69dcc9bbff4f4f4ec
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog — smeditor
2
2
 
3
+ ## 0.3.0 — 2026-09-13
4
+
5
+ - Added explicit `light` and `dark` theme selection across Core, React, and the Rails adapter. Rails now also supports a global `config.default_theme` and per-editor `theme` option.
6
+ - The editor now defaults to the light theme when no theme is configured; dark mode is enabled only when explicitly requested, instead of following `prefers-color-scheme`.
7
+ - Fixed React `EditorContent` overriding an editor created with `editable: false` back to an editable surface when the `editable` prop is omitted.
8
+
9
+ ## 0.2.5 — 2026-09-09
10
+
11
+ - Fixed the Rails selected-text bubble toolbar being displaced to the right or vertically aligned with the text when the host application creates a CSS containing block (for example with `transform`, `filter`, `perspective`, or modal/animated wrappers).
12
+ - Bubble positioning now matches the playground: it is centered above the live selection, flips below near the top viewport edge, and clamps horizontally to the viewport.
13
+ - The Rails bubble is positioned in editor-local coordinates, so host layout transforms no longer corrupt its placement.
14
+
3
15
  ## 0.2.4 — 2026-09-09
4
16
 
5
17
  - Added the selected-text bubble formatting toolbar to the self-contained Rails adapter, matching the playground behavior.
data/README.md CHANGED
@@ -42,6 +42,7 @@ 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.default_theme = "light" # or "dark"
45
46
  config.min_height = 320 # desktop, px when numeric
46
47
  config.tablet_min_height = 280 # <= 1024px
47
48
  config.mobile_min_height = 220 # <= 640px
@@ -64,6 +65,7 @@ end
64
65
  <%= form_with model: @article do |form| %>
65
66
  <%= form.smeditor_editor :content,
66
67
  kit: "full",
68
+ theme: "light",
67
69
  placeholder: "Write…",
68
70
  min_height: 420,
69
71
  tablet_min_height: 340,
@@ -81,6 +83,7 @@ observe the value without CKEditor-specific integration.
81
83
  Available options:
82
84
 
83
85
  - `kit: "starter" | "full"`
86
+ - `theme: "light" | "dark"` — defaults to `config.default_theme` (`"light"`)
84
87
  - `placeholder:`
85
88
  - `class:`
86
89
  - `label:` — editor ARIA label
@@ -463,13 +463,18 @@ function selectionForSurface(surface) {
463
463
  * This mirrors React's <BubbleMenu />: selecting non-empty text opens a small
464
464
  * formatting toolbar above the live DOM range.
465
465
  */
466
- function buildBubbleMenu(editor, surface) {
466
+ function buildBubbleMenu(editor, surface, container) {
467
467
  var _a;
468
468
  const menu = document.createElement("div");
469
469
  menu.className = "smeditor-floating smeditor-bubble-menu";
470
470
  menu.setAttribute("role", "toolbar");
471
471
  menu.setAttribute("aria-label", "Selected text formatting");
472
- menu.style.position = "fixed";
472
+ // Keep the popover in the editor's own coordinate space. `position: fixed`
473
+ // becomes relative to transformed/filtering ancestors in browsers, which can
474
+ // shove the menu to the right or down in host applications. Absolute local
475
+ // positioning is stable because both the selection and container are measured
476
+ // in viewport coordinates and then converted to editor-local coordinates.
477
+ menu.style.position = "absolute";
473
478
  menu.style.display = "none";
474
479
  menu.style.zIndex = "2147483000";
475
480
  const buttons = [
@@ -515,20 +520,27 @@ function buildBubbleMenu(editor, surface) {
515
520
  menu.style.left = "-9999px";
516
521
  menu.style.top = "-9999px";
517
522
  const rect = menu.getBoundingClientRect();
523
+ const containerRect = container.getBoundingClientRect();
518
524
  const margin = 8;
519
525
  const offset = 8;
520
526
  const viewportWidth = window.innerWidth;
521
527
  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`;
528
+ // Calculate the desired position in viewport coordinates first, exactly like
529
+ // the React playground's Floating primitive, including viewport clamping and
530
+ // top/bottom collision handling. Then translate that point into the local
531
+ // coordinate system of the editor shell. This survives transformed parents,
532
+ // zoomed panels, modals, sidebars and other containing-block creators.
533
+ let viewportLeft = anchor.left + anchor.width / 2 - rect.width / 2;
534
+ const maxViewportLeft = Math.max(margin, viewportWidth - rect.width - margin);
535
+ viewportLeft = Math.max(margin, Math.min(viewportLeft, maxViewportLeft));
536
+ let viewportTop = anchor.top - rect.height - offset;
537
+ if (viewportTop < margin)
538
+ viewportTop = anchor.bottom + offset;
539
+ if (viewportTop + rect.height > viewportHeight - margin) {
540
+ viewportTop = Math.max(margin, anchor.top - rect.height - offset);
541
+ }
542
+ menu.style.left = `${Math.round(viewportLeft - containerRect.left)}px`;
543
+ menu.style.top = `${Math.round(viewportTop - containerRect.top)}px`;
532
544
  menu.style.visibility = "visible";
533
545
  };
534
546
  const scheduleUpdate = () => {
@@ -583,10 +595,15 @@ function bootMount(mount) {
583
595
  const kit = mount.dataset.smeditorKit === "full" ? "full" : "starter";
584
596
  const uploadUrl = mount.dataset.smeditorUploadUrl || undefined;
585
597
  const placeholder = mount.dataset.smeditorPlaceholder || undefined;
586
- const explicitTheme = ((_b = (_a = mount.parentElement) === null || _a === void 0 ? void 0 : _a.closest("[data-theme]")) === null || _b === void 0 ? void 0 : _b.dataset.theme)
598
+ const configuredTheme = mount.dataset.smeditorTheme;
599
+ const inheritedTheme = ((_b = (_a = mount.parentElement) === null || _a === void 0 ? void 0 : _a.closest("[data-theme]")) === null || _b === void 0 ? void 0 : _b.dataset.theme)
587
600
  || document.documentElement.dataset.theme;
588
- if (explicitTheme === "light" || explicitTheme === "dark")
589
- mount.dataset.theme = explicitTheme;
601
+ const theme = configuredTheme === "dark" || configuredTheme === "light"
602
+ ? configuredTheme
603
+ : inheritedTheme === "dark" || inheritedTheme === "light"
604
+ ? inheritedTheme
605
+ : "light";
606
+ mount.dataset.theme = theme;
590
607
  const root = shadowRootFor(mount);
591
608
  const shell = document.createElement("div");
592
609
  shell.className = "smeditor";
@@ -609,6 +626,7 @@ function bootMount(mount) {
609
626
  extensions: kitExtensions(kit, uploadUrl),
610
627
  content: input.value || "<p></p>",
611
628
  placeholder,
629
+ theme,
612
630
  deepSelection: true,
613
631
  onUpdate: ({ editor: current }) => {
614
632
  input.value = current.getHTML();
@@ -618,8 +636,8 @@ function bootMount(mount) {
618
636
  });
619
637
  const toolbar = buildToolbar(editor, kit, uploadUrl);
620
638
  shell.insertBefore(toolbar, surface);
621
- const bubbleMenu = buildBubbleMenu(editor, surface);
622
- root.appendChild(bubbleMenu);
639
+ const bubbleMenu = buildBubbleMenu(editor, surface, shell);
640
+ shell.appendChild(bubbleMenu);
623
641
  input.smeditorInstance = editor;
624
642
  mount.smeditorInstance = editor;
625
643
  mount.dataset.smeditorBooted = "1";
@@ -788,7 +806,7 @@ function createEditor(options) {
788
806
  }
789
807
  class Editor {
790
808
  constructor(options) {
791
- var _a, _b, _c;
809
+ var _a, _b, _c, _d;
792
810
  this.selection = null;
793
811
  this.history = new history_js_1.History();
794
812
  this.emitter = new events_js_1.EventEmitter();
@@ -818,10 +836,13 @@ class Editor {
818
836
  // Capture the finished composed text in one shot.
819
837
  this.handleDOMInput();
820
838
  };
821
- this.options = options;
822
- this.element = (_a = options.element) !== null && _a !== void 0 ? _a : null;
839
+ this.options = {
840
+ ...options,
841
+ theme: (_a = options.theme) !== null && _a !== void 0 ? _a : "light",
842
+ };
843
+ this.element = (_b = options.element) !== null && _b !== void 0 ? _b : null;
823
844
  // 1. Resolve extensions
824
- this.extensions = (0, schema_js_1.flattenExtensions)((_b = options.extensions) !== null && _b !== void 0 ? _b : []);
845
+ this.extensions = (0, schema_js_1.flattenExtensions)((_c = options.extensions) !== null && _c !== void 0 ? _c : []);
825
846
  // 2. Compile schema
826
847
  this.schema = (0, schema_js_1.compileSchema)(this.extensions);
827
848
  // 3. Normalize initial content into JSON
@@ -830,7 +851,7 @@ class Editor {
830
851
  this.commands = this.buildCommandMap();
831
852
  // 5. Lifecycle: onCreate for each extension
832
853
  for (const ext of this.extensions) {
833
- (_c = ext.onCreate) === null || _c === void 0 ? void 0 : _c.call(ext, this);
854
+ (_d = ext.onCreate) === null || _d === void 0 ? void 0 : _d.call(ext, this);
834
855
  }
835
856
  // 6. Mount to DOM if an element was given
836
857
  if (this.element) {
@@ -1117,9 +1138,10 @@ class Editor {
1117
1138
  this.attachToElement();
1118
1139
  }
1119
1140
  attachToElement() {
1120
- var _a;
1141
+ var _a, _b;
1121
1142
  const el = this.element;
1122
1143
  el.setAttribute("contenteditable", String(this.options.editable !== false));
1144
+ el.setAttribute("data-theme", (_a = this.options.theme) !== null && _a !== void 0 ? _a : "light");
1123
1145
  el.classList.add("smeditor-editor");
1124
1146
  el.addEventListener("input", this.onDomInput);
1125
1147
  el.addEventListener("beforeinput", this.onDomBeforeInput);
@@ -1133,7 +1155,7 @@ class Editor {
1133
1155
  // Keep the model selection in sync in both regular DOM and Shadow DOM.
1134
1156
  // Chromium exposes the live shadow selection on ShadowRoot; listening to
1135
1157
  // 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);
1158
+ const root = (_b = el.getRootNode) === null || _b === void 0 ? void 0 : _b.call(el);
1137
1159
  const targets = [document];
1138
1160
  if (root && root !== document && "addEventListener" in root)
1139
1161
  targets.push(root);
@@ -1,15 +1,11 @@
1
1
  /* SMEditor default theme tokens.
2
2
  *
3
- * Two ways to pick a theme:
3
+ * Theme selection:
4
4
  *
5
- * 1. Do nothing → the editor follows `prefers-color-scheme`.
6
- * 2. Set `data-theme="light"` or `data-theme="dark"` on any element
7
- * that wraps the editor (or on <html>). The explicit attribute
8
- * wins over the media query.
9
- *
10
- * Why both: developers want auto for new users (matches the rest of
11
- * their app), but a theme toggle in product UI needs explicit control
12
- * that survives reloads. Supporting both lets either pattern work.
5
+ * 1. Do nothing → light theme.
6
+ * 2. Set `data-theme="light"` or `data-theme="dark"` on the editor or
7
+ * on an ancestor. SMEditor sets `data-theme="light"` by default when
8
+ * initialized without a theme option.
13
9
  */
14
10
 
15
11
  /* ---------------------------------------------------------------------------
@@ -87,8 +83,7 @@
87
83
  }
88
84
 
89
85
  /* ---------------------------------------------------------------------------
90
- * Dark theme — applied when explicitly tagged, OR via media query when
91
- * no explicit theme is set on the document.
86
+ * Dark theme — applied only when explicitly tagged.
92
87
  * ------------------------------------------------------------------------ */
93
88
  :host([data-theme="dark"]),
94
89
  [data-theme="dark"] {
@@ -110,31 +105,6 @@
110
105
  0 10px 20px -15px rgba(0, 0, 0, 0.45);
111
106
  }
112
107
 
113
- /* Auto-dark: only when the document root has no explicit override. The
114
- * `:not([data-theme])` guard means a `data-theme="light"` setting on
115
- * any ancestor wins, even at night. */
116
- @media (prefers-color-scheme: dark) {
117
- :root:not([data-theme="light"]),
118
- :host(:not([data-theme="light"])) {
119
- --sme-color-surface: #0f1115;
120
- --sme-color-surface-muted: #1a1d23;
121
- --sme-color-border: #2a2d35;
122
- --sme-color-border-strong: #3a3f49;
123
- --sme-color-text: #e7e9ee;
124
- --sme-color-text-muted: #9aa0aa;
125
- --sme-color-placeholder: #5c606b;
126
- --sme-color-accent: #a78bfa;
127
- --sme-color-accent-soft: #2a1d54;
128
- --sme-color-primary: var(--sme-color-accent);
129
- --sme-color-focus: #c4b5fd;
130
- --sme-color-danger: #fca5a5;
131
- --sme-color-danger-soft: #451a1a;
132
- --sme-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
133
- --sme-shadow-md: 0 10px 38px -10px rgba(0, 0, 0, 0.5),
134
- 0 10px 20px -15px rgba(0, 0, 0, 0.45);
135
- }
136
- }
137
-
138
108
  /* SMEditor default theme.
139
109
  *
140
110
  * Imports tokens, then styles the structural classes set by core
@@ -143,6 +113,7 @@
143
113
  */
144
114
 
145
115
  .smeditor {
116
+ position: relative;
146
117
  font-family: var(--sme-font-sans);
147
118
  color: var(--sme-color-text);
148
119
  background: var(--sme-color-surface);
@@ -14,6 +14,9 @@ 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
+ # "light" is the default. Set "dark" globally or pass theme: per editor.
18
+ config.default_theme = "light"
19
+
17
20
  # Responsive minimum editor height. Numeric values are pixels. You can also
18
21
  # use CSS lengths such as "24rem" or "45vh".
19
22
  config.min_height = 320
@@ -18,6 +18,8 @@ module SMEditor
18
18
  attr_accessor :allowed_tags
19
19
  # Which bundle the form helper boots: "starter" or "full".
20
20
  attr_accessor :default_kit
21
+ # Default visual theme: "light" or "dark".
22
+ attr_accessor :default_theme
21
23
  # Upload endpoint used by the JavaScript adapter when uploads are enabled.
22
24
  attr_accessor :upload_path
23
25
  # Include the packaged JS/CSS automatically with the first editor field.
@@ -47,6 +49,7 @@ module SMEditor
47
49
  table thead tbody tr td th
48
50
  ]
49
51
  @default_kit = "starter"
52
+ @default_theme = "light"
50
53
  @upload_path = "/smeditor/uploads"
51
54
  @auto_include_assets = true
52
55
  @min_height = "320px"
@@ -11,24 +11,28 @@ module SMEditor
11
11
  # include assets in <head> can call `smeditor_assets` there and disable
12
12
  # auto inclusion with config.auto_include_assets = false.
13
13
  def smeditor_assets
14
- return "".html_safe if defined?(@_smeditor_assets_rendered) && @_smeditor_assets_rendered
15
-
16
- @_smeditor_assets_rendered = true
17
- safe_join([
18
- stylesheet_link_tag("smeditor", "data-turbo-track": "reload"),
19
- javascript_include_tag("smeditor", defer: true, "data-turbo-track": "reload"),
20
- ])
14
+ if defined?(@_smeditor_assets_rendered) && @_smeditor_assets_rendered
15
+ "".html_safe
16
+ else
17
+ @_smeditor_assets_rendered = true
18
+ safe_join([
19
+ stylesheet_link_tag("smeditor", "data-turbo-track": "reload"),
20
+ javascript_include_tag("smeditor", defer: true, "data-turbo-track": "reload"),
21
+ ])
22
+ end
21
23
  end
22
24
 
23
25
  # form - a Rails FormBuilder
24
26
  # method - the model attribute (stored as an HTML string)
25
27
  # options - :kit ("starter" | "full"), :class, :placeholder,
26
- # :upload_url, :label, :include_assets, :min_height,
28
+ # :theme ("light" | "dark"), :upload_url, :label,
29
+ # :include_assets, :min_height,
27
30
  # :tablet_min_height, :mobile_min_height
28
31
  def smeditor_editor(form, method, options = {})
29
32
  object = form.object
30
33
  current = object.respond_to?(method) ? object.public_send(method) : nil
31
34
  kit = (options[:kit] || SMEditor.config.default_kit).to_s
35
+ theme = smeditor_theme(options.fetch(:theme, SMEditor.config.default_theme))
32
36
  upload_url = options.fetch(:upload_url, smeditor_upload_url)
33
37
  include_assets = options.fetch(:include_assets, SMEditor.config.auto_include_assets)
34
38
  stylesheet_url = asset_path("smeditor.css")
@@ -49,6 +53,7 @@ module SMEditor
49
53
  data: {
50
54
  smeditor: true,
51
55
  smeditor_kit: kit,
56
+ smeditor_theme: theme,
52
57
  smeditor_placeholder: options[:placeholder],
53
58
  smeditor_upload_url: upload_url,
54
59
  smeditor_label: options[:label],
@@ -71,9 +76,20 @@ module SMEditor
71
76
 
72
77
  def smeditor_css_length(value, option_name)
73
78
  normalized = value.is_a?(Numeric) ? "#{value}px" : value.to_s.strip
74
- return normalized if normalized.match?(CSS_LENGTH)
79
+ if normalized.match?(CSS_LENGTH)
80
+ normalized
81
+ else
82
+ raise ArgumentError, "#{option_name} must be a number (pixels) or a CSS length such as 320px, 24rem, or 45vh"
83
+ end
84
+ end
85
+
86
+ def smeditor_theme(value)
87
+ theme = value.to_s
88
+ unless %w[light dark].include?(theme)
89
+ raise ArgumentError, 'theme must be "light" or "dark"'
90
+ end
75
91
 
76
- raise ArgumentError, "#{option_name} must be a number (pixels) or a CSS length such as 320px, 24rem, or 45vh"
92
+ theme
77
93
  end
78
94
 
79
95
  def smeditor_upload_url
@@ -2,6 +2,6 @@
2
2
 
3
3
  module SMEditor
4
4
  module Rails
5
- VERSION = "0.2.4"
5
+ VERSION = "0.3.0"
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.4
4
+ version: 0.3.0
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.4
99
+ documentation_uri: https://rubydoc.info/gems/smeditor/0.3.0
100
100
  rubygems_mfa_required: 'true'
101
101
  rdoc_options: []
102
102
  require_paths: