Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EcencyConfigManager } from "@/config";
import { FragmentsDialog } from "@/features/shared/fragments";
import { parseAllExtensionsToDoc } from "@/features/tiptap-editor";
import { parseAllExtensionsToDoc, resolveInsertContent } from "@/features/tiptap-editor";
import { Editor } from "@tiptap/core";
import { simpleMarkdownToHTML } from "@ecency/render-helper";
import { PublishEditorHtmlWarning } from "./publish-editor-html-warning";
Expand All @@ -23,11 +23,13 @@ export function PublishEditorToolbarFragments({ showFragments, setShowFragments,
return;
}

editor
?.chain()
.focus()
.insertContent(parseAllExtensionsToDoc(simpleMarkdownToHTML(e)))
.run();
const content = editor
? resolveInsertContent(editor.schema, parseAllExtensionsToDoc(simpleMarkdownToHTML(e)), e)
: null;

if (content) {
editor?.chain().focus().insertContent(content).run();
}
setShowFragments(false);
},
[editor, setShowFragments]
Expand Down
18 changes: 12 additions & 6 deletions apps/web/src/app/publish/_components/publish-translate-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
LIBRETRANSLATE_TARGETS,
normLang
} from "@/features/shared/entry-translate/iso639";
import { parseAllExtensionsToDoc } from "@/features/tiptap-editor";
import { parseAllExtensionsToDoc, resolveInsertContent } from "@/features/tiptap-editor";
import { postBodySummary, simpleMarkdownToHTML } from "@ecency/render-helper";
import { Editor } from "@tiptap/core";
import { Button } from "@ui/button";
Expand Down Expand Up @@ -132,11 +132,17 @@ export function PublishTranslateDialog({ show, setShow, editor }: Props) {

const apply = () => {
const appendix = `\n\n---\n\n## ${languageDisplayName(target, target)}\n\n${translated}`;
editor
?.chain()
.focus("end")
.insertContent(parseAllExtensionsToDoc(simpleMarkdownToHTML(appendix)))
.run();
const content = editor
? resolveInsertContent(
editor.schema,
parseAllExtensionsToDoc(simpleMarkdownToHTML(appendix)),
appendix
)
: null;

if (content) {
editor?.chain().focus("end").insertContent(content).run();
}
if (addTitleMarker && title?.trim()) {
const marker = ` [${source.toUpperCase()} | ${target.toUpperCase()}]`;
if (title.length + marker.length <= SUBMIT_TITLE_MAX_LENGTH) {
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/features/tiptap-editor/functions/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./parse-all-extensions-to-doc";
export * from "./markdown-to-html";
export * from "./normalize-link-href";
export * from "./resolve-insert-content";
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const RENDERS_AS_NODE = [
*/
function keptOnUnwrap(el: Element) {
return Array.from(el.childNodes).filter(
(node) => node.nodeType === Node.ELEMENT_NODE || node.textContent?.trim()
(node) => node.nodeType === Node.ELEMENT_NODE || hasVisibleText(node.textContent)
);
}

Expand All @@ -80,24 +80,37 @@ const BLOCK_LEVEL = [LEADING_BLOCK, "p", "div"].join(", ");
function unwrapTableStructure(el: Element): Node[] {
return Array.from(el.childNodes).flatMap((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) {
return node.textContent?.trim() ? [node] : [];
return hasVisibleText(node.textContent) ? [node] : [];
}

const child = node as Element;
return child.matches(TABLE_STRUCTURE) ? unwrapTableStructure(child) : [child];
});
}

/**
* Characters that take up no space, so text made only of them reads as empty.
* `trim()` already drops U+00A0, but not these: they are format characters
* rather than whitespace, and a list item holding only one of them would
* otherwise count as content and render as a blank line.
*/
const ZERO_WIDTH = /[\u200B-\u200D\u2060\uFEFF]/g;

/** True when the text holds something a reader would actually see. */
function hasVisibleText(value?: string | null) {
return !!value?.replace(ZERO_WIDTH, "").trim();
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

/** True when the element holds nothing the schema would render. */
function holdsNothingRenderable(el: Element) {
return !el.textContent?.trim() && !el.querySelector(RENDERS_AS_NODE);
return !hasVisibleText(el.textContent) && !el.querySelector(RENDERS_AS_NODE);
}

/** True when the item holds visible text ahead of the given child. */
function hasTextBefore(child: Element) {
let node: ChildNode | null = child.previousSibling;
while (node) {
if (node.textContent?.trim()) {
if (hasVisibleText(node.textContent)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove ignored zero-width nodes before prepending

When a list item has only a zero-width text node before a leading block—for example, the tight nested-list markdown - \u200b\n - nested—this now reports no preceding text and prepends an empty <p>, but leaves the zero-width node in place. ProseMirror still wraps that surviving inline node in its own paragraph, so the item acquires both a zero-width paragraph and the newly inserted empty paragraph, producing an extra blank line. Either remove the ignored nodes before prepending or continue treating them as satisfying the leading paragraph requirement.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in cf1f5dd600.

Reproduced it first rather than taking the reasoning: <ul><li>\u200B<ul><li>x</li></ul></li></ul> rendered as <li><p>\u200B</p><p></p><ul>…, two paragraphs, exactly as you describe. Same for the tight markdown form.

Took your first option. Anything text-like ahead of the block is invisible by definition at that point, since hasTextBefore just said so, and dropping it is consistent with treating zero-width characters as invisible everywhere else in the file. Keeping them as satisfying the requirement would have meant the leading paragraph was a zero-width one, which renders as a blank line just the same.

It also fixes the whitespace and newline forms, which had the same shape before the zero-width change and were producing the extra paragraph already: <li>\n<ul>… now yields one empty paragraph rather than two. Four cases added, 3 of which fail without the fix.

return true;
}
node = node.previousSibling;
Expand Down Expand Up @@ -355,7 +368,7 @@ export function parseAllExtensionsToDoc(value?: string) {
// behind something like an empty <span> still counts as leading the item.
// Cheap matches() first: textContent walks the whole nested subtree.
let first = li.firstElementChild;
while (first && !first.matches(RENDERS_AS_NODE) && !first.textContent?.trim()) {
while (first && !first.matches(RENDERS_AS_NODE) && !hasVisibleText(first.textContent)) {
first = first.nextElementSibling;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { createNodeFromContent } from "@tiptap/core";
import { Schema } from "@tiptap/pm/model";

/** A plain-text node, the one shape insertContent inserts verbatim. */
interface TextContent {
type: "text";
text: string;
}

/**
* Decides what to hand `insertContent` so that content the schema renders as
* nothing does not come back as literal markup.
*
* ⛔ Never pass normalised HTML to `insertContent` directly. When the parsed
* result is EMPTY, tiptap's `insertContentAt` leaves its `isOnlyTextContent`
* flag true (the forEach over the nodes runs zero times) and falls through to
* `tr.insertText(value)` with the HTML STRING, so pasting say
* `<iframe src=...></iframe>` puts `&lt;iframe src=...&gt;` in the document as
* visible text. The same branch double-escapes when the parse is text alone:
* `A &amp; B` arrives as those literal characters rather than `A & B`.
* See @tiptap/core 2.26.2 dist/index.js, insertContentAt.
*
* @param schema the editor's schema, which decides what renders at all
* @param html normalised HTML, straight from parseAllExtensionsToDoc
* @param fallbackText what the user actually had, used when nothing renders
* @returns the value for insertContent, or null when there is nothing to insert
*/
export function resolveInsertContent(
schema: Schema,
html: string,
fallbackText?: string
): string | TextContent | null {
const content = createNodeFromContent(html, schema, {
parseOptions: { preserveWhitespace: "full" }
});

let text = "";
let isOnlyText = content.childCount > 0;

content.forEach((node) => {
isOnlyText = isOnlyText && node.isText && node.marks.length === 0;
text += node.text ?? "";
});

// Renders as nothing. Show what the user actually had rather than our
// intermediate HTML, so nothing is silently dropped and nothing is invented.
if (content.childCount === 0) {
return fallbackText?.length ? { type: "text", text: fallbackText } : null;
}

// Text alone: insert the parsed text, not the HTML that encoded it.
if (isOnlyText) {
return text.length ? { type: "text", text } : null;
}

return html;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Editor } from "@tiptap/core";
import { ClipboardStrategy } from "./clipboard-strategy";
import { parseAllExtensionsToDoc } from "../../functions";
import { parseAllExtensionsToDoc, resolveInsertContent } from "../../functions";
import { simpleMarkdownToHTML } from "@ecency/render-helper";

export class ClipboardPluginTextStrategy implements ClipboardStrategy {
Expand All @@ -17,11 +17,16 @@ export class ClipboardPluginTextStrategy implements ClipboardStrategy {
if (/<[a-z]+>.*<\/[a-z]+>/gim.test(pastedText)) {
this.onHtmlPaste();
} else {
const parsedText = parseAllExtensionsToDoc(
simpleMarkdownToHTML(pastedText)
);
const parsedText = parseAllExtensionsToDoc(simpleMarkdownToHTML(pastedText));
// Falls back to the clipboard text when nothing in the paste renders, so
// the editor never shows our intermediate HTML as literal markup.
const content = this.editor
? resolveInsertContent(this.editor.schema, parsedText, pastedText)
: null;

this.editor?.chain().insertContent(parsedText).run();
if (content) {
this.editor?.chain().insertContent(content).run();
}
}

event.preventDefault();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,12 @@ describe("pasting a markdown list with blank items", () => {
// them as content left the item schema-empty and lost the whole paste.
["an audio element", '<audio src="x"></audio>'],
["a video element", '<video src="x"></video>'],
["an iframe", '<iframe src="x"></iframe>']
["an iframe", '<iframe src="x"></iframe>'],
// trim() drops U+00A0 but not the zero-width format characters, so these
// used to count as content and render as a blank-looking bullet.
["a zero-width space", "\u200B"],
["a byte order mark", "\uFEFF"],
["a zero-width joiner", "\u200D"]
])(
"normalises an item holding only %s to a single empty paragraph",
(_label: string, filler: string) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { vi } from "vitest";

// Only the hive-post pass needs stubbing here: it is the one that reads
// `el.innerText`, which jsdom does not implement, and it calls `.trim()` on it
// unguarded, so a hive-post-shaped href throws in a spec instead of exercising
// anything. Same re-mock pattern CLAUDE.md documents for `@/utils`.
vi.mock("@/features/tiptap-editor/extensions", async () => ({
...(await vi.importActual("@/features/tiptap-editor/extensions")),
HIVE_POST_PURE_REGEX: /$a^/
}));
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated

import { Editor, getSchema } from "@tiptap/core";
import { simpleMarkdownToHTML } from "@ecency/render-helper";

import { parseAllExtensionsToDoc } from "@/features/tiptap-editor/functions/parse-all-extensions-to-doc";
import { resolveInsertContent } from "@/features/tiptap-editor/functions/resolve-insert-content";
import { PUBLISH_EDITOR_EXTENSIONS } from "./publish-editor-extensions";

const schema = getSchema(PUBLISH_EDITOR_EXTENSIONS);

/** Pastes exactly as the clipboard text strategy does. */
function paste(pastedText: string): { html: string; text: string } {
const editor = new Editor({ extensions: PUBLISH_EDITOR_EXTENSIONS, content: "<p>draft</p>" });
try {
const parsed = parseAllExtensionsToDoc(simpleMarkdownToHTML(pastedText));
const content = resolveInsertContent(editor.schema, parsed, pastedText);
if (content) {
editor.chain().insertContent(content).run();
}
return { html: editor.getHTML(), text: editor.getText() };
} finally {
editor.destroy();
}
}

// Regression: when the parsed result is empty, tiptap's insertContentAt never
// falsifies its isOnlyTextContent flag (the forEach runs zero times) and falls
// through to tr.insertText with the HTML STRING, so the markup lands in the
// document as visible text. Confirmed identical in jsdom and real Chromium, so
// this is tiptap behaviour rather than a DOM quirk.
describe("pasting content the editor cannot render", () => {
// Both before and after the fix the result is literal text, so "is it escaped"
// cannot tell them apart. What distinguishes them is WHOSE text it is: the
// editor used to receive our sanitised, attribute-normalised HTML, which the
// author never typed and which had already lost part of what they pasted.
it.each([
["an iframe embed", "<iframe src=https://a.test/x></iframe>"],
["a video element", "<video src=x controls></video>"]
])("keeps the text the user actually copied for %s", (_l: string, pastedText: string) => {
expect(paste(pastedText).text).toContain(pastedText);
});

it.each([
// the sanitiser quotes the attribute, so this form is ours and not the author's
["a requoted iframe src", "<iframe src=https://a.test/x></iframe>", 'src="https://a.test/x"'],
// and it drops src from <video> while inventing controls="", losing the URL
["an invented video attribute", "<video src=x controls></video>", 'controls=""']
])("never inserts our converted HTML: %s", (_l: string, pastedText: string, ours: string) => {
expect(paste(pastedText).text).not.toContain(ours);
});

it("keeps the rest of the document intact", () => {
expect(paste("<video src=x controls></video>").text).toContain("draft");
});
});

describe("resolveInsertContent", () => {
it("passes ordinary HTML straight through", () => {
expect(resolveInsertContent(schema, "<p>ok</p>", "ok")).toBe("<p>ok</p>");
});

it("returns the fallback text when nothing renders", () => {
expect(resolveInsertContent(schema, '<iframe src="x"></iframe>', "raw text")).toEqual({
type: "text",
text: "raw text"
});
});

it("returns null when nothing renders and there is no fallback", () => {
expect(resolveInsertContent(schema, '<iframe src="x"></iframe>')).toBeNull();
});

// The same insertText branch double-escapes text-only content, so `A &amp; B`
// used to reach the document as those literal characters.
it("decodes entities when the content is text alone", () => {
expect(resolveInsertContent(schema, "A &amp; B", "A & B")).toEqual({
type: "text",
text: "A & B"
});
});

it("inserts decoded text rather than the HTML that encoded it", () => {
const editor = new Editor({ extensions: PUBLISH_EDITOR_EXTENSIONS, content: "<p></p>" });
try {
const content = resolveInsertContent(editor.schema, "1 &lt; 2", "1 < 2");
editor.chain().insertContent(content!).run();

expect(editor.getHTML()).toBe("<p>1 &lt; 2</p>");
} finally {
editor.destroy();
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ import {
* cannot drift from production. Extensions that add no node or mark (Placeholder,
* TextAlign, Selection) are left out; the marks the editor adds are not exercised
* by the paste-normalisation specs.
*
* ⚠️ Two things these specs CANNOT settle, both verified against real Chromium:
*
* 1. jsdom does not implement `element.innerText`. Any pass that reads it is
* inert here, and `el.innerText.trim()` throws outright, which is why the
* hive-post regex is stubbed in every spec that touches this pipeline.
* 2. jsdom and Chromium genuinely disagree on HTML foster parenting: loose text
* inside a `<table>` is moved BEFORE the table by Chromium, per the HTML5
* "in table" insertion mode, and left AFTER it by jsdom. Document ORDER for
* that shape is not trustworthy here, so verify it in a browser rather than
* asserting on it in a spec.
*/
export const PUBLISH_EDITOR_EXTENSIONS: AnyExtension[] = [
StarterKit.configure({ strike: false }) as AnyExtension,
Expand Down
Loading