Merge pull request #523 from erzabytyci/user-tiers

fix: table positioning and coloring light-dark mode
This commit is contained in:
erzabytyci
2026-05-22 12:19:41 +03:00
committed by GitHub
4 changed files with 364 additions and 5 deletions
@@ -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) {
+19
View File
@@ -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 (