mirror of
https://github.com/OHTU-German-learning-website/OHTU-German-learning-website.git
synced 2026-10-12 19:13:51 +03:00
fix: table positioning and coloring light-dark mode
This commit is contained in:
@@ -137,7 +137,7 @@ export default function EditorSection({
|
||||
)}
|
||||
<Row justify="space-between" pb="xl">
|
||||
<Editor
|
||||
defaultContent={initialContent}
|
||||
defaultContent={editorContent}
|
||||
updateEditorContent={(content) => {
|
||||
setEditorContent(content);
|
||||
}}
|
||||
|
||||
@@ -192,7 +192,65 @@ function sanitize(data) {
|
||||
const cleaned = purify.sanitize(data, {
|
||||
ADD_ATTR: ["target", "width", "height", "data-size"],
|
||||
});
|
||||
return cleaned;
|
||||
return normalizeTableSpacing(cleaned);
|
||||
}
|
||||
|
||||
function isEffectivelyEmptyBlock(node) {
|
||||
if (!node || node.nodeType !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tagName = String(node.tagName || "").toUpperCase();
|
||||
if (tagName !== "P" && tagName !== "DIV") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = String(node.textContent || "")
|
||||
.replace(/[\u00a0\u200b\ufeff]/g, " ")
|
||||
.trim();
|
||||
if (text.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const meaningfulChildren = Array.from(node.children || []).filter(
|
||||
(child) => String(child.tagName || "").toUpperCase() !== "BR"
|
||||
);
|
||||
return meaningfulChildren.length === 0;
|
||||
}
|
||||
|
||||
function normalizeTableSpacing(html) {
|
||||
const dom = new JSDOM(`<div id="root">${String(html || "")}</div>`);
|
||||
const { document } = dom.window;
|
||||
const root = document.getElementById("root");
|
||||
if (!root) {
|
||||
return String(html || "");
|
||||
}
|
||||
|
||||
const tables = root.querySelectorAll("table");
|
||||
tables.forEach((table) => {
|
||||
let prev = table.previousElementSibling;
|
||||
while (isEffectivelyEmptyBlock(prev)) {
|
||||
const toRemove = prev;
|
||||
prev = prev.previousElementSibling;
|
||||
toRemove.remove();
|
||||
}
|
||||
|
||||
let next = table.nextElementSibling;
|
||||
while (isEffectivelyEmptyBlock(next)) {
|
||||
const toRemove = next;
|
||||
next = next.nextElementSibling;
|
||||
toRemove.remove();
|
||||
}
|
||||
});
|
||||
|
||||
while (isEffectivelyEmptyBlock(root.firstElementChild)) {
|
||||
root.firstElementChild.remove();
|
||||
}
|
||||
while (isEffectivelyEmptyBlock(root.lastElementChild)) {
|
||||
root.lastElementChild.remove();
|
||||
}
|
||||
|
||||
return root.innerHTML;
|
||||
}
|
||||
|
||||
async function slugIsInUse(type, slug) {
|
||||
|
||||
@@ -617,6 +617,20 @@ label {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Keep published editor tables borderless unless explicitly restyled by page-specific CSS. */
|
||||
.rendered-html table,
|
||||
.rendered-html table tbody,
|
||||
.rendered-html table thead,
|
||||
.rendered-html table tr,
|
||||
.rendered-html table td,
|
||||
.rendered-html table th {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.rendered-html table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.rendered-html {
|
||||
display: flow-root;
|
||||
width: 100%;
|
||||
@@ -687,4 +701,9 @@ label {
|
||||
.ql-table-dropdown-label,
|
||||
.ql-table-dropdown-properties-label {
|
||||
color: var(--bg1);
|
||||
}
|
||||
|
||||
/* Keep the insertion caret readable when text color is auto-adjusted. */
|
||||
.ql-editor {
|
||||
caret-color: currentColor;
|
||||
}
|
||||
@@ -3,8 +3,130 @@ import "quill/dist/quill.snow.css";
|
||||
import "quill-table-better/dist/quill-table-better.css";
|
||||
import "quill-resize-module/dist/resize.css";
|
||||
|
||||
function parseCssColorToRgb(value) {
|
||||
const color = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!color || color === "transparent") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hexMatch = color.match(
|
||||
/^#([\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i
|
||||
);
|
||||
if (hexMatch) {
|
||||
const hex = hexMatch[1];
|
||||
const normalized =
|
||||
hex.length === 3 || hex.length === 4
|
||||
? hex
|
||||
.split("")
|
||||
.map((char) => `${char}${char}`)
|
||||
.join("")
|
||||
: hex;
|
||||
const r = parseInt(normalized.slice(0, 2), 16);
|
||||
const g = parseInt(normalized.slice(2, 4), 16);
|
||||
const b = parseInt(normalized.slice(4, 6), 16);
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
const rgbMatch = color.match(
|
||||
/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/
|
||||
);
|
||||
if (rgbMatch) {
|
||||
const alpha = Number(rgbMatch[4] ?? 1);
|
||||
if (Number.isFinite(alpha) && alpha <= 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
r: Math.min(255, Math.max(0, Math.round(Number(rgbMatch[1])))),
|
||||
g: Math.min(255, Math.max(0, Math.round(Number(rgbMatch[2])))),
|
||||
b: Math.min(255, Math.max(0, Math.round(Number(rgbMatch[3])))),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function channelToLinear(channel) {
|
||||
const srgb = channel / 255;
|
||||
if (srgb <= 0.04045) {
|
||||
return srgb / 12.92;
|
||||
}
|
||||
return ((srgb + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
function relativeLuminance({ r, g, b }) {
|
||||
return (
|
||||
0.2126 * channelToLinear(r) +
|
||||
0.7152 * channelToLinear(g) +
|
||||
0.0722 * channelToLinear(b)
|
||||
);
|
||||
}
|
||||
|
||||
function contrastRatio(l1, l2) {
|
||||
const brightest = Math.max(l1, l2);
|
||||
const darkest = Math.min(l1, l2);
|
||||
return (brightest + 0.05) / (darkest + 0.05);
|
||||
}
|
||||
|
||||
function pickReadableForeground(backgroundColor) {
|
||||
const backgroundRgb = parseCssColorToRgb(backgroundColor);
|
||||
if (!backgroundRgb) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const backgroundLuminance = relativeLuminance(backgroundRgb);
|
||||
const whiteContrast = contrastRatio(backgroundLuminance, 1);
|
||||
const blackContrast = contrastRatio(backgroundLuminance, 0);
|
||||
|
||||
return blackContrast >= whiteContrast ? "#000000" : "#ffffff";
|
||||
}
|
||||
|
||||
function applyTableAutoContrast(quill) {
|
||||
if (!quill?.root || typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidateNodes = quill.root.querySelectorAll("table, td, th");
|
||||
let mutated = false;
|
||||
|
||||
candidateNodes.forEach((node) => {
|
||||
const computedBg = window.getComputedStyle(node).backgroundColor;
|
||||
const nextForeground = pickReadableForeground(computedBg);
|
||||
|
||||
if (!nextForeground) {
|
||||
if (node.dataset.autoContrastApplied === "true") {
|
||||
node.style.removeProperty("color");
|
||||
node.style.removeProperty("caret-color");
|
||||
delete node.dataset.autoContrastApplied;
|
||||
mutated = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const currentColor = (node.style.color || "").trim().toLowerCase();
|
||||
const currentCaretColor = (node.style.caretColor || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const desiredColor = nextForeground.toLowerCase();
|
||||
|
||||
if (
|
||||
currentColor !== desiredColor ||
|
||||
currentCaretColor !== desiredColor ||
|
||||
node.dataset.autoContrastApplied !== "true"
|
||||
) {
|
||||
node.style.color = nextForeground;
|
||||
node.style.caretColor = nextForeground;
|
||||
node.dataset.autoContrastApplied = "true";
|
||||
mutated = true;
|
||||
}
|
||||
});
|
||||
|
||||
return mutated;
|
||||
}
|
||||
|
||||
function setHtmlContent(quill, html) {
|
||||
const content = String(html || "");
|
||||
const content = normalizeEditorHtml(String(html || ""));
|
||||
quill.setContents([], "silent");
|
||||
if (!content) {
|
||||
return;
|
||||
@@ -12,6 +134,99 @@ function setHtmlContent(quill, html) {
|
||||
quill.clipboard.dangerouslyPasteHTML(0, content, "api");
|
||||
}
|
||||
|
||||
function isNodeEffectivelyEmptyText(node) {
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!["P", "DIV"].includes(node.tagName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = (node.textContent || "")
|
||||
.replace(/[\u00a0\u200b\ufeff]/g, " ")
|
||||
.trim();
|
||||
if (text.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const meaningfulChildren = Array.from(node.children).filter((child) => {
|
||||
return child.tagName !== "BR";
|
||||
});
|
||||
|
||||
return meaningfulChildren.length === 0;
|
||||
}
|
||||
|
||||
function normalizeEditorHtml(rawHtml) {
|
||||
const html = String(rawHtml || "");
|
||||
if (!html) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") {
|
||||
return html;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = html;
|
||||
|
||||
// Remove transient table-selection classes so saved HTML does not keep
|
||||
// "selected" visuals when reopening or rendering the page.
|
||||
const transientClasses = [
|
||||
"ql-cell-focused",
|
||||
"ql-cell-selected",
|
||||
"ql-table-tooltip-hover",
|
||||
];
|
||||
wrapper.querySelectorAll("[class]").forEach((node) => {
|
||||
transientClasses.forEach((className) => node.classList.remove(className));
|
||||
if (!node.className.trim()) {
|
||||
node.removeAttribute("class");
|
||||
}
|
||||
});
|
||||
|
||||
const tables = wrapper.querySelectorAll("table");
|
||||
tables.forEach((table) => {
|
||||
let prev = table.previousElementSibling;
|
||||
while (isNodeEffectivelyEmptyText(prev)) {
|
||||
const toRemove = prev;
|
||||
prev = prev.previousElementSibling;
|
||||
toRemove.remove();
|
||||
}
|
||||
|
||||
let next = table.nextElementSibling;
|
||||
while (isNodeEffectivelyEmptyText(next)) {
|
||||
const toRemove = next;
|
||||
next = next.nextElementSibling;
|
||||
toRemove.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Remove leading/trailing empty blocks introduced by Quill around content.
|
||||
while (isNodeEffectivelyEmptyText(wrapper.firstElementChild)) {
|
||||
wrapper.firstElementChild.remove();
|
||||
}
|
||||
while (isNodeEffectivelyEmptyText(wrapper.lastElementChild)) {
|
||||
wrapper.lastElementChild.remove();
|
||||
}
|
||||
|
||||
// Collapse consecutive empty paragraphs/divs to a single one to stop
|
||||
// cumulative "one line lower" drift between repeated save/edit cycles.
|
||||
let cursor = wrapper.firstElementChild;
|
||||
while (cursor) {
|
||||
const next = cursor.nextElementSibling;
|
||||
if (
|
||||
isNodeEffectivelyEmptyText(cursor) &&
|
||||
isNodeEffectivelyEmptyText(next)
|
||||
) {
|
||||
next.remove();
|
||||
continue;
|
||||
}
|
||||
cursor = next;
|
||||
}
|
||||
|
||||
return wrapper.innerHTML;
|
||||
}
|
||||
|
||||
const ClientEditor = (props) => {
|
||||
const containerRef = useRef(null);
|
||||
const updateEditorContentRef = useRef(props.updateEditorContent);
|
||||
@@ -131,14 +346,39 @@ const ClientEditor = (props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const isManagedTableCellTarget = (target) => {
|
||||
const cell = target.closest("td,th");
|
||||
if (!(cell instanceof HTMLTableCellElement)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const table = cell.closest("table");
|
||||
if (!(table instanceof HTMLTableElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return table.classList.contains("ql-table-better");
|
||||
};
|
||||
|
||||
const onWindowResize = () => scheduleReposition();
|
||||
const onEditorScroll = () => scheduleReposition();
|
||||
const onEditorClick = () => scheduleReposition();
|
||||
const onEditorMouseDownCapture = (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isManagedTableCellTarget(target)) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
const onEditorPointerDown = (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menus = target.closest(".ql-table-menus-container");
|
||||
if (menus instanceof HTMLElement) {
|
||||
const rect = menus.getBoundingClientRect();
|
||||
@@ -153,6 +393,11 @@ const ClientEditor = (props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isManagedTableCellTarget(target)) {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// quill-table-better has click handlers that call getAttribute on possibly null nodes.
|
||||
// Block only invalid clicks before plugin bubbling listeners execute.
|
||||
const checkContainer = target.closest(".ql-table-check-container");
|
||||
@@ -165,6 +410,11 @@ const ClientEditor = (props) => {
|
||||
window.addEventListener("resize", onWindowResize);
|
||||
quill.root.addEventListener("scroll", onEditorScroll, true);
|
||||
quill.root.addEventListener("click", onEditorClick, true);
|
||||
quill.container.addEventListener(
|
||||
"mousedown",
|
||||
onEditorMouseDownCapture,
|
||||
true
|
||||
);
|
||||
quill.container.addEventListener(
|
||||
"pointerdown",
|
||||
onEditorPointerDown,
|
||||
@@ -187,6 +437,11 @@ const ClientEditor = (props) => {
|
||||
window.removeEventListener("resize", onWindowResize);
|
||||
quill.root.removeEventListener("scroll", onEditorScroll, true);
|
||||
quill.root.removeEventListener("click", onEditorClick, true);
|
||||
quill.container.removeEventListener(
|
||||
"mousedown",
|
||||
onEditorMouseDownCapture,
|
||||
true
|
||||
);
|
||||
quill.container.removeEventListener(
|
||||
"pointerdown",
|
||||
onEditorPointerDown,
|
||||
@@ -266,7 +521,7 @@ const ClientEditor = (props) => {
|
||||
},
|
||||
onChangeSize: (_blot, _activeEle, _size) => {
|
||||
// Capture resized image HTML and send to parent immediately.
|
||||
const html = quill.root.innerHTML;
|
||||
const html = normalizeEditorHtml(quill.root.innerHTML);
|
||||
lastUserHtmlRef.current = html;
|
||||
updateEditorContentRef.current?.(html);
|
||||
},
|
||||
@@ -287,14 +542,31 @@ const ClientEditor = (props) => {
|
||||
if (source === Quill.sources.SILENT) {
|
||||
return;
|
||||
}
|
||||
const html = quill.root.innerHTML;
|
||||
|
||||
applyTableAutoContrast(quill);
|
||||
|
||||
const html = normalizeEditorHtml(quill.root.innerHTML);
|
||||
lastUserHtmlRef.current = html;
|
||||
updateEditorContentRef.current?.(html);
|
||||
});
|
||||
|
||||
const syncNormalizedContent = () => {
|
||||
const normalizedHtml = normalizeEditorHtml(quill.root.innerHTML);
|
||||
if (normalizedHtml !== quill.root.innerHTML) {
|
||||
setHtmlContent(quill, normalizedHtml);
|
||||
}
|
||||
if (normalizedHtml !== props.defaultContent) {
|
||||
lastUserHtmlRef.current = normalizedHtml;
|
||||
updateEditorContentRef.current?.(normalizedHtml);
|
||||
}
|
||||
};
|
||||
|
||||
quillRef.current = quill;
|
||||
setupTablePropertiesRepositioning();
|
||||
if (props.defaultContent) {
|
||||
setHtmlContent(quill, props.defaultContent);
|
||||
applyTableAutoContrast(quill);
|
||||
syncNormalizedContent();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -329,6 +601,16 @@ const ClientEditor = (props) => {
|
||||
}
|
||||
|
||||
setHtmlContent(quill, nextContent);
|
||||
applyTableAutoContrast(quill);
|
||||
|
||||
const html = normalizeEditorHtml(quill.root.innerHTML);
|
||||
if (html !== nextContent) {
|
||||
setHtmlContent(quill, html);
|
||||
}
|
||||
if (html !== nextContent || html !== lastUserHtmlRef.current) {
|
||||
lastUserHtmlRef.current = html;
|
||||
updateEditorContentRef.current?.(html);
|
||||
}
|
||||
}, [props.defaultContent]);
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user