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 1068e7b2ad..dafca682d5 100644 --- a/foundations/core/packages/text-markdown/src/__tests__/markdown.test.ts +++ b/foundations/core/packages/text-markdown/src/__tests__/markdown.test.ts @@ -1250,3 +1250,49 @@ describe('normalizeMarkdown', () => { expect(normalizeMarkdown(input)).toBe(expected) }) }) + +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 dd204f3d79..6c144a94a4 100644 --- a/foundations/core/packages/text-markdown/src/serializer.ts +++ b/foundations/core/packages/text-markdown/src/serializer.ts @@ -224,6 +224,21 @@ export const storeNodes: Record = { } } }, + 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 1fb1554f17..59206da9ec 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 c0502d398f..49cefd38b1 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 fcce7a7d29..7cb006ac8e 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 f89bda1454..a524a7bf07 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 d97660caa0..3fcc463248 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 eb1feec2a7..1ca743a2f7 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 50ea48d5f9..7f836a7bdb 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 249cebd31f..a788991311 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 9e3c9bcf9c..3fdc7343e0 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 ed1d95d1c2..69d79fc91b 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