Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0

### Fixed

- Flatten pasted HTML table cells that span columns into ordinary GFM cells immediately, so saving and reopening keeps their content in the column where it was pasted instead of carrying an unrepresentable table span.
- Keep a character reference in a link or image title, a definition's title, or an image description as it was written, such as the `©` in `[l](d.md "t ©")` or `![a ©](d.png)`. Saving wrote it as the character it names, so a file kept to plain ASCII did not stay that way. Once the title is edited, the reference is saved as its character, the same as anywhere else. An image description edited through the image's Markdown now reads a reference typed there as the character it names, the way the file does, where it used to keep the reference's characters as literal text. A definition title holding an escaped reference, such as `\©`, lost its backslash on save and reopened as `©`; it is now saved as written.

- Keep a character reference on a line that ends in a space or a tab, such as ` ` in ` a ` followed by a second line, as it was written. Saving wrote every reference on such a line as the character it names, so a space a reference named at the start of a paragraph or list item was gone once the file reopened. The reference is now written back as it was; the space or tab ending the line is still left out.
Expand Down
2 changes: 1 addition & 1 deletion docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi
- List items and blockquotes may contain other block-level elements.
- Ordered lists render with visual continuation.
- Clicking a task-list checkbox toggles it checked or unchecked.
- Tables render as editable table blocks. Basic table editing uses visual table interaction; pipe-delimited Markdown is not exposed in the editor surface. A row holding more or fewer cells than the header is read as the columns the header declares, which is what a Markdown reader shows; cells beyond the header are dropped and missing cells are filled at the end of the row. A table written with a header and delimiter row and no body rows is kept and rendered as a header-only table.
- Tables render as editable table blocks. Basic table editing uses visual table interaction; pipe-delimited Markdown is not exposed in the editor surface. A row holding more or fewer cells than the header is read as the columns the header declares, which is what a Markdown reader shows; cells beyond the header are dropped and missing cells are filled at the end of the row. A pasted HTML body cell spanning columns is flattened into its starting column and ordinary empty cells for the columns it covered, because GFM cannot represent the span. A table written with a header and delimiter row and no body rows is kept and rendered as a header-only table.
- Code blocks render as styled monospace blocks with syntax highlighting when available. Focused code blocks edit code content directly. Language metadata controls are deferred.
- Footnote definitions render as editable definition blocks that always show their source: `[^` and `]:` as muted monospace marker runs, the label between them in bold, and the definition body in muted text, with a small gap separating the label from each marker run. The presentation does not depend on the caret: it neither appears when a caret arrives nor resolves when one leaves. The label is document text a caret and a selection reach and edit; the marker runs are chrome that hold no document position, so a caret aimed at one resolves inside the definition. A backspace at the start of the definition's body moves the caret to the end of the label rather than merging the body into it.
- Editing a footnote definition's label renames the definition and every reference that named it, so the resolution key moves on both sides together and no reference is left naming a label the file no longer defines. The label commits when the caret leaves it, and a file written while the caret is still in it is written with the label the author typed. An empty label, a label holding a bracket or a line ending, and a label another definition already answers to do not commit; the label the definition was read with stands. `Undo` restores the label the typing began from and returns the caret to it, which reopens the edit, so the rename it reverses settles when the caret next leaves or when the file is written. Editing a reference label still does not create, rename, delete, or modify any definition.
Expand Down
24 changes: 24 additions & 0 deletions src/features/editor/plugins/tableShape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const HEADER_ONLY_MARKDOWN = "| Header only | No body rows |\n| --- | --- |\n";
const RAGGED_HTML =
"<table><tr><th>A</th><th>B</th></tr><tr><td>one</td></tr>" +
"<tr><td>two</td><td>three</td><td>ignored</td></tr></table>";
const COLSPAN_HTML =
'<table><tr><th>A</th><th>B</th></tr><tr><td colspan="2">merged</td></tr></table>';

const markdownCell = (value: string): MarkdownNode => ({
type: "tableCell",
Expand Down Expand Up @@ -151,6 +153,28 @@ describe("table shape plugin", () => {
]);
});

it("flattens a pasted colspan cell before it is saved", async () => {
const mounted = await mountEditor("");

dispatchClipboardEvent(mounted.view.dom, "paste", {
[TEXT_HTML_MIME_TYPE]: COLSPAN_HTML,
[TEXT_PLAIN_MIME_TYPE]: "merged",
});

const table = mounted.view.state.doc.firstChild;

expect(getTableCellTexts(mounted)).toEqual([
["A", "B"],
["merged", ""],
]);
expect(table?.child(1)?.child(0)?.attrs.colspan).toBe(1);

const beforeDoc: unknown = mounted.view.state.doc.toJSON();
const reopened = await mountEditor(mounted.getMarkdown());

expect(reopened.view.state.doc.toJSON()).toEqual(beforeDoc);
});

it("matches every ragged table of one paste", async () => {
const mounted = await mountEditor("");

Expand Down
33 changes: 31 additions & 2 deletions src/features/editor/plugins/tableShape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,31 @@ const repairRow = (
return tr;
};

const flattenRowColspans = (
state: EditorState,
row: ProseMirrorNode,
rowPos: number,
transaction: Transaction | null,
) => {
let tr = transaction;
let cellPos = rowPos + 1;

row.forEach((cell) => {
if (cell.attrs.colspan > 1 && cell.attrs.rowspan === 1) {
tr ??= state.tr;
tr.setNodeMarkup(tr.mapping.map(cellPos), undefined, {
...cell.attrs,
colspan: 1,
colwidth: null,
});
}

cellPos += cell.nodeSize;
});

return tr;
};

const repairTableRows = (
state: EditorState,
table: ProseMirrorNode,
Expand All @@ -101,8 +126,12 @@ const repairTableRows = (
for (let index = 0; index < table.childCount; index += 1) {
const row = table.child(index);

if (index > 0 && row.childCount !== headerRow.childCount) {
tr = repairRow(state, headerRow, row, rowPos, tr);
if (index > 0) {
tr = flattenRowColspans(state, row, rowPos, tr);

if (row.childCount !== headerRow.childCount) {
tr = repairRow(state, headerRow, row, rowPos, tr);
}
}

rowPos += row.nodeSize;
Expand Down