smeditor 0.2.5 → 0.3.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.
@@ -23,9 +23,10 @@ const ICONS = {
23
23
  code: '<path d="m6 5-3 3 3 3"/><path d="m10 5 3 3-3 3"/>',
24
24
  eraser: '<path d="m9 3 5 5-6 6H4l-1-1z"/><path d="m6 6 5 5"/><path d="M9 14h5"/>',
25
25
  link: '<path d="M7 9a3 3 0 0 0 4 0l2-2a3 3 0 1 0-4-4L8 4"/><path d="M9 7a3 3 0 0 0-4 0l-2 2a3 3 0 1 0 4 4l1-1"/>',
26
- highlight: '<path d="M3 13l3-1 6-6-2-2-6 6z"/><path d="M2 14h12"/>',
27
- textColor: '<text x="3" y="11" font-size="9" font-weight="700" fill="currentColor" stroke="none">A</text><rect x="3" y="13" width="10" height="2" fill="currentColor" stroke="none"/>',
28
- background: '<rect x="2" y="2" width="12" height="12" rx="1.5"/><path d="M2 2l12 12"/>',
26
+ highlight: '<path d="M3.5 11.5l2.5-.8 6-6-1.7-1.7-6 6z"/><path d="M9 4.3l1.7 1.7"/>',
27
+ textColor: '<path d="M4 12 8 2.5 12 12"/><path d="M5.6 8.5h4.8"/>',
28
+ underlineColor: '<path d="M4.5 2.5v4.5a3.5 3.5 0 0 0 7 0V2.5"/>',
29
+ background: '<path d="M3 7.5 7.5 3l5 5-4.5 4.5z"/><path d="M13 11.2c0 .8-.5 1.3-1 1.3s-1-.5-1-1.3.5-1.5 1-2.2c.5.7 1 1.4 1 2.2z" fill="currentColor"/>',
29
30
  blockquote: '<path d="M3 5v3c0 1.5-.7 2.5-2 3"/><path d="M9 5v3c0 1.5-.7 2.5-2 3"/>',
30
31
  codeBlock: '<rect x="1.5" y="3" width="13" height="10" rx="1.5"/><path d="m5 6.5-2 1.5 2 1.5"/><path d="m11 6.5 2 1.5-2 1.5"/>',
31
32
  hr: '<path d="M2 8h12"/><path d="M2 4h8"/><path d="M2 12h6"/>',
@@ -90,7 +91,7 @@ function kitExtensions(kit, uploadUrl) {
90
91
  const upload = uploadHandler(uploadUrl);
91
92
  if (!upload)
92
93
  return [...base];
93
- return base.map((extension) => extension.name === "image" ? starter_kit_1.ImageExtension.configure({ upload }) : extension);
94
+ return base.map((extension) => extension.name === "image" && starter_kit_1.ImageExtension.configure ? starter_kit_1.ImageExtension.configure({ upload }) : extension);
94
95
  }
95
96
  function run(editor, command, ...args) {
96
97
  const fn = editor.commands[command];
@@ -127,6 +128,7 @@ function commandButton(editor, iconName, title, command, active, label) {
127
128
  const value = active();
128
129
  el.dataset.active = value ? "true" : "false";
129
130
  el.classList.toggle("is-active", value);
131
+ el.setAttribute("aria-pressed", String(value));
130
132
  }
131
133
  };
132
134
  el.__smeditorRefresh = refresh;
@@ -145,10 +147,13 @@ function dropdown(ariaLabel, triggerContent, items, options = {}) {
145
147
  trigger.title = ariaLabel;
146
148
  const label = document.createElement("span");
147
149
  label.className = "smeditor-dropdown__label";
148
- if (typeof triggerContent === "string")
150
+ if (typeof triggerContent === "string") {
151
+ label.classList.add("smeditor-dropdown__label--text");
149
152
  label.textContent = triggerContent;
153
+ }
150
154
  else
151
155
  label.appendChild(triggerContent);
156
+ let currentIcon = "";
152
157
  trigger.appendChild(label);
153
158
  let summary = null;
154
159
  if (options.summary) {
@@ -179,7 +184,7 @@ function dropdown(ariaLabel, triggerContent, items, options = {}) {
179
184
  menu.className = "smeditor-dropdown__menu" + (options.align === "right" ? " is-right" : "");
180
185
  menu.setAttribute("role", "menu");
181
186
  menu.setAttribute("aria-label", ariaLabel);
182
- for (const spec of items()) {
187
+ for (const spec of items(close)) {
183
188
  if (spec.separatorBefore) {
184
189
  const sep = document.createElement("div");
185
190
  sep.className = "smeditor-dropdown__separator";
@@ -230,6 +235,11 @@ function dropdown(ariaLabel, triggerContent, items, options = {}) {
230
235
  menu.appendChild(item);
231
236
  }
232
237
  wrapper.appendChild(menu);
238
+ // Keep the popover on screen: flip it to the trigger's right edge
239
+ // when it would overflow the viewport (last toolbar items, bubble).
240
+ if (options.align !== "right" && menu.getBoundingClientRect().right > window.innerWidth - 8) {
241
+ menu.classList.add("is-right");
242
+ }
233
243
  };
234
244
  trigger.addEventListener("mousedown", (event) => event.preventDefault());
235
245
  trigger.addEventListener("click", (event) => {
@@ -245,8 +255,22 @@ function dropdown(ariaLabel, triggerContent, items, options = {}) {
245
255
  };
246
256
  document.addEventListener("pointerdown", outside, true);
247
257
  const refresh = () => {
258
+ var _a;
248
259
  if (summary && options.summary)
249
260
  summary.textContent = options.summary();
261
+ if (options.labelText) {
262
+ const text = options.labelText();
263
+ label.textContent = text;
264
+ trigger.title = `${ariaLabel}: ${text}`;
265
+ }
266
+ if (options.icon) {
267
+ const name = options.icon();
268
+ if (name !== currentIcon) {
269
+ currentIcon = name;
270
+ label.replaceChildren(svgIcon(name));
271
+ }
272
+ }
273
+ (_a = options.decorate) === null || _a === void 0 ? void 0 : _a.call(options, trigger);
250
274
  if (options.active) {
251
275
  const value = options.active();
252
276
  trigger.dataset.active = value ? "true" : "false";
@@ -254,42 +278,128 @@ function dropdown(ariaLabel, triggerContent, items, options = {}) {
254
278
  }
255
279
  };
256
280
  wrapper.__smeditorRefresh = refresh;
281
+ wrapper.__smeditorClose = close;
257
282
  wrapper.__smeditorDispose = () => document.removeEventListener("pointerdown", outside, true);
258
283
  refresh();
259
284
  return wrapper;
260
285
  }
261
- function colorDropdown(editor, ariaLabel, iconName, markName, setCommand, unsetCommand, extraAttrs = {}) {
262
- const colors = [
263
- "#1a1a1f", "#6b7280", "#ef4444", "#f97316", "#eab308",
264
- "#22c55e", "#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899",
265
- "#ffffff", "#d1d5db", "#fecaca", "#fed7aa", "#fef08a",
266
- "#bbf7d0", "#a5f3fc", "#bfdbfe", "#ddd6fe", "#fbcfe8",
267
- ];
268
- return dropdown(ariaLabel, svgIcon(iconName), () => {
286
+ const PALETTE = [
287
+ "#1a1a1f", "#6b7280", "#ef4444", "#f97316", "#eab308",
288
+ "#22c55e", "#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899",
289
+ "#ffffff", "#d1d5db", "#fecaca", "#fed7aa", "#fef08a",
290
+ "#bbf7d0", "#a5f3fc", "#bfdbfe", "#ddd6fe", "#fbcfe8",
291
+ ];
292
+ /** Text colour options on a fill; null = automatic contrast. */
293
+ const FILL_TEXT = [["Auto", null], ["White", "#ffffff"], ["Dark", "#1a1a1f"]];
294
+ function sameColor(a, b) {
295
+ return typeof a === "string" && typeof b === "string" && a.trim().toLowerCase() === b.trim().toLowerCase();
296
+ }
297
+ /**
298
+ * The text colour the user chose for a fill: null when it is the
299
+ * automatic contrast colour for the current fill (so re-filling with a
300
+ * new colour keeps the text readable), otherwise the explicit colour.
301
+ */
302
+ function fillTextChoice(attrs) {
303
+ const fill = typeof (attrs === null || attrs === void 0 ? void 0 : attrs.color) === "string" ? attrs.color : null;
304
+ const text = typeof (attrs === null || attrs === void 0 ? void 0 : attrs.textColor) === "string" ? attrs.textColor : null;
305
+ return fill && text && !sameColor(text, (0, core_1.contrastTextColor)(fill)) ? text : null;
306
+ }
307
+ function panelButton(text, className, action) {
308
+ const el = document.createElement("button");
309
+ el.type = "button";
310
+ el.className = className;
311
+ el.textContent = text;
312
+ el.addEventListener("mousedown", (event) => event.preventDefault());
313
+ el.addEventListener("click", action);
314
+ return el;
315
+ }
316
+ /**
317
+ * Colour picker dropdown. The trigger carries a bar filled with the colour
318
+ * applied to the current selection, the palette marks that colour, and a
319
+ * labelled "Remove" button clears it — so the state of the selection is
320
+ * always visible and every applied colour can be undone from here.
321
+ */
322
+ function colorDropdown(editor, spec) {
323
+ const current = () => editor.getMarkAttributes(spec.mark);
324
+ const icon = document.createElement("span");
325
+ icon.className = "smeditor-color-trigger";
326
+ icon.appendChild(svgIcon(spec.icon));
327
+ const bar = document.createElement("span");
328
+ bar.className = "smeditor-color-trigger__bar";
329
+ icon.appendChild(bar);
330
+ const wrapper = dropdown(spec.label, icon, (close) => {
331
+ const attrs = current();
332
+ const panel = document.createElement("div");
333
+ panel.className = "smeditor-color-panel";
334
+ panel.setAttribute("role", "group");
335
+ panel.setAttribute("aria-label", spec.label);
336
+ const title = document.createElement("div");
337
+ title.className = "smeditor-color-panel__title";
338
+ title.textContent = spec.label;
339
+ panel.appendChild(title);
269
340
  const grid = document.createElement("div");
270
341
  grid.className = "smeditor-color-grid";
271
- grid.setAttribute("role", "group");
272
- const clear = document.createElement("button");
273
- clear.type = "button";
274
- clear.className = "smeditor-color-grid__swatch smeditor-color-grid__swatch--clear";
275
- clear.title = `Clear ${ariaLabel.toLowerCase()}`;
276
- clear.setAttribute("aria-label", clear.title);
277
- clear.addEventListener("mousedown", (e) => e.preventDefault());
278
- clear.addEventListener("click", () => run(editor, unsetCommand));
279
- grid.appendChild(clear);
280
- for (const color of colors) {
342
+ for (const color of PALETTE) {
281
343
  const swatch = document.createElement("button");
282
344
  swatch.type = "button";
283
345
  swatch.className = "smeditor-color-grid__swatch";
284
346
  swatch.style.backgroundColor = color;
285
347
  swatch.title = color;
286
- swatch.setAttribute("aria-label", `${ariaLabel}: ${color}`);
287
- swatch.addEventListener("mousedown", (e) => e.preventDefault());
288
- swatch.addEventListener("click", () => run(editor, setCommand, { color, ...extraAttrs }));
348
+ swatch.setAttribute("aria-label", `${spec.label}: ${color}`);
349
+ const selected = sameColor(attrs === null || attrs === void 0 ? void 0 : attrs.color, color);
350
+ swatch.classList.toggle("is-active", selected);
351
+ swatch.setAttribute("aria-pressed", String(selected));
352
+ swatch.addEventListener("mousedown", (event) => event.preventDefault());
353
+ swatch.addEventListener("click", () => {
354
+ run(editor, spec.set, spec.textOn ? { color, textColor: fillTextChoice(attrs) } : { color });
355
+ close();
356
+ });
289
357
  grid.appendChild(swatch);
290
358
  }
291
- return [{ label: "", content: grid }];
292
- }, { active: () => editor.isActive(markName) });
359
+ panel.appendChild(grid);
360
+ if (spec.textOn) {
361
+ const label = document.createElement("div");
362
+ label.className = "smeditor-color-panel__title";
363
+ label.textContent = spec.textOn;
364
+ const chips = document.createElement("div");
365
+ chips.className = "smeditor-color-panel__widths";
366
+ const choice = fillTextChoice(attrs);
367
+ for (const [text, value] of FILL_TEXT) {
368
+ const chip = panelButton(text, "smeditor-color-panel__chip", () => {
369
+ var _a;
370
+ run(editor, spec.set, { color: (_a = attrs === null || attrs === void 0 ? void 0 : attrs.color) !== null && _a !== void 0 ? _a : PALETTE[7], textColor: value });
371
+ close();
372
+ });
373
+ if (value)
374
+ chip.style.setProperty("--sme-chip-swatch", value);
375
+ chip.setAttribute("aria-label", `${spec.textOn}: ${text}`);
376
+ const selected = Boolean(attrs) && choice === value;
377
+ chip.classList.toggle("is-active", selected);
378
+ chip.setAttribute("aria-pressed", String(selected));
379
+ chips.appendChild(chip);
380
+ }
381
+ panel.append(label, chips);
382
+ }
383
+ const clear = panelButton(`Remove ${spec.label.toLowerCase()}`, "smeditor-color-panel__clear", () => {
384
+ run(editor, spec.unset);
385
+ close();
386
+ });
387
+ clear.disabled = !(attrs === null || attrs === void 0 ? void 0 : attrs.color);
388
+ panel.appendChild(clear);
389
+ return [{ label: "", content: panel }];
390
+ }, {
391
+ active: () => { var _a; return Boolean((_a = current()) === null || _a === void 0 ? void 0 : _a.color); },
392
+ decorate: (trigger) => {
393
+ var _a;
394
+ const color = (_a = current()) === null || _a === void 0 ? void 0 : _a.color;
395
+ const value = typeof color === "string" ? color : "";
396
+ bar.style.backgroundColor = value;
397
+ bar.dataset.empty = value ? "false" : "true";
398
+ trigger.title = value ? `${spec.label}: ${value}` : spec.label;
399
+ },
400
+ });
401
+ wrapper.classList.add("smeditor-dropdown--color");
402
+ return wrapper;
293
403
  }
294
404
  function blockTypeSummary(editor) {
295
405
  if ((0, core_1.selectionInsideWrapper)(editor.getJSON(), editor.getSelection(), "blockquote"))
@@ -339,11 +449,81 @@ function tablePicker(editor) {
339
449
  return [{ label: "", content: box }];
340
450
  });
341
451
  }
342
- function textDropdown(editor, ariaLabel, label, values, command, unset) {
343
- return dropdown(ariaLabel, label, () => values.map(([text, value]) => ({
344
- label: text,
345
- action: () => value === null && unset ? run(editor, unset) : run(editor, command, { [command === "setFontFamily" ? "family" : command === "setFontSize" ? "size" : "value"]: value }),
346
- })));
452
+ function normalizeValue(value) {
453
+ return String(value !== null && value !== void 0 ? value : "").replace(/["'\s]/g, "").toLowerCase();
454
+ }
455
+ /**
456
+ * Value picker (font family, size, line height). The trigger shows the
457
+ * value applied to the selection instead of a generic caption, and the
458
+ * matching menu item is marked as checked.
459
+ */
460
+ function valueDropdown(spec) {
461
+ const selected = () => {
462
+ const value = spec.current();
463
+ const match = value === null ? null : spec.values.find(([, v]) => v !== null && normalizeValue(v) === normalizeValue(value));
464
+ const name = match ? match[0].split(" · ")[0] : value;
465
+ return { value, label: spec.showValue ? value : name };
466
+ };
467
+ return dropdown(spec.ariaLabel, spec.placeholder, () => {
468
+ const { value } = selected();
469
+ return spec.values.map(([text, v]) => ({
470
+ label: text,
471
+ active: () => (v === null ? value === null : value !== null && normalizeValue(v) === normalizeValue(value)),
472
+ action: () => spec.apply(v),
473
+ }));
474
+ }, {
475
+ labelText: () => { var _a; return (_a = selected().label) !== null && _a !== void 0 ? _a : spec.placeholder; },
476
+ active: () => spec.current() !== null,
477
+ });
478
+ }
479
+ function markValue(editor, mark, attr) {
480
+ var _a;
481
+ const value = (_a = editor.getMarkAttributes(mark)) === null || _a === void 0 ? void 0 : _a[attr];
482
+ return typeof value === "string" && value ? value : null;
483
+ }
484
+ function blockValue(editor, attr) {
485
+ const value = (0, core_1.getBlockAttr)(editor.getJSON(), editor.getSelection(), attr);
486
+ return typeof value === "string" && value ? value : null;
487
+ }
488
+ function currentAlign(editor) {
489
+ var _a;
490
+ return (_a = blockValue(editor, "textAlign")) !== null && _a !== void 0 ? _a : "left";
491
+ }
492
+ function currentListIcon(editor) {
493
+ const inside = (name) => (0, core_1.selectionInsideWrapper)(editor.getJSON(), editor.getSelection(), name);
494
+ if (inside("task_list"))
495
+ return "task";
496
+ if (inside("ordered_list"))
497
+ return "ordered";
498
+ return "bullet";
499
+ }
500
+ function inlineMarkButtons(editor) {
501
+ const buttons = [
502
+ commandButton(editor, "bold", "Bold", () => run(editor, "toggleBold"), () => editor.isActive("bold")),
503
+ commandButton(editor, "italic", "Italic", () => run(editor, "toggleItalic"), () => editor.isActive("italic")),
504
+ commandButton(editor, "underline", "Underline", () => run(editor, "toggleUnderline"), () => editor.isActive("underline")),
505
+ commandButton(editor, "strike", "Strike-through", () => run(editor, "toggleStrike"), () => editor.isActive("strike")),
506
+ ];
507
+ return buttons;
508
+ }
509
+ function linkButton(editor) {
510
+ return commandButton(editor, "link", "Link", () => {
511
+ if (editor.isActive("link"))
512
+ return run(editor, "unsetLink");
513
+ const href = window.prompt("Link URL", "https://");
514
+ return href ? run(editor, "setLink", { href }) : false;
515
+ }, () => editor.isActive("link"));
516
+ }
517
+ const COLOR_SPECS = [
518
+ { label: "Text color", icon: "textColor", mark: "text_color", set: "setTextColor", unset: "unsetTextColor" },
519
+ { label: "Fill", icon: "background", mark: "background_color", set: "setBackgroundColor", unset: "unsetBackgroundColor", textOn: "Text on fill" },
520
+ { label: "Highlight", icon: "highlight", mark: "highlight", set: "setHighlight", unset: "unsetHighlight", textOn: "Text on highlight" },
521
+ { label: "Underline color", icon: "underlineColor", mark: "underline", set: "setUnderlineColor", unset: "unsetUnderlineColor" },
522
+ ];
523
+ function colorDropdowns(editor, marks) {
524
+ return COLOR_SPECS
525
+ .filter((spec) => marks.includes(spec.mark) && hasCommand(editor, spec.set) && hasCommand(editor, spec.unset))
526
+ .map((spec) => colorDropdown(editor, spec));
347
527
  }
348
528
  function buildToolbar(editor, kit, uploadUrl) {
349
529
  const toolbar = document.createElement("div");
@@ -368,22 +548,26 @@ function buildToolbar(editor, kit, uploadUrl) {
368
548
  { label: "Bullet List", icon: "bullet", shortcut: "⌘⇧8", active: () => (0, core_1.selectionInsideWrapper)(editor.getJSON(), editor.getSelection(), "bullet_list"), action: () => run(editor, "toggleBulletList") },
369
549
  { label: "Ordered List", icon: "ordered", shortcut: "⌘⇧7", active: () => (0, core_1.selectionInsideWrapper)(editor.getJSON(), editor.getSelection(), "ordered_list"), action: () => run(editor, "toggleOrderedList") },
370
550
  { label: "Task List", icon: "task", shortcut: "⌘⇧9", active: () => (0, core_1.selectionInsideWrapper)(editor.getJSON(), editor.getSelection(), "task_list"), action: () => run(editor, "toggleTaskList") },
371
- ], { active: () => ["bullet_list", "ordered_list", "task_list"].some((name) => (0, core_1.selectionInsideWrapper)(editor.getJSON(), editor.getSelection(), name)) });
551
+ ], {
552
+ icon: () => currentListIcon(editor),
553
+ active: () => ["bullet_list", "ordered_list", "task_list"].some((name) => (0, core_1.selectionInsideWrapper)(editor.getJSON(), editor.getSelection(), name)),
554
+ });
372
555
  add(block, lists, divider());
373
- add(commandButton(editor, "bold", "Bold", () => run(editor, "toggleBold"), () => editor.isActive("bold")), commandButton(editor, "italic", "Italic", () => run(editor, "toggleItalic"), () => editor.isActive("italic")), commandButton(editor, "strike", "Strike-through", () => run(editor, "toggleStrike"), () => editor.isActive("strike")), commandButton(editor, "underline", "Underline", () => run(editor, "toggleUnderline"), () => editor.isActive("underline")), commandButton(editor, "eraser", "Clear formatting", () => run(editor, "clearFormatting")), commandButton(editor, "link", "Link", () => {
374
- if (editor.isActive("link"))
375
- return run(editor, "unsetLink");
376
- const href = window.prompt("Link URL", "https://");
377
- return href ? run(editor, "setLink", { href }) : false;
378
- }, () => editor.isActive("link")), divider());
556
+ add(...inlineMarkButtons(editor), commandButton(editor, "eraser", "Clear formatting", () => run(editor, "clearFormatting")), linkButton(editor), divider());
557
+ const alignItem = (label, icon, align) => ({
558
+ label, icon, active: () => currentAlign(editor) === align, action: () => run(editor, "setTextAlign", { align }),
559
+ });
379
560
  const align = dropdown("Alignment", svgIcon("alignLeft"), () => [
380
- { label: "Left", icon: "alignLeft", active: () => editor.isActive("text_align", { align: "left" }), action: () => run(editor, "setTextAlign", { align: "left" }) },
381
- { label: "Center", icon: "alignCenter", active: () => editor.isActive("text_align", { align: "center" }), action: () => run(editor, "setTextAlign", { align: "center" }) },
382
- { label: "Right", icon: "alignRight", active: () => editor.isActive("text_align", { align: "right" }), action: () => run(editor, "setTextAlign", { align: "right" }) },
383
- { label: "Justify", icon: "alignJustify", active: () => editor.isActive("text_align", { align: "justify" }), action: () => run(editor, "setTextAlign", { align: "justify" }) },
561
+ alignItem("Left", "alignLeft", "left"),
562
+ alignItem("Center", "alignCenter", "center"),
563
+ alignItem("Right", "alignRight", "right"),
564
+ alignItem("Justify", "alignJustify", "justify"),
384
565
  { label: "Increase indent", icon: "indent", action: () => run(editor, "indent"), separatorBefore: true },
385
566
  { label: "Decrease indent", icon: "outdent", action: () => run(editor, "outdent") },
386
- ]);
567
+ ], {
568
+ icon: () => { var _a; return ((_a = { center: "alignCenter", right: "alignRight", justify: "alignJustify" }[currentAlign(editor)]) !== null && _a !== void 0 ? _a : "alignLeft"); },
569
+ active: () => currentAlign(editor) !== "left",
570
+ });
387
571
  add(align, divider());
388
572
  add(commandButton(editor, "blockquote", "Blockquote", () => run(editor, "toggleBlockquote"), () => (0, core_1.selectionInsideWrapper)(editor.getJSON(), editor.getSelection(), "blockquote")), commandButton(editor, "codeBlock", "Code block", () => run(editor, "setCodeBlock"), () => editor.isActive("code_block")), commandButton(editor, "hr", "Horizontal rule", () => run(editor, "insertHorizontalRule")));
389
573
  const image = dropdown("Insert image", svgIcon("image"), () => {
@@ -409,7 +593,28 @@ function buildToolbar(editor, kit, uploadUrl) {
409
593
  add(image, tablePicker(editor));
410
594
  if (kit === "full") {
411
595
  add(divider());
412
- add(colorDropdown(editor, "Highlight", "highlight", "highlight", "setHighlight", "unsetHighlight"), colorDropdown(editor, "Text color", "textColor", "text_color", "setTextColor", "unsetTextColor"), colorDropdown(editor, "Text stroke", "textColor", "text_stroke", "setTextStroke", "unsetTextStroke", { width: 1 }), colorDropdown(editor, "Background color", "background", "background_color", "setBackgroundColor", "unsetBackgroundColor"), textDropdown(editor, "Font family", "Font", [["Default", null], ["Sans-serif", "ui-sans-serif, system-ui, sans-serif"], ["Serif", "ui-serif, Georgia, serif"], ["Monospace", "ui-monospace, Menlo, monospace"], ["Georgia", "Georgia, serif"], ["Courier", "'Courier New', monospace"]], "setFontFamily", "unsetFontFamily"), textDropdown(editor, "Font size", "Size", [["Default", null], ["Small · 13px", "13px"], ["Normal · 16px", "16px"], ["Large · 20px", "20px"], ["Huge · 28px", "28px"]], "setFontSize", "unsetFontSize"), textDropdown(editor, "Line height", "Spacing", [["Default", null], ["Tight · 1", "1"], ["Snug · 1.15", "1.15"], ["Normal · 1.5", "1.5"], ["Relaxed · 2", "2"]], "setLineHeight"));
596
+ add(...colorDropdowns(editor, ["text_color", "background_color", "highlight", "underline"]));
597
+ add(valueDropdown({
598
+ ariaLabel: "Font family",
599
+ placeholder: "Font",
600
+ values: [["Default", null], ["Sans-serif", "ui-sans-serif, system-ui, sans-serif"], ["Serif", "ui-serif, Georgia, serif"], ["Monospace", "ui-monospace, Menlo, monospace"], ["Georgia", "Georgia, serif"], ["Courier", "'Courier New', monospace"]],
601
+ current: () => markValue(editor, "font_family", "family"),
602
+ apply: (value) => (value === null ? run(editor, "unsetFontFamily") : run(editor, "setFontFamily", { family: value })),
603
+ }), valueDropdown({
604
+ ariaLabel: "Font size",
605
+ placeholder: "Size",
606
+ showValue: true,
607
+ values: [["Default", null], ["Small · 13px", "13px"], ["Normal · 16px", "16px"], ["Large · 20px", "20px"], ["Huge · 28px", "28px"]],
608
+ current: () => markValue(editor, "font_size", "size"),
609
+ apply: (value) => (value === null ? run(editor, "unsetFontSize") : run(editor, "setFontSize", { size: value })),
610
+ }), valueDropdown({
611
+ ariaLabel: "Line height",
612
+ placeholder: "Spacing",
613
+ showValue: true,
614
+ values: [["Default", null], ["Tight · 1", "1"], ["Snug · 1.15", "1.15"], ["Normal · 1.5", "1.5"], ["Relaxed · 2", "2"]],
615
+ current: () => blockValue(editor, "lineHeight"),
616
+ apply: (value) => run(editor, "setLineHeight", { value }),
617
+ }));
413
618
  if (hasCommand(editor, "addComment")) {
414
619
  add(commandButton(editor, "", "Comment", () => {
415
620
  const body = window.prompt("New comment");
@@ -424,7 +629,7 @@ function buildToolbar(editor, kit, uploadUrl) {
424
629
  { label: "Superscript", icon: "superscript", active: () => editor.isActive("superscript"), action: () => run(editor, "toggleSuperscript") },
425
630
  { label: "Increase indent", icon: "indent", action: () => run(editor, "indent"), separatorBefore: true },
426
631
  { label: "Decrease indent", icon: "outdent", action: () => run(editor, "outdent") },
427
- ], { align: "right" });
632
+ ], { align: "right", active: () => ["code", "subscript", "superscript"].some((mark) => editor.isActive(mark)) });
428
633
  add(more);
429
634
  }
430
635
  const refresh = () => {
@@ -477,24 +682,29 @@ function buildBubbleMenu(editor, surface, container) {
477
682
  menu.style.position = "absolute";
478
683
  menu.style.display = "none";
479
684
  menu.style.zIndex = "2147483000";
480
- const buttons = [
481
- commandButton(editor, "bold", "Bold", () => run(editor, "toggleBold"), () => editor.isActive("bold")),
482
- commandButton(editor, "italic", "Italic", () => run(editor, "toggleItalic"), () => editor.isActive("italic")),
483
- commandButton(editor, "underline", "Underline", () => run(editor, "toggleUnderline"), () => editor.isActive("underline")),
484
- commandButton(editor, "strike", "Strike-through", () => run(editor, "toggleStrike"), () => editor.isActive("strike")),
485
- commandButton(editor, "link", "Link", () => {
486
- if (editor.isActive("link"))
487
- return run(editor, "unsetLink");
488
- const href = window.prompt("Link URL", "https://");
489
- return href ? run(editor, "setLink", { href }) : false;
490
- }, () => editor.isActive("link")),
491
- ];
685
+ // Same controls (and the same live state) as the matching toolbar ones:
686
+ // inline marks, link, the colour pickers the kit provides, and a clear
687
+ // button that removes every inline style from the selection at once.
688
+ const buttons = [...inlineMarkButtons(editor)];
689
+ if (hasCommand(editor, "toggleCode")) {
690
+ buttons.push(commandButton(editor, "code", "Inline code", () => run(editor, "toggleCode"), () => editor.isActive("code")));
691
+ }
692
+ buttons.push(linkButton(editor));
693
+ const colors = colorDropdowns(editor, ["text_color", "background_color", "underline"]);
694
+ if (colors.length)
695
+ buttons.push(divider(), ...colors);
696
+ if (hasCommand(editor, "clearFormatting")) {
697
+ buttons.push(divider(), commandButton(editor, "eraser", "Clear formatting", () => run(editor, "clearFormatting")));
698
+ }
492
699
  menu.append(...buttons);
493
700
  // Keeping pointer-down inside the menu from focusing a button preserves the
494
701
  // editor's text selection, which the formatting command needs.
495
702
  menu.addEventListener("mousedown", (event) => event.preventDefault());
496
703
  let frame = 0;
497
704
  const hide = () => {
705
+ var _a, _b;
706
+ for (const button of buttons)
707
+ (_b = (_a = button).__smeditorClose) === null || _b === void 0 ? void 0 : _b.call(_a);
498
708
  menu.style.display = "none";
499
709
  menu.style.visibility = "hidden";
500
710
  };
@@ -533,8 +743,11 @@ function buildBubbleMenu(editor, surface, container) {
533
743
  let viewportLeft = anchor.left + anchor.width / 2 - rect.width / 2;
534
744
  const maxViewportLeft = Math.max(margin, viewportWidth - rect.width - margin);
535
745
  viewportLeft = Math.max(margin, Math.min(viewportLeft, maxViewportLeft));
746
+ // Never cover the main toolbar: when there is no room between the top
747
+ // of the writing surface and the selection, open below the selection.
748
+ const topLimit = Math.max(margin, surface.getBoundingClientRect().top);
536
749
  let viewportTop = anchor.top - rect.height - offset;
537
- if (viewportTop < margin)
750
+ if (viewportTop < topLimit)
538
751
  viewportTop = anchor.bottom + offset;
539
752
  if (viewportTop + rect.height > viewportHeight - margin) {
540
753
  viewportTop = Math.max(margin, anchor.top - rect.height - offset);
@@ -552,7 +765,14 @@ function buildBubbleMenu(editor, surface, container) {
552
765
  const selectionTarget = selectionRoot && selectionRoot !== document
553
766
  ? selectionRoot
554
767
  : null;
768
+ const hideOnBlur = () => requestAnimationFrame(() => {
769
+ const active = surface.getRootNode().activeElement;
770
+ if (active !== surface && !menu.contains(active))
771
+ hide();
772
+ });
555
773
  const disposeSelection = editor.on("selectionUpdate", scheduleUpdate);
774
+ const disposeUpdate = editor.on("update", scheduleUpdate);
775
+ surface.addEventListener("blur", hideOnBlur);
556
776
  document.addEventListener("selectionchange", scheduleUpdate);
557
777
  selectionTarget === null || selectionTarget === void 0 ? void 0 : selectionTarget.addEventListener("selectionchange", scheduleUpdate);
558
778
  surface.addEventListener("pointerup", scheduleUpdate);
@@ -560,9 +780,14 @@ function buildBubbleMenu(editor, surface, container) {
560
780
  window.addEventListener("resize", scheduleUpdate);
561
781
  window.addEventListener("scroll", scheduleUpdate, true);
562
782
  menu.__smeditorDispose = () => {
783
+ var _a, _b;
563
784
  if (frame)
564
785
  cancelAnimationFrame(frame);
565
786
  disposeSelection();
787
+ disposeUpdate();
788
+ surface.removeEventListener("blur", hideOnBlur);
789
+ for (const button of buttons)
790
+ (_b = (_a = button).__smeditorDispose) === null || _b === void 0 ? void 0 : _b.call(_a);
566
791
  document.removeEventListener("selectionchange", scheduleUpdate);
567
792
  selectionTarget === null || selectionTarget === void 0 ? void 0 : selectionTarget.removeEventListener("selectionchange", scheduleUpdate);
568
793
  surface.removeEventListener("pointerup", scheduleUpdate);
@@ -595,10 +820,15 @@ function bootMount(mount) {
595
820
  const kit = mount.dataset.smeditorKit === "full" ? "full" : "starter";
596
821
  const uploadUrl = mount.dataset.smeditorUploadUrl || undefined;
597
822
  const placeholder = mount.dataset.smeditorPlaceholder || undefined;
598
- 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)
823
+ const configuredTheme = mount.dataset.smeditorTheme;
824
+ 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)
599
825
  || document.documentElement.dataset.theme;
600
- if (explicitTheme === "light" || explicitTheme === "dark")
601
- mount.dataset.theme = explicitTheme;
826
+ const theme = configuredTheme === "dark" || configuredTheme === "light"
827
+ ? configuredTheme
828
+ : inheritedTheme === "dark" || inheritedTheme === "light"
829
+ ? inheritedTheme
830
+ : "light";
831
+ mount.dataset.theme = theme;
602
832
  const root = shadowRootFor(mount);
603
833
  const shell = document.createElement("div");
604
834
  shell.className = "smeditor";
@@ -621,6 +851,7 @@ function bootMount(mount) {
621
851
  extensions: kitExtensions(kit, uploadUrl),
622
852
  content: input.value || "<p></p>",
623
853
  placeholder,
854
+ theme,
624
855
  deepSelection: true,
625
856
  onUpdate: ({ editor: current }) => {
626
857
  input.value = current.getHTML();
@@ -695,8 +926,8 @@ if (typeof window !== "undefined") {
695
926
  * stay internal so we can evolve them without breaking consumers.
696
927
  */
697
928
  Object.defineProperty(exports, "__esModule", { value: true });
698
- exports.selectionBlockRange = exports.normalizeDeepPoint = exports.resolveSelectionTarget = exports.removeNodeAt = exports.mapNodeAt = exports.descendToLeafPath = exports.parentAt = exports.nodeAt = exports.isContainerBlock = exports.isLeafBlock = exports.splitBlock = exports.removeMarkFromInlineRange = exports.setMarkOnInlineRange = exports.applyMarkToInlineRange = exports.replaceInlineRange = exports.deleteInlineRange = exports.insertInlineAt = exports.splitInlineAt = exports.blockVisibleText = exports.inlineLength = exports.visibleLength = exports.stripBlockPrefix = exports.getBlockText = exports.leafBlocksInSelection = exports.setBlockType = exports.clearMarksAcrossSelection = exports.unsetMarkAcrossSelection = exports.setMarkAcrossSelection = exports.toggleMarkInDoc = exports.selectionHasMark = exports.hasMark = exports.emptyDocument = exports.docsEqual = exports.cloneDoc = exports.EventEmitter = exports.History = exports.hardenLinkAttrs = exports.sanitizeLinkTarget = exports.sanitizeURL = exports.sanitizeCSSTextStrokeWidth = exports.sanitizeCSSLineHeight = exports.sanitizeCSSFontSize = exports.sanitizeCSSFontFamily = exports.sanitizeCSSColor = exports.escapeHTML = exports.parseHTML = exports.serializeToHTML = exports.flattenExtensions = exports.compileSchema = exports.createEditor = void 0;
699
- exports.VERSION = exports.getBlockAttr = exports.setBlockAttrs = exports.selectionInsideWrapper = exports.toggleWrap = exports.unwrapBlock = exports.wrapBlocks = void 0;
929
+ exports.resolveSelectionTarget = exports.removeNodeAt = exports.mapNodeAt = exports.descendToLeafPath = exports.parentAt = exports.nodeAt = exports.isContainerBlock = exports.isLeafBlock = exports.splitBlock = exports.removeMarkFromInlineRange = exports.setMarkOnInlineRange = exports.applyMarkToInlineRange = exports.replaceInlineRange = exports.deleteInlineRange = exports.insertInlineAt = exports.splitInlineAt = exports.blockVisibleText = exports.inlineLength = exports.visibleLength = exports.stripBlockPrefix = exports.getBlockText = exports.leafBlocksInSelection = exports.setBlockType = exports.clearMarksAcrossSelection = exports.unsetMarkAcrossSelection = exports.setMarkAcrossSelection = exports.toggleMarkInDoc = exports.selectionMarkAttrs = exports.selectionHasMark = exports.hasMark = exports.emptyDocument = exports.docsEqual = exports.cloneDoc = exports.EventEmitter = exports.History = exports.hardenLinkAttrs = exports.sanitizeLinkTarget = exports.sanitizeURL = exports.sanitizeCSSTextStrokeWidth = exports.sanitizeCSSLineHeight = exports.sanitizeCSSFontSize = exports.sanitizeCSSFontFamily = exports.contrastTextColor = exports.sanitizeCSSColor = exports.escapeHTML = exports.parseHTML = exports.serializeToHTML = exports.flattenExtensions = exports.compileSchema = exports.createEditor = void 0;
930
+ exports.VERSION = exports.getBlockAttr = exports.setBlockAttrs = exports.selectionInsideWrapper = exports.toggleWrap = exports.unwrapBlock = exports.wrapBlocks = exports.selectionBlockRange = exports.normalizeDeepPoint = void 0;
700
931
  var editor_js_1 = require("./editor.js");
701
932
  Object.defineProperty(exports, "createEditor", { enumerable: true, get: function () { return editor_js_1.createEditor; } });
702
933
  var schema_js_1 = require("./schema.js");
@@ -708,6 +939,7 @@ Object.defineProperty(exports, "parseHTML", { enumerable: true, get: function ()
708
939
  Object.defineProperty(exports, "escapeHTML", { enumerable: true, get: function () { return serializer_js_1.escapeHTML; } });
709
940
  var css_js_1 = require("./css.js");
710
941
  Object.defineProperty(exports, "sanitizeCSSColor", { enumerable: true, get: function () { return css_js_1.sanitizeCSSColor; } });
942
+ Object.defineProperty(exports, "contrastTextColor", { enumerable: true, get: function () { return css_js_1.contrastTextColor; } });
711
943
  Object.defineProperty(exports, "sanitizeCSSFontFamily", { enumerable: true, get: function () { return css_js_1.sanitizeCSSFontFamily; } });
712
944
  Object.defineProperty(exports, "sanitizeCSSFontSize", { enumerable: true, get: function () { return css_js_1.sanitizeCSSFontSize; } });
713
945
  Object.defineProperty(exports, "sanitizeCSSLineHeight", { enumerable: true, get: function () { return css_js_1.sanitizeCSSLineHeight; } });
@@ -725,6 +957,7 @@ Object.defineProperty(exports, "docsEqual", { enumerable: true, get: function ()
725
957
  Object.defineProperty(exports, "emptyDocument", { enumerable: true, get: function () { return doc_utils_js_1.emptyDocument; } });
726
958
  Object.defineProperty(exports, "hasMark", { enumerable: true, get: function () { return doc_utils_js_1.hasMark; } });
727
959
  Object.defineProperty(exports, "selectionHasMark", { enumerable: true, get: function () { return doc_utils_js_1.selectionHasMark; } });
960
+ Object.defineProperty(exports, "selectionMarkAttrs", { enumerable: true, get: function () { return doc_utils_js_1.selectionMarkAttrs; } });
728
961
  Object.defineProperty(exports, "toggleMarkInDoc", { enumerable: true, get: function () { return doc_utils_js_1.toggleMarkInDoc; } });
729
962
  Object.defineProperty(exports, "setMarkAcrossSelection", { enumerable: true, get: function () { return doc_utils_js_1.setMarkAcrossSelection; } });
730
963
  Object.defineProperty(exports, "unsetMarkAcrossSelection", { enumerable: true, get: function () { return doc_utils_js_1.unsetMarkAcrossSelection; } });
@@ -800,7 +1033,7 @@ function createEditor(options) {
800
1033
  }
801
1034
  class Editor {
802
1035
  constructor(options) {
803
- var _a, _b, _c;
1036
+ var _a, _b, _c, _d;
804
1037
  this.selection = null;
805
1038
  this.history = new history_js_1.History();
806
1039
  this.emitter = new events_js_1.EventEmitter();
@@ -830,10 +1063,13 @@ class Editor {
830
1063
  // Capture the finished composed text in one shot.
831
1064
  this.handleDOMInput();
832
1065
  };
833
- this.options = options;
834
- this.element = (_a = options.element) !== null && _a !== void 0 ? _a : null;
1066
+ this.options = {
1067
+ ...options,
1068
+ theme: (_a = options.theme) !== null && _a !== void 0 ? _a : "light",
1069
+ };
1070
+ this.element = (_b = options.element) !== null && _b !== void 0 ? _b : null;
835
1071
  // 1. Resolve extensions
836
- this.extensions = (0, schema_js_1.flattenExtensions)((_b = options.extensions) !== null && _b !== void 0 ? _b : []);
1072
+ this.extensions = (0, schema_js_1.flattenExtensions)((_c = options.extensions) !== null && _c !== void 0 ? _c : []);
837
1073
  // 2. Compile schema
838
1074
  this.schema = (0, schema_js_1.compileSchema)(this.extensions);
839
1075
  // 3. Normalize initial content into JSON
@@ -842,7 +1078,7 @@ class Editor {
842
1078
  this.commands = this.buildCommandMap();
843
1079
  // 5. Lifecycle: onCreate for each extension
844
1080
  for (const ext of this.extensions) {
845
- (_c = ext.onCreate) === null || _c === void 0 ? void 0 : _c.call(ext, this);
1081
+ (_d = ext.onCreate) === null || _d === void 0 ? void 0 : _d.call(ext, this);
846
1082
  }
847
1083
  // 6. Mount to DOM if an element was given
848
1084
  if (this.element) {
@@ -1070,6 +1306,11 @@ class Editor {
1070
1306
  }
1071
1307
  return map;
1072
1308
  }
1309
+ getMarkAttributes(name) {
1310
+ if (!this.schema.marks[name])
1311
+ return null;
1312
+ return (0, doc_utils_js_1.selectionMarkAttrs)(this.doc, this.selection, name);
1313
+ }
1073
1314
  isActive(name, attrs) {
1074
1315
  var _a, _b;
1075
1316
  // Mark active check
@@ -1129,9 +1370,10 @@ class Editor {
1129
1370
  this.attachToElement();
1130
1371
  }
1131
1372
  attachToElement() {
1132
- var _a;
1373
+ var _a, _b;
1133
1374
  const el = this.element;
1134
1375
  el.setAttribute("contenteditable", String(this.options.editable !== false));
1376
+ el.setAttribute("data-theme", (_a = this.options.theme) !== null && _a !== void 0 ? _a : "light");
1135
1377
  el.classList.add("smeditor-editor");
1136
1378
  el.addEventListener("input", this.onDomInput);
1137
1379
  el.addEventListener("beforeinput", this.onDomBeforeInput);
@@ -1145,7 +1387,7 @@ class Editor {
1145
1387
  // Keep the model selection in sync in both regular DOM and Shadow DOM.
1146
1388
  // Chromium exposes the live shadow selection on ShadowRoot; listening to
1147
1389
  // that root as well as Document avoids missing drag-selection events.
1148
- const root = (_a = el.getRootNode) === null || _a === void 0 ? void 0 : _a.call(el);
1390
+ const root = (_b = el.getRootNode) === null || _b === void 0 ? void 0 : _b.call(el);
1149
1391
  const targets = [document];
1150
1392
  if (root && root !== document && "addEventListener" in root)
1151
1393
  targets.push(root);
@@ -1686,6 +1928,8 @@ function sanitizePastedStyleValue(name, value) {
1686
1928
  if (name === "background-color" || name === "background") {
1687
1929
  return (0, css_js_1.sanitizeCSSColor)(value);
1688
1930
  }
1931
+ if (name === "text-decoration-color")
1932
+ return (0, css_js_1.sanitizeCSSColor)(value);
1689
1933
  if (name === "font-family")
1690
1934
  return (0, css_js_1.sanitizeCSSFontFamily)(value);
1691
1935
  if (name === "font-size")
@@ -2067,33 +2311,66 @@ exports.parseHTML = parseHTML;
2067
2311
  function serializeToHTML(doc, schema) {
2068
2312
  if (!doc || doc.type !== "doc" || !doc.content)
2069
2313
  return "";
2070
- return doc.content.map((node) => renderNode(node, schema)).join("");
2314
+ return renderChildren(doc.content, schema);
2071
2315
  }
2072
- function renderNode(node, schema) {
2073
- var _a, _b, _c;
2074
- if (node.type === "text") {
2075
- let html = escapeHTML((_a = node.text) !== null && _a !== void 0 ? _a : "");
2076
- // Wrap text in mark elements, innermost first.
2077
- if (node.marks && node.marks.length > 0) {
2078
- for (const mark of node.marks) {
2079
- const spec = schema.marks[mark.type];
2080
- if (!(spec === null || spec === void 0 ? void 0 : spec.toDOM))
2081
- continue;
2082
- html = wrapWithSpec(spec.toDOM(mark), html);
2083
- }
2316
+ /**
2317
+ * Marks of a node in a stable outer→inner order (`rank`, then schema
2318
+ * registration order). A fixed order means `<mark><span style="color">` never flips
2319
+ * to `<span style="color"><mark>` between renders, which used to make
2320
+ * the DOM churn (and the caret jump) after every formatting change.
2321
+ */
2322
+ function orderedMarks(node, schema) {
2323
+ var _a;
2324
+ const order = Object.keys(schema.marks);
2325
+ return ((_a = node.marks) !== null && _a !== void 0 ? _a : [])
2326
+ .filter((mark) => { var _a; return Boolean((_a = schema.marks[mark.type]) === null || _a === void 0 ? void 0 : _a.toDOM); })
2327
+ .sort((a, b) => {
2328
+ var _a, _b;
2329
+ return ((_a = schema.marks[a.type].rank) !== null && _a !== void 0 ? _a : 0) - ((_b = schema.marks[b.type].rank) !== null && _b !== void 0 ? _b : 0) ||
2330
+ order.indexOf(a.type) - order.indexOf(b.type);
2331
+ });
2332
+ }
2333
+ function markKey(mark) {
2334
+ var _a;
2335
+ return mark ? `${mark.type}:${JSON.stringify((_a = mark.attrs) !== null && _a !== void 0 ? _a : {})}` : "";
2336
+ }
2337
+ /**
2338
+ * Render sibling nodes, merging runs that share the same mark into one
2339
+ * element: "a", "b" both highlighted render as `<mark>ab</mark>`, not
2340
+ * `<mark>a</mark><mark>b</mark>` (which showed visible seams between the
2341
+ * pieces of one highlight / background / outline).
2342
+ */
2343
+ function renderChildren(nodes, schema, depth = 0) {
2344
+ let out = "";
2345
+ let i = 0;
2346
+ while (i < nodes.length) {
2347
+ const mark = orderedMarks(nodes[i], schema)[depth];
2348
+ if (!mark) {
2349
+ out += renderNode(nodes[i], schema);
2350
+ i += 1;
2351
+ continue;
2084
2352
  }
2085
- return html;
2353
+ const key = markKey(mark);
2354
+ let j = i + 1;
2355
+ while (j < nodes.length && markKey(orderedMarks(nodes[j], schema)[depth]) === key)
2356
+ j += 1;
2357
+ const inner = renderChildren(nodes.slice(i, j), schema, depth + 1);
2358
+ out += wrapWithSpec(schema.marks[mark.type].toDOM(mark), inner);
2359
+ i = j;
2086
2360
  }
2361
+ return out;
2362
+ }
2363
+ function renderNode(node, schema) {
2364
+ var _a, _b, _c;
2365
+ // Marks are applied by renderChildren, which groups equal neighbours.
2366
+ if (node.type === "text")
2367
+ return escapeHTML((_a = node.text) !== null && _a !== void 0 ? _a : "");
2087
2368
  const spec = schema.nodes[node.type];
2088
2369
  if (!(spec === null || spec === void 0 ? void 0 : spec.toDOM)) {
2089
2370
  // Unknown node — render children if any.
2090
- return ((_b = node.content) !== null && _b !== void 0 ? _b : [])
2091
- .map((c) => renderNode(c, schema))
2092
- .join("");
2371
+ return renderChildren((_b = node.content) !== null && _b !== void 0 ? _b : [], schema);
2093
2372
  }
2094
- let inner = ((_c = node.content) !== null && _c !== void 0 ? _c : [])
2095
- .map((c) => renderNode(c, schema))
2096
- .join("");
2373
+ let inner = renderChildren((_c = node.content) !== null && _c !== void 0 ? _c : [], schema);
2097
2374
  // An empty leaf block (a blank paragraph, an empty table cell) must
2098
2375
  // still render with a <br> inside it — otherwise the element
2099
2376
  // collapses to zero height in contenteditable and the caret can't
@@ -2843,6 +3120,7 @@ exports.stripBlockPrefix = stripBlockPrefix;
2843
3120
  exports.forEachTextNode = forEachTextNode;
2844
3121
  exports.hasMark = hasMark;
2845
3122
  exports.selectionHasMark = selectionHasMark;
3123
+ exports.selectionMarkAttrs = selectionMarkAttrs;
2846
3124
  exports.textsInSelection = textsInSelection;
2847
3125
  exports.toggleMarkInDoc = toggleMarkInDoc;
2848
3126
  exports.setMarkAcrossSelection = setMarkAcrossSelection;
@@ -2979,6 +3257,23 @@ function selectionHasMark(doc, selection, type, attrs) {
2979
3257
  return false;
2980
3258
  return texts.every((t) => hasMark(t, type, attrs));
2981
3259
  }
3260
+ /**
3261
+ * Attributes of a mark shared by the whole selection (the word under a
3262
+ * bare caret). Returns the attrs of the first matching mark when every
3263
+ * selected text node carries a mark of `type`, otherwise null. Toolbars
3264
+ * use it to show the current colour / font / size of the selection.
3265
+ */
3266
+ function selectionMarkAttrs(doc, selection, type) {
3267
+ var _a;
3268
+ if (!selection)
3269
+ return null;
3270
+ const texts = leafRangesInSelection(doc, selection).flatMap(({ node, from, to }) => { var _a; return textNodesInInlineRange((_a = node.content) !== null && _a !== void 0 ? _a : [], from, to); });
3271
+ const marks = texts.map((t) => { var _a, _b; return (_b = (_a = t.marks) === null || _a === void 0 ? void 0 : _a.find((m) => m.type === type)) !== null && _b !== void 0 ? _b : null; });
3272
+ const first = marks[0];
3273
+ if (!first || marks.some((m) => m === null))
3274
+ return null;
3275
+ return { ...((_a = first.attrs) !== null && _a !== void 0 ? _a : {}) };
3276
+ }
2982
3277
  /** Collect every text node intersecting the selection. */
2983
3278
  function textsInSelection(doc, selection) {
2984
3279
  const result = [];
@@ -3091,9 +3386,8 @@ function toggleMarkInDoc(doc, selection, type, attrs) {
3091
3386
  * Behaviour:
3092
3387
  * - a real range → the mark is set on just that span, replacing any
3093
3388
  * existing mark of the same type so colours/sizes swap cleanly;
3094
- * - a collapsed caret → the mark is applied to the whole block the
3095
- * caret sits in, so a toolbar click with no selection still does
3096
- * something predictable.
3389
+ * - a collapsed caret → the mark is applied to the word under the
3390
+ * caret (the whole block only when no word is there).
3097
3391
  *
3098
3392
  * Returns a new document, or null when nothing changed.
3099
3393
  */
@@ -3164,8 +3458,12 @@ function leafRangesInSelection(doc, selection) {
3164
3458
  let from;
3165
3459
  let to;
3166
3460
  if (collapsed) {
3167
- from = 0;
3168
- to = len;
3461
+ // A bare caret targets the word it sits in (or touches), like
3462
+ // Google Docs / Word. Only when there is no word under the caret
3463
+ // (empty block, caret between spaces) does the whole block apply.
3464
+ const word = wordRangeAt(node, Math.min(start.offset, len));
3465
+ from = word ? word[0] : 0;
3466
+ to = word ? word[1] : len;
3169
3467
  }
3170
3468
  else {
3171
3469
  const isStart = samePath(path, start.path);
@@ -3177,6 +3475,21 @@ function leafRangesInSelection(doc, selection) {
3177
3475
  })
3178
3476
  .filter(({ from, to }) => to > from);
3179
3477
  }
3478
+ const WORD_CHAR = /[\p{L}\p{N}_'\u2019-]/u;
3479
+ /**
3480
+ * The [from, to) visible-offset range of the word at `offset` in a leaf
3481
+ * block, or null when the caret touches no word characters.
3482
+ */
3483
+ function wordRangeAt(block, offset) {
3484
+ const text = (0, transform_js_1.blockVisibleText)(block);
3485
+ let from = Math.max(0, Math.min(offset, text.length));
3486
+ let to = from;
3487
+ while (from > 0 && WORD_CHAR.test(text[from - 1]))
3488
+ from--;
3489
+ while (to < text.length && WORD_CHAR.test(text[to]))
3490
+ to++;
3491
+ return to > from ? [from, to] : null;
3492
+ }
3180
3493
  function inlineContentLength(content) {
3181
3494
  // Use the same visible-width measure as splitInlineAt / setMarkOnInlineRange:
3182
3495
  // an atomic inline node (a mention pill) contributes its full label
@@ -3714,6 +4027,7 @@ exports.sanitizeCSSTextStrokeWidth = sanitizeCSSTextStrokeWidth;
3714
4027
  exports.sanitizeURL = sanitizeURL;
3715
4028
  exports.sanitizeLinkTarget = sanitizeLinkTarget;
3716
4029
  exports.hardenLinkAttrs = hardenLinkAttrs;
4030
+ exports.contrastTextColor = contrastTextColor;
3717
4031
  const MAX_COLOR_LENGTH = 80;
3718
4032
  const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/;
3719
4033
  const CSS_IDENTIFIER = /^[a-zA-Z]+$/;
@@ -3909,6 +4223,33 @@ function normaliseProtocols(protocols) {
3909
4223
  .map((protocol) => protocol.trim().replace(/:$/, "").toLowerCase())
3910
4224
  .filter(Boolean));
3911
4225
  }
4226
+ const LIGHT_TEXT = "#ffffff";
4227
+ const DARK_TEXT = "#1a1a1f";
4228
+ function rgbChannels(color) {
4229
+ const hex = /^#([0-9a-f]{3,8})$/i.exec(color);
4230
+ if (hex) {
4231
+ const raw = hex[1];
4232
+ const full = raw.length <= 4 ? raw.slice(0, 3).split("").map((c) => c + c).join("") : raw.slice(0, 6);
4233
+ return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16));
4234
+ }
4235
+ const rgb = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i.exec(color);
4236
+ return rgb ? [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])] : null;
4237
+ }
4238
+ /**
4239
+ * A text colour that stays readable on `fill`: white on dark fills,
4240
+ * near-black on light ones (WCAG relative luminance). Formats it can't
4241
+ * measure (named colours, hsl) get white.
4242
+ */
4243
+ function contrastTextColor(fill) {
4244
+ const rgb = rgbChannels(String(fill).trim());
4245
+ if (!rgb)
4246
+ return LIGHT_TEXT;
4247
+ const [r, g, b] = rgb.map((v) => {
4248
+ const c = v / 255;
4249
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
4250
+ });
4251
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.4 ? DARK_TEXT : LIGHT_TEXT;
4252
+ }
3912
4253
 
3913
4254
  },{}],
3914
4255
  11:[function(require,module,exports){
@@ -4570,21 +4911,51 @@ exports.default = exports.ItalicExtension;
4570
4911
  /**
4571
4912
  * @smeditor/extension-underline
4572
4913
  *
4573
- * Renders to `<u>`; parses both `<u>` and the legacy CSS form
4914
+ * Renders to `<u>`, or `<u style="text-decoration-color: …">` when the
4915
+ * underline has its own colour; parses both `<u>` and the legacy CSS form
4574
4916
  * `<span style="text-decoration: underline">`. Shortcut: Mod-u.
4917
+ *
4918
+ * The underline renders innermost (rank 2), so an uncoloured underline
4919
+ * follows the colour of the text it sits under.
4575
4920
  */
4576
4921
  Object.defineProperty(exports, "__esModule", { value: true });
4577
4922
  exports.UnderlineExtension = void 0;
4578
4923
  const core_1 = require("@smeditor/core");
4924
+ function decorationColor(style) {
4925
+ const m = /(?:^|;)\s*text-decoration-color\s*:\s*([^;]+)/i.exec(style);
4926
+ return m ? (0, core_1.sanitizeCSSColor)(m[1].trim()) : null;
4927
+ }
4928
+ function colorAttrs(el) {
4929
+ var _a;
4930
+ const color = decorationColor((_a = el.getAttribute("style")) !== null && _a !== void 0 ? _a : "");
4931
+ return color ? { color } : {};
4932
+ }
4933
+ function applyUnderline(editor, color) {
4934
+ const selection = editor.getSelection();
4935
+ const next = (0, core_1.setMarkAcrossSelection)(editor.getJSON(), selection, {
4936
+ type: "underline",
4937
+ ...(color ? { attrs: { color } } : {}),
4938
+ });
4939
+ if (!next)
4940
+ return false;
4941
+ editor.dispatch({ doc: next, selection, addToHistory: true });
4942
+ return true;
4943
+ }
4579
4944
  exports.UnderlineExtension = {
4580
4945
  name: "underline",
4581
4946
  marks: [
4582
4947
  {
4583
4948
  name: "underline",
4584
4949
  inclusive: true,
4585
- toDOM: () => ["u", 0],
4950
+ rank: 2,
4951
+ attrs: { color: { default: null } },
4952
+ toDOM: (mark) => {
4953
+ var _a;
4954
+ const color = (0, core_1.sanitizeCSSColor)((_a = mark.attrs) === null || _a === void 0 ? void 0 : _a.color);
4955
+ return color ? ["u", { style: `text-decoration-color: ${color}` }, 0] : ["u", 0];
4956
+ },
4586
4957
  parseDOM: [
4587
- { tag: "u" },
4958
+ { tag: "u", getAttrs: (el) => colorAttrs(el) },
4588
4959
  {
4589
4960
  // Catch <span style="text-decoration: underline"> from MS Word
4590
4961
  // / Google Docs paste. We don't try to be exhaustive — common
@@ -4593,7 +4964,7 @@ exports.UnderlineExtension = {
4593
4964
  getAttrs: (el) => {
4594
4965
  var _a;
4595
4966
  const style = (_a = el.getAttribute("style")) !== null && _a !== void 0 ? _a : "";
4596
- return /text-decoration:\s*underline/i.test(style) ? null : false;
4967
+ return /text-decoration(?:-line)?:\s*[^;]*underline/i.test(style) ? colorAttrs(el) : false;
4597
4968
  },
4598
4969
  },
4599
4970
  ],
@@ -4611,6 +4982,16 @@ exports.UnderlineExtension = {
4611
4982
  });
4612
4983
  return true;
4613
4984
  },
4985
+ /** setUnderlineColor({ color }) — underlines the selection in `color`. */
4986
+ setUnderlineColor: (opts) => (editor) => {
4987
+ const color = (0, core_1.sanitizeCSSColor)(opts === null || opts === void 0 ? void 0 : opts.color);
4988
+ return color ? applyUnderline(editor, color) : false;
4989
+ },
4990
+ /**
4991
+ * unsetUnderlineColor() — keeps the underline but drops its own
4992
+ * colour, so it follows the text colour again.
4993
+ */
4994
+ unsetUnderlineColor: () => (editor) => editor.isActive("underline") ? applyUnderline(editor, null) : false,
4614
4995
  },
4615
4996
  keyboardShortcuts: {
4616
4997
  "Mod-u": (editor) => {
@@ -4639,6 +5020,8 @@ exports.StrikeExtension = {
4639
5020
  marks: [
4640
5021
  {
4641
5022
  name: "strike",
5023
+ // Innermost, so the line takes the colour of the text it crosses.
5024
+ rank: 2,
4642
5025
  inclusive: true,
4643
5026
  toDOM: () => ["s", 0],
4644
5027
  parseDOM: [
@@ -8603,8 +8986,10 @@ exports.default = exports.SuperscriptExtension;
8603
8986
  *
8604
8987
  * Background-color highlighter mark. Renders as `<mark>` (semantic).
8605
8988
  * Optional `color` attr — when set, becomes
8606
- * `<mark style="background-color: COLOR">`. When unset, plain `<mark>`
8607
- * inherits the browser's default yellow.
8989
+ * `<mark style="background-color: COLOR; color: TEXT">`, where TEXT keeps
8990
+ * the text readable on the highlight: white on dark colours, near-black
8991
+ * on light ones, or `textColor` when given. When unset, plain `<mark>`
8992
+ * uses the theme's default yellow.
8608
8993
  *
8609
8994
  * Security: only safe-looking CSS color tokens are accepted (hex,
8610
8995
  * rgb/rgba, hsl/hsla, named keywords). Anything else is rejected at
@@ -8619,25 +9004,29 @@ exports.HighlightExtension = {
8619
9004
  {
8620
9005
  name: "highlight",
8621
9006
  inclusive: true,
8622
- attrs: { color: { default: null } },
9007
+ attrs: { color: { default: null }, textColor: { default: null } },
8623
9008
  toDOM: (mark) => {
8624
- var _a;
9009
+ var _a, _b, _c;
8625
9010
  const color = (0, core_1.sanitizeCSSColor)((_a = mark.attrs) === null || _a === void 0 ? void 0 : _a.color);
8626
- return color
8627
- ? ["mark", { style: `background-color: ${color}` }, 0]
8628
- : ["mark", 0];
9011
+ if (!color)
9012
+ return ["mark", 0];
9013
+ const text = (_c = (0, core_1.sanitizeCSSColor)((_b = mark.attrs) === null || _b === void 0 ? void 0 : _b.textColor)) !== null && _c !== void 0 ? _c : (0, core_1.contrastTextColor)(color);
9014
+ return ["mark", { style: `background-color: ${color}; color: ${text}` }, 0];
8629
9015
  },
8630
9016
  parseDOM: [
8631
9017
  {
8632
9018
  tag: "mark",
8633
9019
  getAttrs: (el) => {
8634
- var _a;
9020
+ var _a, _b, _c;
8635
9021
  const style = (_a = el.getAttribute("style")) !== null && _a !== void 0 ? _a : "";
8636
9022
  const m = /background(?:-color)?\s*:\s*([^;]+)/i.exec(style);
8637
9023
  if (!m)
8638
9024
  return null;
8639
9025
  const color = (0, core_1.sanitizeCSSColor)(m[1]);
8640
- return color ? { color } : false;
9026
+ if (!color)
9027
+ return false;
9028
+ const text = /(?:^|;)\s*color\s*:\s*([^;]+)/i.exec(style);
9029
+ return { color, textColor: (_c = (0, core_1.sanitizeCSSColor)((_b = text === null || text === void 0 ? void 0 : text[1]) === null || _b === void 0 ? void 0 : _b.trim())) !== null && _c !== void 0 ? _c : (0, core_1.contrastTextColor)(color) };
8641
9030
  },
8642
9031
  },
8643
9032
  ],
@@ -8645,19 +9034,24 @@ exports.HighlightExtension = {
8645
9034
  ],
8646
9035
  commands: {
8647
9036
  /**
8648
- * setHighlight({ color }) — highlights the selected text. With a
8649
- * colour it applies (and replaces) a coloured highlight on just
8650
- * the selected range; without one it toggles a plain `<mark>`.
9037
+ * setHighlight({ color, textColor? }) — highlights the selected text.
9038
+ * With a colour it applies (and replaces) a coloured highlight on just
9039
+ * the selected range, making the text readable on it (automatic
9040
+ * white / near-black, or `textColor`) and removing any text colour or
9041
+ * fill there; without one it toggles a plain `<mark>`.
8651
9042
  */
8652
9043
  setHighlight: (opts = {}) => (editor) => {
9044
+ var _a;
8653
9045
  const color = opts.color ? (0, core_1.sanitizeCSSColor)(opts.color) : null;
8654
9046
  const selection = editor.getSelection();
8655
9047
  if (!selection)
8656
9048
  return false;
8657
9049
  if (color) {
8658
- const next = (0, core_1.setMarkAcrossSelection)(editor.getJSON(), selection, {
9050
+ const textColor = (_a = (0, core_1.sanitizeCSSColor)(opts.textColor)) !== null && _a !== void 0 ? _a : (0, core_1.contrastTextColor)(color);
9051
+ const cleared = ["text_color", "background_color"].reduce((doc, mark) => { var _a; return (_a = (0, core_1.unsetMarkAcrossSelection)(doc, selection, mark)) !== null && _a !== void 0 ? _a : doc; }, editor.getJSON());
9052
+ const next = (0, core_1.setMarkAcrossSelection)(cleared, selection, {
8659
9053
  type: "highlight",
8660
- attrs: { color },
9054
+ attrs: { color, textColor },
8661
9055
  });
8662
9056
  if (!next)
8663
9057
  return false;
@@ -8706,6 +9100,9 @@ exports.TextColorExtension = {
8706
9100
  marks: [
8707
9101
  {
8708
9102
  name: "text_color",
9103
+ // Inside fills, so an explicit text colour wins over the fill's
9104
+ // automatic one.
9105
+ rank: 1,
8709
9106
  inclusive: true,
8710
9107
  attrs: { color: { default: null } },
8711
9108
  toDOM: (mark) => {
@@ -8717,8 +9114,16 @@ exports.TextColorExtension = {
8717
9114
  {
8718
9115
  tag: "*",
8719
9116
  getAttrs: (el) => {
8720
- var _a;
8721
- const style = (_a = el.getAttribute("style")) !== null && _a !== void 0 ? _a : "";
9117
+ var _a, _b;
9118
+ // A fill span's colour is the fill's readable text colour,
9119
+ // not a separate text colour (see extension-background-color).
9120
+ // Same for a coloured <mark>: its colour belongs to the highlight.
9121
+ if (el.tagName.toLowerCase() === "mark")
9122
+ return false;
9123
+ const classes = ((_a = el.getAttribute("class")) !== null && _a !== void 0 ? _a : "").split(/\s+/);
9124
+ if (classes.includes("smeditor-fill") || classes.includes("smeditor-text-outline"))
9125
+ return false;
9126
+ const style = (_b = el.getAttribute("style")) !== null && _b !== void 0 ? _b : "";
8722
9127
  // Match `color:` only at the start of a declaration so a
8723
9128
  // `background-color:` declaration is never mistaken for it.
8724
9129
  const m = /(?:^|;)\s*color\s*:\s*([^;]+)/i.exec(style);
@@ -8872,27 +9277,50 @@ exports.default = exports.TextStrokeExtension;
8872
9277
  /**
8873
9278
  * @smeditor/extension-background-color
8874
9279
  *
8875
- * Inline background color — `<span style="background-color: …">`.
8876
- * Distinct from `highlight` (`<mark>`): highlight is semantic, this
8877
- * is purely cosmetic so it doesn't render with `<mark>`'s default
8878
- * yellow.
9280
+ * Fills the area behind the selected text and keeps the text readable on
9281
+ * it: the text becomes white on dark fills and near-black on light ones
9282
+ * (or a colour chosen explicitly). Renders as
9283
+ * `<span class="smeditor-fill" style="background-color: …; color: …">`.
9284
+ *
9285
+ * The class tells the text-colour mark that this span's `color` belongs
9286
+ * to the fill, so it isn't parsed as a separate text colour. Plain
9287
+ * `background-color` spans (older content, pastes) are still read; their
9288
+ * text gets the automatic contrast colour.
9289
+ *
9290
+ * Distinct from `highlight` (`<mark>`): highlight is a semantic marker
9291
+ * and doesn't touch the text colour.
8879
9292
  */
8880
9293
  Object.defineProperty(exports, "__esModule", { value: true });
8881
- exports.BackgroundColorExtension = void 0;
9294
+ exports.BackgroundColorExtension = exports.FILL_CLASS = void 0;
9295
+ exports.isFillElement = isFillElement;
8882
9296
  const core_1 = require("@smeditor/core");
9297
+ exports.FILL_CLASS = "smeditor-fill";
9298
+ /** Class used for filled text by 0.3.2 of the Rails adapter; read as a fill. */
9299
+ const LEGACY_FILL_CLASSES = [exports.FILL_CLASS, "smeditor-text-outline"];
9300
+ /** True when an element is a fill span rendered by this extension. */
9301
+ function isFillElement(el) {
9302
+ var _a;
9303
+ const classes = ((_a = el.getAttribute("class")) !== null && _a !== void 0 ? _a : "").split(/\s+/);
9304
+ return LEGACY_FILL_CLASSES.some((name) => classes.includes(name));
9305
+ }
9306
+ function declaration(style, name) {
9307
+ const m = new RegExp(`(?:^|;)\\s*${name}\\s*:\\s*([^;]+)`, "i").exec(style);
9308
+ return m ? m[1].trim() : null;
9309
+ }
8883
9310
  exports.BackgroundColorExtension = {
8884
9311
  name: "background_color",
8885
9312
  marks: [
8886
9313
  {
8887
9314
  name: "background_color",
8888
9315
  inclusive: true,
8889
- attrs: { color: { default: null } },
9316
+ attrs: { color: { default: null }, textColor: { default: null } },
8890
9317
  toDOM: (mark) => {
8891
- var _a;
9318
+ var _a, _b, _c;
8892
9319
  const color = (0, core_1.sanitizeCSSColor)((_a = mark.attrs) === null || _a === void 0 ? void 0 : _a.color);
8893
- return color
8894
- ? ["span", { style: `background-color: ${color}` }, 0]
8895
- : ["span", 0];
9320
+ if (!color)
9321
+ return ["span", 0];
9322
+ const text = (_c = (0, core_1.sanitizeCSSColor)((_b = mark.attrs) === null || _b === void 0 ? void 0 : _b.textColor)) !== null && _c !== void 0 ? _c : (0, core_1.contrastTextColor)(color);
9323
+ return ["span", { class: exports.FILL_CLASS, style: `background-color: ${color}; color: ${text}` }, 0];
8896
9324
  },
8897
9325
  parseDOM: [
8898
9326
  {
@@ -8902,26 +9330,34 @@ exports.BackgroundColorExtension = {
8902
9330
  if (el.tagName.toLowerCase() === "mark")
8903
9331
  return false;
8904
9332
  const style = (_a = el.getAttribute("style")) !== null && _a !== void 0 ? _a : "";
8905
- const m = /background(?:-color)?\s*:\s*([^;]+)/i.exec(style);
8906
- if (!m)
9333
+ const color = (0, core_1.sanitizeCSSColor)(declaration(style, "background(?:-color)?"));
9334
+ if (!color)
8907
9335
  return false;
8908
- const color = (0, core_1.sanitizeCSSColor)(m[1]);
8909
- return color ? { color } : false;
9336
+ const text = isFillElement(el) ? (0, core_1.sanitizeCSSColor)(declaration(style, "color")) : null;
9337
+ return { color, textColor: text !== null && text !== void 0 ? text : (0, core_1.contrastTextColor)(color) };
8910
9338
  },
8911
9339
  },
8912
9340
  ],
8913
9341
  },
8914
9342
  ],
8915
9343
  commands: {
8916
- /** setBackgroundColor({ color }) — applies to the selected range. */
9344
+ /**
9345
+ * setBackgroundColor({ color, textColor? }) — fills the selected text.
9346
+ * The text becomes `textColor`, or white / near-black automatically
9347
+ * for contrast; any text colour or highlight on the range is removed
9348
+ * so nothing paints over the fill.
9349
+ */
8917
9350
  setBackgroundColor: (opts) => (editor) => {
9351
+ var _a;
8918
9352
  const color = (0, core_1.sanitizeCSSColor)(opts === null || opts === void 0 ? void 0 : opts.color);
8919
9353
  if (!color)
8920
9354
  return false;
9355
+ const textColor = (_a = (0, core_1.sanitizeCSSColor)(opts === null || opts === void 0 ? void 0 : opts.textColor)) !== null && _a !== void 0 ? _a : (0, core_1.contrastTextColor)(color);
8921
9356
  const selection = editor.getSelection();
8922
- const next = (0, core_1.setMarkAcrossSelection)(editor.getJSON(), selection, {
9357
+ const cleared = ["text_color", "highlight"].reduce((doc, mark) => { var _a; return (_a = (0, core_1.unsetMarkAcrossSelection)(doc, selection, mark)) !== null && _a !== void 0 ? _a : doc; }, editor.getJSON());
9358
+ const next = (0, core_1.setMarkAcrossSelection)(cleared, selection, {
8923
9359
  type: "background_color",
8924
- attrs: { color },
9360
+ attrs: { color, textColor },
8925
9361
  });
8926
9362
  if (!next)
8927
9363
  return false;