tosijs-styled-editor 0.4.2 → 0.4.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.
package/dist/module.js CHANGED
@@ -250,6 +250,99 @@ function caretGeometryAt(marker, root) {
250
250
  }
251
251
  return null;
252
252
  }
253
+ function forSchemeTest(value) {
254
+ return value.replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g, "").replace(/[\u0000-\u001f\u007f]/g, "");
255
+ }
256
+ function isSafeUrl(value) {
257
+ const trimmed = forSchemeTest(value);
258
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(trimmed))
259
+ return true;
260
+ if (/^data:image\/(png|jpeg|jpg|gif|webp|avif|bmp|svg\+xml);/i.test(trimmed)) {
261
+ return !/^data:image\/svg/i.test(trimmed);
262
+ }
263
+ return /^(https?|mailto|tel):/i.test(trimmed);
264
+ }
265
+ var FORBIDDEN_TAGS = new Set([
266
+ "script",
267
+ "iframe",
268
+ "object",
269
+ "embed",
270
+ "link",
271
+ "meta",
272
+ "base",
273
+ "style",
274
+ "form",
275
+ "noscript",
276
+ "template",
277
+ "animate",
278
+ "animatetransform",
279
+ "animatemotion",
280
+ "set"
281
+ ]);
282
+ var URL_ATTRIBUTES = [
283
+ "href",
284
+ "src",
285
+ "srcset",
286
+ "xlink:href",
287
+ "action",
288
+ "formaction",
289
+ "ping",
290
+ "poster",
291
+ "background",
292
+ "cite",
293
+ "data",
294
+ "longdesc",
295
+ "profile",
296
+ "usemap",
297
+ "manifest"
298
+ ];
299
+ var DANGEROUS_SCHEME = /^(javascript|vbscript|livescript|mocha|data:text\/html)/i;
300
+ var FORBIDDEN_ATTRIBUTES = new Set(["ping"]);
301
+ var localNameGetter = Object.getOwnPropertyDescriptor(Element.prototype, "localName")?.get;
302
+ var attributesGetter = Object.getOwnPropertyDescriptor(Element.prototype, "attributes")?.get;
303
+ function getLocalName(el) {
304
+ return localNameGetter ? localNameGetter.call(el) : el.localName;
305
+ }
306
+ function getAttributes(el) {
307
+ return attributesGetter ? attributesGetter.call(el) : el.attributes;
308
+ }
309
+ function sanitizeInPlace(root) {
310
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
311
+ const doomed = [];
312
+ let node;
313
+ while (node = walker.nextNode()) {
314
+ const el = node;
315
+ const localName = String(getLocalName(el) ?? "").toLowerCase();
316
+ if (FORBIDDEN_TAGS.has(localName)) {
317
+ doomed.push(el);
318
+ continue;
319
+ }
320
+ for (const attr of Array.from(getAttributes(el) ?? [])) {
321
+ const name = attr.name.toLowerCase();
322
+ if (name.startsWith("on")) {
323
+ el.removeAttribute(attr.name);
324
+ continue;
325
+ }
326
+ if (FORBIDDEN_ATTRIBUTES.has(name)) {
327
+ el.removeAttribute(attr.name);
328
+ continue;
329
+ }
330
+ const normalized = forSchemeTest(attr.value);
331
+ if (DANGEROUS_SCHEME.test(normalized)) {
332
+ el.removeAttribute(attr.name);
333
+ continue;
334
+ }
335
+ if (URL_ATTRIBUTES.includes(name) && !isSafeUrl(attr.value)) {
336
+ el.removeAttribute(attr.name);
337
+ }
338
+ }
339
+ }
340
+ for (const el of doomed)
341
+ el.remove();
342
+ }
343
+ function isSafeNavigationUrl(value) {
344
+ return isSafeUrl(value) && !/^data:/i.test(forSchemeTest(value));
345
+ }
253
346
  // src/selection.ts
254
347
  function characters(text) {
255
348
  const Segmenter = Intl.Segmenter;
@@ -1173,6 +1266,10 @@ var commands = {
1173
1266
  setLink(ctx, url, target = "_blank") {
1174
1267
  if (!url)
1175
1268
  return;
1269
+ if (!isSafeNavigationUrl(url)) {
1270
+ console.error("setLink: refusing unsafe URL scheme", url);
1271
+ return;
1272
+ }
1176
1273
  ctx.selectable.resetBounds();
1177
1274
  spanify(ctx.root, false);
1178
1275
  ctx.selectable.markBounds();
@@ -1456,6 +1553,15 @@ var commands = {
1456
1553
  ctx.updateUndo("new");
1457
1554
  }
1458
1555
  };
1556
+ function runCommand(ctx, name, ...args) {
1557
+ const registry = ctx.commands ?? commands;
1558
+ const fn = registry[name];
1559
+ if (fn) {
1560
+ fn(ctx, ...args);
1561
+ } else {
1562
+ console.error("unrecognized command", name);
1563
+ }
1564
+ }
1459
1565
  function executeCommand(ctx, commandString) {
1460
1566
  const registry = ctx.commands ?? commands;
1461
1567
  const commandList = commandString.split(/;\s*/);
@@ -1669,7 +1775,7 @@ function insertMenu(editor) {
1669
1775
  if (!url)
1670
1776
  return;
1671
1777
  const target = prompt("Target (blank for a new tab)", "_blank");
1672
- editor.doCommand(`setLink ${url} ${target || "_blank"}`);
1778
+ editor.doCommandWith("setLink", url, target || "_blank");
1673
1779
  }
1674
1780
  },
1675
1781
  {
@@ -1684,7 +1790,7 @@ function insertMenu(editor) {
1684
1790
  if (!url)
1685
1791
  return;
1686
1792
  const alt = prompt("Alt text (describe the image)") || "";
1687
- editor.doCommand(`insertImage ${url} ${alt}`);
1793
+ editor.doCommandWith("insertImage", url, alt);
1688
1794
  }
1689
1795
  },
1690
1796
  null,
@@ -1821,6 +1927,7 @@ class TosijsStyledEditor extends WebComponent {
1821
1927
  cursor: "text",
1822
1928
  overflowY: "auto",
1823
1929
  position: "relative",
1930
+ touchAction: "manipulation",
1824
1931
  transition: "padding-top 0.15s ease-out, padding-bottom 0.15s ease-out"
1825
1932
  },
1826
1933
  ':host [part="doc"] > *': {
@@ -1870,6 +1977,8 @@ class TosijsStyledEditor extends WebComponent {
1870
1977
  margin: "0",
1871
1978
  border: "0",
1872
1979
  outline: "none",
1980
+ fontSize: "16px",
1981
+ lineHeight: "1",
1873
1982
  background: "var(--editor-text)",
1874
1983
  color: "transparent",
1875
1984
  caretColor: "transparent",
@@ -2149,6 +2258,7 @@ class TosijsStyledEditor extends WebComponent {
2149
2258
  }
2150
2259
  }
2151
2260
  commands = { ...commands };
2261
+ sanitize = sanitizeInPlace;
2152
2262
  content = [
2153
2263
  slot({
2154
2264
  name: "menubar",
@@ -2247,6 +2357,16 @@ class TosijsStyledEditor extends WebComponent {
2247
2357
  this.selectable.focusTarget = this.parts.caret;
2248
2358
  this.selectable.onBoundsChanged = () => this.syncCaret();
2249
2359
  this.syncCaret();
2360
+ this.parts.doc.addEventListener("scroll", this.handleGeometryChange, {
2361
+ capture: true,
2362
+ passive: true
2363
+ });
2364
+ window.addEventListener("resize", this.handleGeometryChange, {
2365
+ passive: true
2366
+ });
2367
+ this.geometryObserver = new ResizeObserver(this.handleGeometryChange);
2368
+ this.geometryObserver.observe(this.parts.doc, { box: "content-box" });
2369
+ this.geometryObserver.observe(this, { box: "border-box" });
2250
2370
  this.applyWidgets();
2251
2371
  this.trackBar(this.parts.menubar, "has-menubar");
2252
2372
  this.trackBar(this.parts.toolbar, "has-toolbar");
@@ -2280,7 +2400,19 @@ class TosijsStyledEditor extends WebComponent {
2280
2400
  disconnectedCallback() {
2281
2401
  super.disconnectedCallback();
2282
2402
  this.selectable?.destroy();
2403
+ this.parts?.doc?.removeEventListener("scroll", this.handleGeometryChange, {
2404
+ capture: true
2405
+ });
2406
+ window.removeEventListener("resize", this.handleGeometryChange);
2407
+ this.geometryObserver?.disconnect();
2283
2408
  }
2409
+ geometryObserver = null;
2410
+ handleGeometryChange = () => {
2411
+ this.syncCaret();
2412
+ if (this.touchAffordances?.style.display === "block") {
2413
+ this.positionAffordances();
2414
+ }
2415
+ };
2284
2416
  markerRect(marker) {
2285
2417
  const geometry = caretGeometryAt(marker, this.parts.doc);
2286
2418
  if (geometry) {
@@ -2303,7 +2435,7 @@ class TosijsStyledEditor extends WebComponent {
2303
2435
  const start = doc.querySelector(".sel-start");
2304
2436
  const end = doc.querySelector(".sel-end");
2305
2437
  const anchor = end || start;
2306
- const collapsed = doc.querySelectorAll(".selected").length === 0;
2438
+ const collapsed = doc.querySelector(".selected") === null;
2307
2439
  const host = this.getBoundingClientRect();
2308
2440
  const view = doc.getBoundingClientRect();
2309
2441
  const hostStyle = getComputedStyle(this);
@@ -2352,6 +2484,9 @@ class TosijsStyledEditor extends WebComponent {
2352
2484
  doCommand(commandString) {
2353
2485
  executeCommand(this.getContext(), commandString);
2354
2486
  }
2487
+ doCommandWith(name, ...args) {
2488
+ runCommand(this.getContext(), name, ...args);
2489
+ }
2355
2490
  focus() {
2356
2491
  this.selectable?.focus();
2357
2492
  }
@@ -2368,7 +2503,7 @@ class TosijsStyledEditor extends WebComponent {
2368
2503
  return Array.from(this.parts.doc.querySelectorAll(".selected-block"));
2369
2504
  }
2370
2505
  insertionPoint() {
2371
- return this.parts.doc.querySelector("input.caret");
2506
+ return this.parts.doc.querySelector(".sel-end");
2372
2507
  }
2373
2508
  block(node) {
2374
2509
  let current = node;
@@ -2394,10 +2529,9 @@ class TosijsStyledEditor extends WebComponent {
2394
2529
  moveCaretToCell(cell) {
2395
2530
  this.selectable.unmark();
2396
2531
  this.selectable.removeBounds();
2397
- const start = document.createElement("input");
2398
- start.className = "sel-start";
2399
- const end = document.createElement("input");
2400
- end.className = "sel-end caret";
2532
+ const bounds = this.selectable.createBounds();
2533
+ const end = bounds.lastChild;
2534
+ const start = bounds.firstChild;
2401
2535
  cell.insertBefore(start, cell.firstChild);
2402
2536
  cell.appendChild(end);
2403
2537
  this.selectable.normalize();
@@ -3171,14 +3305,18 @@ class TosijsStyledEditor extends WebComponent {
3171
3305
  if (!link || !this.parts.doc.contains(link))
3172
3306
  return;
3173
3307
  if (evt.metaKey || evt.ctrlKey) {
3174
- window.open(link.getAttribute("href") || "", link.getAttribute("target") || "_blank", "noopener");
3308
+ const href = link.getAttribute("href") || "";
3309
+ if (isSafeNavigationUrl(href)) {
3310
+ window.open(href, "_blank", "noopener");
3311
+ }
3175
3312
  }
3176
3313
  evt.preventDefault();
3177
3314
  };
3178
3315
  handleShortcut(evt) {
3179
3316
  const key = evt.key.toLowerCase();
3180
3317
  const shortcutStr = `${evt.ctrlKey || evt.metaKey ? "ctrl+" : ""}${key}`;
3181
- const btn = this.parts.toolbar.querySelector(`[data-shortcut="${shortcutStr}"]`);
3318
+ const escaped = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(shortcutStr) : shortcutStr.replace(/["\\]/g, "\\$&");
3319
+ const btn = this.parts.toolbar.querySelector(`[data-shortcut="${escaped}"]`);
3182
3320
  if (btn) {
3183
3321
  const value = btn.getAttribute("value");
3184
3322
  if (value) {
@@ -3388,6 +3526,7 @@ class TosijsStyledEditor extends WebComponent {
3388
3526
  } else {
3389
3527
  const temp = document.createElement("div");
3390
3528
  temp.innerHTML = html;
3529
+ this.sanitize(temp);
3391
3530
  while (temp.firstChild) {
3392
3531
  ip.before(temp.firstChild);
3393
3532
  }
@@ -3481,7 +3620,7 @@ class TosijsStyledEditor extends WebComponent {
3481
3620
  reader.readAsDataURL(file);
3482
3621
  });
3483
3622
  if (url)
3484
- this.doCommand(`insertImage ${url} ${file.name}`);
3623
+ this.doCommandWith("insertImage", url, file.name);
3485
3624
  }
3486
3625
  }
3487
3626
  cellEdgeAt(evt) {
@@ -3511,6 +3650,7 @@ class TosijsStyledEditor extends WebComponent {
3511
3650
  this.touchAffordances.style.display = "none";
3512
3651
  this.parts.doc.style.paddingTop = "";
3513
3652
  this.parts.doc.style.paddingBottom = "";
3653
+ this.syncCaret();
3514
3654
  return;
3515
3655
  }
3516
3656
  const selStart = this.parts.doc.querySelector(".sel-start");
@@ -3519,12 +3659,12 @@ class TosijsStyledEditor extends WebComponent {
3519
3659
  this.touchAffordances.style.display = "none";
3520
3660
  this.parts.doc.style.paddingTop = "";
3521
3661
  this.parts.doc.style.paddingBottom = "";
3662
+ this.syncCaret();
3522
3663
  return;
3523
3664
  }
3524
3665
  const docRect = this.parts.doc.getBoundingClientRect();
3525
3666
  const startRect = this.markerRect(selStart);
3526
3667
  const endRect = this.markerRect(selEnd);
3527
- let needsPaddingChange = false;
3528
3668
  if (this.touchDrags.size === 0) {
3529
3669
  const existingTopPad = parseFloat(this.parts.doc.style.paddingTop) || 0;
3530
3670
  const existingBottomPad = parseFloat(this.parts.doc.style.paddingBottom) || 0;
@@ -3535,23 +3675,13 @@ class TosijsStyledEditor extends WebComponent {
3535
3675
  const hadTop = this.parts.doc.style.paddingTop;
3536
3676
  const hadBottom = this.parts.doc.style.paddingBottom;
3537
3677
  if (wantTop !== hadTop || wantBottom !== hadBottom) {
3538
- needsPaddingChange = true;
3539
3678
  this.parts.doc.style.paddingTop = wantTop;
3540
3679
  this.parts.doc.style.paddingBottom = wantBottom;
3541
3680
  }
3542
3681
  }
3543
- if (needsPaddingChange) {
3544
- this.touchAffordances.style.opacity = "0";
3545
- this.touchAffordances.style.display = "block";
3546
- setTimeout(() => {
3547
- this.positionAffordances();
3548
- this.touchAffordances.style.opacity = "1";
3549
- }, 160);
3550
- } else {
3551
- this.positionAffordances();
3552
- this.touchAffordances.style.display = "block";
3553
- this.touchAffordances.style.opacity = "1";
3554
- }
3682
+ this.positionAffordances();
3683
+ this.touchAffordances.style.display = "block";
3684
+ this.touchAffordances.style.opacity = "1";
3555
3685
  }
3556
3686
  positionAffordances() {
3557
3687
  const selStart = this.parts.doc.querySelector(".sel-start");
@@ -3672,9 +3802,9 @@ class TosijsStyledEditor extends WebComponent {
3672
3802
  this.hideTouchMenu();
3673
3803
  return;
3674
3804
  }
3675
- this.showTouchMenu();
3805
+ this.showTouchMenu(evt);
3676
3806
  };
3677
- showTouchMenu() {
3807
+ showTouchMenu(openedBy) {
3678
3808
  this.hideTouchMenu();
3679
3809
  const menu = document.createElement("div");
3680
3810
  menu.className = "touch-menu not-selectable do-not-spanify";
@@ -3771,13 +3901,13 @@ class TosijsStyledEditor extends WebComponent {
3771
3901
  this.touchMenuEl = menu;
3772
3902
  this.touchAffordances.appendChild(menu);
3773
3903
  this.touchMenuDismiss = (e) => {
3904
+ if (e === openedBy)
3905
+ return;
3774
3906
  if (this.touchMenuEl && !this.touchMenuEl.contains(e.target)) {
3775
3907
  this.hideTouchMenu();
3776
3908
  }
3777
3909
  };
3778
- setTimeout(() => {
3779
- this.parts.doc.addEventListener("pointerdown", this.touchMenuDismiss);
3780
- }, 0);
3910
+ this.parts.doc.addEventListener("pointerdown", this.touchMenuDismiss);
3781
3911
  }
3782
3912
  touchMenuDismiss = null;
3783
3913
  hideTouchMenu() {
@@ -3848,14 +3978,17 @@ class TosijsStyledEditor extends WebComponent {
3848
3978
  if (!select)
3849
3979
  return;
3850
3980
  const value = select.value;
3851
- if (value) {
3852
- this.doCommand(value);
3853
- }
3981
+ if (!value)
3982
+ return;
3983
+ const name = value.trim().split(/[\s;]/)[0];
3984
+ if (!name || !(name in this.commands))
3985
+ return;
3986
+ this.doCommand(value);
3854
3987
  };
3855
3988
  }
3856
3989
  var tosijsStyledEditor = TosijsStyledEditor.elementCreator();
3857
3990
  // src/version.ts
3858
- var version = "0.4.2";
3991
+ var version = "0.4.4";
3859
3992
  export {
3860
3993
  MENU_CLASS,
3861
3994
  Selectable,
@@ -3889,6 +4022,7 @@ export {
3889
4022
  insertMenu,
3890
4023
  isBefore,
3891
4024
  isHeaderCell,
4025
+ isSafeNavigationUrl,
3892
4026
  isTableCell,
3893
4027
  justificationMenu,
3894
4028
  lastLeafNode,
@@ -3906,6 +4040,8 @@ export {
3906
4040
  previousLeafNode,
3907
4041
  renumberFootnotes,
3908
4042
  rowOfCell,
4043
+ runCommand,
4044
+ sanitizeInPlace,
3909
4045
  setColumnWidths,
3910
4046
  siblingOrder,
3911
4047
  spanify,
@@ -59,6 +59,35 @@ export declare class TosijsStyledEditor extends WebComponent<EditableParts> {
59
59
  set value(html: string);
60
60
  /** The editable commands — extend this object to add custom commands */
61
61
  commands: Record<string, Command>;
62
+ /**
63
+ * Sanitizer applied to pasted and dropped content, before it enters the
64
+ * document. Replace it to use a different one:
65
+ *
66
+ * ```js
67
+ * editor.sanitize = (root) => {
68
+ * DOMPurify.sanitize(root, {
69
+ * IN_PLACE: true,
70
+ * FORBID_TAGS: ['style'],
71
+ * CUSTOM_ELEMENT_HANDLING: {
72
+ * tagNameCheck: /^[a-z][a-z0-9]*-[a-z0-9-]*$/,
73
+ * attributeNameCheck: /^data-|^slot$|^dir$/,
74
+ * },
75
+ * })
76
+ * }
77
+ * ```
78
+ *
79
+ * It takes a detached ELEMENT and mutates it, rather than taking and
80
+ * returning an HTML string, deliberately: a string signature would force a
81
+ * serialize-and-reparse round trip, and that round trip is where mutation
82
+ * XSS lives — markup that is inert when parsed once can become executable
83
+ * when re-parsed from its own serialization. DOMPurify's `IN_PLACE: true`
84
+ * has the same shape for the same reason.
85
+ *
86
+ * The default is `sanitizeInPlace`. Setting this to a no-op disables
87
+ * sanitization; see the security section of the README for what is and is
88
+ * not covered.
89
+ */
90
+ sanitize: (root: Element) => void;
62
91
  content: (HTMLDivElement | HTMLSlotElement)[];
63
92
  formResetCallback(): void;
64
93
  connectedCallback(): void;
@@ -70,6 +99,17 @@ export declare class TosijsStyledEditor extends WebComponent<EditableParts> {
70
99
  */
71
100
  private applyWidgets;
72
101
  disconnectedCallback(): void;
102
+ private geometryObserver;
103
+ /**
104
+ * Repaint everything positioned over the document.
105
+ *
106
+ * Deliberately NOT coalesced with requestAnimationFrame. Measured: one
107
+ * repaint costs 0.015ms — 0.09% of a frame — and the browser already
108
+ * delivers scroll at most once per frame (60 events across 60 frames). So
109
+ * rAF bought no work reduction and cost a frame of latency, which shows up
110
+ * as the caret trailing the text while scrolling.
111
+ */
112
+ private handleGeometryChange;
73
113
  /**
74
114
  * Position the caret (and, for an expanded selection, its two edges) over the
75
115
  * document from the inert markers' rects. Nothing here is in the text, so
@@ -90,14 +130,30 @@ export declare class TosijsStyledEditor extends WebComponent<EditableParts> {
90
130
  private getContext;
91
131
  /** Execute a command string */
92
132
  doCommand(commandString: string): void;
133
+ /**
134
+ * Run one command with pre-separated arguments.
135
+ *
136
+ * Use this whenever an argument is a runtime value (a URL, a filename): the
137
+ * string form splits on `;` and whitespace, so those values can inject a
138
+ * second command — and a data URI contains `;` by spec.
139
+ */
140
+ doCommandWith(name: string, ...args: string[]): void;
93
141
  focus(): void;
94
142
  normalize(): void;
95
143
  /** Get all leaf nodes in the current selection */
96
144
  selectedLeafNodes(): Node[];
97
145
  /** Get the selected blocks */
98
146
  selectedBlocks(): Element[];
99
- /** Get the caret input if it exists in the doc */
100
- insertionPoint(): HTMLInputElement | null;
147
+ /**
148
+ * The caret marker, if the doc has one.
149
+ *
150
+ * This selected `input.caret` until the markers stopped being `<input>`
151
+ * elements, after which it matched nothing and returned null forever —
152
+ * silently disabling every command that inserts at the caret
153
+ * (insertFootnote, insertTable, insertImage, setLink and the rest), because
154
+ * they all bail on a null insertion point.
155
+ */
156
+ insertionPoint(): HTMLElement | null;
101
157
  /** Get the top-level block containing a node */
102
158
  block(node: Node): Element | null;
103
159
  /** Find the table cell containing the caret, if any */
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const version = "0.4.2";
1
+ export declare const version = "0.4.4";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tosijs-styled-editor",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Rich text editor web component — no contentEditable, no browser selection APIs",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,11 +26,13 @@
26
26
  "dist",
27
27
  "!dist/*.tsbuildinfo",
28
28
  "README.md",
29
+ "CHANGELOG.md",
29
30
  "LICENSE"
30
31
  ],
31
32
  "scripts": {
32
33
  "start": "bun bin/site.ts",
33
34
  "make": "bun bin/site.ts --build",
35
+ "prepublishOnly": "bun run make && bun test && bun run lint",
34
36
  "test": "bun test",
35
37
  "lint": "bun tsc --noEmit --noUnusedLocals --noUnusedParameters",
36
38
  "format": "bun prettier --write .",