Skip to content

feat: add a gif node to markup, with serialization and viewer support - #25

Open
shanzez wants to merge 2 commits into
Platform-Collective:developfrom
exavizco:feat/gif-markup-node
Open

shanzez wants to merge 2 commits into
Platform-Collective:developfrom
exavizco:feat/gif-markup-node

Conversation

@shanzez

@shanzez shanzez commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Markup has no way to represent an animated GIF. Images exist, but the image node is disabled in both message composers via kitOptions (AttachmentRefInput.svelte and communication's TextInput.svelte), so a GIF sent in chat has nowhere to live.

This adds a first-class gif node rather than re-enabling images, which leaves the composers' existing image policy untouched.

It is deliberately source-agnostic: the node accepts either a blob reference or an external src, so a workspace-hosted GIF and a third-party one both work. No provider is included here.

What changed

  • MarkupNodeType.gif in text-core, plus a GifNode tiptap extension registered in the server kit as well as the client, so stored markup round-trips through the server.
  • Serialization both ways for markdown and HTML. The HTML serializer reads src or a blob reference — an earlier draft read only attrs.src and silently emitted src="undefined" for blob-backed GIFs.
  • insertGif on TextEditorHandler, implemented in every editor that exposes one. There are five call sites, not four: TextEditor.svelte holds the low-level export the others delegate to.
  • Enabled in both composers, opt-in per consumer. There are two ref-action arrays (defaultRefActions and communication's defaultMessageInputActions) fanning out to ~10 surfaces, so a blanket swap would have changed editors that should not gain the action.
  • Rendering in both markup viewers. NodeContent.svelte had a branch for image and none for gif, so a GIF rendered as the literal text unknown node: "gif". LiteNodeContent.svelte rendered nothing at all.

Two things worth a reviewer's attention

gif is added to nonEmptyNodes. Without it, a message containing only a GIF counts as empty, so canSubmit stays false and the send button never enables — the feature looks broken in exactly the case people use it for. emoji is already on that list for the same reason. This is easy to miss because every serialization test passes while the feature is unusable.

markupViewerCoverage.test.ts is a structural test. It reads the viewer sources and asserts that each composable node type has a branch, because the missing-branch bug above passed the entire serialization suite. Reading files needs node types, hence the types addition to packages/presentation's tsconfig. If you would rather not have a source-reading test, say so and I will drop it — but something needs to catch that class of bug.

Verification

Built and exercised on a self-hosted instance running this node in production chat, including the GIF-only message case. rush validate and rush svelte-check pass on the branch this was developed on. This PR is a clean cherry-pick onto develop; CI here is the first run against this base.

Markup had no way to represent an animated GIF. Images exist, but the image
node is disabled in both message composers via kitOptions, so a GIF sent in
chat had nowhere to live.

This adds a first-class `gif` node rather than re-enabling images, which
keeps the composers' existing image policy untouched.

What changed:

- `MarkupNodeType.gif` plus a GifNode tiptap extension, registered in the
  server kit as well as the client so stored markup round-trips.
- Markdown and HTML serialization both ways. The HTML case reads `src` OR a
  blob reference; an earlier draft read only `src` and silently produced
  `src="undefined"` for blob-backed GIFs.
- `insertGif` on the editor handler, implemented across every editor that
  exposes one so no composer is silently missing it.
- The node enabled in both message composers.
- Rendering in BOTH markup viewers. `NodeContent.svelte` had a branch for
  image and none for gif, so a GIF rendered as the literal text
  `unknown node: "gif"`; `LiteNodeContent.svelte` rendered nothing at all.

Two things worth calling out for reviewers:

`gif` is added to `nonEmptyNodes` in text-core. Without it a message
containing only a GIF counts as empty, so `canSubmit` stays false and the
send button never enables. `emoji` is already on that list for the same
reason. This is easy to miss because serialization tests all pass while the
feature is unusable.

`markupViewerCoverage.test.ts` is a structural test: it reads the viewer
sources and asserts each composable node type has a branch. It exists
because the missing-branch bug above passed every serialization test in the
suite. `packages/presentation` had jest and node types but no test
script, so this adds `test` and `_phase:test` to run it in CI.

Signed-off-by: Shannon Kerr <shannon@exaviz.com>
@shanzez
shanzez force-pushed the feat/gif-markup-node branch from 8f2737e to 90b8bbe Compare October 9, 2026 02:59
@shanzez

shanzez commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current develop. The earlier build failure (fs, path and __dirname unresolved in markupViewerCoverage.test.ts) is gone now that packages/presentation declares node and jest types on develop. The PR still adds the test / _phase:test scripts there, which also brings the existing drawing.test.ts into CI; it passes.

Checked locally with the build cache off: rush validate, rush test, rush svelte-check and rush format on the 9 touched packages (presentation: 106/106 tests).

This is part 1 of a GIF picker for the message composers: it only adds the gif markup node, its serialization, and rendering in both viewers. The picker UI would follow as a separate PR if this direction is welcome. Happy to adjust the node shape or naming.

@ArtyomSavchenko

Copy link
Copy Markdown
Member

Hi @shanzez
Thanks for the contribution. Could you please take a look at the following review comments?

  1. Markdown serializer uses the wrong escaping, so a GIF whose alt text contains " turns into literal text.
    text-markdown/src/serializer.ts writes the gif case as a raw <img …> tag, but escapes each attribute with state.esc. That function is the markdown escaper: it backslash-escapes ` * \ ~ [ ] and leaves ", <, > and & untouched. I ran the generated output through markdown-it and htmlparser2:

With alt='Cat says "hi"', markdown-it no longer recognises the tag as html_inline. The whole <img data-type="gif" …> becomes a plain text token, so the GIF disappears and the reader sees raw HTML. Titles from GIF providers often contain quotes.
With alt='so excited [yay]', the stored alt becomes so *excited* [yay]. Backslashes inside an HTML attribute are never unescaped, so they pile up again on every save. The idempotence test doesn’t catch this because it only uses a file-id.

Fix: use state.htmlEsc(...) for every attribute in that case (it already exists in the same file). Also add tests with a " and a * in the alt text. The existing image fallback path at around line 207 has the same bug, but it rarely runs.

  1. In any schema where image is enabled, a GIF gets parsed back as an image node.
    ImageNode.parseHTML includes a catch-all img[src] rule. GifNode matches img[data-type="gif"] at the same default priority. When priorities tie, ProseMirror uses the order in the schema, and image comes before gif in both ServerKit and StaticEditorKit. I tested it:

Full editor (image enabled): parses as image.
Composer (image disabled): parses as gif.

This affects every path that goes HTML → ProseMirror with the default kit. That includes htmlToJSON/htmlToMarkup in text/markup/utils.ts (which the api-client markup client and the importers use), and copy/paste inside a document editor. A GIF copied out of a doc and pasted into chat arrives as an image node, which the composer then drops. The PR’s HTML tests only cover the separate text-html parser, where the data-type check works, so this gap isn’t covered.

Fix: add priority: 60 to the gif parse rule, or register gif before image in both kits. Add a test that runs jsonToHTML → htmlToJSON through the server kit with image enabled.

  1. markupToHtml writes the raw blob id as src (src="blob-1"). The test comment says this “emits a resolvable src”, but a bare blob id is a relative URL and won’t resolve anywhere. That matters for Gmail (NewMessage(s).svelte calls markupToHtml). After parsing that HTML back, the node also has src = alongside file-id, so later markdown output carries a bogus src. Either leave src out when file-id is set and let consumers resolve it, or use the same URL pattern as renderHTML.

  2. Turning on _phase:test for packages/presentation means CI will now run all the existing tests in src/tests/, including drawing.test.ts under jsdom, which apparently weren’t run before. Please confirm they pass, or split that change out.

- Markdown: escape the raw <img> attributes with htmlEsc. The markdown
  escaper left " intact, so a quoted alt broke the tag into text, and
  its backslashes piled up in the alt on every save. The sized image
  fallback had the same bug and gets the same fix.
- Parse priority 60 on the gif rule, so ImageNode's img[src] rule
  cannot claim a gif in kits where image is enabled (ServerKit,
  StaticEditorKit via GifExtension).
- markupToHtml emits file-id alone for a library gif. A bare blob id
  is not a resolvable src, and it came back as a bogus src on parse.
- Tests for each: quoted and punctuated alt round trips (gif and
  sized image), a ServerKit jsonToHTML -> htmlToJSON round trip in its
  own file because utils.test.ts mocks @tiptap/html, and no src for a
  library gif. Each new test fails without its fix.

Signed-off-by: Shannon Kerr <shannon@exaviz.com>
@shanzez

shanzez commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

@ArtyomSavchenko thanks for the careful review, all four reproduced. Fixed in d40aca2:

  1. Markdown escaping. The gif tag now uses state.htmlEsc for every attribute, and so does the sized-image fallback. New tests round-trip an alt containing " and one containing * and [ ], for the gif and the sized image, and check the second serialization is identical to the first. All three failed before the change.
  2. Parse priority. Added priority: 60 to the gif rule. GifExtension in the editor kit extends GifNode, so it covers both kits. The new test runs jsonToHTML -> htmlToJSON through ServerKit with image enabled. It lives in its own file (text/src/markup/__tests__/gif-html.test.ts) because utils.test.ts mocks @tiptap/html. Before the fix it came back as image.
  3. Bare blob id as src. markupToHtml now emits file-id alone for a library gif and leaves resolution to the consumer. A test checks there is no src= and that the parse back has file-id with no src.
  4. _phase:test in presentation. Confirmed: all 6 suites pass, drawing.test.ts under jsdom included (106/106), and the CI test job on the previous push was green. Happy to split it out if you'd still rather.

Locally with the build cache off: rush build, validate, test, format and svelte-check on the 9 touched packages.

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.

2 participants