From 2739bd17f548fc667ff404fc8725fe9bd5f5be6c Mon Sep 17 00:00:00 2001 From: Sascha Greuel Date: Sat, 4 Jul 2026 21:13:41 +0200 Subject: [PATCH 1/3] Prevent inherited content color from being pasted Hook into the clipboard input transformation before CKEditor converts pasted HTML into model content. When browsers copy normal WSC text, they may serialize inherited --wcfContentText as an inline color style. CKEditor then treats that as an intentional font color and persists it in the submitted markup. Strip only color styles that match the current WSC content text color, while preserving other formatting such as bold text. Arbitrary external colors are intentionally left untouched because browser clipboard HTML does not distinguish inherited page color from intentional author formatting. --- .../src/woltlabpastefromoffice.ts | 162 +++++++++++++++++- 1 file changed, 159 insertions(+), 3 deletions(-) diff --git a/plugins/ckeditor5-woltlab-paste-from-office/src/woltlabpastefromoffice.ts b/plugins/ckeditor5-woltlab-paste-from-office/src/woltlabpastefromoffice.ts index 613e254..2c481ee 100644 --- a/plugins/ckeditor5-woltlab-paste-from-office/src/woltlabpastefromoffice.ts +++ b/plugins/ckeditor5-woltlab-paste-from-office/src/woltlabpastefromoffice.ts @@ -10,12 +10,24 @@ import { Plugin } from "@ckeditor/ckeditor5-core"; import { ClipboardContentInsertionEvent, + ClipboardInputTransformationEvent, ClipboardObserver, ClipboardPipeline, } from "@ckeditor/ckeditor5-clipboard"; -import { Model, ModelDocumentFragment } from "@ckeditor/ckeditor5-engine"; +import { + Model, + ModelDocumentFragment, + ViewDocumentFragment, + ViewElement, + ViewUpcastWriter, +} from "@ckeditor/ckeditor5-engine"; + +const INHERITED_TEXT_COLOR_VARIABLE = "--wcfContentText"; +const COLOR_STYLE = "color"; export class WoltlabPasteFromOffice extends Plugin { + #colorNormalizer?: CanvasRenderingContext2D | null; + static get pluginName() { return "WoltlabPasteFromOffice"; } @@ -23,6 +35,16 @@ export class WoltlabPasteFromOffice extends Plugin { init() { this.editor.editing.view.addObserver(ClipboardObserver); + this.editor.plugins + .get(ClipboardPipeline) + .on( + "inputTransformation", + (event, data) => { + this.#removeInheritedTextColor(data.content); + }, + { priority: "high" }, + ); + this.editor.plugins .get(ClipboardPipeline) .on("contentInsertion", (event, data) => { @@ -36,7 +58,7 @@ export class WoltlabPasteFromOffice extends Plugin { * Pasting from the web version of Excel can sometimes prepend a block of CSS * in front of the table. This block is added as a plain text paragraph that * needs to be removed. - * + * * This can be easily detected by checking if a paragraph followed by a table * is pasted and the paragraph contains certain strings that are unique to * MS office. @@ -45,7 +67,7 @@ export class WoltlabPasteFromOffice extends Plugin { documentFragment: ModelDocumentFragment, model: Model, ): boolean { - let range = model.createRangeIn(documentFragment); + const range = model.createRangeIn(documentFragment); if (documentFragment.childCount !== 2) { return false; } @@ -91,6 +113,140 @@ export class WoltlabPasteFromOffice extends Plugin { writer.remove(items[0]); }); } + + #removeInheritedTextColor(documentFragment: ViewDocumentFragment): void { + const colors = this.#getInheritedTextColors(); + if (colors.size === 0) { + return; + } + + const writer = new ViewUpcastWriter(documentFragment.document); + const range = writer.createRangeIn(documentFragment); + const elements = Array.from(range.getItems()).filter( + (item): item is ViewElement => item.is("element"), + ); + + for (const element of elements) { + const color = element.getStyle(COLOR_STYLE); + if (color === undefined) { + continue; + } + + const normalizedColor = this.#normalizeColor(color); + if (normalizedColor !== undefined && colors.has(normalizedColor)) { + writer.removeStyle(COLOR_STYLE, element); + } + } + } + + #getInheritedTextColors(): Set { + const colors = new Set(); + const editableElement = this.editor.ui.getEditableElement(); + + if (editableElement !== null) { + this.#addComputedTextColors(colors, editableElement); + } + + if (document.body !== null) { + this.#addComputedTextColors(colors, document.body); + } + + this.#addComputedTextColors(colors, document.documentElement); + this.#addStyleSheetTextColors(colors); + + return colors; + } + + #addComputedTextColors(colors: Set, element: HTMLElement): void { + const style = getComputedStyle(element); + + this.#addColor(colors, style.color); + this.#addColor( + colors, + style.getPropertyValue(INHERITED_TEXT_COLOR_VARIABLE), + ); + } + + #addStyleSheetTextColors(colors: Set): void { + for (const styleSheet of Array.from(document.styleSheets)) { + let cssRules: CSSRuleList; + + try { + cssRules = styleSheet.cssRules; + } catch { + continue; + } + + this.#addStyleSheetRuleTextColors(colors, cssRules); + } + } + + #addStyleSheetRuleTextColors( + colors: Set, + cssRules: CSSRuleList, + ): void { + for (const rule of Array.from(cssRules)) { + if ("style" in rule) { + this.#addColor( + colors, + ( + rule as CSSRule & { style: CSSStyleDeclaration } + ).style.getPropertyValue(INHERITED_TEXT_COLOR_VARIABLE), + ); + } + + if ("cssRules" in rule) { + this.#addStyleSheetRuleTextColors( + colors, + (rule as CSSRule & { cssRules: CSSRuleList }).cssRules, + ); + } + } + } + + #addColor(colors: Set, color: string): void { + const normalizedColor = this.#normalizeColor(color); + + if (normalizedColor !== undefined) { + colors.add(normalizedColor); + } + } + + #normalizeColor(color: string): string | undefined { + const trimmedColor = color.trim(); + if (trimmedColor === "" || trimmedColor.includes("var(")) { + return undefined; + } + + const context = this.#getColorNormalizer(); + if (context !== null) { + context.fillStyle = "#000001"; + context.fillStyle = trimmedColor; + + const normalizedColor = context.fillStyle.toLowerCase(); + if ( + normalizedColor !== "#000001" || + trimmedColor.replace(/\s/g, "").toLowerCase() === "rgb(0,0,1)" + ) { + return normalizedColor; + } + } + + return trimmedColor + .replace(/\s/g, "") + .replace(/^rgba\((\d+),(\d+),(\d+),(?:1|1\.0)\)$/i, "rgb($1,$2,$3)") + .toLowerCase(); + } + + #getColorNormalizer(): CanvasRenderingContext2D | null { + if (this.#colorNormalizer !== undefined) { + return this.#colorNormalizer; + } + + this.#colorNormalizer = document.createElement("canvas").getContext("2d"); + + return this.#colorNormalizer; + } } export default WoltlabPasteFromOffice; From ba70257627cd3ad8a45620bbd46d1e841b1474e7 Mon Sep 17 00:00:00 2001 From: Sascha Greuel Date: Thu, 23 Jul 2026 13:20:25 +0200 Subject: [PATCH 2/3] Fix handling of inherited text colors on paste Cache editor text colors and refresh them when the color scheme changes, instead of scanning page stylesheets for every paste. Preserve nested color overrides so removing a default inner color does not expose a different ancestor color. --- app.ts | 2 + modules.ts | 1 + .../src/woltlabpastefromoffice.ts | 158 +-------------- .../package.json | 6 + .../src/index.ts | 8 + .../src/woltlabpastetextcolor.ts | 184 ++++++++++++++++++ 6 files changed, 202 insertions(+), 157 deletions(-) create mode 100644 plugins/ckeditor5-woltlab-paste-text-color/package.json create mode 100644 plugins/ckeditor5-woltlab-paste-text-color/src/index.ts create mode 100644 plugins/ckeditor5-woltlab-paste-text-color/src/woltlabpastetextcolor.ts diff --git a/app.ts b/app.ts index a3997a4..36477e3 100644 --- a/app.ts +++ b/app.ts @@ -109,6 +109,7 @@ import { WoltlabMention, WoltlabMetacode, WoltlabPasteFromOffice, + WoltlabPasteTextColor, WoltlabSmiley, WoltlabSpoiler, WoltlabToolbarGroup, @@ -179,6 +180,7 @@ const defaultConfig: EditorConfig = { WoltlabMention.WoltlabMention, WoltlabMetacode.WoltlabMetacode, WoltlabPasteFromOffice.WoltlabPasteFromOffice, + WoltlabPasteTextColor.WoltlabPasteTextColor, WoltlabSmiley.WoltlabSmiley, WoltlabSpoiler.WoltlabSpoiler, WoltlabToolbarGroup.WoltlabToolbarGroup, diff --git a/modules.ts b/modules.ts index e1706d0..9713548 100644 --- a/modules.ts +++ b/modules.ts @@ -49,6 +49,7 @@ export * as WoltlabMedia from "./plugins/ckeditor5-woltlab-media/src"; export * as WoltlabMention from "./plugins/ckeditor5-woltlab-mention/src"; export * as WoltlabMetacode from "./plugins/ckeditor5-woltlab-metacode/src"; export * as WoltlabPasteFromOffice from "./plugins/ckeditor5-woltlab-paste-from-office"; +export * as WoltlabPasteTextColor from "./plugins/ckeditor5-woltlab-paste-text-color/src"; export * as WoltlabSmiley from "./plugins/ckeditor5-woltlab-smiley/src"; export * as WoltlabSpoiler from "./plugins/ckeditor5-woltlab-spoiler/src"; export * as WoltlabToolbarGroup from "./plugins/ckeditor5-woltlab-toolbar-group/src"; diff --git a/plugins/ckeditor5-woltlab-paste-from-office/src/woltlabpastefromoffice.ts b/plugins/ckeditor5-woltlab-paste-from-office/src/woltlabpastefromoffice.ts index 2c481ee..23e6203 100644 --- a/plugins/ckeditor5-woltlab-paste-from-office/src/woltlabpastefromoffice.ts +++ b/plugins/ckeditor5-woltlab-paste-from-office/src/woltlabpastefromoffice.ts @@ -10,24 +10,12 @@ import { Plugin } from "@ckeditor/ckeditor5-core"; import { ClipboardContentInsertionEvent, - ClipboardInputTransformationEvent, ClipboardObserver, ClipboardPipeline, } from "@ckeditor/ckeditor5-clipboard"; -import { - Model, - ModelDocumentFragment, - ViewDocumentFragment, - ViewElement, - ViewUpcastWriter, -} from "@ckeditor/ckeditor5-engine"; - -const INHERITED_TEXT_COLOR_VARIABLE = "--wcfContentText"; -const COLOR_STYLE = "color"; +import { Model, ModelDocumentFragment } from "@ckeditor/ckeditor5-engine"; export class WoltlabPasteFromOffice extends Plugin { - #colorNormalizer?: CanvasRenderingContext2D | null; - static get pluginName() { return "WoltlabPasteFromOffice"; } @@ -35,16 +23,6 @@ export class WoltlabPasteFromOffice extends Plugin { init() { this.editor.editing.view.addObserver(ClipboardObserver); - this.editor.plugins - .get(ClipboardPipeline) - .on( - "inputTransformation", - (event, data) => { - this.#removeInheritedTextColor(data.content); - }, - { priority: "high" }, - ); - this.editor.plugins .get(ClipboardPipeline) .on("contentInsertion", (event, data) => { @@ -113,140 +91,6 @@ export class WoltlabPasteFromOffice extends Plugin { writer.remove(items[0]); }); } - - #removeInheritedTextColor(documentFragment: ViewDocumentFragment): void { - const colors = this.#getInheritedTextColors(); - if (colors.size === 0) { - return; - } - - const writer = new ViewUpcastWriter(documentFragment.document); - const range = writer.createRangeIn(documentFragment); - const elements = Array.from(range.getItems()).filter( - (item): item is ViewElement => item.is("element"), - ); - - for (const element of elements) { - const color = element.getStyle(COLOR_STYLE); - if (color === undefined) { - continue; - } - - const normalizedColor = this.#normalizeColor(color); - if (normalizedColor !== undefined && colors.has(normalizedColor)) { - writer.removeStyle(COLOR_STYLE, element); - } - } - } - - #getInheritedTextColors(): Set { - const colors = new Set(); - const editableElement = this.editor.ui.getEditableElement(); - - if (editableElement !== null) { - this.#addComputedTextColors(colors, editableElement); - } - - if (document.body !== null) { - this.#addComputedTextColors(colors, document.body); - } - - this.#addComputedTextColors(colors, document.documentElement); - this.#addStyleSheetTextColors(colors); - - return colors; - } - - #addComputedTextColors(colors: Set, element: HTMLElement): void { - const style = getComputedStyle(element); - - this.#addColor(colors, style.color); - this.#addColor( - colors, - style.getPropertyValue(INHERITED_TEXT_COLOR_VARIABLE), - ); - } - - #addStyleSheetTextColors(colors: Set): void { - for (const styleSheet of Array.from(document.styleSheets)) { - let cssRules: CSSRuleList; - - try { - cssRules = styleSheet.cssRules; - } catch { - continue; - } - - this.#addStyleSheetRuleTextColors(colors, cssRules); - } - } - - #addStyleSheetRuleTextColors( - colors: Set, - cssRules: CSSRuleList, - ): void { - for (const rule of Array.from(cssRules)) { - if ("style" in rule) { - this.#addColor( - colors, - ( - rule as CSSRule & { style: CSSStyleDeclaration } - ).style.getPropertyValue(INHERITED_TEXT_COLOR_VARIABLE), - ); - } - - if ("cssRules" in rule) { - this.#addStyleSheetRuleTextColors( - colors, - (rule as CSSRule & { cssRules: CSSRuleList }).cssRules, - ); - } - } - } - - #addColor(colors: Set, color: string): void { - const normalizedColor = this.#normalizeColor(color); - - if (normalizedColor !== undefined) { - colors.add(normalizedColor); - } - } - - #normalizeColor(color: string): string | undefined { - const trimmedColor = color.trim(); - if (trimmedColor === "" || trimmedColor.includes("var(")) { - return undefined; - } - - const context = this.#getColorNormalizer(); - if (context !== null) { - context.fillStyle = "#000001"; - context.fillStyle = trimmedColor; - - const normalizedColor = context.fillStyle.toLowerCase(); - if ( - normalizedColor !== "#000001" || - trimmedColor.replace(/\s/g, "").toLowerCase() === "rgb(0,0,1)" - ) { - return normalizedColor; - } - } - - return trimmedColor - .replace(/\s/g, "") - .replace(/^rgba\((\d+),(\d+),(\d+),(?:1|1\.0)\)$/i, "rgb($1,$2,$3)") - .toLowerCase(); - } - - #getColorNormalizer(): CanvasRenderingContext2D | null { - if (this.#colorNormalizer !== undefined) { - return this.#colorNormalizer; - } - - this.#colorNormalizer = document.createElement("canvas").getContext("2d"); - - return this.#colorNormalizer; - } } export default WoltlabPasteFromOffice; diff --git a/plugins/ckeditor5-woltlab-paste-text-color/package.json b/plugins/ckeditor5-woltlab-paste-text-color/package.json new file mode 100644 index 0000000..5ba73f2 --- /dev/null +++ b/plugins/ckeditor5-woltlab-paste-text-color/package.json @@ -0,0 +1,6 @@ +{ + "author": "SoftCreatR Media", + "license": "LGPL-2.1-or-later", + "main": "src/index.ts", + "private": true +} diff --git a/plugins/ckeditor5-woltlab-paste-text-color/src/index.ts b/plugins/ckeditor5-woltlab-paste-text-color/src/index.ts new file mode 100644 index 0000000..9b0fcb4 --- /dev/null +++ b/plugins/ckeditor5-woltlab-paste-text-color/src/index.ts @@ -0,0 +1,8 @@ +/** + * @author Sascha Greuel + * @copyright 2026 SoftCreatR Media + * @license LGPL-2.1-or-later + * @since 6.2 + */ + +export { WoltlabPasteTextColor } from "./woltlabpastetextcolor"; diff --git a/plugins/ckeditor5-woltlab-paste-text-color/src/woltlabpastetextcolor.ts b/plugins/ckeditor5-woltlab-paste-text-color/src/woltlabpastetextcolor.ts new file mode 100644 index 0000000..ddaae9f --- /dev/null +++ b/plugins/ckeditor5-woltlab-paste-text-color/src/woltlabpastetextcolor.ts @@ -0,0 +1,184 @@ +/** + * Removes text-color styles that only reproduce the editor's default text color. + * + * @author Sascha Greuel + * @copyright 2026 SoftCreatR Media + * @license LGPL-2.1-or-later + * @since 6.2 + */ + +import { Plugin } from "@ckeditor/ckeditor5-core"; +import { + ClipboardInputTransformationEvent, + ClipboardPipeline, +} from "@ckeditor/ckeditor5-clipboard"; +import { + ViewDocumentFragment, + ViewElement, + ViewUpcastWriter, +} from "@ckeditor/ckeditor5-engine"; + +const COLOR_STYLE = "color"; +const DEFAULT_TEXT_COLOR_VARIABLE = "--wcfContentText"; + +export class WoltlabPasteTextColor extends Plugin { + #colorNormalizer?: CanvasRenderingContext2D | null; + readonly #defaultTextColors = new Set(); + #colorSchemeObserver?: MutationObserver; + + static get pluginName() { + return "WoltlabPasteTextColor"; + } + + init(): void { + this.editor.plugins + .get(ClipboardPipeline) + .on( + "inputTransformation", + (event, data) => { + this.#removeDefaultTextColors(data.content); + }, + { priority: "high" }, + ); + } + + afterInit(): void { + // Reading computed styles once keeps pastes independent of stylesheet size. + this.#rememberEditorTextColors(); + + this.#colorSchemeObserver = new MutationObserver(() => { + // Keep the colors for both schemes; clipboard content may come from a + // tab that has not switched schemes yet. + this.#rememberEditorTextColors(); + }); + this.#colorSchemeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-color-scheme"], + }); + } + + destroy(): void { + this.#colorSchemeObserver?.disconnect(); + + super.destroy(); + } + + #removeDefaultTextColors(documentFragment: ViewDocumentFragment): void { + if (this.#defaultTextColors.size === 0) { + return; + } + + const writer = new ViewUpcastWriter(documentFragment.document); + const range = writer.createRangeIn(documentFragment); + const elements = Array.from(range.getItems()).filter( + (item): item is ViewElement => item.is("element"), + ); + + for (const element of elements) { + const color = element.getStyle(COLOR_STYLE); + if (color === undefined) { + continue; + } + + const normalizedColor = this.#normalizeColor(color); + if ( + normalizedColor !== undefined && + this.#defaultTextColors.has(normalizedColor) && + !this.#hasAncestorWithDifferentTextColor(element, normalizedColor) + ) { + writer.removeStyle(COLOR_STYLE, element); + } + } + } + + #hasAncestorWithDifferentTextColor( + element: ViewElement, + color: string, + ): boolean { + let parent = element.parent; + + while (parent !== null) { + if (parent.is("element")) { + const parentColor = parent.getStyle(COLOR_STYLE); + if (parentColor !== undefined) { + const normalizedParentColor = this.#normalizeColor(parentColor); + + // An explicit parent color changes what the child would inherit. + // Preserve the child style unless the two colors are identical. + if ( + normalizedParentColor === undefined || + normalizedParentColor !== color + ) { + return true; + } + } + } + + parent = parent.parent; + } + + return false; + } + + #rememberEditorTextColors(): void { + const editableElement = this.editor.ui.getEditableElement(); + if (editableElement === null) { + return; + } + + const style = getComputedStyle(editableElement); + this.#addDefaultTextColor(style.color); + this.#addDefaultTextColor( + style.getPropertyValue(DEFAULT_TEXT_COLOR_VARIABLE), + ); + } + + #addDefaultTextColor(color: string): void { + const normalizedColor = this.#normalizeColor(color); + if (normalizedColor !== undefined) { + // Keep colors from previous schemes because the clipboard can originate + // from another tab that has not switched schemes yet. + this.#defaultTextColors.add(normalizedColor); + } + } + + #normalizeColor(color: string): string | undefined { + const trimmedColor = color.trim(); + if (trimmedColor === "" || trimmedColor.includes("var(")) { + return undefined; + } + + const context = this.#getColorNormalizer(); + if (context !== null) { + // Canvas returns the previous value for an unsupported color. A sentinel + // makes that case distinguishable from a valid black value. + context.fillStyle = "#000001"; + context.fillStyle = trimmedColor; + + const normalizedColor = context.fillStyle.toLowerCase(); + if ( + normalizedColor !== "#000001" || + trimmedColor.replace(/\s/g, "").toLowerCase() === "rgb(0,0,1)" + ) { + return normalizedColor; + } + } + + return trimmedColor + .replace(/\s/g, "") + .replace(/^rgba\((\d+),(\d+),(\d+),(?:1|1\.0)\)$/i, "rgb($1,$2,$3)") + .toLowerCase(); + } + + #getColorNormalizer(): CanvasRenderingContext2D | null { + if (this.#colorNormalizer !== undefined) { + return this.#colorNormalizer; + } + + this.#colorNormalizer = document.createElement("canvas").getContext("2d"); + + return this.#colorNormalizer; + } +} + +export default WoltlabPasteTextColor; From 4a86a1b05604b52cdfeb227b4fa76c040ad969f1 Mon Sep 17 00:00:00 2001 From: Sascha Greuel Date: Thu, 23 Jul 2026 14:23:22 +0200 Subject: [PATCH 3/3] Corrected lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CKEditor initializes plugins before the editable DOM element exists. We'll defer caching and observer setup until the editor’s ready event. --- .../src/woltlabpastetextcolor.ts | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/plugins/ckeditor5-woltlab-paste-text-color/src/woltlabpastetextcolor.ts b/plugins/ckeditor5-woltlab-paste-text-color/src/woltlabpastetextcolor.ts index ddaae9f..bbc8e95 100644 --- a/plugins/ckeditor5-woltlab-paste-text-color/src/woltlabpastetextcolor.ts +++ b/plugins/ckeditor5-woltlab-paste-text-color/src/woltlabpastetextcolor.ts @@ -43,17 +43,20 @@ export class WoltlabPasteTextColor extends Plugin { } afterInit(): void { - // Reading computed styles once keeps pastes independent of stylesheet size. - this.#rememberEditorTextColors(); - - this.#colorSchemeObserver = new MutationObserver(() => { - // Keep the colors for both schemes; clipboard content may come from a - // tab that has not switched schemes yet. + // The editable DOM element is created after CKEditor initializes plugins. + this.listenTo(this.editor, "ready", () => { + // Reading computed styles once keeps pastes independent of stylesheet size. this.#rememberEditorTextColors(); - }); - this.#colorSchemeObserver.observe(document.documentElement, { - attributes: true, - attributeFilter: ["data-color-scheme"], + + this.#colorSchemeObserver = new MutationObserver(() => { + // Keep the colors for both schemes; clipboard content may come from a + // tab that has not switched schemes yet. + this.#rememberEditorTextColors(); + }); + this.#colorSchemeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-color-scheme"], + }); }); } @@ -122,7 +125,7 @@ export class WoltlabPasteTextColor extends Plugin { #rememberEditorTextColors(): void { const editableElement = this.editor.ui.getEditableElement(); - if (editableElement === null) { + if (editableElement === undefined) { return; }