From 9b57914707eceeb4f3e93834e53908b863261a14 Mon Sep 17 00:00:00 2001 From: Shannon Kerr Date: Sun, 16 Aug 2026 21:50:00 -0400 Subject: [PATCH 1/2] feat: add a gif node to markup, with serialization and viewer support 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 --- .../src/markup/__tests__/utils.test.ts | 32 +++++- .../core/packages/text-core/src/markup/dsl.ts | 10 ++ .../packages/text-core/src/markup/model.ts | 1 + .../packages/text-core/src/markup/utils.ts | 1 + .../text-html/src/__tests__/html.test.ts | 47 ++++++++ .../core/packages/text-html/src/parser.ts | 15 ++- .../core/packages/text-html/src/serializer.ts | 19 ++++ .../src/__tests__/markdown.test.ts | 46 ++++++++ .../packages/text-markdown/src/serializer.ts | 15 +++ .../core/packages/text/src/kits/server-kit.ts | 2 + .../text/src/markup/__tests__/utils.test.ts | 20 ++++ .../core/packages/text/src/nodes/gif.ts | 94 ++++++++++++++++ .../core/packages/text/src/nodes/index.ts | 1 + packages/presentation/package.json | 2 + .../__tests__/markupViewerCoverage.test.ts | 70 ++++++++++++ .../src/components/markup/NodeContent.svelte | 17 +++ .../markup/lite/LiteNodeContent.svelte | 15 +++ .../src/components/AttachmentRefInput.svelte | 3 +- .../src/components/TextInput.svelte | 4 + .../components/CollaborativeTextEditor.svelte | 3 + .../src/components/ReferenceInput.svelte | 3 + .../src/components/StyledTextEditor.svelte | 3 + .../src/components/TextEditor.svelte | 10 ++ .../src/components/extension/gifExt.ts | 61 +++++++++++ .../src/kits/__tests__/gifKit.test.ts | 103 ++++++++++++++++++ .../src/kits/editor-kit.ts | 2 + plugins/text-editor/src/types.ts | 1 + 27 files changed, 594 insertions(+), 6 deletions(-) create mode 100644 foundations/core/packages/text/src/nodes/gif.ts create mode 100644 packages/presentation/src/__tests__/markupViewerCoverage.test.ts create mode 100644 plugins/text-editor-resources/src/components/extension/gifExt.ts create mode 100644 plugins/text-editor-resources/src/kits/__tests__/gifKit.test.ts diff --git a/foundations/core/packages/text-core/src/markup/__tests__/utils.test.ts b/foundations/core/packages/text-core/src/markup/__tests__/utils.test.ts index 740aa1c64f..07b4b94240 100644 --- a/foundations/core/packages/text-core/src/markup/__tests__/utils.test.ts +++ b/foundations/core/packages/text-core/src/markup/__tests__/utils.test.ts @@ -1,4 +1,6 @@ -import { hashAttrs, stripHash } from '../utils' +import { hashAttrs, isEmptyMarkup, jsonToMarkup, stripHash } from '../utils' +import { MarkupNodeType } from '../model' +import { nodeDoc, nodeGif, nodeParagraph, nodeText } from '../dsl' describe('hashAttrs', () => { it('should return a hash of length 8', () => { @@ -47,3 +49,31 @@ describe('stripHash', () => { expect(result).toEqual(name) }) }) + +describe('isEmptyMarkup with a gif node', () => { + // the enum entry must exist, or every serializer case for gif is unreachable. + it('should define MarkupNodeType.gif', () => { + expect(MarkupNodeType.gif).toBe('gif') + }) + + // a gif-only message must not read as empty, or the composer's send button never + // enables (ReferenceInput.svelte binds canSubmit to !isEmptyMarkup). emoji is already on + // the nonEmptyNodes allowlist for exactly this reason. + it('should not report a document containing only a gif as empty', () => { + const doc = nodeDoc(nodeParagraph(nodeGif({ 'file-id': 'blob-1', width: 320, height: 240 }))) + expect(isEmptyMarkup(jsonToMarkup(doc))).toBe(false) + }) + + it('should still report a document with an empty paragraph as empty', () => { + expect(isEmptyMarkup(jsonToMarkup(nodeDoc(nodeParagraph())))).toBe(true) + }) + + it('should still report a document with text as not empty', () => { + expect(isEmptyMarkup(jsonToMarkup(nodeDoc(nodeParagraph(nodeText('hi')))))).toBe(false) + }) + + it('should report undefined and the empty string as empty', () => { + expect(isEmptyMarkup(undefined)).toBe(true) + expect(isEmptyMarkup('')).toBe(true) + }) +}) diff --git a/foundations/core/packages/text-core/src/markup/dsl.ts b/foundations/core/packages/text-core/src/markup/dsl.ts index f72a1bb2db..efcc060e0f 100644 --- a/foundations/core/packages/text-core/src/markup/dsl.ts +++ b/foundations/core/packages/text-core/src/markup/dsl.ts @@ -33,6 +33,16 @@ export function nodeImage (attrs: { src: string, alt?: string, width?: number, h return { type: MarkupNodeType.image, attrs } } +export function nodeGif (attrs: { + 'file-id'?: string + src?: string + alt?: string + width?: number + height?: number +}): MarkupNode { + return { type: MarkupNodeType.gif, attrs } +} + export function nodeReference (attrs: { id: string, label: string, objectclass: string }): MarkupNode { return { type: MarkupNodeType.reference, attrs } } diff --git a/foundations/core/packages/text-core/src/markup/model.ts b/foundations/core/packages/text-core/src/markup/model.ts index 9b275c14f0..0a0b0b3cd4 100644 --- a/foundations/core/packages/text-core/src/markup/model.ts +++ b/foundations/core/packages/text-core/src/markup/model.ts @@ -26,6 +26,7 @@ export enum MarkupNodeType { file = 'file', reference = 'reference', emoji = 'emoji', + gif = 'gif', hard_break = 'hardBreak', ordered_list = 'orderedList', bullet_list = 'bulletList', diff --git a/foundations/core/packages/text-core/src/markup/utils.ts b/foundations/core/packages/text-core/src/markup/utils.ts index dd5efd7630..f9a2305a14 100644 --- a/foundations/core/packages/text-core/src/markup/utils.ts +++ b/foundations/core/packages/text-core/src/markup/utils.ts @@ -94,6 +94,7 @@ const nonEmptyNodes = [ MarkupNodeType.image, MarkupNodeType.reference, MarkupNodeType.emoji, + MarkupNodeType.gif, MarkupNodeType.subLink, MarkupNodeType.table ] diff --git a/foundations/core/packages/text-html/src/__tests__/html.test.ts b/foundations/core/packages/text-html/src/__tests__/html.test.ts index 7df7fb1c60..1d0c6b6b43 100644 --- a/foundations/core/packages/text-html/src/__tests__/html.test.ts +++ b/foundations/core/packages/text-html/src/__tests__/html.test.ts @@ -579,3 +579,50 @@ describe('htmlToMarkup', () => { }) }) }) + +describe('gif node', () => { + const gifDoc = (attrs: Record): MarkupNode => ({ + type: 'doc' as any, + content: [{ type: 'paragraph' as any, content: [{ type: 'gif' as any, attrs }] }] + }) + + // the one that matters. The image case in this serializer reads attrs.src ONLY, so a + // gif case copied from it would emit src="undefined" for a library gif and lose the blob. + it('emits a resolvable src for a gif with file-id set and src null', () => { + const html = markupToHtml(gifDoc({ 'file-id': 'blob-1', src: null, width: 320 })) + expect(html).toContain('blob-1') + expect(html).not.toContain('undefined') + }) + + it('emits the external src for a gif with no file-id', () => { + const html = markupToHtml(gifDoc({ 'file-id': null, src: 'https://media.example.com/x.gif?cid=1' })) + expect(html).toContain('https://media.example.com/x.gif?cid=1') + expect(html).not.toContain('undefined') + }) + + // img is mapped to MarkupNodeType.image unconditionally by default. Without the + // data-type discrimination a gif comes back as an image node, which the message composers + // drop because image is disabled in their kitOptions. Silent message loss. + it('parses its own html back to a gif node, not an image node', () => { + const back = htmlToMarkup(markupToHtml(gifDoc({ 'file-id': 'blob-1' }))) + const found: string[] = [] + const walk = (n: any): void => { + if (n?.type != null) found.push(n.type) + ;(n?.content ?? []).forEach(walk) + } + walk(back) + expect(found).toContain('gif') + expect(found).not.toContain('image') + }) + + it('still parses a plain img without data-type as an image node', () => { + const found: string[] = [] + const walk = (n: any): void => { + if (n?.type != null) found.push(n.type) + ;(n?.content ?? []).forEach(walk) + } + walk(htmlToMarkup('

')) + expect(found).toContain('image') + expect(found).not.toContain('gif') + }) +}) diff --git a/foundations/core/packages/text-html/src/parser.ts b/foundations/core/packages/text-html/src/parser.ts index 44a8aa9fe9..5a1b879ef9 100644 --- a/foundations/core/packages/text-html/src/parser.ts +++ b/foundations/core/packages/text-html/src/parser.ts @@ -31,7 +31,10 @@ interface HtmlTagHandler { } interface HtmlNodeRule { - node: MarkupNodeType + // A function lets one tag map to different node types by attribute, e.g. an that is a + // gif rather than an image. Without it a gif round-tripped through HTML comes back as an + // image node, which the message composers drop because image is off in their kitOptions. + node: MarkupNodeType | ((attrs: Record) => MarkupNodeType) getAttrs?: Record | ((attrs: Record) => Record | undefined) wrapNode?: boolean wrapContent?: boolean @@ -134,6 +137,7 @@ class HtmlParseState { function nodeHandler ({ node, getAttrs, wrapContent, wrapNode }: HtmlNodeRule): HtmlTagHandler { const wrapStack: boolean[] = [] + const nodeStack: MarkupNodeType[] = [] return { handleOpenTag: (state: HtmlParseState, tag: string, attributes: Record) => { @@ -151,7 +155,9 @@ function nodeHandler ({ node, getAttrs, wrapContent, wrapNode }: HtmlNodeRule): } wrapStack.push(shouldWrapNode) - state.openNode(node, attrs) + const nodeType = typeof node === 'function' ? node(attributes) : node + nodeStack.push(nodeType) + state.openNode(nodeType, attrs) if (wrapContent === true) { state.openNode(MarkupNodeType.paragraph) @@ -162,7 +168,7 @@ function nodeHandler ({ node, getAttrs, wrapContent, wrapNode }: HtmlNodeRule): state.closeNode(MarkupNodeType.paragraph) } - state.closeNode(node) + state.closeNode(nodeStack.pop() ?? (typeof node === 'function' ? node({}) : node)) if (wrapStack.pop() === true) { state.closeNode(MarkupNodeType.paragraph) } @@ -317,7 +323,8 @@ const nodeRules: Record = { node: MarkupNodeType.list_item }, img: { - node: MarkupNodeType.image, + node: (attributes: Record) => + attributes['data-type'] === 'gif' ? MarkupNodeType.gif : MarkupNodeType.image, wrapNode: true, getAttrs: (attributes: Record) => { return { diff --git a/foundations/core/packages/text-html/src/serializer.ts b/foundations/core/packages/text-html/src/serializer.ts index ff427a2e4c..670b5cddc9 100644 --- a/foundations/core/packages/text-html/src/serializer.ts +++ b/foundations/core/packages/text-html/src/serializer.ts @@ -218,6 +218,25 @@ function addNodeContent (builder: NodeBuilder, node?: MarkupNode): void { builder.closeTag('pre') } else if (node.type === MarkupNodeType.text) { builder.addText(node.text ?? '') + } else if (node.type === MarkupNodeType.gif) { + // Deliberately not modelled on the image case below: that one reads attrs.src only, so a + // library gif (file-id set, src null) would serialize to src="undefined" and lose the blob + // reference. file-id wins, src is the fallback for an external source. + // Null-check the raw attrs: toString(null) yields the string 'null', so comparing the + // stringified value would treat an absent file-id as present and emit src="null". + const rawFileId = attrs['file-id'] + const rawSrc = attrs.src + const imgAttrs: Record = { 'data-type': 'gif' } + if (rawFileId != null) { + imgAttrs['file-id'] = toString(rawFileId) + imgAttrs.src = toString(rawFileId) + } else if (rawSrc != null) { + imgAttrs.src = toString(rawSrc) + } + if (attrs.alt != null) imgAttrs.alt = toString(attrs.alt) + if (attrs.width != null) imgAttrs.width = toString(attrs.width) + if (attrs.height != null) imgAttrs.height = toString(attrs.height) + builder.openTag('img', imgAttrs, { selfClosing: true }) } else if (node.type === MarkupNodeType.image) { const src = toString(attrs.src) const alt = toString(attrs.alt) diff --git a/foundations/core/packages/text-markdown/src/__tests__/markdown.test.ts b/foundations/core/packages/text-markdown/src/__tests__/markdown.test.ts index 33f1481d5e..8377734350 100644 --- a/foundations/core/packages/text-markdown/src/__tests__/markdown.test.ts +++ b/foundations/core/packages/text-markdown/src/__tests__/markdown.test.ts @@ -1251,3 +1251,49 @@ describe('markdownToMarkup -> markupToMarkdown', () => { }) }) }) + +describe('gif node', () => { + const gifDoc = (attrs: Record): MarkupNode => ({ + type: MarkupNodeType.doc, + content: [{ type: MarkupNodeType.paragraph, content: [{ type: MarkupNodeType.gif, attrs }] }] + }) + + // a missing serializer case is NOT silent here. serializer.ts render() throws for an + // unregistered type, which fails the WHOLE message, not just the GIF. + it('serializes without throwing', () => { + expect(() => markupToMarkdown(gifDoc({ 'file-id': 'blob-1' }), options)).not.toThrow() + }) + + // serialize direction on its own, so a failure localises. + it('serializes a library gif carrying its file-id', () => { + const md = markupToMarkdown(gifDoc({ 'file-id': 'blob-1', width: 320, height: 240 }), options) + expect(md).toContain('blob-1') + }) + + // parse direction on its own. The emoji node fails exactly here: it has no parser + // token at all, so markdown never reconstructs it and the content is lost for good. + it('round-trips a library gif back to a gif node, not an image node', () => { + const md = markupToMarkdown(gifDoc({ 'file-id': 'blob-1', width: 320, height: 240 }), options) + const back = markdownToMarkup(md, options) + const node = (back.content?.[0] as MarkupNode)?.content?.[0] + expect(node?.type).toBe(MarkupNodeType.gif) + expect(node?.attrs?.['file-id']).toBe('blob-1') + }) + + // an external src must survive unmodified, query params included. This is what lets a + // third-party source reuse the node later without a schema change. + it('round-trips an external src without rewriting the url', () => { + const src = 'https://media.example.com/x.gif?cid=abc&ct=g' + const back = markdownToMarkup(markupToMarkdown(gifDoc({ src }), options), options) + const node = (back.content?.[0] as MarkupNode)?.content?.[0] + expect(node?.type).toBe(MarkupNodeType.gif) + expect(node?.attrs?.src).toBe(src) + }) + + // idempotence, so an edit-and-resave cycle cannot corrupt via double escaping. + it('is stable across two serializations', () => { + const once = markupToMarkdown(gifDoc({ 'file-id': 'blob-1' }), options) + const twice = markupToMarkdown(markdownToMarkup(once, options), options) + expect(normalizeMarkdown(twice)).toEqual(normalizeMarkdown(once)) + }) +}) diff --git a/foundations/core/packages/text-markdown/src/serializer.ts b/foundations/core/packages/text-markdown/src/serializer.ts index 4392157497..eb3191a03a 100644 --- a/foundations/core/packages/text-markdown/src/serializer.ts +++ b/foundations/core/packages/text-markdown/src/serializer.ts @@ -220,6 +220,21 @@ export const storeNodes: Record = { } }, /* eslint-enable secure-coding/no-improper-sanitization */ + gif: (state, node) => { + // Emitted as a tagged rather than markdown image syntax, because markdown's ![](...) + // carries no way to distinguish a gif from an image and the parser would hand it back as an + // image node. 'file-id' and 'src' are independent; file-id wins. + const attrs = nodeAttrs(node) + state.write( + '${state.esc(`${attrs.alt}`)}' + ) + }, reference: (state, node) => { const attrs = nodeAttrs(node) let url = state.refUrl diff --git a/foundations/core/packages/text/src/kits/server-kit.ts b/foundations/core/packages/text/src/kits/server-kit.ts index 3778c22fd0..1c4c604bda 100644 --- a/foundations/core/packages/text/src/kits/server-kit.ts +++ b/foundations/core/packages/text/src/kits/server-kit.ts @@ -17,6 +17,7 @@ import { NoteBaseExtension } from '../marks/noteBase' import { QMSInlineCommentMark } from '../marks/qmsInlineCommentMark' import { EmojiNode } from '../nodes/emoji' +import { GifNode } from '../nodes/gif' import { FileNode } from '../nodes/file' import { ImageNode } from '../nodes/image' import { ReferenceNode } from '../nodes/reference' @@ -47,6 +48,7 @@ export const ServerKitFactory = (e: ExtensionFactory) => image: e(ImageNode), embed: e(EmbedNode), emoji: e(EmojiNode), + gif: e(GifNode), inlineNote: e(NoteBaseExtension), // Semi-deprecated, should be removed in the future qmsInlineCommentMark: e(QMSInlineCommentMark) // Semi-deprecated, should be removed in the future diff --git a/foundations/core/packages/text/src/markup/__tests__/utils.test.ts b/foundations/core/packages/text/src/markup/__tests__/utils.test.ts index 6a4b55e6ee..ea9c4ff91f 100644 --- a/foundations/core/packages/text/src/markup/__tests__/utils.test.ts +++ b/foundations/core/packages/text/src/markup/__tests__/utils.test.ts @@ -441,3 +441,23 @@ describe('jsonToText', () => { expect(() => jsonToText(node)).toThrow('Empty text nodes are not allowed') }) }) + +describe('gif node in the server kit', () => { + // without this registration the collaborator strips the node server-side, so a GIF + // survives locally and disappears once the message syncs. The emoji node is registered the + // same way for the same reason. + it('is present in the server kit schema', () => { + const schema = getSchema(extensions) + expect(schema.nodes.gif).toBeDefined() + }) + + // a gif must be selectable and must not be an atom, unlike emoji. This is the one of + // the four reasons not to reuse insertEmoji that no serializer test covers. + it('is selectable and not an atom, unlike emoji', () => { + const schema = getSchema(extensions) + expect(schema.nodes.gif.spec.atom).not.toBe(true) + expect(schema.nodes.gif.spec.selectable).not.toBe(false) + expect(schema.nodes.emoji.spec.atom).toBe(true) + expect(schema.nodes.emoji.spec.selectable).toBe(false) + }) +}) diff --git a/foundations/core/packages/text/src/nodes/gif.ts b/foundations/core/packages/text/src/nodes/gif.ts new file mode 100644 index 0000000000..f441d99ccd --- /dev/null +++ b/foundations/core/packages/text/src/nodes/gif.ts @@ -0,0 +1,94 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { Node, mergeAttributes } from '@tiptap/core' +import type { Blob, Ref } from '@hcengineering/core' + +declare module '@tiptap/core' { + interface Commands { + gif: { + insertGif: (attrs: { + 'file-id'?: Ref + src?: string + width?: number + height?: number + alt?: string + }) => ReturnType + } + } +} + +// A GIF is its own node rather than an image node: the image node is switched off in the +// message composers via kitOptions, so an image would be dropped by ProseMirror there. It is +// not an emoji node either, since emoji renders at 1.3em and its serializers drop the blob. +// +// 'file-id' carries a workspace blob for a library GIF. 'src' carries an external URL for a +// third-party source that forbids re-hosting. They are independent and 'file-id' wins, so a +// serializer must never read 'src' alone. +export const GifNode = Node.create({ + name: 'gif', + group: 'inline', + inline: true, + draggable: true, + selectable: true, + + addAttributes () { + return { + 'file-id': { + default: null + }, + src: { + default: null + }, + width: { + default: null + }, + height: { + default: null + }, + alt: { + default: null + } + } + }, + + addCommands () { + return { + insertGif: + (attrs) => + ({ commands }) => { + return commands.insertContent({ type: this.name, attrs }) + } + } + }, + + parseHTML () { + return [ + { + tag: `img[data-type="${this.name}"]` + } + ] + }, + + renderHTML ({ HTMLAttributes }) { + const imgAttributes = mergeAttributes({ 'data-type': this.name }, HTMLAttributes) + const fileId = imgAttributes['file-id'] + if (fileId != null) { + imgAttributes.src = `platform://platform/files/workspace/?file=${fileId}` + } + + return ['img', imgAttributes] + } +}) diff --git a/foundations/core/packages/text/src/nodes/index.ts b/foundations/core/packages/text/src/nodes/index.ts index 11bd4eb863..22a821cc32 100644 --- a/foundations/core/packages/text/src/nodes/index.ts +++ b/foundations/core/packages/text/src/nodes/index.ts @@ -16,6 +16,7 @@ export * from './image' export * from './reference' export * from './emoji' +export * from './gif' export * from './todo' export * from './file' export * from './codeblock' diff --git a/packages/presentation/package.json b/packages/presentation/package.json index d1ddb5a028..99cceea3e0 100644 --- a/packages/presentation/package.json +++ b/packages/presentation/package.json @@ -11,7 +11,9 @@ "svelte-check": "do-svelte-check", "_phase:svelte-check": "do-svelte-check", "build:watch": "compile ui", + "test": "jest --passWithNoTests --silent", "_phase:build": "compile ui", + "_phase:test": "jest --passWithNoTests --silent", "_phase:format": "format src", "_phase:validate": "compile validate" }, diff --git a/packages/presentation/src/__tests__/markupViewerCoverage.test.ts b/packages/presentation/src/__tests__/markupViewerCoverage.test.ts new file mode 100644 index 0000000000..61bf9c1c9c --- /dev/null +++ b/packages/presentation/src/__tests__/markupViewerCoverage.test.ts @@ -0,0 +1,70 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { readFileSync } from 'fs' +import { join } from 'path' + +// Structural, because no component-mounting harness exists in this repo. That gap is exactly how +// the defect this file guards against reached review: a gif node serialized correctly, round +// tripped correctly, passed 257 tests, and then rendered in chat as the literal text +// unknown node: "gif" - because NodeContent.svelte had a branch for image and none for gif, and no +// test reads that file. Serialization coverage says nothing about the read path. + +const COMPONENTS = join(__dirname, '..', 'components', 'markup') + +function source (relative: string): string { + return readFileSync(join(COMPONENTS, relative), 'utf8') +} + +// Every node type a message composer can put into stored markup. A viewer that lacks a branch for +// one of these does not fail: it prints the type name as text (NodeContent) or renders nothing at +// all (LiteNodeContent), so the message looks broken or empty to the reader while the content is +// perfectly intact in the database. +const COMPOSABLE_NODES = ['emoji', 'gif'] + +describe('markup viewers cover every composable node type', () => { + it.each(COMPOSABLE_NODES)('NodeContent.svelte handles %s', (nodeType) => { + expect(source('NodeContent.svelte')).toContain(`node.type === MarkupNodeType.${nodeType}`) + }) + + it.each(COMPOSABLE_NODES)('LiteNodeContent.svelte handles %s', (nodeType) => { + expect(source('lite/LiteNodeContent.svelte')).toContain(`node.type === MarkupNodeType.${nodeType}`) + }) + + // The fallback is what makes a missing branch silent rather than loud, so pin both halves: the + // fallback still exists (this test is not passing because someone deleted it) and it is the LAST + // branch (a node type added after it would be unreachable). + it('NodeContent.svelte still ends in the unknown-node fallback', () => { + const text = source('NodeContent.svelte') + expect(text).toContain('unknown node:') + const lastBranch = text.lastIndexOf('node.type === MarkupNodeType.') + expect(text.indexOf('unknown node:')).toBeGreaterThan(lastBranch) + }) + + // A gif carries the blob in 'file-id' and an external URL in 'src', independently, and 'file-id' + // wins. The editor node view establishes that precedence; a viewer that reads 'src' alone shows + // nothing for every library gif, which is the common case. + it.each([['NodeContent.svelte'], ['lite/LiteNodeContent.svelte']])( + '%s prefers file-id over src for a gif', + (file) => { + const text = source(file) + const gifBranch = text.indexOf('node.type === MarkupNodeType.gif') + expect(gifBranch).toBeGreaterThan(-1) + const branch = text.slice(gifBranch, gifBranch + 900) + expect(branch).toContain("attrs['file-id']") + expect(branch.indexOf("attrs['file-id']")).toBeLessThan(branch.indexOf('attrs.src')) + } + ) +}) diff --git a/packages/presentation/src/components/markup/NodeContent.svelte b/packages/presentation/src/components/markup/NodeContent.svelte index 84608a183d..2827f6b8a0 100644 --- a/packages/presentation/src/components/markup/NodeContent.svelte +++ b/packages/presentation/src/components/markup/NodeContent.svelte @@ -141,6 +141,23 @@ {:else if node.type === MarkupNodeType.code_block} + {:else if node.type === MarkupNodeType.gif} + + {@const alt = toString(attrs.alt)} + {@const width = toString(attrs.width)} + {@const height = toString(attrs.height)} +
+ {#if attrs['file-id'] != null} + {#await getBlobRef(toRefBlob(attrs['file-id'])) then blobSrc} + + {/await} + {:else if attrs.src != null} + + {/if} +
{:else if node.type === MarkupNodeType.image} {@const src = toString(attrs.src)} {@const alt = toString(attrs.alt)} diff --git a/packages/presentation/src/components/markup/lite/LiteNodeContent.svelte b/packages/presentation/src/components/markup/lite/LiteNodeContent.svelte index de56ce4ee0..2c1619970d 100644 --- a/packages/presentation/src/components/markup/lite/LiteNodeContent.svelte +++ b/packages/presentation/src/components/markup/lite/LiteNodeContent.svelte @@ -123,6 +123,21 @@ {node.attrs?.emoji} {/if} + {:else if node.type === MarkupNodeType.gif} + + {@const alt = toString(attrs.alt)} + + {#if attrs['file-id'] != null} + {#await getBlobRef(toRefBlob(attrs['file-id'])) then blobSrc} + + {/await} + {:else if attrs.src != null} + + {/if} + {:else if node.type === MarkupNodeType.taskList} {:else if node.type === MarkupNodeType.taskItem} diff --git a/plugins/attachment-resources/src/components/AttachmentRefInput.svelte b/plugins/attachment-resources/src/components/AttachmentRefInput.svelte index 74f4e088f9..c3bd92b035 100644 --- a/plugins/attachment-resources/src/components/AttachmentRefInput.svelte +++ b/plugins/attachment-resources/src/components/AttachmentRefInput.svelte @@ -520,7 +520,8 @@ {placeholder} kitOptions={{ file: false, - image: false + image: false, + gif: true }} >
diff --git a/plugins/communication-resources/src/components/TextInput.svelte b/plugins/communication-resources/src/components/TextInput.svelte index f71947b48d..1956189606 100644 --- a/plugins/communication-resources/src/components/TextInput.svelte +++ b/plugins/communication-resources/src/components/TextInput.svelte @@ -68,6 +68,9 @@ insertText: (text) => { editor?.insertText(text) }, + insertGif: (attrs: { 'file-id'?: Ref, src?: string, width?: number, height?: number, alt?: string }) => { + editor?.insertGif(attrs) + }, insertEmoji: (text: string, image?: Ref) => { editor?.insertEmoji(text, image) }, @@ -163,6 +166,7 @@ file: false, image: false, emoji: true, + gif: true, reference: true, hooks: { emptyContent: { diff --git a/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte b/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte index fde19c1104..d9e5ca3775 100644 --- a/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte +++ b/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte @@ -154,6 +154,9 @@ insertText: (text) => { editor?.commands.insertContent(text) }, + insertGif: (attrs: { 'file-id'?: Ref, src?: string, width?: number, height?: number, alt?: string }) => { + editor?.commands.insertGif(attrs) + }, insertEmoji: (text: string, image?: Ref) => { editor?.commands.insertEmoji(text, image === undefined ? 'unicode' : 'image', image) }, diff --git a/plugins/text-editor-resources/src/components/ReferenceInput.svelte b/plugins/text-editor-resources/src/components/ReferenceInput.svelte index b4da72ec02..b20aba5acc 100644 --- a/plugins/text-editor-resources/src/components/ReferenceInput.svelte +++ b/plugins/text-editor-resources/src/components/ReferenceInput.svelte @@ -83,6 +83,9 @@ insertText: (text) => { editor?.insertText(text) }, + insertGif: (attrs: { 'file-id'?: Ref, src?: string, width?: number, height?: number, alt?: string }) => { + editor?.insertGif(attrs) + }, insertEmoji: (text: string, image?: Ref) => { editor?.insertEmoji(text, image) }, diff --git a/plugins/text-editor-resources/src/components/StyledTextEditor.svelte b/plugins/text-editor-resources/src/components/StyledTextEditor.svelte index 3d6c616b57..10eb3ac70f 100644 --- a/plugins/text-editor-resources/src/components/StyledTextEditor.svelte +++ b/plugins/text-editor-resources/src/components/StyledTextEditor.svelte @@ -82,6 +82,9 @@ insertText: (text) => { editor?.insertText(text) }, + insertGif: (attrs: { 'file-id'?: Ref, src?: string, width?: number, height?: number, alt?: string }) => { + editor?.insertGif(attrs) + }, insertEmoji: (text: string, image?: Ref) => { editor?.insertEmoji(text, image) }, diff --git a/plugins/text-editor-resources/src/components/TextEditor.svelte b/plugins/text-editor-resources/src/components/TextEditor.svelte index 25aeaf72d8..f334b360b8 100644 --- a/plugins/text-editor-resources/src/components/TextEditor.svelte +++ b/plugins/text-editor-resources/src/components/TextEditor.svelte @@ -78,6 +78,16 @@ return editor } + export function insertGif(attrs: { + 'file-id'?: Ref + src?: string + width?: number + height?: number + alt?: string + }): void { + editor?.commands.insertGif(attrs) + } + export function insertEmoji(text: string, image?: Ref): void { editor?.commands.insertEmoji(text, image === undefined ? 'unicode' : 'image', image) } diff --git a/plugins/text-editor-resources/src/components/extension/gifExt.ts b/plugins/text-editor-resources/src/components/extension/gifExt.ts new file mode 100644 index 0000000000..7d745b398d --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/gifExt.ts @@ -0,0 +1,61 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type Blob, type Ref } from '@hcengineering/core' +import { getBlobRef } from '@hcengineering/presentation' +import { GifNode } from '@hcengineering/text' + +export interface GifExtensionOptions { + getBlobRef: (fileId: Ref, filename?: string, size?: number) => Promise<{ src: string, srcset: string }> +} + +// A GIF is served unresized: the front deliberately skips preview generation for image/gif so +// the animation survives, so there is no point requesting a sized variant here. +export const GifExtension = GifNode.extend({ + addOptions () { + return { + getBlobRef: async (file, name, size) => await getBlobRef(file, name, size) + } + }, + + addNodeView () { + return ({ node, HTMLAttributes }) => { + const img = document.createElement('img') + img.setAttribute('data-type', this.name) + img.className = 'text-editor-gif' + + const alt = node.attrs.alt + if (alt != null) img.alt = alt + const width = node.attrs.width + if (width != null) img.width = width + const height = node.attrs.height + if (height != null) img.height = height + + const fileId = node.attrs['file-id'] + if (fileId != null) { + void this.options.getBlobRef(fileId).then(({ src, srcset }) => { + img.src = src + if (srcset !== '') img.srcset = srcset + }) + } else if (node.attrs.src != null) { + // An external source. The URL is used exactly as provided: some providers forbid + // modifying their media URLs, including stripping query parameters. + img.src = node.attrs.src + } + + return { dom: img } + } + } +}) diff --git a/plugins/text-editor-resources/src/kits/__tests__/gifKit.test.ts b/plugins/text-editor-resources/src/kits/__tests__/gifKit.test.ts new file mode 100644 index 0000000000..22ddf4c601 --- /dev/null +++ b/plugins/text-editor-resources/src/kits/__tests__/gifKit.test.ts @@ -0,0 +1,103 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { readFileSync } from 'fs' +import { join } from 'path' +// GifNode, not GifExtension: the extension imports @hcengineering/presentation, which pulls +// Svelte into jest. The node carries the semantics; the extension only adds a node view. +import { GifNode } from '@hcengineering/text' + +// Structural assertions, because the failure being guarded is a registration that produces no +// error. The editor kit and the composers' kitOptions are plain source; a schema built here +// from GifNode would prove only that GifNode works, which is not the risk. Resolve from +// __dirname so the test cannot pass vacuously against an empty read under a different cwd. +const repoRoot = join(__dirname, '../../../../..') +const read = (rel: string): string => readFileSync(join(repoRoot, rel), 'utf8') + +const EDITOR_KIT = 'plugins/text-editor-resources/src/kits/editor-kit.ts' +const COMPOSERS = [ + 'plugins/attachment-resources/src/components/AttachmentRefInput.svelte', + 'plugins/communication-resources/src/components/TextInput.svelte' +] + +describe('gif registration in the editor kit', () => { + it('reads real files, not an empty string', () => { + expect(read(EDITOR_KIT).length).toBeGreaterThan(1000) + for (const c of COMPOSERS) expect(read(c).length).toBeGreaterThan(1000) + }) + + // BRD 1.5. Without the kit entry the gif node does not exist in any editor schema and + // an inserted gif is dropped by ProseMirror with no error. + it('registers the gif extension in the editor kit', () => { + expect(read(EDITOR_KIT)).toMatch(/gif:\s*e\(GifExtension\)/) + }) + + // the tab is useless on a surface whose schema lacks the node. Both composers must + // enable it explicitly, the same way emoji is enabled explicitly in the communication one. + it.each(COMPOSERS)('enables gif in the kitOptions of %s', (composer) => { + expect(read(composer)).toMatch(/gif:\s*true/) + }) + + // image stays off. Flipping it would switch on inline image paste in chat, colliding + // with the attachment flow, and is the tempting shortcut this whole node exists to avoid. + it.each(COMPOSERS)('leaves the image node disabled in %s', (composer) => { + expect(read(composer)).toMatch(/image:\s*false/) + }) +}) + +describe('gif node semantics', () => { + // inline and selectable, so it sits in a line of text and can be selected or deleted + // like any other content. Schema construction itself is covered by the server-kit registration test + // kit; asserting it again here would only re-test tiptap. + it('is an inline, selectable, non-atom node', () => { + expect(GifNode.name).toBe('gif') + expect(GifNode.config.inline).toBe(true) + expect(GifNode.config.group).toBe('inline') + expect(GifNode.config.selectable).toBe(true) + expect(GifNode.config.atom).not.toBe(true) + }) + + it('declares file-id and src as independent attributes', () => { + const attrs = (GifNode.config.addAttributes as () => Record).call(GifNode) + expect(Object.keys(attrs)).toEqual(expect.arrayContaining(['file-id', 'src', 'width', 'height', 'alt'])) + }) +}) + +// BRD section 2 - implemented in every handler or it is a silent no-op in that surface. +// Five files, not four: TextEditor.svelte holds the low-level export that ReferenceInput, +// StyledTextEditor and the communication TextInput all delegate to. +const HANDLERS = [ + 'plugins/text-editor/src/types.ts', + 'plugins/text-editor-resources/src/components/TextEditor.svelte', + 'plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte', + 'plugins/text-editor-resources/src/components/ReferenceInput.svelte', + 'plugins/text-editor-resources/src/components/StyledTextEditor.svelte', + 'plugins/communication-resources/src/components/TextInput.svelte' +] + +describe('insertGif across every editor handler', () => { + it.each(HANDLERS)('is declared or implemented in %s', (file) => { + const src = read(file) + expect(src.length).toBeGreaterThan(500) + expect(src).toContain('insertGif') + }) + + // Every file that implements insertEmoji must also implement insertGif. Pins the pairing so a + // sixth surface added later cannot quietly ship with only half the handler. + it('is implemented everywhere insertEmoji is', () => { + const missing = HANDLERS.filter((f) => read(f).includes('insertEmoji') && !read(f).includes('insertGif')) + expect(missing).toEqual([]) + }) +}) diff --git a/plugins/text-editor-resources/src/kits/editor-kit.ts b/plugins/text-editor-resources/src/kits/editor-kit.ts index 82669655cb..70cdb617b1 100644 --- a/plugins/text-editor-resources/src/kits/editor-kit.ts +++ b/plugins/text-editor-resources/src/kits/editor-kit.ts @@ -43,6 +43,7 @@ import { EmbedNode } from '../components/extension/embed/embed' import { defaultDriveEmbedOptions, DriveEmbedProvider } from '../components/extension/embed/providers/drive' import { defaultYoutubeEmbedUrlOptions, YoutubeEmbedProvider } from '../components/extension/embed/providers/youtube' import { EmojiExtension } from '../components/extension/emoji' +import { GifExtension } from '../components/extension/gifExt' import { FileExtension } from '../components/extension/fileExt' import { HardBreakExtension } from '../components/extension/hardBreak' import { EditableExtension } from '../components/extension/hooks/editable' @@ -106,6 +107,7 @@ const StaticEditorKit = extensionKit( file: e(FileExtension, { inline: true }), image: e(ImageExtension), emoji: e(EmojiExtension), + gif: e(GifExtension), mathematics: e(MathematicsExtension, context.mode === 'full'), highlight: e(Highlight, { multicolor: false }), subscript: e(Subscript), diff --git a/plugins/text-editor/src/types.ts b/plugins/text-editor/src/types.ts index 97dbffa236..7cbdd6d991 100644 --- a/plugins/text-editor/src/types.ts +++ b/plugins/text-editor/src/types.ts @@ -17,6 +17,7 @@ export type CollaboratorType = 'local' | 'cloud' export interface TextEditorHandler { insertText: (html: string) => void insertEmoji: (text: string, image: Ref) => void + insertGif: (attrs: { 'file-id'?: Ref, src?: string, width?: number, height?: number, alt?: string }) => void insertMarkup: (markup: Markup) => void insertTemplate: (name: string, markup: string) => void insertTable: (options: { rows?: number, cols?: number, withHeaderRow?: boolean }) => void From 9adfaabb288de82e7e55669ec1dfead2d5523514 Mon Sep 17 00:00:00 2001 From: Shannon Kerr Date: Fri, 9 Oct 2026 15:42:59 -0400 Subject: [PATCH 2/2] fix: address review on the gif markup node - Markdown: escape the raw 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 --- .../text-html/src/__tests__/html.test.ts | 17 +++++--- .../core/packages/text-html/src/serializer.ts | 3 +- .../src/__tests__/markdown.test.ts | 37 +++++++++++++++++ .../packages/text-markdown/src/serializer.ts | 15 +++---- .../src/markup/__tests__/gif-html.test.ts | 41 +++++++++++++++++++ .../core/packages/text/src/nodes/gif.ts | 11 +++-- 6 files changed, 107 insertions(+), 17 deletions(-) create mode 100644 foundations/core/packages/text/src/markup/__tests__/gif-html.test.ts diff --git a/foundations/core/packages/text-html/src/__tests__/html.test.ts b/foundations/core/packages/text-html/src/__tests__/html.test.ts index 1d0c6b6b43..147901cd18 100644 --- a/foundations/core/packages/text-html/src/__tests__/html.test.ts +++ b/foundations/core/packages/text-html/src/__tests__/html.test.ts @@ -586,12 +586,19 @@ describe('gif node', () => { content: [{ type: 'paragraph' as any, content: [{ type: 'gif' as any, attrs }] }] }) - // the one that matters. The image case in this serializer reads attrs.src ONLY, so a - // gif case copied from it would emit src="undefined" for a library gif and lose the blob. - it('emits a resolvable src for a gif with file-id set and src null', () => { + // A bare blob id is not a resolvable URL, so a library gif carries file-id only and the + // consumer resolves it. It must not emit src="undefined", src="null" or src="". + it('emits file-id and no src for a gif with file-id set and src null', () => { const html = markupToHtml(gifDoc({ 'file-id': 'blob-1', src: null, width: 320 })) - expect(html).toContain('blob-1') - expect(html).not.toContain('undefined') + expect(html).toContain('file-id="blob-1"') + expect(html).not.toContain('src=') + }) + + it('round-trips a library gif without inventing a src', () => { + const back = htmlToMarkup(markupToHtml(gifDoc({ 'file-id': 'blob-1', src: null }))) + const node = (back.content?.[0] as MarkupNode)?.content?.[0] + expect(node?.attrs?.['file-id']).toBe('blob-1') + expect(node?.attrs?.src ?? null).toBeNull() }) it('emits the external src for a gif with no file-id', () => { diff --git a/foundations/core/packages/text-html/src/serializer.ts b/foundations/core/packages/text-html/src/serializer.ts index 670b5cddc9..98e923fada 100644 --- a/foundations/core/packages/text-html/src/serializer.ts +++ b/foundations/core/packages/text-html/src/serializer.ts @@ -227,9 +227,10 @@ function addNodeContent (builder: NodeBuilder, node?: MarkupNode): void { const rawFileId = attrs['file-id'] const rawSrc = attrs.src const imgAttrs: Record = { 'data-type': 'gif' } + // A bare blob id is not a resolvable URL, so a library gif carries file-id alone and the + // consumer resolves it. if (rawFileId != null) { imgAttrs['file-id'] = toString(rawFileId) - imgAttrs.src = toString(rawFileId) } else if (rawSrc != null) { imgAttrs.src = toString(rawSrc) } diff --git a/foundations/core/packages/text-markdown/src/__tests__/markdown.test.ts b/foundations/core/packages/text-markdown/src/__tests__/markdown.test.ts index 8377734350..a90b63cdbb 100644 --- a/foundations/core/packages/text-markdown/src/__tests__/markdown.test.ts +++ b/foundations/core/packages/text-markdown/src/__tests__/markdown.test.ts @@ -1252,6 +1252,29 @@ describe('markdownToMarkup -> markupToMarkdown', () => { }) }) +describe('sized external image', () => { + // A sized image without a file-id is written as a raw tag, so its attributes need HTML + // escaping, the same as the gif tag. + it('round-trips alt text containing a double quote and markdown punctuation', () => { + const alt = 'Cat says "hi" *now*' + const doc: MarkupNode = { + type: MarkupNodeType.doc, + content: [ + { + type: MarkupNodeType.paragraph, + content: [{ type: MarkupNodeType.image, attrs: { src: 'https://example.com/a.png', width: 320, alt } }] + } + ] + } + const once = markupToMarkdown(doc, options) + const back = markdownToMarkup(once, options) + const node = (back.content?.[0] as MarkupNode)?.content?.[0] + expect(node?.type).toBe(MarkupNodeType.image) + expect(node?.attrs?.alt).toBe(alt) + expect(markupToMarkdown(back, options)).toEqual(once) + }) +}) + describe('gif node', () => { const gifDoc = (attrs: Record): MarkupNode => ({ type: MarkupNodeType.doc, @@ -1290,6 +1313,20 @@ describe('gif node', () => { expect(node?.attrs?.src).toBe(src) }) + // alt text comes from GIF provider titles, which often contain quotes and markdown + // punctuation. The tag is raw HTML, so attributes need HTML escaping, not markdown escaping. + it.each([ + ['a double quote', 'Cat says "hi"'], + ['markdown punctuation', 'so *excited* [yay]'] + ])('round-trips alt text containing %s', (_, alt) => { + const once = markupToMarkdown(gifDoc({ 'file-id': 'blob-1', alt }), options) + const back = markdownToMarkup(once, options) + const node = (back.content?.[0] as MarkupNode)?.content?.[0] + expect(node?.type).toBe(MarkupNodeType.gif) + expect(node?.attrs?.alt).toBe(alt) + expect(markupToMarkdown(back, options)).toEqual(once) + }) + // idempotence, so an edit-and-resave cycle cannot corrupt via double escaping. it('is stable across two serializations', () => { const once = markupToMarkdown(gifDoc({ 'file-id': 'blob-1' }), options) diff --git a/foundations/core/packages/text-markdown/src/serializer.ts b/foundations/core/packages/text-markdown/src/serializer.ts index eb3191a03a..9ce094fdcb 100644 --- a/foundations/core/packages/text-markdown/src/serializer.ts +++ b/foundations/core/packages/text-markdown/src/serializer.ts @@ -219,22 +219,23 @@ export const storeNodes: Record = { } } }, - /* eslint-enable secure-coding/no-improper-sanitization */ gif: (state, node) => { // Emitted as a tagged rather than markdown image syntax, because markdown's ![](...) // carries no way to distinguish a gif from an image and the parser would hand it back as an - // image node. 'file-id' and 'src' are independent; file-id wins. + // image node. 'file-id' and 'src' are independent; file-id wins. Attributes of a raw tag + // need HTML escaping: markdown escaping leaves " intact and its backslashes pile up per save. const attrs = nodeAttrs(node) state.write( '${state.esc(`${attrs.alt}`)}' ) }, + /* eslint-enable secure-coding/no-improper-sanitization */ reference: (state, node) => { const attrs = nodeAttrs(node) let url = state.refUrl diff --git a/foundations/core/packages/text/src/markup/__tests__/gif-html.test.ts b/foundations/core/packages/text/src/markup/__tests__/gif-html.test.ts new file mode 100644 index 0000000000..e08d2904d8 --- /dev/null +++ b/foundations/core/packages/text/src/markup/__tests__/gif-html.test.ts @@ -0,0 +1,41 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { MarkupNodeType, type MarkupNode } from '@hcengineering/text-core' +import { ServerKit } from '../../kits/server-kit' +import { htmlToJSON, jsonToHTML } from '../utils' + +// Kept apart from utils.test.ts, which mocks @tiptap/html. +describe('gif node html round trip in the server kit', () => { + // ServerKit has image enabled, and ImageNode's catch-all img[src] rule must not win over + // the gif rule, or a gif pasted from a document arrives in chat as an image the composer drops. + it('parses its own html back to a gif node with image enabled', () => { + const doc: MarkupNode = { + type: MarkupNodeType.doc, + content: [ + { type: MarkupNodeType.paragraph, content: [{ type: MarkupNodeType.gif, attrs: { 'file-id': 'blob-1' } }] } + ] + } + const back = htmlToJSON(jsonToHTML(doc, [ServerKit]), [ServerKit]) + const node = (back.content?.[0] as MarkupNode)?.content?.[0] + expect(node?.type).toBe(MarkupNodeType.gif) + expect(node?.attrs?.['file-id']).toBe('blob-1') + }) + + it('still parses a plain img as an image node', () => { + const back = htmlToJSON('

', [ServerKit]) + expect((back.content?.[0] as MarkupNode)?.content?.[0]?.type).toBe(MarkupNodeType.image) + }) +}) diff --git a/foundations/core/packages/text/src/nodes/gif.ts b/foundations/core/packages/text/src/nodes/gif.ts index f441d99ccd..08cdffb0d4 100644 --- a/foundations/core/packages/text/src/nodes/gif.ts +++ b/foundations/core/packages/text/src/nodes/gif.ts @@ -68,16 +68,19 @@ export const GifNode = Node.create({ return { insertGif: (attrs) => - ({ commands }) => { - return commands.insertContent({ type: this.name, attrs }) - } + ({ commands }) => { + return commands.insertContent({ type: this.name, attrs }) + } } }, parseHTML () { return [ { - tag: `img[data-type="${this.name}"]` + // Above the default 50, so ImageNode's catch-all img[src] rule cannot claim a gif in + // kits where image is enabled. + tag: `img[data-type="${this.name}"]`, + priority: 60 } ] },