smeditor 0.3.0 → 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);
@@ -701,8 +926,8 @@ if (typeof window !== "undefined") {
701
926
  * stay internal so we can evolve them without breaking consumers.
702
927
  */
703
928
  Object.defineProperty(exports, "__esModule", { value: true });
704
- 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;
705
- 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;
706
931
  var editor_js_1 = require("./editor.js");
707
932
  Object.defineProperty(exports, "createEditor", { enumerable: true, get: function () { return editor_js_1.createEditor; } });
708
933
  var schema_js_1 = require("./schema.js");
@@ -714,6 +939,7 @@ Object.defineProperty(exports, "parseHTML", { enumerable: true, get: function ()
714
939
  Object.defineProperty(exports, "escapeHTML", { enumerable: true, get: function () { return serializer_js_1.escapeHTML; } });
715
940
  var css_js_1 = require("./css.js");
716
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; } });
717
943
  Object.defineProperty(exports, "sanitizeCSSFontFamily", { enumerable: true, get: function () { return css_js_1.sanitizeCSSFontFamily; } });
718
944
  Object.defineProperty(exports, "sanitizeCSSFontSize", { enumerable: true, get: function () { return css_js_1.sanitizeCSSFontSize; } });
719
945
  Object.defineProperty(exports, "sanitizeCSSLineHeight", { enumerable: true, get: function () { return css_js_1.sanitizeCSSLineHeight; } });
@@ -731,6 +957,7 @@ Object.defineProperty(exports, "docsEqual", { enumerable: true, get: function ()
731
957
  Object.defineProperty(exports, "emptyDocument", { enumerable: true, get: function () { return doc_utils_js_1.emptyDocument; } });
732
958
  Object.defineProperty(exports, "hasMark", { enumerable: true, get: function () { return doc_utils_js_1.hasMark; } });
733
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; } });
734
961
  Object.defineProperty(exports, "toggleMarkInDoc", { enumerable: true, get: function () { return doc_utils_js_1.toggleMarkInDoc; } });
735
962
  Object.defineProperty(exports, "setMarkAcrossSelection", { enumerable: true, get: function () { return doc_utils_js_1.setMarkAcrossSelection; } });
736
963
  Object.defineProperty(exports, "unsetMarkAcrossSelection", { enumerable: true, get: function () { return doc_utils_js_1.unsetMarkAcrossSelection; } });
@@ -1079,6 +1306,11 @@ class Editor {
1079
1306
  }
1080
1307
  return map;
1081
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
+ }
1082
1314
  isActive(name, attrs) {
1083
1315
  var _a, _b;
1084
1316
  // Mark active check
@@ -1696,6 +1928,8 @@ function sanitizePastedStyleValue(name, value) {
1696
1928
  if (name === "background-color" || name === "background") {
1697
1929
  return (0, css_js_1.sanitizeCSSColor)(value);
1698
1930
  }
1931
+ if (name === "text-decoration-color")
1932
+ return (0, css_js_1.sanitizeCSSColor)(value);
1699
1933
  if (name === "font-family")
1700
1934
  return (0, css_js_1.sanitizeCSSFontFamily)(value);
1701
1935
  if (name === "font-size")
@@ -2077,33 +2311,66 @@ exports.parseHTML = parseHTML;
2077
2311
  function serializeToHTML(doc, schema) {
2078
2312
  if (!doc || doc.type !== "doc" || !doc.content)
2079
2313
  return "";
2080
- return doc.content.map((node) => renderNode(node, schema)).join("");
2314
+ return renderChildren(doc.content, schema);
2081
2315
  }
2082
- function renderNode(node, schema) {
2083
- var _a, _b, _c;
2084
- if (node.type === "text") {
2085
- let html = escapeHTML((_a = node.text) !== null && _a !== void 0 ? _a : "");
2086
- // Wrap text in mark elements, innermost first.
2087
- if (node.marks && node.marks.length > 0) {
2088
- for (const mark of node.marks) {
2089
- const spec = schema.marks[mark.type];
2090
- if (!(spec === null || spec === void 0 ? void 0 : spec.toDOM))
2091
- continue;
2092
- html = wrapWithSpec(spec.toDOM(mark), html);
2093
- }
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;
2094
2352
  }
2095
- 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;
2096
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 : "");
2097
2368
  const spec = schema.nodes[node.type];
2098
2369
  if (!(spec === null || spec === void 0 ? void 0 : spec.toDOM)) {
2099
2370
  // Unknown node — render children if any.
2100
- return ((_b = node.content) !== null && _b !== void 0 ? _b : [])
2101
- .map((c) => renderNode(c, schema))
2102
- .join("");
2371
+ return renderChildren((_b = node.content) !== null && _b !== void 0 ? _b : [], schema);
2103
2372
  }
2104
- let inner = ((_c = node.content) !== null && _c !== void 0 ? _c : [])
2105
- .map((c) => renderNode(c, schema))
2106
- .join("");
2373
+ let inner = renderChildren((_c = node.content) !== null && _c !== void 0 ? _c : [], schema);
2107
2374
  // An empty leaf block (a blank paragraph, an empty table cell) must
2108
2375
  // still render with a <br> inside it — otherwise the element
2109
2376
  // collapses to zero height in contenteditable and the caret can't
@@ -2853,6 +3120,7 @@ exports.stripBlockPrefix = stripBlockPrefix;
2853
3120
  exports.forEachTextNode = forEachTextNode;
2854
3121
  exports.hasMark = hasMark;
2855
3122
  exports.selectionHasMark = selectionHasMark;
3123
+ exports.selectionMarkAttrs = selectionMarkAttrs;
2856
3124
  exports.textsInSelection = textsInSelection;
2857
3125
  exports.toggleMarkInDoc = toggleMarkInDoc;
2858
3126
  exports.setMarkAcrossSelection = setMarkAcrossSelection;
@@ -2989,6 +3257,23 @@ function selectionHasMark(doc, selection, type, attrs) {
2989
3257
  return false;
2990
3258
  return texts.every((t) => hasMark(t, type, attrs));
2991
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
+ }
2992
3277
  /** Collect every text node intersecting the selection. */
2993
3278
  function textsInSelection(doc, selection) {
2994
3279
  const result = [];
@@ -3101,9 +3386,8 @@ function toggleMarkInDoc(doc, selection, type, attrs) {
3101
3386
  * Behaviour:
3102
3387
  * - a real range → the mark is set on just that span, replacing any
3103
3388
  * existing mark of the same type so colours/sizes swap cleanly;
3104
- * - a collapsed caret → the mark is applied to the whole block the
3105
- * caret sits in, so a toolbar click with no selection still does
3106
- * 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).
3107
3391
  *
3108
3392
  * Returns a new document, or null when nothing changed.
3109
3393
  */
@@ -3174,8 +3458,12 @@ function leafRangesInSelection(doc, selection) {
3174
3458
  let from;
3175
3459
  let to;
3176
3460
  if (collapsed) {
3177
- from = 0;
3178
- 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;
3179
3467
  }
3180
3468
  else {
3181
3469
  const isStart = samePath(path, start.path);
@@ -3187,6 +3475,21 @@ function leafRangesInSelection(doc, selection) {
3187
3475
  })
3188
3476
  .filter(({ from, to }) => to > from);
3189
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
+ }
3190
3493
  function inlineContentLength(content) {
3191
3494
  // Use the same visible-width measure as splitInlineAt / setMarkOnInlineRange:
3192
3495
  // an atomic inline node (a mention pill) contributes its full label
@@ -3724,6 +4027,7 @@ exports.sanitizeCSSTextStrokeWidth = sanitizeCSSTextStrokeWidth;
3724
4027
  exports.sanitizeURL = sanitizeURL;
3725
4028
  exports.sanitizeLinkTarget = sanitizeLinkTarget;
3726
4029
  exports.hardenLinkAttrs = hardenLinkAttrs;
4030
+ exports.contrastTextColor = contrastTextColor;
3727
4031
  const MAX_COLOR_LENGTH = 80;
3728
4032
  const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/;
3729
4033
  const CSS_IDENTIFIER = /^[a-zA-Z]+$/;
@@ -3919,6 +4223,33 @@ function normaliseProtocols(protocols) {
3919
4223
  .map((protocol) => protocol.trim().replace(/:$/, "").toLowerCase())
3920
4224
  .filter(Boolean));
3921
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
+ }
3922
4253
 
3923
4254
  },{}],
3924
4255
  11:[function(require,module,exports){
@@ -4580,21 +4911,51 @@ exports.default = exports.ItalicExtension;
4580
4911
  /**
4581
4912
  * @smeditor/extension-underline
4582
4913
  *
4583
- * 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
4584
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.
4585
4920
  */
4586
4921
  Object.defineProperty(exports, "__esModule", { value: true });
4587
4922
  exports.UnderlineExtension = void 0;
4588
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
+ }
4589
4944
  exports.UnderlineExtension = {
4590
4945
  name: "underline",
4591
4946
  marks: [
4592
4947
  {
4593
4948
  name: "underline",
4594
4949
  inclusive: true,
4595
- 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
+ },
4596
4957
  parseDOM: [
4597
- { tag: "u" },
4958
+ { tag: "u", getAttrs: (el) => colorAttrs(el) },
4598
4959
  {
4599
4960
  // Catch <span style="text-decoration: underline"> from MS Word
4600
4961
  // / Google Docs paste. We don't try to be exhaustive — common
@@ -4603,7 +4964,7 @@ exports.UnderlineExtension = {
4603
4964
  getAttrs: (el) => {
4604
4965
  var _a;
4605
4966
  const style = (_a = el.getAttribute("style")) !== null && _a !== void 0 ? _a : "";
4606
- return /text-decoration:\s*underline/i.test(style) ? null : false;
4967
+ return /text-decoration(?:-line)?:\s*[^;]*underline/i.test(style) ? colorAttrs(el) : false;
4607
4968
  },
4608
4969
  },
4609
4970
  ],
@@ -4621,6 +4982,16 @@ exports.UnderlineExtension = {
4621
4982
  });
4622
4983
  return true;
4623
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,
4624
4995
  },
4625
4996
  keyboardShortcuts: {
4626
4997
  "Mod-u": (editor) => {
@@ -4649,6 +5020,8 @@ exports.StrikeExtension = {
4649
5020
  marks: [
4650
5021
  {
4651
5022
  name: "strike",
5023
+ // Innermost, so the line takes the colour of the text it crosses.
5024
+ rank: 2,
4652
5025
  inclusive: true,
4653
5026
  toDOM: () => ["s", 0],
4654
5027
  parseDOM: [
@@ -8613,8 +8986,10 @@ exports.default = exports.SuperscriptExtension;
8613
8986
  *
8614
8987
  * Background-color highlighter mark. Renders as `<mark>` (semantic).
8615
8988
  * Optional `color` attr — when set, becomes
8616
- * `<mark style="background-color: COLOR">`. When unset, plain `<mark>`
8617
- * 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.
8618
8993
  *
8619
8994
  * Security: only safe-looking CSS color tokens are accepted (hex,
8620
8995
  * rgb/rgba, hsl/hsla, named keywords). Anything else is rejected at
@@ -8629,25 +9004,29 @@ exports.HighlightExtension = {
8629
9004
  {
8630
9005
  name: "highlight",
8631
9006
  inclusive: true,
8632
- attrs: { color: { default: null } },
9007
+ attrs: { color: { default: null }, textColor: { default: null } },
8633
9008
  toDOM: (mark) => {
8634
- var _a;
9009
+ var _a, _b, _c;
8635
9010
  const color = (0, core_1.sanitizeCSSColor)((_a = mark.attrs) === null || _a === void 0 ? void 0 : _a.color);
8636
- return color
8637
- ? ["mark", { style: `background-color: ${color}` }, 0]
8638
- : ["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];
8639
9015
  },
8640
9016
  parseDOM: [
8641
9017
  {
8642
9018
  tag: "mark",
8643
9019
  getAttrs: (el) => {
8644
- var _a;
9020
+ var _a, _b, _c;
8645
9021
  const style = (_a = el.getAttribute("style")) !== null && _a !== void 0 ? _a : "";
8646
9022
  const m = /background(?:-color)?\s*:\s*([^;]+)/i.exec(style);
8647
9023
  if (!m)
8648
9024
  return null;
8649
9025
  const color = (0, core_1.sanitizeCSSColor)(m[1]);
8650
- 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) };
8651
9030
  },
8652
9031
  },
8653
9032
  ],
@@ -8655,19 +9034,24 @@ exports.HighlightExtension = {
8655
9034
  ],
8656
9035
  commands: {
8657
9036
  /**
8658
- * setHighlight({ color }) — highlights the selected text. With a
8659
- * colour it applies (and replaces) a coloured highlight on just
8660
- * 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>`.
8661
9042
  */
8662
9043
  setHighlight: (opts = {}) => (editor) => {
9044
+ var _a;
8663
9045
  const color = opts.color ? (0, core_1.sanitizeCSSColor)(opts.color) : null;
8664
9046
  const selection = editor.getSelection();
8665
9047
  if (!selection)
8666
9048
  return false;
8667
9049
  if (color) {
8668
- 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, {
8669
9053
  type: "highlight",
8670
- attrs: { color },
9054
+ attrs: { color, textColor },
8671
9055
  });
8672
9056
  if (!next)
8673
9057
  return false;
@@ -8716,6 +9100,9 @@ exports.TextColorExtension = {
8716
9100
  marks: [
8717
9101
  {
8718
9102
  name: "text_color",
9103
+ // Inside fills, so an explicit text colour wins over the fill's
9104
+ // automatic one.
9105
+ rank: 1,
8719
9106
  inclusive: true,
8720
9107
  attrs: { color: { default: null } },
8721
9108
  toDOM: (mark) => {
@@ -8727,8 +9114,16 @@ exports.TextColorExtension = {
8727
9114
  {
8728
9115
  tag: "*",
8729
9116
  getAttrs: (el) => {
8730
- var _a;
8731
- 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 : "";
8732
9127
  // Match `color:` only at the start of a declaration so a
8733
9128
  // `background-color:` declaration is never mistaken for it.
8734
9129
  const m = /(?:^|;)\s*color\s*:\s*([^;]+)/i.exec(style);
@@ -8882,27 +9277,50 @@ exports.default = exports.TextStrokeExtension;
8882
9277
  /**
8883
9278
  * @smeditor/extension-background-color
8884
9279
  *
8885
- * Inline background color — `<span style="background-color: …">`.
8886
- * Distinct from `highlight` (`<mark>`): highlight is semantic, this
8887
- * is purely cosmetic so it doesn't render with `<mark>`'s default
8888
- * 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.
8889
9292
  */
8890
9293
  Object.defineProperty(exports, "__esModule", { value: true });
8891
- exports.BackgroundColorExtension = void 0;
9294
+ exports.BackgroundColorExtension = exports.FILL_CLASS = void 0;
9295
+ exports.isFillElement = isFillElement;
8892
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
+ }
8893
9310
  exports.BackgroundColorExtension = {
8894
9311
  name: "background_color",
8895
9312
  marks: [
8896
9313
  {
8897
9314
  name: "background_color",
8898
9315
  inclusive: true,
8899
- attrs: { color: { default: null } },
9316
+ attrs: { color: { default: null }, textColor: { default: null } },
8900
9317
  toDOM: (mark) => {
8901
- var _a;
9318
+ var _a, _b, _c;
8902
9319
  const color = (0, core_1.sanitizeCSSColor)((_a = mark.attrs) === null || _a === void 0 ? void 0 : _a.color);
8903
- return color
8904
- ? ["span", { style: `background-color: ${color}` }, 0]
8905
- : ["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];
8906
9324
  },
8907
9325
  parseDOM: [
8908
9326
  {
@@ -8912,26 +9330,34 @@ exports.BackgroundColorExtension = {
8912
9330
  if (el.tagName.toLowerCase() === "mark")
8913
9331
  return false;
8914
9332
  const style = (_a = el.getAttribute("style")) !== null && _a !== void 0 ? _a : "";
8915
- const m = /background(?:-color)?\s*:\s*([^;]+)/i.exec(style);
8916
- if (!m)
9333
+ const color = (0, core_1.sanitizeCSSColor)(declaration(style, "background(?:-color)?"));
9334
+ if (!color)
8917
9335
  return false;
8918
- const color = (0, core_1.sanitizeCSSColor)(m[1]);
8919
- 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) };
8920
9338
  },
8921
9339
  },
8922
9340
  ],
8923
9341
  },
8924
9342
  ],
8925
9343
  commands: {
8926
- /** 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
+ */
8927
9350
  setBackgroundColor: (opts) => (editor) => {
9351
+ var _a;
8928
9352
  const color = (0, core_1.sanitizeCSSColor)(opts === null || opts === void 0 ? void 0 : opts.color);
8929
9353
  if (!color)
8930
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);
8931
9356
  const selection = editor.getSelection();
8932
- 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, {
8933
9359
  type: "background_color",
8934
- attrs: { color },
9360
+ attrs: { color, textColor },
8935
9361
  });
8936
9362
  if (!next)
8937
9363
  return false;