@pixerate/editor 0.14.0 → 0.14.2

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/index.d.cts CHANGED
@@ -55,8 +55,16 @@ declare class EditorController {
55
55
  setPlainText(text: string, emitUpdate?: boolean): void;
56
56
  getMarkdown(): string;
57
57
  setMarkdown(markdown: string, emitUpdate?: boolean): void;
58
+ /**
59
+ * Replaces `from`..`to` with literal text: HTML in `text` is not parsed, and
60
+ * newlines split paragraphs.
61
+ */
62
+ private insertPlainText;
63
+ /** Inserts text literally at the selection; HTML in `text` is not parsed. */
58
64
  insertText(text: string): void;
65
+ /** Inserts text literally at `pos`; HTML in `text` is not parsed. */
59
66
  insertTextAt(pos: number, text: string): void;
67
+ /** Replaces a range with literal text; HTML in `text` is not parsed. */
60
68
  replaceRange(from: number, to: number, text: string): void;
61
69
  getCurrentContentString(): string;
62
70
  isDirty(): boolean;
package/dist/index.d.ts CHANGED
@@ -55,8 +55,16 @@ declare class EditorController {
55
55
  setPlainText(text: string, emitUpdate?: boolean): void;
56
56
  getMarkdown(): string;
57
57
  setMarkdown(markdown: string, emitUpdate?: boolean): void;
58
+ /**
59
+ * Replaces `from`..`to` with literal text: HTML in `text` is not parsed, and
60
+ * newlines split paragraphs.
61
+ */
62
+ private insertPlainText;
63
+ /** Inserts text literally at the selection; HTML in `text` is not parsed. */
58
64
  insertText(text: string): void;
65
+ /** Inserts text literally at `pos`; HTML in `text` is not parsed. */
59
66
  insertTextAt(pos: number, text: string): void;
67
+ /** Replaces a range with literal text; HTML in `text` is not parsed. */
60
68
  replaceRange(from: number, to: number, text: string): void;
61
69
  getCurrentContentString(): string;
62
70
  isDirty(): boolean;
package/dist/index.js CHANGED
@@ -2330,38 +2330,98 @@ function escapeHtml(text) {
2330
2330
  if (!text) return "";
2331
2331
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
2332
2332
  }
2333
+ var PARAGRAPH_HTML_REGEX = /^\s*<p[\s>][\s\S]*<\/p>\s*$/i;
2333
2334
  function plainTextToTipTapHtml(text) {
2334
2335
  if (!text) return "<p></p>";
2335
- if (/<p[\s>]/i.test(text)) {
2336
+ if (PARAGRAPH_HTML_REGEX.test(text)) {
2336
2337
  return text;
2337
2338
  }
2338
2339
  return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n").map((line) => `<p>${escapeHtml(line)}</p>`).join("");
2339
2340
  }
2341
+ var BLOCK_TAGS = /* @__PURE__ */ new Set([
2342
+ "P",
2343
+ "DIV",
2344
+ "LI",
2345
+ "UL",
2346
+ "OL",
2347
+ "PRE",
2348
+ "BLOCKQUOTE",
2349
+ "TABLE",
2350
+ "TR",
2351
+ "H1",
2352
+ "H2",
2353
+ "H3",
2354
+ "H4",
2355
+ "H5",
2356
+ "H6",
2357
+ "HR"
2358
+ ]);
2359
+ var MENTION_SPAN_REGEX = /<span\b(?=[^>]*\bdata-type=["']mention["'])([^>]*)>[\s\S]*?<\/span>/gi;
2360
+ function getHtmlAttribute(attrs, name) {
2361
+ const match = new RegExp(`\\b${name}=(?:"([^"]*)"|'([^']*)')`, "i").exec(
2362
+ attrs
2363
+ );
2364
+ return match ? match[1] ?? match[2] : void 0;
2365
+ }
2366
+ function parseHtmlFragment(html) {
2367
+ const template = document.createElement("template");
2368
+ template.innerHTML = html;
2369
+ return template.content;
2370
+ }
2371
+ function decodeHtmlEntities(text) {
2372
+ return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#0*39;/g, "'").replace(/&nbsp;/g, "\xA0").replace(/&amp;/g, "&");
2373
+ }
2340
2374
  function htmlToPlainText(html) {
2341
2375
  if (!html) return "";
2342
- const mentionNormalized = html.replace(
2343
- /<span[^>]*data-type=["']mention["'][^>]*data-label=["']([^"']+)["'][^>]*>.*?<\/span>/gi,
2344
- "@$1"
2345
- ).replace(
2346
- /<span[^>]*data-type=["']mention["'][^>]*data-id=["']([^"']+)["'][^>]*>.*?<\/span>/gi,
2347
- "@$1"
2348
- );
2376
+ const mentionNormalized = html.replace(MENTION_SPAN_REGEX, (match, attrs) => {
2377
+ const label = getHtmlAttribute(attrs, "data-label") || getHtmlAttribute(attrs, "data-id");
2378
+ return label ? `@${label}` : match;
2379
+ });
2349
2380
  if (typeof document !== "undefined") {
2350
- const tempDiv = document.createElement("div");
2351
- tempDiv.innerHTML = mentionNormalized;
2352
- tempDiv.querySelectorAll("br.ProseMirror-trailingBreak").forEach((br) => br.remove());
2353
- tempDiv.querySelectorAll("br").forEach((br) => br.replaceWith("\n"));
2354
- const paragraphs = tempDiv.querySelectorAll("p, div, li, h1, h2, h3, h4, h5, h6");
2355
- if (paragraphs.length > 0) {
2356
- const textParts = [];
2357
- paragraphs.forEach((p) => {
2358
- textParts.push(p.textContent || "");
2359
- });
2360
- return textParts.join("\n");
2361
- }
2362
- return tempDiv.textContent || "";
2363
- }
2364
- return mentionNormalized.replace(/<br\s*\/?>/gi, "\n").replace(/<\/p>/gi, "\n").replace(/<\/div>/gi, "\n").replace(/<\/li>/gi, "\n").replace(/<[^>]+>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#039;/g, "'").trim();
2381
+ const fragment = parseHtmlFragment(mentionNormalized);
2382
+ fragment.querySelectorAll("br.ProseMirror-trailingBreak").forEach((br) => br.remove());
2383
+ const lines = [];
2384
+ let current = null;
2385
+ const flush = () => {
2386
+ if (current !== null) {
2387
+ lines.push(current);
2388
+ current = null;
2389
+ }
2390
+ };
2391
+ const visit = (node) => {
2392
+ if (node.nodeType === 3) {
2393
+ const value = node.nodeValue ?? "";
2394
+ if (current === null && value.trim() === "") return;
2395
+ current = (current ?? "") + value;
2396
+ return;
2397
+ }
2398
+ if (node.nodeType !== 1) return;
2399
+ const tag = node.tagName.toUpperCase();
2400
+ if (tag === "BR") {
2401
+ current = (current ?? "") + "\n";
2402
+ return;
2403
+ }
2404
+ if (!BLOCK_TAGS.has(tag)) {
2405
+ node.childNodes.forEach(visit);
2406
+ return;
2407
+ }
2408
+ flush();
2409
+ const linesBefore = lines.length;
2410
+ node.childNodes.forEach(visit);
2411
+ flush();
2412
+ if (lines.length === linesBefore && tag !== "HR") {
2413
+ lines.push("");
2414
+ }
2415
+ };
2416
+ fragment.childNodes.forEach(visit);
2417
+ flush();
2418
+ return lines.join("\n");
2419
+ }
2420
+ const text = mentionNormalized.replace(/<br\s*\/?>/gi, "\n").replace(
2421
+ /<\/(?:p|div|li|pre|blockquote|h[1-6])>(?:\s*<\/(?:li|ul|ol|blockquote|div)>)*/gi,
2422
+ "\n"
2423
+ ).replace(/<[^>]+>/g, "");
2424
+ return decodeHtmlEntities(text).replace(/\n$/, "");
2365
2425
  }
2366
2426
  function getEditorText(editor) {
2367
2427
  if (!editor) return "";
@@ -2420,9 +2480,7 @@ function extractMentionsFromDoc(docOrEditor) {
2420
2480
  }
2421
2481
  if (typeof docOrEditor === "string") {
2422
2482
  if (typeof document !== "undefined") {
2423
- const div = document.createElement("div");
2424
- div.innerHTML = docOrEditor;
2425
- div.querySelectorAll('span[data-type="mention"]').forEach((el) => {
2483
+ parseHtmlFragment(docOrEditor).querySelectorAll('span[data-type="mention"]').forEach((el) => {
2426
2484
  mentions.push({
2427
2485
  id: el.getAttribute("data-id") || "",
2428
2486
  label: el.getAttribute("data-label") || "",
@@ -2478,6 +2536,114 @@ md2.validateLink = (url) => {
2478
2536
  }
2479
2537
  return defaultValidateLink.call(md2, url);
2480
2538
  };
2539
+ var ALLOWED_HTML_TAGS = /* @__PURE__ */ new Set([
2540
+ "a",
2541
+ "abbr",
2542
+ "b",
2543
+ "blockquote",
2544
+ "br",
2545
+ "code",
2546
+ "del",
2547
+ "details",
2548
+ "div",
2549
+ "em",
2550
+ "h1",
2551
+ "h2",
2552
+ "h3",
2553
+ "h4",
2554
+ "h5",
2555
+ "h6",
2556
+ "hr",
2557
+ "i",
2558
+ "img",
2559
+ "input",
2560
+ "kbd",
2561
+ "li",
2562
+ "mark",
2563
+ "ol",
2564
+ "p",
2565
+ "pre",
2566
+ "s",
2567
+ "span",
2568
+ "strong",
2569
+ "sub",
2570
+ "summary",
2571
+ "sup",
2572
+ "table",
2573
+ "tbody",
2574
+ "td",
2575
+ "tfoot",
2576
+ "th",
2577
+ "thead",
2578
+ "tr",
2579
+ "u",
2580
+ "ul"
2581
+ ]);
2582
+ var ALLOWED_HTML_ATTRS = /* @__PURE__ */ new Set([
2583
+ "alt",
2584
+ "align",
2585
+ "checked",
2586
+ "class",
2587
+ "colspan",
2588
+ "disabled",
2589
+ "height",
2590
+ "href",
2591
+ "rel",
2592
+ "rowspan",
2593
+ "src",
2594
+ "start",
2595
+ "target",
2596
+ "title",
2597
+ "type",
2598
+ "width"
2599
+ ]);
2600
+ var HTML_TAG_REGEX = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[^\s"'>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*\/?>/g;
2601
+ var HTML_ATTR_REGEX = /([^\s"'>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
2602
+ function sanitizeTag(tagText, isClosing, rawTagName, rawAttrs) {
2603
+ const tagName = rawTagName.toLowerCase();
2604
+ if (!ALLOWED_HTML_TAGS.has(tagName)) return escapeHtml(tagText);
2605
+ if (isClosing) return `</${tagName}>`;
2606
+ if (tagName === "input" && !/\stype\s*=\s*["']?checkbox["']?[\s>/]/i.test(tagText)) {
2607
+ return escapeHtml(tagText);
2608
+ }
2609
+ let attrs = "";
2610
+ let attrMatch;
2611
+ HTML_ATTR_REGEX.lastIndex = 0;
2612
+ while ((attrMatch = HTML_ATTR_REGEX.exec(rawAttrs)) !== null) {
2613
+ const name = attrMatch[1].toLowerCase();
2614
+ if (!ALLOWED_HTML_ATTRS.has(name) && !/^data-[a-z0-9_.-]+$/.test(name)) {
2615
+ continue;
2616
+ }
2617
+ const rawValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
2618
+ if (rawValue === void 0) {
2619
+ attrs += ` ${name}`;
2620
+ continue;
2621
+ }
2622
+ const value = md2.utils.unescapeAll(rawValue);
2623
+ if ((name === "href" || name === "src") && // Browsers ignore control characters and whitespace inside schemes.
2624
+ !md2.validateLink(value.replace(/[\u0000-\u0020\u007f]/g, ""))) {
2625
+ continue;
2626
+ }
2627
+ attrs += ` ${name}="${escapeHtml(value)}"`;
2628
+ }
2629
+ return `<${tagName}${attrs}>`;
2630
+ }
2631
+ function sanitizeEmbeddedHtml(html) {
2632
+ let result = "";
2633
+ let lastIndex = 0;
2634
+ let match;
2635
+ HTML_TAG_REGEX.lastIndex = 0;
2636
+ while ((match = HTML_TAG_REGEX.exec(html)) !== null) {
2637
+ result += html.slice(lastIndex, match.index).replace(/</g, "&lt;");
2638
+ result += sanitizeTag(match[0], match[1], match[2], match[3]);
2639
+ lastIndex = match.index + match[0].length;
2640
+ }
2641
+ return result + html.slice(lastIndex).replace(/</g, "&lt;");
2642
+ }
2643
+ var defaultHtmlInline = md2.renderer.rules.html_inline;
2644
+ var defaultHtmlBlock = md2.renderer.rules.html_block;
2645
+ md2.renderer.rules.html_inline = (tokens, idx, options, env, self) => env?.allowUnsafeHtml ? defaultHtmlInline(tokens, idx, options, env, self) : sanitizeEmbeddedHtml(tokens[idx].content);
2646
+ md2.renderer.rules.html_block = (tokens, idx, options, env, self) => env?.allowUnsafeHtml ? defaultHtmlBlock(tokens, idx, options, env, self) : sanitizeEmbeddedHtml(tokens[idx].content);
2481
2647
  function preprocessMarkdownFileLinks(content) {
2482
2648
  if (!content) return "";
2483
2649
  return content.replace(
@@ -2485,8 +2651,8 @@ function preprocessMarkdownFileLinks(content) {
2485
2651
  (_match, rawLabel, href) => {
2486
2652
  const trimmed = rawLabel.trim();
2487
2653
  const codeMatch = trimmed.match(/^`([^`]+)`$/);
2488
- const labelContent = codeMatch ? `<code>${codeMatch[1]}</code>` : trimmed;
2489
- return `<a href="${href}" data-file-link="true" title="Click to copy file path">${labelContent}</a>`;
2654
+ const labelContent = codeMatch ? `<code>${escapeHtml(codeMatch[1])}</code>` : escapeHtml(trimmed);
2655
+ return `<a href="${escapeHtml(href)}" data-file-link="true" title="Click to copy file path">${labelContent}</a>`;
2490
2656
  }
2491
2657
  );
2492
2658
  }
@@ -2502,7 +2668,7 @@ function markdownToTipTapHtml(markdown, options) {
2502
2668
  };
2503
2669
  content = preprocessMarkdownMentions(content, mentionOpts);
2504
2670
  }
2505
- return md2.render(content);
2671
+ return md2.render(content, { allowUnsafeHtml: options?.allowUnsafeHtml });
2506
2672
  }
2507
2673
  function getEditorMarkdown(editor) {
2508
2674
  if (!editor) return "";
@@ -2514,7 +2680,25 @@ function getEditorMarkdown(editor) {
2514
2680
 
2515
2681
  // src/controller.ts
2516
2682
  import { Editor } from "@tiptap/core";
2517
- import { Slice, Fragment as Fragment2, Node as ProsemirrorNode } from "@tiptap/pm/model";
2683
+ import {
2684
+ Slice,
2685
+ Fragment as Fragment2,
2686
+ Node as ProsemirrorNode
2687
+ } from "@tiptap/pm/model";
2688
+ function plainTextToSlice(text, schema) {
2689
+ const nodes = [];
2690
+ text.replace(/\r\n?/g, "\n").split("\n").forEach((line) => {
2691
+ const nodeJson = { type: "paragraph" };
2692
+ if (line.length > 0) {
2693
+ nodeJson.content = [{ type: "text", text: line }];
2694
+ }
2695
+ try {
2696
+ nodes.push(ProsemirrorNode.fromJSON(schema, nodeJson));
2697
+ } catch {
2698
+ }
2699
+ });
2700
+ return Slice.maxOpen(Fragment2.fromArray(nodes));
2701
+ }
2518
2702
  var EditorController = class {
2519
2703
  editor;
2520
2704
  plainTextMode;
@@ -2570,20 +2754,7 @@ var EditorController = class {
2570
2754
  clipboardTextParser: (text, context, _plain, view) => {
2571
2755
  const schema = context?.doc?.type?.schema || context?.schema || view?.state?.schema || this.editor.schema;
2572
2756
  if (!schema) return Slice.empty;
2573
- const blocks = text.replace(/\r\n?/g, "\n").split("\n");
2574
- const nodes = [];
2575
- blocks.forEach((line) => {
2576
- const nodeJson = { type: "paragraph" };
2577
- if (line.length > 0) {
2578
- nodeJson.content = [{ type: "text", text: line }];
2579
- }
2580
- try {
2581
- const node = ProsemirrorNode.fromJSON(schema, nodeJson);
2582
- nodes.push(node);
2583
- } catch {
2584
- }
2585
- });
2586
- return Slice.maxOpen(Fragment2.fromArray(nodes));
2757
+ return plainTextToSlice(text, schema);
2587
2758
  }
2588
2759
  }
2589
2760
  });
@@ -2626,14 +2797,29 @@ var EditorController = class {
2626
2797
  this.editor.commands.setContent(html, emitUpdate);
2627
2798
  }
2628
2799
  }
2800
+ /**
2801
+ * Replaces `from`..`to` with literal text: HTML in `text` is not parsed, and
2802
+ * newlines split paragraphs.
2803
+ */
2804
+ insertPlainText(from, to, text) {
2805
+ const { state, view } = this.editor;
2806
+ const tr = text.includes("\n") || text.includes("\r") ? state.tr.replace(from, to, plainTextToSlice(text, state.schema)) : text ? state.tr.insertText(text, from, to) : state.tr.delete(from, to);
2807
+ view.dispatch(tr.scrollIntoView());
2808
+ }
2809
+ /** Inserts text literally at the selection; HTML in `text` is not parsed. */
2629
2810
  insertText(text) {
2630
- this.editor.commands.insertContent(text);
2811
+ const { from, to } = this.editor.state.selection;
2812
+ this.insertPlainText(from, to, text);
2631
2813
  }
2814
+ /** Inserts text literally at `pos`; HTML in `text` is not parsed. */
2632
2815
  insertTextAt(pos, text) {
2633
- this.editor.chain().focus().insertContentAt(pos, text).run();
2816
+ this.insertPlainText(pos, pos, text);
2817
+ this.editor.commands.focus();
2634
2818
  }
2819
+ /** Replaces a range with literal text; HTML in `text` is not parsed. */
2635
2820
  replaceRange(from, to, text) {
2636
- this.editor.chain().focus().deleteRange({ from, to }).insertContentAt(from, text).run();
2821
+ this.insertPlainText(from, to, text);
2822
+ this.editor.commands.focus();
2637
2823
  }
2638
2824
  getCurrentContentString() {
2639
2825
  if (this.plainTextMode) return this.getPlainText();