Skip to content

Add ImageEmbedStyle.keepsIDInSource - #186

Open
xVanTuring wants to merge 2 commits into
nodes-app:mainfrom
xVanTuring:feat/image-embed-id-in-source
Open

xVanTuring wants to merge 2 commits into
nodes-app:mainfrom
xVanTuring:feat/image-embed-id-in-source

Conversation

@xVanTuring

Copy link
Copy Markdown
Contributor

Stacked on #185. The first commit here is that PR; review only Add ImageEmbedStyle.keepsIDInSource. Happy to rebase once #185 lands (or to close this if the direction is wrong — see the open question at the end).

#185 stops three edits from carrying an embed's id away with them. It cannot stop all of them, because the id lives on an attribute and an attribute only survives while the text around it still parses as an embed.

Delete one bracket:

![[a.png]]   →   ![[a.png]

There is no embed token in that paragraph any more, so the restyle has nothing to re-apply .wikiLinkID to and setAttributes clears it. Type the bracket back and you have ![[a.png]] naming nothing. The stored image is still there, and nothing can reach it.

That is recoverable for an embedder whose ids are names — look the name up again. It is not recoverable for one whose ids point at bytes only that id can find: content-addressed blobs, database rows, a pasted screenshot that never had a filename. There the id is the data's only handle, and a side-channel is the wrong place to keep it.

What this adds

ImageEmbedStyle.keepsIDInSource (default false, so nothing changes for anyone who does not ask). With it on, an image embed is copied through the display transform untouched:

  • the source line reads ![[Name|id]] — longer while the caret sits in the embed, and rendered identically when it does not
  • display is storage, so makeStorageState round-trips it verbatim
  • .wikiLinkID is never written for image embeds, and makeDisplayState records no id for them, so nothing re-appends a suffix that is already in the text
  • every pasteboard flavor carries the id, because the text does
  • node links are untouched and keep using the side-channel

Three defaulted parameters thread it through (makeDisplayState, makeStorageState, displayFragmentAndID) plus the call sites. Source-compatible.

Tests

Tests/MarkdownEngineTests/ImageEmbedIDInSourceTests.swift — 9 tests, including the bracket break-and-restore that motivated this, the edits #185 fixes (now trivially safe, since there is no attribute to lose), no .wikiLinkID written, node links still hiding their id, and the option being off by default.

swift build and swift test are green (522 tests).

Open question

An option is the smallest change that solves it, but it is still a second mode through the display/storage seam, and you may prefer one of:

  • keeping the attribute alive across a syntax break (re-apply from wikiLinkMetadata when a paragraph loses its embed token) — narrower, but it cannot help the cases where the text leaves the buffer entirely
  • making it the behavior for image embeds outright, since their ids are far more often opaque than a node link's
  • scoping it differently, e.g. per-embed rather than per-configuration

Happy to take this any of those ways.

xVanTuring added 2 commits September 22, 2026 14:22
A link or image embed lives in the buffer as its DISPLAY form (`[[Name]]`,
`![[Name]]`); the `|id` suffix sits on the `.wikiLinkID` side-channel and
`makeStorageState` re-attaches it on every writeback. Three ordinary edits
took the attribute away with nothing left to read it from, and the id then
left the document for good — for an image embed that means the picture is
gone and its stored bytes are orphaned, with no way to tell which image the
reference used to name.

- `makeStorageState` probed `.wikiLinkID` only at `contentRange.location`.
  Typed text takes the base typing attributes, so a character inserted at the
  START of a name carried no id and the probe read nil — even though every
  surviving character of the name still had it. Scan the run instead.

- Clicking a rendered embed selects its whole name, so the next keystroke
  replaces every character the attribute sat on and there is nothing left to
  scan. `shouldChangeTextIn` now captures the id when the replaced text was
  covered by one name run and the replacement carries no link grammar, and
  `textDidChange` re-applies it. `![[` / `]]` markers never carry the
  attribute, so replacing a whole embed — or typing a fresh one over it —
  cannot inherit a stale id.

- `copy` serialized the buffer verbatim, so the clipboard held `![[Name]]`
  with no id: pasting it back created a dead reference, and pasting it over
  the original destroyed the only copy of the id (cut/paste goes through the
  same path). The private raw-markdown flavor now carries the storage form,
  rebuilt from the selection's own attributes; the plain-text, HTML and RTF
  flavors keep the display form, so no id leaks into another app.
The previous commit stopped three edits from taking an embed's id away with
them, but the id still lives on a text attribute, and an attribute only
survives while the text around it still parses as an embed. Delete one
bracket and the paragraph restyle has no embed token to re-apply it to; type
the bracket back and you have `![[Name]]` naming nothing. The stored image is
still there, with nothing left that can name it.

So let an embedder put the id where it cannot be lost: in the text.
`keepsIDInSource` copies an image embed through the display transform
untouched — the source line reads `![[Name|id]]`, display IS storage, the
`.wikiLinkID` attribute is never written for it, and every pasteboard flavor
carries the id because the text does. Node links are unaffected, and the
option is off by default, so nothing changes for anyone who does not ask.

The trade is the source line's length while the caret sits in the embed. For
an embedder whose ids point at bytes only that id can find, that is the
cheaper half of the bargain.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant