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 @@ -28,6 +28,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0

### Fixed

- Keep the Markdown an image's description was written with, so `![Alt with *emphasis*](leaf.svg)` keeps its emphasis and `![Outer ![inner](inner.svg)](leaf.svg)` keeps the image inside it, instead of flattening the description to its text on open and losing the inner image's destination from the file on the first save. The image is still named by the text its description spells, and a description edited in the raw image Markdown is written as the text typed there.
- Read a typed `*` or `_` run the way Markdown reads the same characters in a file, so `***text*` gives two literal asterisks before italic text, `_**text**` a literal underscore before bold text, and `_**text**_` italic bold, instead of leaving every marker as text that saved with backslashes and reopened without the formatting. A run whose closing marker is shorter than its opening one is read once the caret leaves it, because another marker typed there would spell something else.
- Pair a `*`, `_`, or `~` typed against bold, italic, or strikethrough with the matching literal marker already on the other side of it, so closing `_**text**` with a `_` gives italic bold and saves `_**text**_`, instead of leaving both markers as text that saved as `\_**text**\_` and reopened without the italic. A marker a file keeps literal by escaping it stays literal.
- Leave a `*`, `_`, or `~` bare on save wherever nothing else on its line could pair with it, so text such as `[a](b)*` keeps its marker as written instead of collecting a backslash merely because a link, an image, or a bold span shares the line with it. A marker that could still pair, including one that could pair with the markers of a span beside it, keeps its backslash.
Expand Down
14 changes: 14 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,20 @@
- The literal-commit path is deliberately given no definitions, so text typed this session that spells a reference stays literal. A definition an author has not looked at should not capture a bracket run they were still writing, and the file keeps that run literal either way.
- A definition's destination form and the blank lines between adjacent definitions are not preserved. Both are classes [issue #251](https://github.com/Azganoth/leafdown/issues/251) tracks for blocks generally, and a definition is now subject to them for the first time because it now survives to be written at all.

### Carry an image description as the source it was written with

**Decision:** An image description holds inline content, and the image node carries it as the source it was written with rather than as content the document holds. The node keeps the alt text the parser derived, which is what the image is named by, and carries the description's source beside it wherever that source says more than the text: emphasis, strong, inline code, strikethrough, a link, or a nested image. The description reaches the file as it stands, and the raw image Markdown a focused image exposes is that same source. Decided in [issue #259](https://github.com/Azganoth/leafdown/issues/259).

**Rationale:** The parser keeps only the text a description spells, so everything else in it was gone from the document on open and gone from the file after one save, with the destination of a nested image unrecoverable. Holding the description as document content would mean giving the image node inline children, which nothing delivers: the mdast image node carries no children to build them from, and the node view's whole surface is a raw Markdown input, so a description rich in the schema would still be edited as text. Carrying the source keeps what the author wrote and leaves the editing surface the one the image already had.

**Consequences:**

- Formatting and a nested image inside a description round-trip byte-identically, and the rendered image is still named by the text the description spells, which is the alt text an `img` element carries.
- A nested image is not a second image the editor renders, resolves, or blocks. It is source text on the image that holds it.
- A description spelling only escapes or character references carries no source of its own. Those differences are answered by the alt text and belong to the issues that settled them.
- Editing the description in the raw image Markdown replaces it with the text typed there, which the file escapes, because reading its markers back as inline content is the parse that input does not run. Editing the destination or the title leaves the description as written, and a copy through the DOM, which carries no authored attributes, falls back to the text as an edited description does.
- A description whose brackets a code span interrupts is left to its text. The source is read against the destination or the reference label the node holds, and a reading those refuse is declined rather than guessed.

## Technical Decisions

### Use Tauri
Expand Down
4 changes: 3 additions & 1 deletion docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,9 @@ Confirmations, warnings, and security blocks affect editor rendering only; sourc
- Missing local images show a clear placeholder.
- Remote image Markdown is preserved, but network images are currently blocked completely; loading them is deferred.
- Local images that resolve outside the current folder context require explicit confirmation before rendering. Instead of a blocking modal, the editor displays an inline placeholder in place of the image, prompting the user to click to load/render it.
- Selecting a rendered or placeholder image exposes the raw image Markdown for editing the alt text and target path.
- Selecting a rendered or placeholder image exposes the raw image Markdown for editing the description and target path.
- An image description keeps the Markdown it was written with, including emphasis, strong, inline code, strikethrough, a link, and a nested image. It is source the image carries rather than content the document holds, so a nested image is written back as it was authored rather than rendered as a second image, and the image is named by the text its description spells.
- The raw image Markdown of a focused image is the source the file holds. Editing the destination or the title leaves the description as it was written; editing the description replaces it with the text typed there, which is written back escaped.

For local-path resolution and asset-protocol handling, see [Architecture](./architecture.md#frontend-responsibilities).

Expand Down
44 changes: 40 additions & 4 deletions src/features/editor/plugins/characterReference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,36 @@ import { $markSchema, $remark } from "@milkdown/kit/utils";
import {
CHARACTER_REFERENCE_MARK_NAME,
characterReferenceMarkSchema,
findAuthoredDescription,
findAuthoredDestination,
findAuthoredReferenceDescription,
splitCharacterReferences,
} from "../utils/characterReferenceMarkdown";
import { findTitleMarker, type TitleMarker } from "../utils/markdownTitle";
import { IMAGE_REFERENCE_MARKDOWN_TYPE } from "../utils/referenceLinkMarkdown";

export const leafdownCharacterReferenceSchema = $markSchema(
CHARACTER_REFERENCE_MARK_NAME,
() => characterReferenceMarkSchema,
);

// A reference is gone from the value by the time the tree exists, and a title keeps its text
// without its markers, so both are recovered by walking the tree against the slice of the file
// each node was built from. A node the parser gave no position, or one another transformer has
// already rebuilt, is left alone.
const readNodeString = (node: MarkdownNode, key: string) => {
const value = (node as Record<string, unknown>)[key];

return typeof value === "string" ? value : null;
};

const markAuthoredDescription = (node: MarkdownNode, description: string | null) => {
if (description !== null) {
(node as { authoredDescription?: string }).authoredDescription = description;
}
};

// A reference is gone from the value by the time the tree exists, a title keeps its text without
// its markers, and an image description keeps only the text its inline content spells, so all
// three are recovered by walking the tree against the slice of the file each node was built from.
// A node the parser gave no position, or one another transformer has already rebuilt, is left
// alone.
const markAuthoredSource = (node: MarkdownNode, source: string) => {
const children = node.children;

Expand Down Expand Up @@ -56,9 +72,29 @@ const markAuthoredSource = (node: MarkdownNode, source: string) => {
(child as { authoredUrl?: string }).authoredUrl = authored;
}

if (child.type === "image") {
markAuthoredDescription(
child,
findAuthoredDescription(raw, readNodeString(child, "alt") ?? "", child.url),
);
}

if (child.title) {
(child as { titleMarker?: TitleMarker }).titleMarker = findTitleMarker(raw);
}
} else if (child.type === IMAGE_REFERENCE_MARKDOWN_TYPE) {
const label = readNodeString(child, "label");

if (label !== null) {
markAuthoredDescription(
child,
findAuthoredReferenceDescription(
source.slice(start.offset, end),
readNodeString(child, "alt") ?? "",
label,
),
);
}
}
}

Expand Down
52 changes: 52 additions & 0 deletions src/features/editor/plugins/imageView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,58 @@ describe("Markdown images", () => {
expect(latestInput.selectionStart).toBe(5);
expect(mounted.getMarkdown()).toBe("![Alpt](./assets/icon.png)\n");
});

const mountDescribedImage = async () => {
mockTauriApiCommand("resolveMarkdownImageTarget", () => ({
kind: "renderable",
path: "C:\\Notes\\assets\\icon.png",
}));

const mounted = await mountImageEditor("![Alt with *emphasis*](./assets/icon.png)");

await waitFor(() => {
expect(
within(mounted.view.dom).getByRole("img", { name: "Alt with emphasis" }),
).toBeInTheDocument();
});

dispatchMouseDown(within(mounted.view.dom).getByRole("img", { name: "Alt with emphasis" }));

return mounted;
};

// The input holds the source the file was written with rather than the text the description
// spells, so what it writes back is what the author is editing.
it("keeps the description the file holds while the rest of the image is edited", async () => {
const mounted = await mountDescribedImage();
const input = within(mounted.view.dom).getByRole("textbox", { name: "Image Markdown" });

expect(input).toHaveValue("![Alt with *emphasis*](./assets/icon.png)");
dispatchInput(input, "![Alt with *emphasis*](./assets/updated.png)");

await waitFor(() => {
expect(mounted.getMarkdown()).toBe("![Alt with *emphasis*](./assets/updated.png)\n");
});
expect(
within(mounted.view.dom).getByRole("img", { name: "Alt with emphasis" }),
).toBeInTheDocument();
});

// Reading the markers a typed description spells back as inline content is the parse this
// input does not run, so an edited description is the text it holds and the file escapes it.
it("writes an edited description as the text it spells", async () => {
const mounted = await mountDescribedImage();
const input = within(mounted.view.dom).getByRole("textbox", { name: "Image Markdown" });

dispatchInput(input, "![Alt with *markers*](./assets/icon.png)");

await waitFor(() => {
expect(mounted.getMarkdown()).toBe("![Alt with \\*markers\\*](./assets/icon.png)\n");
});
expect(
within(mounted.view.dom).getByRole("img", { name: "Alt with *markers*" }),
).toBeInTheDocument();
});
});

describe("reference images", () => {
Expand Down
36 changes: 24 additions & 12 deletions src/features/editor/plugins/imageView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ import { getErrorDescription, handleUnexpectedError } from "@/lib/errors";
import { MutableDisposable } from "@/lib/lifecycle";
import { isSameNullablePath } from "@/lib/path";

import {
AUTHORED_DESCRIPTION_ATTRIBUTE_NAME,
readAuthoredDescription,
} from "../utils/characterReferenceMarkdown";
import {
parseImageMarkdown,
readImageDescription,
serializeImageMarkdown,
type ImageDefinitionResolver,
type ImageMarkdownAttrs,
Expand Down Expand Up @@ -160,10 +165,16 @@ class LeafdownImageNodeView implements NodeView {
}

const currentAttrs = this.getImageAttrs();
const nextAttrs = toNodeAttrs({
...currentAttrs,
...attrs,
});
const editedAttrs = { ...currentAttrs, ...attrs };
// The input holds the description the file was written with, so an edit to the destination or
// the title leaves that description standing. A description the author did change is the text
// they typed, because reading its markers back as inline content is the parse this input does
// not run, and the file escapes them for it.
const keepsDescription = editedAttrs.description === currentAttrs.description;
const nextAttrs = toNodeAttrs(
keepsDescription ? { ...editedAttrs, alt: currentAttrs.alt } : editedAttrs,
keepsDescription ? readAuthoredDescription(this.node.attrs) : null,
);

if (attrs.src !== undefined && attrs.src !== currentAttrs.src) {
this.allowOutsideFolder = false;
Expand Down Expand Up @@ -289,6 +300,10 @@ const readNodeString = (node: ProseMirrorNode, key: string) => {

const imageAttrsFromNode = (node: ProseMirrorNode): ImageMarkdownAttrs => ({
alt: readNodeString(node, "alt"),
description: readImageDescription(
readAuthoredDescription(node.attrs),
readNodeString(node, "alt"),
),
referenceLabel: readNodeString(node, REFERENCE_LABEL_ATTRIBUTE_NAME),
referenceType: readReferenceType(node.attrs),
src: readNodeString(node, "src"),
Expand Down Expand Up @@ -331,17 +346,14 @@ const isSameImageResolutionInput = (
isSameNullablePath(currentInput.folderContextPath, nextInput.folderContextPath) &&
currentInput.target === nextInput.target;

const toNodeAttrs = ({
alt,
referenceLabel,
referenceType,
src,
title,
titleMarker,
}: ImageMarkdownAttrs) => ({
const toNodeAttrs = (
{ alt, referenceLabel, referenceType, src, title, titleMarker }: ImageMarkdownAttrs,
authoredDescription: string | null,
) => ({
alt,
src,
title,
[AUTHORED_DESCRIPTION_ATTRIBUTE_NAME]: authoredDescription,
[TITLE_MARKER_ATTRIBUTE_NAME]: titleMarker,
[REFERENCE_LABEL_ATTRIBUTE_NAME]: referenceLabel,
[REFERENCE_TYPE_ATTRIBUTE_NAME]: referenceType,
Expand Down
133 changes: 133 additions & 0 deletions src/features/editor/tests/imageDescription.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// @vitest-environment happy-dom

import type { EditorView } from "@milkdown/kit/prose/view";
import { beforeEach, describe, expect, it } from "vitest";

import { createMarkdownReferenceContext } from "@/test/factories/editor";
import { setupMilkdownEditorMount } from "@/test/utils/milkdown";
import { mockTauriApiCommand } from "@/test/utils/tauriApi";

const mountEditor = setupMilkdownEditorMount(createMarkdownReferenceContext());

const LEAF_DEFINITION = '[leaf]: ../assets/leaf.svg "Leaf"';

const readImageAttrs = (view: EditorView) => {
const attrs: Record<string, unknown>[] = [];

view.state.doc.descendants((node) => {
if (node.type.name === "image") {
attrs.push(node.attrs);
}

return true;
});

return attrs;
};

// The corpus guard sees only a serialization that stops changing, and a flattened description
// converges perfectly, so these read the document a save and a reopen produce as well as the bytes
// the save wrote.
const saveAndReopen = async (source: string) => {
const before = await mountEditor(source);
const saved = before.getMarkdown();
const after = await mountEditor(saved);

return {
imageAttrs: readImageAttrs(before.view),
reopened: after.view.state.doc.toJSON() as unknown,
saved,
written: before.view.state.doc.toJSON() as unknown,
};
};

describe("Image descriptions", () => {
beforeEach(() => {
mockTauriApiCommand("resolveMarkdownImageTarget", ({ target }) => ({
kind: "renderable",
path: `C:/Notes/${target}`,
}));
});

it.each([
String.raw`![Alt with *emphasis* and ` + "`code`" + String.raw`](../assets/leaf.svg)`,
String.raw`![**strong** and ~~strike~~](../assets/leaf.svg)`,
String.raw`![Alt with [a link](./blocks.md) inside](../assets/leaf.svg)`,
String.raw`![*emphasis*](../assets/leaf.svg "Inline")`,
String.raw`[![*Linked* leaf](../assets/leaf.svg)](https://example.com)`,
])("keeps the inline content an image description holds: %s", async (source) => {
const { reopened, saved, written } = await saveAndReopen(source);

expect(saved).toBe(`${source}\n`);
expect(reopened).toEqual(written);
});

it.each([
`${LEAF_DEFINITION}\n\n![Alt with *emphasis*][leaf]`,
`${LEAF_DEFINITION}\n\n![*emphasis*][]`,
`${LEAF_DEFINITION}\n\n![*emphasis*]`,
])("keeps the inline content a reference image description holds: %s", async (source) => {
const { reopened, saved, written } = await saveAndReopen(source);

expect(saved).toBe(`${source}\n`);
expect(reopened).toEqual(written);
});

// The description is source the image carries rather than a document of its own, so the nested
// image reaches the file as it was written without being an image the editor renders.
it("keeps an image nested in another image's description", async () => {
const source = String.raw`![Outer ![inner](../assets/inner.svg)](../assets/leaf.svg)`;
const { imageAttrs, reopened, saved, written } = await saveAndReopen(source);

expect(saved).toBe(`${source}\n`);
expect(reopened).toEqual(written);
expect(imageAttrs).toEqual([
expect.objectContaining({
alt: "Outer inner",
authoredDescription: "Outer ![inner](../assets/inner.svg)",
src: "../assets/leaf.svg",
}),
]);
});

// The rendered image is named by the text its description spells, which is the alt text
// CommonMark derives rather than the source the file holds.
it("names the rendered image by the text its description spells", async () => {
const source =
String.raw`![Alt with *emphasis* and ` + "`code`" + String.raw`](../assets/leaf.svg)`;
const { imageAttrs } = await saveAndReopen(source);

expect(imageAttrs).toEqual([
expect.objectContaining({
alt: "Alt with emphasis and code",
authoredDescription: String.raw`Alt with *emphasis* and ` + "`code`",
}),
]);
});

// Escapes and character references are differences the alt text answers for on its own, so a
// description spelling only those carries no source of its own.
it.each([
String.raw`![escaped \*not emphasis\*](../assets/leaf.svg)`,
String.raw`![a \[bracket\]](../assets/leaf.svg)`,
String.raw`![plain](../assets/leaf.svg)`,
])("carries no description source where the alt text spells it: %s", async (source) => {
const { imageAttrs, saved } = await saveAndReopen(source);

expect(saved).toBe(`${source}\n`);
expect(imageAttrs).toEqual([expect.objectContaining({ authoredDescription: null })]);
});

// A code span binds more tightly than the brackets around a description, so a bracket inside one
// ends the run this reading walks before the description ends. The reading is confirmed against
// the destination the node holds, which is what leaves such a description to its text.
it("leaves a description a code span interrupts to the text it spells", async () => {
const source = String.raw`![a ` + "`](x.png)`" + String.raw` b](../assets/leaf.svg)`;
const { imageAttrs, reopened, written } = await saveAndReopen(source);

expect(imageAttrs).toEqual([
expect.objectContaining({ authoredDescription: null, src: "../assets/leaf.svg" }),
]);
expect(reopened).toEqual(written);
});
});
Loading