Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import {
WoltlabMention,
WoltlabMetacode,
WoltlabPasteFromOffice,
WoltlabPasteTextColor,
WoltlabSmiley,
WoltlabSpoiler,
WoltlabToolbarGroup,
Expand Down Expand Up @@ -179,6 +180,7 @@ const defaultConfig: EditorConfig = {
WoltlabMention.WoltlabMention,
WoltlabMetacode.WoltlabMetacode,
WoltlabPasteFromOffice.WoltlabPasteFromOffice,
WoltlabPasteTextColor.WoltlabPasteTextColor,
WoltlabSmiley.WoltlabSmiley,
WoltlabSpoiler.WoltlabSpoiler,
WoltlabToolbarGroup.WoltlabToolbarGroup,
Expand Down
1 change: 1 addition & 0 deletions modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}
Expand Down
6 changes: 6 additions & 0 deletions plugins/ckeditor5-woltlab-paste-text-color/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"author": "SoftCreatR Media",
"license": "LGPL-2.1-or-later",
"main": "src/index.ts",
"private": true
}
8 changes: 8 additions & 0 deletions plugins/ckeditor5-woltlab-paste-text-color/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* @author Sascha Greuel
* @copyright 2026 SoftCreatR Media
* @license LGPL-2.1-or-later
* @since 6.2
*/

export { WoltlabPasteTextColor } from "./woltlabpastetextcolor";
Original file line number Diff line number Diff line change
@@ -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<string>();
#colorSchemeObserver?: MutationObserver;

static get pluginName() {
return "WoltlabPasteTextColor";
}

init(): void {
this.editor.plugins
.get(ClipboardPipeline)
.on<ClipboardInputTransformationEvent>(
"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;