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 613e254..23e6203 100644 --- a/plugins/ckeditor5-woltlab-paste-from-office/src/woltlabpastefromoffice.ts +++ b/plugins/ckeditor5-woltlab-paste-from-office/src/woltlabpastefromoffice.ts @@ -36,7 +36,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 +45,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; } 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..bbc8e95 --- /dev/null +++ b/plugins/ckeditor5-woltlab-paste-text-color/src/woltlabpastetextcolor.ts @@ -0,0 +1,187 @@ +/** + * 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 { + // 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 = 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 === undefined) { + 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;