* feat: add MarkdownTables plugin * bleh --------- Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com>
374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
/*
|
|
* Vencord, a Discord client mod
|
|
* Copyright (c) 2026 Vendicated and contributors
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*/
|
|
|
|
import ErrorBoundary from "@components/ErrorBoundary";
|
|
import { EquicordDevs } from "@utils/constants";
|
|
import { classNameFactory } from "@utils/css";
|
|
import definePlugin from "@utils/types";
|
|
import { waitFor } from "@webpack";
|
|
import { Parser, useLayoutEffect, useRef, useState } from "@webpack/common";
|
|
import type { ReactNode } from "react";
|
|
|
|
import { type MarkdownTableMatch, parseMarkdownTableMatch, type TableAlignment } from "./parser";
|
|
import managedStyle from "./styles.css?managed";
|
|
|
|
const TABLE_RULE = "markdownTable";
|
|
const cl = classNameFactory("vc-markdownTables-");
|
|
|
|
type ScrollDirection = "left" | "right" | null;
|
|
type ParsedCell = unknown[];
|
|
type ParserState = Record<string, unknown> & { inline?: boolean; messageId?: string; };
|
|
type ParserParse = (source: string, state: ParserState) => ParsedCell;
|
|
type ParserOutput = (node: unknown, state: ParserState) => ReactNode;
|
|
type MarkdownTableCapture = RegExpExecArray & { markdownTable: MarkdownTableMatch; };
|
|
|
|
interface MarkdownTableNode {
|
|
before: unknown[];
|
|
raw: string;
|
|
header: ParsedCell[];
|
|
alignments: TableAlignment[];
|
|
rows: ParsedCell[][];
|
|
}
|
|
|
|
interface MarkdownRule {
|
|
order: number;
|
|
match(source: string, state: ParserState): MarkdownTableCapture | null;
|
|
parse(capture: MarkdownTableCapture, parse: ParserParse, state: ParserState): MarkdownTableNode;
|
|
react(node: MarkdownTableNode, output: ParserOutput, state: ParserState): ReactNode;
|
|
}
|
|
|
|
interface ScrollMaskState {
|
|
left: boolean;
|
|
right: boolean;
|
|
direction: ScrollDirection;
|
|
}
|
|
|
|
interface MarkdownTableRendererProps {
|
|
node: MarkdownTableNode;
|
|
output: ParserOutput;
|
|
state: ParserState;
|
|
}
|
|
|
|
let shouldInstallTableRule = false;
|
|
let installedRules: MarkdownRules | null = null;
|
|
|
|
type MarkdownRules = Record<string, Partial<MarkdownRule> | undefined>;
|
|
|
|
function alignmentClass(alignment: TableAlignment) {
|
|
if (alignment === "left") return cl("align-left");
|
|
if (alignment === "center") return cl("align-center");
|
|
if (alignment === "right") return cl("align-right");
|
|
return undefined;
|
|
}
|
|
|
|
function createTableCapture(markdownTable: MarkdownTableMatch, input: string): MarkdownTableCapture {
|
|
return Object.assign([markdownTable.raw] as RegExpExecArray, {
|
|
index: 0,
|
|
input,
|
|
groups: undefined,
|
|
markdownTable,
|
|
});
|
|
}
|
|
|
|
function renderParsedNodes(nodes: ParsedCell, output: ParserOutput, state: ParserState) {
|
|
return nodes.map(node => output(node, state));
|
|
}
|
|
|
|
function TableBlock({
|
|
output,
|
|
outputState,
|
|
table,
|
|
}: {
|
|
output: ParserOutput;
|
|
outputState: ParserState;
|
|
table: MarkdownTableNode;
|
|
}) {
|
|
const [showRaw, setShowRaw] = useState(false);
|
|
|
|
return (
|
|
<div className={cl("root")}>
|
|
<div className={cl("toolbar")} role="group" aria-label="Markdown table view">
|
|
<button
|
|
aria-pressed={!showRaw}
|
|
className={cl("toggle", { "toggle-active": !showRaw })}
|
|
onClick={event => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setShowRaw(false);
|
|
}}
|
|
type="button"
|
|
>
|
|
Table
|
|
</button>
|
|
<button
|
|
aria-pressed={showRaw}
|
|
className={cl("toggle", { "toggle-active": showRaw })}
|
|
onClick={event => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setShowRaw(true);
|
|
}}
|
|
type="button"
|
|
>
|
|
Raw
|
|
</button>
|
|
</div>
|
|
{showRaw
|
|
? (
|
|
<pre className={cl("raw")}>
|
|
<code>{table.raw}</code>
|
|
</pre>
|
|
)
|
|
: <TableScrollFrame output={output} outputState={outputState} table={table} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TableScrollFrame({
|
|
output,
|
|
outputState,
|
|
table,
|
|
}: {
|
|
output: ParserOutput;
|
|
outputState: ParserState;
|
|
table: MarkdownTableNode;
|
|
}) {
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const lastScrollLeftRef = useRef(0);
|
|
const frameRef = useRef(0);
|
|
const directionResetRef = useRef(0);
|
|
const [maskState, setMaskState] = useState<ScrollMaskState>({
|
|
left: false,
|
|
right: false,
|
|
direction: null,
|
|
});
|
|
|
|
useLayoutEffect(() => {
|
|
const scrollElement = scrollRef.current;
|
|
if (!scrollElement) return;
|
|
|
|
function updateMask(trackDirection = false) {
|
|
const maxScrollLeft = Math.max(0, scrollElement!.scrollWidth - scrollElement!.clientWidth);
|
|
const scrollLeft = Math.min(Math.max(scrollElement!.scrollLeft, 0), maxScrollLeft);
|
|
let nextDirection: ScrollDirection = null;
|
|
|
|
if (trackDirection && scrollLeft > lastScrollLeftRef.current) {
|
|
nextDirection = "right";
|
|
} else if (trackDirection && scrollLeft < lastScrollLeftRef.current) {
|
|
nextDirection = "left";
|
|
}
|
|
|
|
lastScrollLeftRef.current = scrollLeft;
|
|
|
|
setMaskState(previousState => {
|
|
const nextState: ScrollMaskState = {
|
|
left: scrollLeft > 1,
|
|
right: maxScrollLeft - scrollLeft > 1,
|
|
direction: nextDirection ?? previousState.direction,
|
|
};
|
|
|
|
if (!nextState.left && !nextState.right) {
|
|
nextState.direction = null;
|
|
}
|
|
|
|
return previousState.left === nextState.left
|
|
&& previousState.right === nextState.right
|
|
&& previousState.direction === nextState.direction
|
|
? previousState
|
|
: nextState;
|
|
});
|
|
|
|
if (nextDirection) {
|
|
window.clearTimeout(directionResetRef.current);
|
|
directionResetRef.current = window.setTimeout(() => {
|
|
setMaskState(previousState => previousState.direction
|
|
? { ...previousState, direction: null }
|
|
: previousState);
|
|
}, 450);
|
|
}
|
|
}
|
|
|
|
function scheduleMaskUpdate(trackDirection = false) {
|
|
window.cancelAnimationFrame(frameRef.current);
|
|
frameRef.current = window.requestAnimationFrame(() => updateMask(trackDirection));
|
|
}
|
|
|
|
function handleScroll() {
|
|
scheduleMaskUpdate(true);
|
|
}
|
|
|
|
function handleResize() {
|
|
scheduleMaskUpdate();
|
|
}
|
|
|
|
const resizeObserver = new ResizeObserver(() => scheduleMaskUpdate());
|
|
resizeObserver.observe(scrollElement);
|
|
if (scrollElement.firstElementChild) resizeObserver.observe(scrollElement.firstElementChild);
|
|
|
|
updateMask();
|
|
scrollElement.addEventListener("scroll", handleScroll, { passive: true });
|
|
window.addEventListener("resize", handleResize);
|
|
|
|
return () => {
|
|
resizeObserver.disconnect();
|
|
scrollElement.removeEventListener("scroll", handleScroll);
|
|
window.removeEventListener("resize", handleResize);
|
|
window.cancelAnimationFrame(frameRef.current);
|
|
window.clearTimeout(directionResetRef.current);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
className={cl(
|
|
"scrollFrame",
|
|
{
|
|
"mask-left": maskState.left,
|
|
"mask-right": maskState.right,
|
|
"scroll-left": maskState.direction === "left",
|
|
"scroll-right": maskState.direction === "right",
|
|
},
|
|
)}
|
|
>
|
|
<div className={cl("scroll")} ref={scrollRef}>
|
|
<table className={cl("table")}>
|
|
{table.header.length > 0 && (
|
|
<thead>
|
|
<tr>
|
|
{table.header.map((cell, cellIndex) => (
|
|
<th className={alignmentClass(table.alignments[cellIndex])} key={cellIndex} scope="col">
|
|
{renderParsedNodes(cell, output, outputState)}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
)}
|
|
<tbody>
|
|
{table.rows.map((row, rowIndex) => (
|
|
<tr key={rowIndex}>
|
|
{row.map((cell, cellIndex) => (
|
|
<td className={alignmentClass(table.alignments[cellIndex])} key={cellIndex}>
|
|
{renderParsedNodes(cell, output, outputState)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function parseCellContent(cells: string[], parse: ParserParse, state: ParserState) {
|
|
const inlineState: ParserState = {
|
|
...state,
|
|
inline: true,
|
|
};
|
|
|
|
return cells.map(cell => parse(cell, inlineState));
|
|
}
|
|
|
|
function shouldSkipTableRule(state: ParserState) {
|
|
return state.inline && !state.messageId;
|
|
}
|
|
|
|
const MarkdownTableRenderer = ErrorBoundary.wrap(function MarkdownTableRenderer({
|
|
node,
|
|
output,
|
|
state,
|
|
}: MarkdownTableRendererProps) {
|
|
return (
|
|
<>
|
|
{renderParsedNodes(node.before, output, state)}
|
|
<TableBlock output={output} outputState={state} table={node} />
|
|
</>
|
|
);
|
|
}, { noop: true });
|
|
|
|
function createTableRule(order: number): MarkdownRule {
|
|
return {
|
|
order,
|
|
match(source, state) {
|
|
if (shouldSkipTableRule(state)) return null;
|
|
|
|
const parsed = parseMarkdownTableMatch(source);
|
|
return parsed ? createTableCapture(parsed, source) : null;
|
|
},
|
|
parse(capture, parse, state) {
|
|
const parsed = capture.markdownTable;
|
|
|
|
return {
|
|
before: parsed.leadingMarkdown ? parse(parsed.leadingMarkdown, state) : [],
|
|
raw: parsed.tableRaw,
|
|
alignments: parsed.table.alignments,
|
|
header: parseCellContent(parsed.table.header, parse, state),
|
|
rows: parsed.table.rows.map(row => parseCellContent(row, parse, state)),
|
|
};
|
|
},
|
|
react(node, output, state) {
|
|
return <MarkdownTableRenderer node={node} output={output} state={state} />;
|
|
},
|
|
};
|
|
}
|
|
function scheduleTableRuleInstall(parser: { defaultRules?: MarkdownRules; }) {
|
|
window.setTimeout(() => installTableRuleForParser(parser), 0);
|
|
}
|
|
|
|
function installTableRuleForParser(parser: { defaultRules?: MarkdownRules; }) {
|
|
if (!shouldInstallTableRule) return;
|
|
|
|
const rules = parser.defaultRules;
|
|
if (!rules || rules[TABLE_RULE]) return;
|
|
|
|
installedRules = rules;
|
|
const paragraphOrder = typeof rules.paragraph?.order === "number" ? rules.paragraph.order : 1;
|
|
rules[TABLE_RULE] = createTableRule(paragraphOrder - 0.5);
|
|
}
|
|
|
|
export default definePlugin({
|
|
name: "MarkdownTables",
|
|
description: "Render GitHub-style markdown tables in Discord messages.",
|
|
tags: ["Chat", "Appearance"],
|
|
authors: [EquicordDevs.yafyx],
|
|
managedStyle,
|
|
patches: [
|
|
{
|
|
find: "simple-markdown: Invalid order for rule",
|
|
replacement: {
|
|
match: /paragraph:\{order:/,
|
|
replace: "markdownTable:$self.getTableRule(),$&",
|
|
},
|
|
},
|
|
{
|
|
find: "Unknown markdown rule:",
|
|
replacement: {
|
|
match: /paragraph:{type:/,
|
|
replace: 'markdownTable:{type:"block"},$&',
|
|
},
|
|
},
|
|
],
|
|
|
|
start() {
|
|
shouldInstallTableRule = true;
|
|
|
|
if (Parser?.defaultRules) {
|
|
scheduleTableRuleInstall(Parser);
|
|
}
|
|
|
|
waitFor("parseTopic", scheduleTableRuleInstall);
|
|
},
|
|
|
|
stop() {
|
|
shouldInstallTableRule = false;
|
|
if (installedRules) delete installedRules[TABLE_RULE];
|
|
installedRules = null;
|
|
},
|
|
|
|
getTableRule(paragraphOrder = 1) {
|
|
return createTableRule(paragraphOrder - 0.5);
|
|
},
|
|
});
|