feat: add MarkdownTables plugin (#1089)
* feat: add MarkdownTables plugin * bleh --------- Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
parent
8156840270
commit
768b932570
4 changed files with 1034 additions and 0 deletions
374
src/equicordplugins/markdownTables/index.tsx
Normal file
374
src/equicordplugins/markdownTables/index.tsx
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
/*
|
||||
* 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);
|
||||
},
|
||||
});
|
||||
410
src/equicordplugins/markdownTables/parser.ts
Normal file
410
src/equicordplugins/markdownTables/parser.ts
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export type TableAlignment = "left" | "center" | "right" | null;
|
||||
|
||||
export interface MarkdownTable {
|
||||
header: string[];
|
||||
alignments: TableAlignment[];
|
||||
rows: string[][];
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
}
|
||||
|
||||
export interface MarkdownTableBlock {
|
||||
raw: string;
|
||||
table: MarkdownTable;
|
||||
}
|
||||
|
||||
export interface MarkdownTableMatch {
|
||||
raw: string;
|
||||
leadingMarkdown: string;
|
||||
tableRaw: string;
|
||||
table: MarkdownTable;
|
||||
}
|
||||
|
||||
interface SeparatorParseResult {
|
||||
alignments: TableAlignment[];
|
||||
}
|
||||
|
||||
const separatorCellRe = /^:?-{3,}:?$/;
|
||||
|
||||
function isEscapedAt(value: string, index: number) {
|
||||
let slashCount = 0;
|
||||
|
||||
for (let i = index - 1; i >= 0 && value[i] === "\\"; i--) {
|
||||
slashCount++;
|
||||
}
|
||||
|
||||
return slashCount % 2 === 1;
|
||||
}
|
||||
|
||||
function countEscapedPipes(line: string) {
|
||||
let count = 0;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
if (line[i] === "|" && isEscapedAt(line, i)) count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
function hasUnescapedTableDelimiter(line: string) {
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
if (line[i] === "|" && !isEscapedAt(line, i)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasEscapedTableDelimiters(line: string) {
|
||||
return countEscapedPipes(line) >= 2;
|
||||
}
|
||||
|
||||
function hasTableDelimiter(line: string) {
|
||||
return hasUnescapedTableDelimiter(line) || hasEscapedTableDelimiters(line);
|
||||
}
|
||||
|
||||
function isIndentedCodeLine(line: string) {
|
||||
return /^(?: {4,}|\t)/.test(line);
|
||||
}
|
||||
|
||||
function isFenceLine(line: string) {
|
||||
return /^(?: {0,3})(`{3,}|~{3,})/.test(line);
|
||||
}
|
||||
|
||||
function canBeTableLine(line: string) {
|
||||
return line.trim() !== ""
|
||||
&& !isIndentedCodeLine(line)
|
||||
&& !isFenceLine(line)
|
||||
&& hasTableDelimiter(line);
|
||||
}
|
||||
|
||||
function stripOuterPipes(line: string) {
|
||||
const trimmed = line.trim();
|
||||
let start = 0;
|
||||
let end = trimmed.length;
|
||||
|
||||
if (trimmed[start] === "|") start++;
|
||||
if (trimmed[end - 1] === "|" && !isEscapedAt(trimmed, end - 1)) end--;
|
||||
|
||||
return trimmed.slice(start, end);
|
||||
}
|
||||
|
||||
function hasOuterTablePipes(line: string) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
return (trimmed.startsWith("|") || trimmed.startsWith("\\|"))
|
||||
&& (trimmed.endsWith("|") || trimmed.endsWith("\\|"));
|
||||
}
|
||||
|
||||
export function splitTableRow(line: string) {
|
||||
const cells: string[] = [];
|
||||
const row = stripOuterPipes(hasUnescapedTableDelimiter(line) ? line : line.replace(/\\\|/g, "|"));
|
||||
let current = "";
|
||||
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const char = row[i];
|
||||
|
||||
if (char === "\\" && row[i + 1] === "|" && !isEscapedAt(row, i)) {
|
||||
current += "|";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "|" && !isEscapedAt(row, i)) {
|
||||
cells.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
cells.push(current.trim());
|
||||
return cells;
|
||||
}
|
||||
|
||||
function parseAlignment(cell: string): TableAlignment | undefined {
|
||||
const value = cell.trim();
|
||||
|
||||
if (!separatorCellRe.test(value)) return undefined;
|
||||
if (value.startsWith(":") && value.endsWith(":")) return "center";
|
||||
if (value.startsWith(":")) return "left";
|
||||
if (value.endsWith(":")) return "right";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseSeparator(line: string): SeparatorParseResult | null {
|
||||
if (!canBeTableLine(line)) return null;
|
||||
|
||||
const cells = splitTableRow(line);
|
||||
if (cells.length < 2) return null;
|
||||
|
||||
const alignments = cells.map(parseAlignment);
|
||||
if (alignments.some(alignment => alignment === undefined)) return null;
|
||||
|
||||
return {
|
||||
alignments: alignments as TableAlignment[],
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseCells(cells: string[], width: number) {
|
||||
return Array.from({ length: width }, (_, index) => cells[index] ?? "");
|
||||
}
|
||||
|
||||
function isBlankLine(line: string) {
|
||||
return line.trim() === "";
|
||||
}
|
||||
|
||||
function nextContentLine(lines: string[], cursor: number, fenced?: boolean[]) {
|
||||
for (let index = cursor; index < lines.length; index++) {
|
||||
if (fenced?.[index]) return -1;
|
||||
if (!isBlankLine(lines[index])) return index;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function canStartTableAt(lines: string[], lineIndex: number, fenced?: boolean[]) {
|
||||
if (lineIndex < 0 || lineIndex >= lines.length - 1) return false;
|
||||
if (fenced?.[lineIndex] || fenced?.[lineIndex + 1]) return false;
|
||||
|
||||
return canBeTableLine(lines[lineIndex]) && parseSeparator(lines[lineIndex + 1]) !== null;
|
||||
}
|
||||
|
||||
function getFenceMask(lines: string[]) {
|
||||
const mask = new Array<boolean>(lines.length).fill(false);
|
||||
let fenceChar: "`" | "~" | null = null;
|
||||
let fenceLength = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const openingMatch = /^(?: {0,3})(`{3,}|~{3,})/.exec(line);
|
||||
|
||||
if (!fenceChar) {
|
||||
if (openingMatch) {
|
||||
fenceChar = openingMatch[1][0] as "`" | "~";
|
||||
fenceLength = openingMatch[1].length;
|
||||
mask[i] = true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
mask[i] = true;
|
||||
|
||||
const trimmed = line.trimStart();
|
||||
let closingLength = 0;
|
||||
|
||||
while (trimmed[closingLength] === fenceChar) {
|
||||
closingLength++;
|
||||
}
|
||||
|
||||
if (closingLength >= fenceLength && trimmed.slice(closingLength).trim() === "") {
|
||||
fenceChar = null;
|
||||
fenceLength = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return mask;
|
||||
}
|
||||
|
||||
function parseTableAtLine(lines: string[], lineIndex: number, fenced?: boolean[]) {
|
||||
if (lineIndex >= lines.length - 1) return null;
|
||||
if (fenced?.[lineIndex] || fenced?.[lineIndex + 1]) return null;
|
||||
if (!canBeTableLine(lines[lineIndex]) || !canBeTableLine(lines[lineIndex + 1])) return null;
|
||||
|
||||
const headerCells = splitTableRow(lines[lineIndex]);
|
||||
if (headerCells.length < 2) return null;
|
||||
|
||||
const separator = parseSeparator(lines[lineIndex + 1]);
|
||||
if (!separator) return null;
|
||||
|
||||
const width = separator.alignments.length;
|
||||
const rows: string[][] = [];
|
||||
let cursor = lineIndex + 2;
|
||||
|
||||
while (cursor < lines.length && !fenced?.[cursor]) {
|
||||
const line = lines[cursor];
|
||||
|
||||
if (canBeTableLine(line)) {
|
||||
rows.push(normaliseCells(splitTableRow(line), width));
|
||||
cursor++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rows.length > 0 && isBlankLine(line)) {
|
||||
const nextLineIndex = nextContentLine(lines, cursor + 1, fenced);
|
||||
const nextLine = nextLineIndex >= 0 ? lines[nextLineIndex] : "";
|
||||
|
||||
if (nextLineIndex >= 0 && canBeTableLine(nextLine) && !canStartTableAt(lines, nextLineIndex, fenced) && !parseSeparator(nextLine)) {
|
||||
cursor = nextLineIndex;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const nextLineIndex = nextContentLine(lines, cursor + 1, fenced);
|
||||
const nextLine = nextLineIndex >= 0 ? lines[nextLineIndex] : "";
|
||||
|
||||
if (rows.length > 0 && !isBlankLine(line) && nextLineIndex >= 0 && canBeTableLine(nextLine) && !canStartTableAt(lines, nextLineIndex, fenced) && !parseSeparator(nextLine)) {
|
||||
rows[rows.length - 1][width - 1] = `${rows[rows.length - 1][width - 1]}\n${line.trim()}`.trim();
|
||||
cursor++;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
while (cursor > lineIndex + 2 && isBlankLine(lines[cursor - 1])) {
|
||||
cursor--;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
table: {
|
||||
header: normaliseCells(headerCells, width),
|
||||
alignments: separator.alignments,
|
||||
rows,
|
||||
startLine: lineIndex,
|
||||
endLine: cursor - 1,
|
||||
},
|
||||
nextLineIndex: cursor,
|
||||
};
|
||||
}
|
||||
|
||||
function parseLooseRowsAtLine(lines: string[], lineIndex: number, fenced?: boolean[]) {
|
||||
if (fenced?.[lineIndex] || !canBeTableLine(lines[lineIndex]) || !hasOuterTablePipes(lines[lineIndex])) return null;
|
||||
|
||||
const firstRow = splitTableRow(lines[lineIndex]);
|
||||
const width = firstRow.length;
|
||||
const rows: string[][] = [];
|
||||
let cursor = lineIndex;
|
||||
|
||||
if (width < 3) return null;
|
||||
|
||||
while (cursor < lines.length && !fenced?.[cursor] && canBeTableLine(lines[cursor]) && hasOuterTablePipes(lines[cursor])) {
|
||||
const cells = splitTableRow(lines[cursor]);
|
||||
if (cells.length < 3) break;
|
||||
|
||||
rows.push(normaliseCells(cells, width));
|
||||
cursor++;
|
||||
}
|
||||
|
||||
if (rows.length < 2) return null;
|
||||
|
||||
return {
|
||||
table: {
|
||||
header: [],
|
||||
alignments: Array.from({ length: width }, () => null),
|
||||
rows,
|
||||
startLine: lineIndex,
|
||||
endLine: cursor - 1,
|
||||
},
|
||||
nextLineIndex: cursor,
|
||||
};
|
||||
}
|
||||
|
||||
function stripLineEnding(line: string) {
|
||||
return line.endsWith("\r\n")
|
||||
? line.slice(0, -2)
|
||||
: line.endsWith("\n")
|
||||
? line.slice(0, -1)
|
||||
: line.endsWith("\r")
|
||||
? line.slice(0, -1)
|
||||
: line;
|
||||
}
|
||||
|
||||
function sourceLines(markdown: string) {
|
||||
const lines: Array<{ raw: string; text: string; }> = [];
|
||||
let start = 0;
|
||||
|
||||
for (let i = 0; i < markdown.length; i++) {
|
||||
if (markdown[i] !== "\n") continue;
|
||||
|
||||
const raw = markdown.slice(start, i + 1);
|
||||
lines.push({
|
||||
raw,
|
||||
text: stripLineEnding(raw),
|
||||
});
|
||||
start = i + 1;
|
||||
}
|
||||
|
||||
if (start < markdown.length) {
|
||||
const raw = markdown.slice(start);
|
||||
lines.push({
|
||||
raw,
|
||||
text: stripLineEnding(raw),
|
||||
});
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function rawTableBlock(lines: Array<{ raw: string; }>, nextLineIndex: number) {
|
||||
return lines
|
||||
.slice(0, nextLineIndex)
|
||||
.map((line, index) => index === nextLineIndex - 1 ? stripLineEnding(line.raw) : line.raw)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function parseMarkdownTableBlock(markdown: string): MarkdownTableBlock | null {
|
||||
const lines = sourceLines(markdown);
|
||||
const parsed = parseTableAtLine(lines.map(line => line.text), 0);
|
||||
|
||||
if (!parsed) return null;
|
||||
|
||||
return {
|
||||
raw: rawTableBlock(lines, parsed.nextLineIndex),
|
||||
table: parsed.table,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMarkdownTableMatch(markdown: string): MarkdownTableMatch | null {
|
||||
const lines = sourceLines(markdown);
|
||||
const textLines = lines.map(line => line.text);
|
||||
const fenced = getFenceMask(textLines);
|
||||
|
||||
for (let lineIndex = 0; lineIndex < textLines.length - 1; lineIndex++) {
|
||||
const parsed = parseTableAtLine(textLines, lineIndex, fenced) ?? parseLooseRowsAtLine(textLines, lineIndex, fenced);
|
||||
if (!parsed) continue;
|
||||
|
||||
const leadingMarkdown = lines
|
||||
.slice(0, lineIndex)
|
||||
.map(line => line.raw)
|
||||
.join("");
|
||||
const tableRaw = rawTableBlock(lines.slice(lineIndex), parsed.nextLineIndex - lineIndex);
|
||||
|
||||
return {
|
||||
raw: `${leadingMarkdown}${tableRaw}`,
|
||||
leadingMarkdown,
|
||||
tableRaw,
|
||||
table: parsed.table,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseMarkdownTables(markdown: string): MarkdownTable[] {
|
||||
const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
|
||||
const fenced = getFenceMask(lines);
|
||||
const tables: MarkdownTable[] = [];
|
||||
|
||||
for (let lineIndex = 0; lineIndex < lines.length - 1; lineIndex++) {
|
||||
const parsed = parseTableAtLine(lines, lineIndex, fenced);
|
||||
if (!parsed) continue;
|
||||
|
||||
tables.push(parsed.table);
|
||||
lineIndex = parsed.nextLineIndex - 1;
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
246
src/equicordplugins/markdownTables/styles.css
Normal file
246
src/equicordplugins/markdownTables/styles.css
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
.vc-markdownTables-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.vc-markdownTables-toolbar {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
border-radius: 4px;
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.vc-markdownTables-toggle {
|
||||
min-width: 44px;
|
||||
padding: 2px 8px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.25rem;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vc-markdownTables-toggle:hover {
|
||||
color: var(--text-normal);
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.vc-markdownTables-toggle-active,
|
||||
.vc-markdownTables-toggle-active:hover {
|
||||
color: var(--interactive-active);
|
||||
background: var(--background-modifier-selected);
|
||||
}
|
||||
|
||||
.vc-markdownTables-raw {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
color: inherit;
|
||||
background: var(--background-secondary);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: gray transparent;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.vc-markdownTables-scrollFrame {
|
||||
--vc-markdown-tables-mask-size: 42px;
|
||||
--vc-markdown-tables-fade-color: var(--background-primary);
|
||||
--vc-markdown-tables-fade-mid: color-mix(in srgb, var(--vc-markdown-tables-fade-color) 74%, transparent);
|
||||
--vc-markdown-tables-fade-soft: color-mix(in srgb, var(--vc-markdown-tables-fade-color) 36%, transparent);
|
||||
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vc-markdownTables-scrollFrame::before,
|
||||
.vc-markdownTables-scrollFrame::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
width: var(--vc-markdown-tables-mask-size);
|
||||
content: "";
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
width 180ms ease;
|
||||
}
|
||||
|
||||
.vc-markdownTables-scrollFrame::before {
|
||||
left: 0;
|
||||
background: linear-gradient(to right,
|
||||
var(--vc-markdown-tables-fade-color) 0,
|
||||
var(--vc-markdown-tables-fade-mid) 42%,
|
||||
var(--vc-markdown-tables-fade-soft) 70%,
|
||||
transparent 100%);
|
||||
}
|
||||
|
||||
.vc-markdownTables-scrollFrame::after {
|
||||
right: 0;
|
||||
background: linear-gradient(to left,
|
||||
var(--vc-markdown-tables-fade-color) 0,
|
||||
var(--vc-markdown-tables-fade-mid) 42%,
|
||||
var(--vc-markdown-tables-fade-soft) 70%,
|
||||
transparent 100%);
|
||||
}
|
||||
|
||||
.vc-markdownTables-mask-left::before,
|
||||
.vc-markdownTables-mask-right::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vc-markdownTables-scroll-left::before,
|
||||
.vc-markdownTables-scroll-right::after {
|
||||
width: calc(var(--vc-markdown-tables-mask-size) + 10px);
|
||||
}
|
||||
|
||||
.vc-markdownTables-scroll {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
background: transparent;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: 100% 100%;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: gray transparent;
|
||||
}
|
||||
|
||||
.vc-markdownTables-mask-left .vc-markdownTables-scroll {
|
||||
mask-image: linear-gradient(to right,
|
||||
transparent 0,
|
||||
black var(--vc-markdown-tables-mask-size),
|
||||
black 100%);
|
||||
}
|
||||
|
||||
.vc-markdownTables-mask-right .vc-markdownTables-scroll {
|
||||
mask-image: linear-gradient(to right,
|
||||
black 0,
|
||||
black calc(100% - var(--vc-markdown-tables-mask-size)),
|
||||
transparent 100%);
|
||||
}
|
||||
|
||||
.vc-markdownTables-mask-left.vc-markdownTables-mask-right .vc-markdownTables-scroll {
|
||||
mask-image: linear-gradient(to right,
|
||||
transparent 0,
|
||||
black var(--vc-markdown-tables-mask-size),
|
||||
black calc(100% - var(--vc-markdown-tables-mask-size)),
|
||||
transparent 100%);
|
||||
}
|
||||
|
||||
.vc-markdownTables-table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
color: inherit;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.vc-markdownTables-table th,
|
||||
.vc-markdownTables-table td {
|
||||
max-width: 440px;
|
||||
padding: 6px 10px;
|
||||
border-right: 1px solid var(--background-modifier-accent);
|
||||
border-bottom: 1px solid var(--background-modifier-accent);
|
||||
vertical-align: top;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.vc-markdownTables-table th {
|
||||
font-weight: 600;
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.vc-markdownTables-table tbody tr:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.vc-markdownTables-table th:last-child,
|
||||
.vc-markdownTables-table td:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.vc-markdownTables-table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
@supports (color: color-mix(in srgb, white, black)) {
|
||||
.vc-markdownTables-scrollFrame {
|
||||
border-color: color-mix(in srgb, currentcolor 24%, transparent);
|
||||
}
|
||||
|
||||
.vc-markdownTables-toolbar,
|
||||
.vc-markdownTables-raw {
|
||||
border-color: color-mix(in srgb, currentcolor 18%, transparent);
|
||||
}
|
||||
|
||||
.vc-markdownTables-table th,
|
||||
.vc-markdownTables-table td {
|
||||
border-right-color: color-mix(in srgb, currentcolor 18%, transparent);
|
||||
border-bottom-color: color-mix(in srgb, currentcolor 18%, transparent);
|
||||
}
|
||||
|
||||
.vc-markdownTables-table th {
|
||||
background: color-mix(in srgb, currentcolor 7%, transparent);
|
||||
}
|
||||
|
||||
.vc-markdownTables-table tbody tr:hover {
|
||||
background: color-mix(in srgb, currentcolor 5%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.vc-markdownTables-scrollFrame:is(.theme-dark *) {
|
||||
--vc-markdown-tables-fade-color: color-mix(in srgb, var(--background-primary) 88%, black);
|
||||
}
|
||||
|
||||
.vc-markdownTables-scrollFrame:is(.theme-light *) {
|
||||
--vc-markdown-tables-fade-color: color-mix(in srgb, var(--background-primary) 88%, white);
|
||||
}
|
||||
|
||||
.vc-markdownTables-raw::-webkit-scrollbar,
|
||||
.vc-markdownTables-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.vc-markdownTables-raw::-webkit-scrollbar-thumb,
|
||||
.vc-markdownTables-scroll::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background-color: gray;
|
||||
}
|
||||
|
||||
.vc-markdownTables-raw::-webkit-scrollbar-track,
|
||||
.vc-markdownTables-scroll::-webkit-scrollbar-track,
|
||||
.vc-markdownTables-raw::-webkit-scrollbar-corner,
|
||||
.vc-markdownTables-scroll::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.vc-markdownTables-align-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.vc-markdownTables-align-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vc-markdownTables-align-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
|
@ -664,6 +664,10 @@ export const EquicordDevs = Object.freeze({
|
|||
name: "nobody",
|
||||
id: 0n
|
||||
},
|
||||
yafyx: {
|
||||
name: "kiniyaku",
|
||||
id: 658209494609821699n
|
||||
},
|
||||
thororen: {
|
||||
name: "thororen",
|
||||
id: 848339671629299742n
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue