Skip to content

Commit d73a30f

Browse files
committed
Improving tables
1 parent ab2804b commit d73a30f

5 files changed

Lines changed: 206 additions & 58 deletions

File tree

public/r/editor.json

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

registry/default/editor/editor.tsx

Lines changed: 94 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ export type ImageUploadHandler = (
6161

6262
const DEFAULT_MAX_IMAGE_BYTES = 1_000_000;
6363
const UPLOADED_IMAGE_PRELOAD_TIMEOUT_MS = 8_000;
64+
const MARKDOWN_TABLE_ROW_PATTERN = /^\s*\|.*\|\s*$/;
65+
const MARKDOWN_TABLE_DELIMITER_CELL_PATTERN = /^:?-{3,}:?$/;
66+
const TABLE_CELL_NBSP_PATTERN = /^(?: |\u00A0)+$/i;
6467
const UploadableImage = Image.extend({
6568
addAttributes() {
6669
return {
@@ -183,6 +186,52 @@ const toUploadableAttrs = (attrs: unknown): UploadableImageAttrs => {
183186
return attrs as UploadableImageAttrs;
184187
};
185188

189+
const splitMarkdownTableCells = (line: string): string[] => {
190+
const trimmed = line.trim();
191+
if (trimmed.length < 2 || !trimmed.startsWith("|") || !trimmed.endsWith("|")) return [];
192+
193+
const row = trimmed.slice(1, -1);
194+
const cells: string[] = [];
195+
let start = 0;
196+
197+
for (let index = 0; index < row.length; index += 1) {
198+
if (row[index] !== "|") continue;
199+
200+
let slashCount = 0;
201+
for (let slashIndex = index - 1; slashIndex >= 0 && row[slashIndex] === "\\"; slashIndex -= 1) {
202+
slashCount += 1;
203+
}
204+
if (slashCount % 2 === 1) continue;
205+
206+
cells.push(row.slice(start, index));
207+
start = index + 1;
208+
}
209+
210+
cells.push(row.slice(start));
211+
return cells;
212+
};
213+
214+
const isMarkdownTableDelimiterLine = (line: string): boolean => {
215+
if (!MARKDOWN_TABLE_ROW_PATTERN.test(line)) return false;
216+
const cells = splitMarkdownTableCells(line);
217+
if (!cells.length) return false;
218+
return cells.every((cell) => MARKDOWN_TABLE_DELIMITER_CELL_PATTERN.test(cell.trim()));
219+
};
220+
221+
const normalizeMarkdownTables = (markdown: string): string =>
222+
markdown
223+
.split("\n")
224+
.map((line) => {
225+
if (!MARKDOWN_TABLE_ROW_PATTERN.test(line) || isMarkdownTableDelimiterLine(line)) return line;
226+
227+
const cells = splitMarkdownTableCells(line);
228+
if (!cells.length) return line;
229+
230+
const normalizedCells = cells.map((cell) => (TABLE_CELL_NBSP_PATTERN.test(cell.trim()) ? "" : cell.trim()));
231+
return `| ${normalizedCells.join(" | ")} |`;
232+
})
233+
.join("\n");
234+
186235
const blockOptions: Array<{ value: BlockType; label: string }> = [
187236
{ value: "paragraph", label: "Text" },
188237
{ value: "heading1", label: "Heading 1" },
@@ -224,7 +273,7 @@ export function Editor({
224273
const objectUrlByUploadIdRef = useRef(new Map<string, string>());
225274
const expectedBlobByUploadIdRef = useRef(new Map<string, string>());
226275
const tiptapSurfaceClass = cn(
227-
"border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] md:text-sm [&_p.is-empty::before]:text-muted-foreground [&_p.is-empty::before]:content-[attr(data-placeholder)] [&_p.is-empty::before]:pointer-events-none [&_p.is-empty::before]:float-left [&_p.is-empty::before]:h-0 [&_img[data-uploading=true]]:opacity-70 [&_img[data-uploading=true]]:animate-pulse [&_img[data-upload-error]]:ring-2 [&_img[data-upload-error]]:ring-destructive [&_img[data-upload-error]]:ring-offset-2 [&_img[data-upload-error]]:ring-offset-background",
276+
"border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] md:text-sm [&_p.is-empty::before]:text-muted-foreground [&_p.is-empty::before]:content-[attr(data-placeholder)] [&_p.is-empty::before]:pointer-events-none [&_p.is-empty::before]:float-left [&_p.is-empty::before]:h-0 [&_td_p.is-empty::before]:content-none [&_th_p.is-empty::before]:content-none [&_img[data-uploading=true]]:opacity-70 [&_img[data-uploading=true]]:animate-pulse [&_img[data-upload-error]]:ring-2 [&_img[data-upload-error]]:ring-destructive [&_img[data-upload-error]]:ring-offset-2 [&_img[data-upload-error]]:ring-offset-background",
228277
editorClassName,
229278
);
230279

@@ -249,8 +298,18 @@ export function Editor({
249298
TableHeader,
250299
TableCell,
251300
Placeholder.configure({
252-
placeholder: ({ node }: { node: ProseMirrorNode }): string =>
253-
node.type.name === "paragraph" ? "Press '/' for commands" : "",
301+
placeholder: ({
302+
node,
303+
editor: currentEditor,
304+
}: {
305+
node: ProseMirrorNode;
306+
editor: TiptapEditor;
307+
}): string =>
308+
node.type.name === "paragraph" &&
309+
!currentEditor.isActive("tableCell") &&
310+
!currentEditor.isActive("tableHeader")
311+
? "Press '/' for commands"
312+
: "",
254313
showOnlyCurrent: true,
255314
includeChildren: true,
256315
}),
@@ -330,7 +389,7 @@ export function Editor({
330389
onUpdate: ({ editor: nextEditor }) => {
331390
const nextValue =
332391
format === "markdown"
333-
? nextEditor.getMarkdown()
392+
? normalizeMarkdownTables(nextEditor.getMarkdown())
334393
: nextEditor
335394
.getHTML()
336395
.replace(/\sdata-upload-id="[^"]*"/g, "")
@@ -380,7 +439,7 @@ export function Editor({
380439
if (!editor) return;
381440
if (value === lastEmittedValueRef.current) return;
382441

383-
const current = format === "markdown" ? editor.getMarkdown() : editor.getHTML();
442+
const current = format === "markdown" ? normalizeMarkdownTables(editor.getMarkdown()) : editor.getHTML();
384443
const hasChanged =
385444
format === "markdown" ? value.trimEnd() !== current.trimEnd() : value !== current;
386445

@@ -880,26 +939,28 @@ export function Editor({
880939
>
881940
<div className="flex flex-col gap-1">
882941
<div className="border-border bg-popover flex flex-nowrap items-center gap-0.5 overflow-x-auto rounded-md border p-1 shadow-sm whitespace-nowrap">
883-
<div className="group/native-select relative w-fit">
884-
<select
885-
id="block-style"
886-
value={activeState.blockType}
887-
onChange={(event) => setBlockType(event.target.value as BlockType)}
888-
disabled={disabled}
889-
aria-label="Block style"
890-
className="h-7 w-full appearance-none rounded-md border border-transparent bg-transparent px-2 pr-5.5 text-sm shadow-none outline-none hover:bg-accent focus-visible:outline-none focus-visible:ring-0 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
891-
>
892-
{blockOptions.map((option) => (
893-
<option key={option.value} value={option.value}>
894-
{option.label}
895-
</option>
896-
))}
897-
</select>
898-
<ChevronDownIcon
899-
className="text-muted-foreground pointer-events-none absolute top-1/2 right-1.5 size-3.5 -translate-y-1/2 opacity-50"
900-
aria-hidden="true"
901-
/>
902-
</div>
942+
{!isInTable ? (
943+
<div className="group/native-select relative w-fit">
944+
<select
945+
id="block-style"
946+
value={activeState.blockType}
947+
onChange={(event) => setBlockType(event.target.value as BlockType)}
948+
disabled={disabled}
949+
aria-label="Block style"
950+
className="h-7 w-full appearance-none rounded-md border border-transparent bg-transparent px-2 pr-5.5 text-sm shadow-none outline-none hover:bg-accent focus-visible:outline-none focus-visible:ring-0 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
951+
>
952+
{blockOptions.map((option) => (
953+
<option key={option.value} value={option.value}>
954+
{option.label}
955+
</option>
956+
))}
957+
</select>
958+
<ChevronDownIcon
959+
className="text-muted-foreground pointer-events-none absolute top-1/2 right-1.5 size-3.5 -translate-y-1/2 opacity-50"
960+
aria-hidden="true"
961+
/>
962+
</div>
963+
) : null}
903964
{inlineActions.map((action) =>
904965
renderIconButton({
905966
label: action.label,
@@ -957,6 +1018,7 @@ export function Editor({
9571018
onKeyDown={(event) => {
9581019
if (event.key === "Enter") {
9591020
event.preventDefault();
1021+
event.stopPropagation();
9601022
applyLink();
9611023
}
9621024
}}
@@ -989,6 +1051,13 @@ export function Editor({
9891051
placeholder="Describe image"
9901052
value={imageAltText}
9911053
onChange={(event) => setImageAltText(event.target.value)}
1054+
onKeyDown={(event) => {
1055+
if (event.key === "Enter") {
1056+
event.preventDefault();
1057+
event.stopPropagation();
1058+
applyImageAlt();
1059+
}
1060+
}}
9921061
disabled={disabled}
9931062
className={`${toolbarInputClass} min-w-56 flex-1`}
9941063
/>

registry/default/editor/slash-command/suggestion.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ type SuggestionOptions = {
3939
imageSlashFallback?: SlashImageFallback;
4040
};
4141

42+
const TABLE_SAFE_COMMANDS = new Set(["Image"]);
43+
4244
type RequestImageAndInsertArgs = ImagePickerContext & {
4345
onRequestImage: ImagePickerHandler | null;
4446
onInsertLocalImageFile: ((context: ImagePickerContext & Omit<ImagePickerFileResult, "kind">) => void | Promise<void>) | null;
@@ -157,11 +159,14 @@ type SuggestionRenderLifecycle = NonNullable<ReturnType<NonNullable<SlashSuggest
157159
type SuggestionKeyDownProps = Parameters<NonNullable<SuggestionRenderLifecycle["onKeyDown"]>>[0];
158160

159161
const createSuggestion = (options: SuggestionOptions = {}): SlashSuggestion => ({
160-
items: ({ query }: { query: string }) =>
161-
getAllItems(options)
162+
items: ({ query, editor }: { query: string; editor: Editor }) => {
163+
const isInTableCell = editor.isActive("tableCell") || editor.isActive("tableHeader");
164+
return getAllItems(options)
165+
.filter((item) => !isInTableCell || TABLE_SAFE_COMMANDS.has(item.title))
162166
.filter((item) => options.enableImages !== false || item.title !== "Image")
163167
.filter((item) => item.title.toLowerCase().includes(query.toLowerCase()))
164-
.slice(0, 10),
168+
.slice(0, 10);
169+
},
165170

166171
render: (): SuggestionRenderLifecycle => {
167172
let component: ReactRenderer<CommandsListHandle> | null = null;

src/components/ui/editor.tsx

Lines changed: 94 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ export type ImageUploadHandler = (
6161

6262
const DEFAULT_MAX_IMAGE_BYTES = 1_000_000;
6363
const UPLOADED_IMAGE_PRELOAD_TIMEOUT_MS = 8_000;
64+
const MARKDOWN_TABLE_ROW_PATTERN = /^\s*\|.*\|\s*$/;
65+
const MARKDOWN_TABLE_DELIMITER_CELL_PATTERN = /^:?-{3,}:?$/;
66+
const TABLE_CELL_NBSP_PATTERN = /^(?:&nbsp;|\u00A0)+$/i;
6467
const UploadableImage = Image.extend({
6568
addAttributes() {
6669
return {
@@ -183,6 +186,52 @@ const toUploadableAttrs = (attrs: unknown): UploadableImageAttrs => {
183186
return attrs as UploadableImageAttrs;
184187
};
185188

189+
const splitMarkdownTableCells = (line: string): string[] => {
190+
const trimmed = line.trim();
191+
if (trimmed.length < 2 || !trimmed.startsWith("|") || !trimmed.endsWith("|")) return [];
192+
193+
const row = trimmed.slice(1, -1);
194+
const cells: string[] = [];
195+
let start = 0;
196+
197+
for (let index = 0; index < row.length; index += 1) {
198+
if (row[index] !== "|") continue;
199+
200+
let slashCount = 0;
201+
for (let slashIndex = index - 1; slashIndex >= 0 && row[slashIndex] === "\\"; slashIndex -= 1) {
202+
slashCount += 1;
203+
}
204+
if (slashCount % 2 === 1) continue;
205+
206+
cells.push(row.slice(start, index));
207+
start = index + 1;
208+
}
209+
210+
cells.push(row.slice(start));
211+
return cells;
212+
};
213+
214+
const isMarkdownTableDelimiterLine = (line: string): boolean => {
215+
if (!MARKDOWN_TABLE_ROW_PATTERN.test(line)) return false;
216+
const cells = splitMarkdownTableCells(line);
217+
if (!cells.length) return false;
218+
return cells.every((cell) => MARKDOWN_TABLE_DELIMITER_CELL_PATTERN.test(cell.trim()));
219+
};
220+
221+
const normalizeMarkdownTables = (markdown: string): string =>
222+
markdown
223+
.split("\n")
224+
.map((line) => {
225+
if (!MARKDOWN_TABLE_ROW_PATTERN.test(line) || isMarkdownTableDelimiterLine(line)) return line;
226+
227+
const cells = splitMarkdownTableCells(line);
228+
if (!cells.length) return line;
229+
230+
const normalizedCells = cells.map((cell) => (TABLE_CELL_NBSP_PATTERN.test(cell.trim()) ? "" : cell.trim()));
231+
return `| ${normalizedCells.join(" | ")} |`;
232+
})
233+
.join("\n");
234+
186235
const blockOptions: Array<{ value: BlockType; label: string }> = [
187236
{ value: "paragraph", label: "Text" },
188237
{ value: "heading1", label: "Heading 1" },
@@ -224,7 +273,7 @@ export function Editor({
224273
const objectUrlByUploadIdRef = useRef(new Map<string, string>());
225274
const expectedBlobByUploadIdRef = useRef(new Map<string, string>());
226275
const tiptapSurfaceClass = cn(
227-
"border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] md:text-sm [&_p.is-empty::before]:text-muted-foreground [&_p.is-empty::before]:content-[attr(data-placeholder)] [&_p.is-empty::before]:pointer-events-none [&_p.is-empty::before]:float-left [&_p.is-empty::before]:h-0 [&_img[data-uploading=true]]:opacity-70 [&_img[data-uploading=true]]:animate-pulse [&_img[data-upload-error]]:ring-2 [&_img[data-upload-error]]:ring-destructive [&_img[data-upload-error]]:ring-offset-2 [&_img[data-upload-error]]:ring-offset-background",
276+
"border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] md:text-sm [&_p.is-empty::before]:text-muted-foreground [&_p.is-empty::before]:content-[attr(data-placeholder)] [&_p.is-empty::before]:pointer-events-none [&_p.is-empty::before]:float-left [&_p.is-empty::before]:h-0 [&_td_p.is-empty::before]:content-none [&_th_p.is-empty::before]:content-none [&_img[data-uploading=true]]:opacity-70 [&_img[data-uploading=true]]:animate-pulse [&_img[data-upload-error]]:ring-2 [&_img[data-upload-error]]:ring-destructive [&_img[data-upload-error]]:ring-offset-2 [&_img[data-upload-error]]:ring-offset-background",
228277
editorClassName,
229278
);
230279

@@ -249,8 +298,18 @@ export function Editor({
249298
TableHeader,
250299
TableCell,
251300
Placeholder.configure({
252-
placeholder: ({ node }: { node: ProseMirrorNode }): string =>
253-
node.type.name === "paragraph" ? "Press '/' for commands" : "",
301+
placeholder: ({
302+
node,
303+
editor: currentEditor,
304+
}: {
305+
node: ProseMirrorNode;
306+
editor: TiptapEditor;
307+
}): string =>
308+
node.type.name === "paragraph" &&
309+
!currentEditor.isActive("tableCell") &&
310+
!currentEditor.isActive("tableHeader")
311+
? "Press '/' for commands"
312+
: "",
254313
showOnlyCurrent: true,
255314
includeChildren: true,
256315
}),
@@ -330,7 +389,7 @@ export function Editor({
330389
onUpdate: ({ editor: nextEditor }) => {
331390
const nextValue =
332391
format === "markdown"
333-
? nextEditor.getMarkdown()
392+
? normalizeMarkdownTables(nextEditor.getMarkdown())
334393
: nextEditor
335394
.getHTML()
336395
.replace(/\sdata-upload-id="[^"]*"/g, "")
@@ -380,7 +439,7 @@ export function Editor({
380439
if (!editor) return;
381440
if (value === lastEmittedValueRef.current) return;
382441

383-
const current = format === "markdown" ? editor.getMarkdown() : editor.getHTML();
442+
const current = format === "markdown" ? normalizeMarkdownTables(editor.getMarkdown()) : editor.getHTML();
384443
const hasChanged =
385444
format === "markdown" ? value.trimEnd() !== current.trimEnd() : value !== current;
386445

@@ -880,26 +939,28 @@ export function Editor({
880939
>
881940
<div className="flex flex-col gap-1">
882941
<div className="border-border bg-popover flex flex-nowrap items-center gap-0.5 overflow-x-auto rounded-md border p-1 shadow-sm whitespace-nowrap">
883-
<div className="group/native-select relative w-fit">
884-
<select
885-
id="block-style"
886-
value={activeState.blockType}
887-
onChange={(event) => setBlockType(event.target.value as BlockType)}
888-
disabled={disabled}
889-
aria-label="Block style"
890-
className="h-7 w-full appearance-none rounded-md border border-transparent bg-transparent px-2 pr-5.5 text-sm shadow-none outline-none hover:bg-accent focus-visible:outline-none focus-visible:ring-0 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
891-
>
892-
{blockOptions.map((option) => (
893-
<option key={option.value} value={option.value}>
894-
{option.label}
895-
</option>
896-
))}
897-
</select>
898-
<ChevronDownIcon
899-
className="text-muted-foreground pointer-events-none absolute top-1/2 right-1.5 size-3.5 -translate-y-1/2 opacity-50"
900-
aria-hidden="true"
901-
/>
902-
</div>
942+
{!isInTable ? (
943+
<div className="group/native-select relative w-fit">
944+
<select
945+
id="block-style"
946+
value={activeState.blockType}
947+
onChange={(event) => setBlockType(event.target.value as BlockType)}
948+
disabled={disabled}
949+
aria-label="Block style"
950+
className="h-7 w-full appearance-none rounded-md border border-transparent bg-transparent px-2 pr-5.5 text-sm shadow-none outline-none hover:bg-accent focus-visible:outline-none focus-visible:ring-0 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
951+
>
952+
{blockOptions.map((option) => (
953+
<option key={option.value} value={option.value}>
954+
{option.label}
955+
</option>
956+
))}
957+
</select>
958+
<ChevronDownIcon
959+
className="text-muted-foreground pointer-events-none absolute top-1/2 right-1.5 size-3.5 -translate-y-1/2 opacity-50"
960+
aria-hidden="true"
961+
/>
962+
</div>
963+
) : null}
903964
{inlineActions.map((action) =>
904965
renderIconButton({
905966
label: action.label,
@@ -957,6 +1018,7 @@ export function Editor({
9571018
onKeyDown={(event) => {
9581019
if (event.key === "Enter") {
9591020
event.preventDefault();
1021+
event.stopPropagation();
9601022
applyLink();
9611023
}
9621024
}}
@@ -989,6 +1051,13 @@ export function Editor({
9891051
placeholder="Describe image"
9901052
value={imageAltText}
9911053
onChange={(event) => setImageAltText(event.target.value)}
1054+
onKeyDown={(event) => {
1055+
if (event.key === "Enter") {
1056+
event.preventDefault();
1057+
event.stopPropagation();
1058+
applyImageAlt();
1059+
}
1060+
}}
9921061
disabled={disabled}
9931062
className={`${toolbarInputClass} min-w-56 flex-1`}
9941063
/>

0 commit comments

Comments
 (0)