diff --git a/zeppelin-web-angular/projects/zeppelin-react/README.md b/zeppelin-web-angular/projects/zeppelin-react/README.md index 4c45e4d7ad6..1a81621ec73 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/README.md +++ b/zeppelin-web-angular/projects/zeppelin-react/README.md @@ -201,4 +201,6 @@ Pass `describeParagraph` and `onOpenParagraph` to enable paragraph reference but HTML is displayed as text and dangerous link URLs are blocked. -See **Assistant / MarkdownAnswer** in Storybook for complete answers, streaming, and paragraph reference examples in both themes. +Markdown images load only after you select **Load image**. Review the displayed destination first: loading may transmit information included in the URL. Only HTTP(S) URLs are supported. + +See **Assistant / MarkdownAnswer** in Storybook for examples in both themes. diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.css.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.css.ts index a39fa0701a1..2e54a84c9e0 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.css.ts +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.css.ts @@ -119,6 +119,58 @@ export const paragraphReference = style({ } }); +export const imagePlaceholder = style({ + display: 'inline-flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: vars.space2, + maxWidth: '100%', + margin: `${vars.space1} 0`, + padding: vars.space3, + border: `1px solid ${vars.border}`, + borderRadius: vars.radius, + background: vars.fill, + verticalAlign: 'middle' +}); + +export const imageDescription = style({ + fontWeight: 600 +}); + +export const imageDetails = style({ + display: 'flex', + flexDirection: 'column', + gap: vars.space1, + maxWidth: '100%', + color: vars.textSecondary, + fontSize: vars.fontSizeSm, + overflowWrap: 'anywhere' +}); + +export const loadImage = style({ + padding: `${vars.space1} ${vars.space2}`, + border: `1px solid ${vars.accentBorder}`, + borderRadius: vars.radiusSm, + background: vars.accentBg, + color: vars.accentText, + font: 'inherit', + cursor: 'pointer', + selectors: { + '&:hover': { color: vars.accentTextHover } + } +}); + +export const loadedImage = style({ + display: 'block', + maxWidth: '100%', + height: 'auto' +}); + +export const imageLink = style({ + display: 'inline', + maxWidth: '100%' +}); + export const codeBlock = style({ minWidth: 0, maxWidth: '100%', diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.spec.tsx index 629c6ae6209..46889eef7dd 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.spec.tsx +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.spec.tsx @@ -16,6 +16,7 @@ */ import { fireEvent, render, screen, within } from '@testing-library/react'; +import { StrictMode, useLayoutEffect } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { visuallyHidden } from '@/shared/ui/assistant-theme/AssistantScope.css'; import { MarkdownAnswer } from './MarkdownAnswer'; @@ -416,6 +417,274 @@ describe('MarkdownAnswer', () => { }); }); + describe('image loading', () => { + const sourceA = 'https://images.example.test/plot.svg?q=SYNTHETIC_NOTEBOOK_VALUE'; + const sourceB = 'https://images.example.test/plot.svg?q=UPDATED_NOTEBOOK_VALUE'; + const imageMarkdown = (source: string) => `![Quarterly revenue](${source} "Revenue plot")`; + + it('shows the resolved destination and warning without an image until explicitly activated', () => { + const { container } = render(); + + expect(screen.queryByRole('img')).toBeNull(); + expect(container.querySelector('img, [src], [srcset], link[rel="preload"]')).toBeNull(); + expect(screen.getByText(`Image URL: ${sourceA}`)).toBeTruthy(); + expect(screen.getByText('Host: images.example.test')).toBeTruthy(); + expect(screen.getByText(/loading.*sends a request.*information from.*answer/i)).toBeTruthy(); + expect(container.textContent).toContain('Quarterly revenue'); + const button = screen.getByRole('button', { name: /load image/i }); + + fireEvent.click(button); + + const image = screen.getByRole('img', { name: 'Quarterly revenue' }); + expect(image.getAttribute('src')).toBe(sourceA); + expect(image.getAttribute('alt')).toBe('Quarterly revenue'); + expect(image.getAttribute('title')).toBe('Revenue plot'); + expect(image.hasAttribute('srcset')).toBe(false); + }); + + it('accepts activation before passive image registration has run', () => { + const ActivateOnMount = () => { + useLayoutEffect(() => { + fireEvent.click(screen.getByRole('button', { name: /load image/i })); + }, []); + return ; + }; + + render(); + + expect(screen.getByRole('img', { name: 'Quarterly revenue' }).getAttribute('src')).toBe(sourceA); + }); + + it.each(['/assistant-plot.svg?q=relative', '//images.example.test/plot.svg?q=protocol-relative'])( + 'resolves destination %s against the actual document base URI', + relativeSource => { + const resolvedSource = new URL(relativeSource, document.baseURI); + render(); + + expect(screen.getByText(resolvedSource.href, { exact: false })).toBeTruthy(); + expect(screen.getByText(text => text.endsWith(resolvedSource.hostname))).toBeTruthy(); + expect(screen.queryByRole('img')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: /load image/i })); + expect(screen.getByRole('img').getAttribute('src')).toBe(resolvedSource.href); + } + ); + + it('requires fresh consent for query changes, including returning to a previously loaded URL', () => { + const { rerender } = render(); + fireEvent.click(screen.getByRole('button', { name: /load image/i })); + expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA); + + rerender(); + expect(screen.queryByRole('img')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: /load image/i })); + expect(screen.getByRole('img').getAttribute('src')).toBe(sourceB); + + rerender(); + expect(screen.queryByRole('img')).toBeNull(); + expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy(); + }); + + it('revokes approval when a different destination is shown without being activated', () => { + const { rerender } = render(); + fireEvent.click(screen.getByRole('button', { name: /load image/i })); + expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA); + + rerender(); + expect(screen.queryByRole('img')).toBeNull(); + expect(screen.getByText(sourceB, { exact: false })).toBeTruthy(); + expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy(); + + rerender(); + expect(screen.queryByRole('img')).toBeNull(); + expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy(); + }); + + it('requires fresh consent when the destination path changes', () => { + const { rerender } = render(); + fireEvent.click(screen.getByRole('button', { name: /load image/i })); + + rerender(); + + expect(screen.queryByRole('img')).toBeNull(); + expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy(); + }); + + it('keeps approval for an unchanged URL while more answer text is appended', () => { + const content = `Revenue increased.\n\n${imageMarkdown(sourceA)}`; + const { rerender } = render(); + fireEvent.click(screen.getByRole('button', { name: /load image/i })); + + rerender(); + + expect(screen.getByRole('img', { name: 'Quarterly revenue' }).getAttribute('src')).toBe(sourceA); + expect(screen.queryByRole('button', { name: /load image/i })).toBeNull(); + expect(screen.getByText('The next quarter also improved.')).toBeTruthy(); + }); + + it('keeps approval and updates the title when a reference definition title is appended', () => { + const content = `![Existing][plot]\n\n[plot]: ${sourceA}`; + const { rerender } = render(); + fireEvent.click(screen.getByRole('button', { name: /load image/i })); + expect(screen.getByRole('img', { name: 'Existing' }).getAttribute('src')).toBe(sourceA); + + rerender(); + + const image = screen.getByRole('img', { name: 'Existing' }); + expect(image.getAttribute('src')).toBe(sourceA); + expect(image.getAttribute('title')).toBe('Revenue plot'); + }); + + it('keeps consent with its source occurrence when an earlier image reference resolves', () => { + const source = 'https://images.example.test/plot.svg'; + const initial = `![New][later] ![Existing](${source})`; + const { rerender } = render(); + fireEvent.click(screen.getByRole('button', { name: /Existing/ })); + expect(screen.getByRole('img', { name: 'Existing' }).getAttribute('src')).toBe(source); + + rerender(); + + expect(screen.getByRole('button', { name: /New/ })).toBeTruthy(); + expect(screen.queryByRole('img', { name: 'New' })).toBeNull(); + expect(screen.getByRole('img', { name: 'Existing' }).getAttribute('src')).toBe(source); + expect(screen.queryByRole('button', { name: /Existing/ })).toBeNull(); + expect(screen.getAllByRole('img')).toHaveLength(1); + }); + + it('keeps existing consent through StrictMode replay when an earlier image reference resolves', () => { + const initial = `![New][later] ![Existing](${sourceA})`; + const { rerender } = render( + + + + ); + fireEvent.click(screen.getByRole('button', { name: /Existing/ })); + expect(screen.getByRole('img', { name: 'Existing' }).getAttribute('src')).toBe(sourceA); + + rerender( + + + + ); + + expect(screen.getByRole('img', { name: 'Existing' }).getAttribute('src')).toBe(sourceA); + expect(screen.queryByRole('img', { name: 'New' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: /New/ })); + + expect(screen.getByRole('img', { name: 'Existing' }).getAttribute('src')).toBe(sourceA); + expect(screen.getByRole('img', { name: 'New' }).getAttribute('src')).toBe(sourceB); + }); + + it('does not transfer approval to a replacement image at the same source position', () => { + const { rerender } = render(); + fireEvent.click(screen.getByRole('button', { name: /Load image: A/ })); + expect(screen.getByRole('img', { name: 'A' })).toBeTruthy(); + + rerender(); + + expect(screen.queryByRole('img')).toBeNull(); + expect(screen.getByRole('button', { name: /Load image: B/ })).toBeTruthy(); + }); + + it('does not share approval with another image in the same answer', () => { + render(); + const buttons = screen.getAllByRole('button', { name: /load image/i }); + + fireEvent.click(buttons[0]); + + expect(screen.getAllByRole('img')).toHaveLength(1); + expect(screen.getByRole('img', { name: 'Quarterly revenue' })).toBeTruthy(); + expect(screen.queryByRole('img', { name: 'Comparison' })).toBeNull(); + expect(screen.getAllByRole('button', { name: /load image/i })).toHaveLength(1); + }); + + it('does not share approval between independently rendered answers', () => { + const first = render(); + fireEvent.click(within(first.container).getByRole('button', { name: /load image/i })); + const second = render(); + + expect(within(first.container).getByRole('img')).toBeTruthy(); + expect(within(second.container).queryByRole('img')).toBeNull(); + expect(within(second.container).getByRole('button', { name: /load image/i })).toBeTruthy(); + }); + + it('requires fresh consent when removed image content is restored in the same answer', () => { + const content = imageMarkdown(sourceA); + const { rerender } = render(); + fireEvent.click(screen.getByRole('button', { name: /load image/i })); + expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA); + + rerender(); + expect(screen.queryByRole('img')).toBeNull(); + expect(screen.queryByRole('button', { name: /load image/i })).toBeNull(); + expect(screen.getByText('No answer selected.')).toBeTruthy(); + + rerender(); + expect(screen.queryByRole('img')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: /load image/i })); + expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA); + }); + + it('requires fresh consent when a previously displayed answer is restored after unmounting', () => { + const content = imageMarkdown(sourceA); + const first = render(); + fireEvent.click(screen.getByRole('button', { name: /load image/i })); + expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA); + + first.unmount(); + render(); + + expect(screen.queryByRole('img')).toBeNull(); + expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy(); + }); + + it.each(['javascript:alert%281%29', 'ftp://images.example.test/plot.svg', ''])( + 'does not offer loading for the unsupported or empty image destination %s', + source => { + const { container } = render(); + + expect(screen.queryByRole('img')).toBeNull(); + expect(screen.queryByRole('button', { name: /load image/i })).toBeNull(); + expect(container.querySelector('[src], [srcset]')).toBeNull(); + expect(container.textContent).toContain('Quarterly revenue'); + } + ); + + it('keeps the original link separate from the image loading button and preserves mixed content', () => { + const href = 'https://zeppelin.apache.org/docs/latest/'; + const { container } = render( + + ); + const button = screen.getByRole('button', { name: /load image/i }); + const links = screen.getAllByRole('link'); + + expect(button.closest('a')).toBeNull(); + expect(button.closest('p')).not.toBeNull(); + expect(container.querySelector('p p, p div')).toBeNull(); + expect(container.textContent).toContain('Before'); + expect(screen.getByText('bold').tagName).toBe('STRONG'); + expect(container.textContent).toContain('after'); + for (const link of links) { + expect(link.getAttribute('href')).toBe(href); + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')?.split(/\s+/)).toEqual(expect.arrayContaining(['noopener', 'noreferrer'])); + } + + fireEvent.click(button); + + expect(screen.getByRole('img', { name: 'Quarterly revenue' }).getAttribute('src')).toBe(sourceA); + expect(screen.getAllByRole('link').map(link => link.getAttribute('href'))).toEqual(links.map(() => href)); + }); + + it('retains a navigation link for an image-only Markdown link without nesting its load button', () => { + const href = 'https://zeppelin.apache.org/docs/latest/'; + render(); + + const button = screen.getByRole('button', { name: /load image/i }); + expect(button.closest('a')).toBeNull(); + expect(screen.getByRole('link').getAttribute('href')).toBe(href); + }); + }); + it.each(dangerousUrls)('renders the rejected URL %s as non-navigable text', url => { const { container } = render(); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.stories.tsx index 58aa97396d6..3268ed88aed 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.stories.tsx +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.stories.tsx @@ -30,6 +30,36 @@ const streamingChunks = [ 'GROUP BY product;\n```\n\nRevenue is grouped by product.' ]; +const imageFixtureUrl = new URL('../../../../test/fixtures/markdown-answer-image.svg?no-inline', import.meta.url).href; +const imageQuerySeparator = imageFixtureUrl.includes('?') ? '&' : '?'; +const streamingImageChunks = [ + '### Quarterly revenue\n\n![Quarterly revenue chart][revenue-chart]\n\nRevenue increased across the quarter.', + '\n\nThe strongest growth came from recurring sales.', + '\n\nThe updated chart includes the final month of the quarter.' +]; + +const StreamingImagesExample = (args: MarkdownAnswerProps) => { + const [step, setStep] = useState(0); + const completed = step === streamingImageChunks.length - 1; + const version = completed ? 'B' : 'A'; + const content = `${streamingImageChunks.slice(0, step + 1).join('')}\n\n[revenue-chart]: ${imageFixtureUrl}${imageQuerySeparator}version=${version}`; + + return ( +
+ + +

+ Chunk {step + 1} of {streamingImageChunks.length} +

+ +
+ ); +}; + const ParagraphReferenceExample = ({ onOpenParagraph, ...args }: MarkdownAnswerProps) => { const [selectedParagraph, setSelectedParagraph] = useState('No paragraph selected.'); @@ -237,3 +267,27 @@ export const Streaming: Story = { } } }; + +export const ImageLoading: Story = { + args: { + content: [ + '### Quarterly revenue', + '', + 'Revenue increased across the quarter, led by recurring sales.', + '', + `![Quarterly revenue chart](${imageFixtureUrl} "Quarterly revenue")`, + '', + 'The regional breakdown is shown in the chart accompanying the documentation:', + '', + `[Regional comparison: **revenue** ![Regional revenue chart](${imageFixtureUrl}${imageQuerySeparator}region=north "Regional revenue")](https://zeppelin.apache.org/docs/latest/)`, + '', + 'Northern Europe contributed the largest share.' + ].join('\n') + } +}; + +export const StreamingImages: Story = { + render: args => , + args: { content: streamingImageChunks[0] }, + argTypes: { content: { control: false } } +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.tsx index b5c69baf096..56f6d330875 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.tsx +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.tsx @@ -18,11 +18,12 @@ import { createContext, useContext, useId, useMemo, type ComponentPropsWithoutRef, type MouseEvent } from 'react'; import Markdown, { type Components, type ExtraProps } from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import type { Link, Parent, Root, Text } from 'mdast'; -import type { Plugin, PluggableList } from 'unified'; +import type { PluggableList } from 'unified'; import { visuallyHidden } from '@/shared/ui/assistant-theme/AssistantScope.css'; import * as styles from './MarkdownAnswer.css'; +import { MarkdownAnswerImage, MarkdownAnswerImageScope } from './MarkdownAnswerImage'; +import { isParagraphId, remarkParagraphReferences } from './remarkParagraphReferences'; export interface MarkdownAnswerProps { /** Markdown text of the answer. While it is still arriving, it may end inside a code fence. */ @@ -39,74 +40,26 @@ type AnswerContextValue = Pick({ footnotePrefix: '' }); -const paragraphIdPattern = /^paragraph_\d+_-?\d+$/; - -const textReferences = (node: Text): Array | undefined => { - // Match a complete ID without splitting Unicode letters, numbers or combining marks. - const pattern = new RegExp('(^|[^\\p{L}\\p{N}\\p{M}_+-])(paragraph_\\d+_-?\\d+)(?![\\p{L}\\p{N}\\p{M}_+-])', 'gu'); - let references: Array | undefined; - let offset = 0; - let match: RegExpExecArray | null; - - while ((match = pattern.exec(node.value)) !== null) { - const id = match[2]; - const start = match.index + match[1].length; - references ??= []; - if (start > offset) { - references.push({ type: 'text', value: node.value.slice(offset, start) }); - } - references.push({ type: 'link', url: `#${id}`, children: [{ type: 'text', value: id }] }); - offset = start + id.length; - } - - if (references && offset < node.value.length) { - references.push({ type: 'text', value: node.value.slice(offset) }); - } - return references; -}; - -const transformReferences = (parent: Parent): void => { - let children: Parent['children'] | undefined; - - for (let index = 0; index < parent.children.length; index++) { - const child = parent.children[index]; - let references: Array | undefined; - if (child.type === 'text') { - references = textReferences(child); - } else if (child.type === 'inlineCode' && paragraphIdPattern.test(child.value)) { - references = [{ type: 'link', url: `#${child.value}`, children: [child] }]; - } else if (child.type !== 'link' && child.type !== 'linkReference' && 'children' in child) { - transformReferences(child); - } - - if (references) { - children ??= parent.children.slice(0, index); - children.push(...references); - } else { - children?.push(child); - } - } - - if (children) parent.children = children; -}; - -const remarkParagraphReferences: Plugin<[], Root> = () => transformReferences; const remarkPlugins: PluggableList = [remarkGfm, remarkParagraphReferences]; // remark-rehype gives the footnotes heading this fixed id and points every footnote reference at it. const footnoteLabelId = 'footnote-label'; -const AnswerLink = ({ node: _node, href, children, title, ...props }: ComponentPropsWithoutRef<'a'> & ExtraProps) => { +const containsImage = (node: NonNullable): boolean => + node.tagName === 'img' || node.children.some(child => child.type === 'element' && containsImage(child)); + +const AnswerLink = ({ node, href, children, title, ...props }: ComponentPropsWithoutRef<'a'> & ExtraProps) => { const { describeParagraph, onOpenParagraph, footnotePrefix } = useContext(AnswerContext); if (!href) return <>{children}; + const imageLink = node ? containsImage(node) : false; const fragment = href.startsWith('#'); const paragraphId = fragment ? href.slice(1) : undefined; - if (paragraphId && paragraphIdPattern.test(paragraphId)) { + if (paragraphId && isParagraphId(paragraphId)) { const description = describeParagraph?.(paragraphId); if (description && onOpenParagraph) { - return ( + const reference = ( + )} + + ); +}; + +export const MarkdownAnswerImage = ({ node, src, alt, title }: ComponentPropsWithoutRef<'img'> & ExtraProps) => { + const base = typeof document === 'undefined' ? undefined : document.baseURI; + const url = useMemo(() => resolveImageUrl(src, base), [src, base]); + const start = node?.position?.start.offset; + const end = node?.position?.end.offset; + // Reference definitions can acquire a title later without changing the image occurrence. + const occurrence = useMemo( + () => (start === undefined || end === undefined ? undefined : JSON.stringify([start, end, alt])), + [start, end, alt] + ); + if (!url) { + return ( + + Image description: {alt || 'No description provided.'} + Image unavailable: only HTTP(S) image URLs can be loaded. + + ); + } + return ( + + ); +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/remarkParagraphReferences.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/remarkParagraphReferences.ts new file mode 100644 index 00000000000..65c96c3152b --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/remarkParagraphReferences.ts @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache 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 + * + * http://www.apache.org/licenses/LICENSE-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 { Link, Parent, Root, Text } from 'mdast'; +import type { Plugin } from 'unified'; + +const paragraphIdPattern = /^paragraph_\d+_-?\d+$/; + +export const isParagraphId = (value: string): boolean => paragraphIdPattern.test(value); + +const textReferences = (node: Text): Array | undefined => { + // Match a complete ID without splitting Unicode letters, numbers or combining marks. + const pattern = new RegExp('(^|[^\\p{L}\\p{N}\\p{M}_+-])(paragraph_\\d+_-?\\d+)(?![\\p{L}\\p{N}\\p{M}_+-])', 'gu'); + let references: Array | undefined; + let offset = 0; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(node.value)) !== null) { + const id = match[2]; + const start = match.index + match[1].length; + references ??= []; + if (start > offset) { + references.push({ type: 'text', value: node.value.slice(offset, start) }); + } + references.push({ type: 'link', url: `#${id}`, children: [{ type: 'text', value: id }] }); + offset = start + id.length; + } + + if (references && offset < node.value.length) { + references.push({ type: 'text', value: node.value.slice(offset) }); + } + return references; +}; + +const transformReferences = (parent: Parent): void => { + let children: Parent['children'] | undefined; + + for (let index = 0; index < parent.children.length; index++) { + const child = parent.children[index]; + let references: Array | undefined; + if (child.type === 'text') { + references = textReferences(child); + } else if (child.type === 'inlineCode' && isParagraphId(child.value)) { + references = [{ type: 'link', url: `#${child.value}`, children: [child] }]; + } else if (child.type !== 'link' && child.type !== 'linkReference' && 'children' in child) { + transformReferences(child); + } + + if (references) { + children ??= parent.children.slice(0, index); + children.push(...references); + } else { + children?.push(child); + } + } + + if (children) parent.children = children; +}; + +export const remarkParagraphReferences: Plugin<[], Root> = () => transformReferences; diff --git a/zeppelin-web-angular/projects/zeppelin-react/test/fixtures/markdown-answer-image.svg b/zeppelin-web-angular/projects/zeppelin-react/test/fixtures/markdown-answer-image.svg new file mode 100644 index 00000000000..27fa8004ee5 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/test/fixtures/markdown-answer-image.svg @@ -0,0 +1,36 @@ + + + Quarterly revenue + Revenue grows from 120 in January to 180 in February and 240 in March. + + + Quarterly revenue + + + + + + 120 + 180 + 240 + January + February + March + + + diff --git a/zeppelin-web-angular/projects/zeppelin-react/test/storybook/markdown-answer-images.spec.mjs b/zeppelin-web-angular/projects/zeppelin-react/test/storybook/markdown-answer-images.spec.mjs new file mode 100644 index 00000000000..ba258f7d8cf --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/test/storybook/markdown-answer-images.spec.mjs @@ -0,0 +1,139 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache 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 + * + * http://www.apache.org/licenses/LICENSE-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 'node:fs'; +import { test, expect } from '@playwright/test'; + +const fixture = readFileSync(new URL('../fixtures/markdown-answer-image.svg', import.meta.url)); +const source = 'https://images.example.test/plot.svg?q=SYNTHETIC_NOTEBOOK_VALUE'; +const storyId = 'assistant-markdownanswer--image-loading'; +const includesText = value => new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + +async function interceptImages(page, baseURL) { + const requests = []; + const origin = new URL(baseURL).origin; + const isFixture = url => url.pathname.includes('markdown-answer-image') && url.pathname.endsWith('.svg'); + const isObserved = url => /^https?:$/.test(url.protocol) && (url.origin !== origin || isFixture(url)); + page.on('request', request => { + if (isObserved(new URL(request.url()))) requests.push(request.url()); + }); + await page.route('**/*', async route => { + const url = new URL(route.request().url()); + if (url.hostname === 'images.example.test' || isFixture(url)) { + await route.fulfill({ status: 200, contentType: 'image/svg+xml', body: fixture }); + } else if (url.origin !== origin) { + // Unexpected external requests fail the assertions without contacting a real server. + await route.abort(); + } else { + await route.continue(); + } + }); + return requests; +} + +async function openImages(page) { + await page.goto(`/iframe.html?id=${storyId}&viewMode=story`); + const root = page.locator('#storybook-root'); + await expect(root.getByRole('button', { name: /load image/i }).first()).toBeVisible(); + return root; +} + +async function setContent(page, content) { + await page.evaluate( + ({ id, content }) => { + window.__STORYBOOK_PREVIEW__.channel.emit('updateStoryArgs', { storyId: id, updatedArgs: { content } }); + }, + { id: storyId, content } + ); +} + +async function expectLoadedImage(image, url) { + await expect(image).toHaveAttribute('src', url); + await expect.poll(() => image.evaluate(element => element.complete && element.naturalWidth > 0)).toBe(true); +} + +test('only keyboard-confirmed destinations are requested, including after a query change', async ({ + page, + baseURL +}) => { + const requests = await interceptImages(page, baseURL); + const root = await openImages(page); + await setContent(page, `Revenue improved.\n\n![Quarterly revenue chart](${source})`); + const load = root.getByRole('button', { name: /load image/i }); + await expect(load).toHaveAccessibleDescription(includesText(source)); + await expect(root.locator('img, [srcset], link[rel="preload"]')).toHaveCount(0); + await page.waitForLoadState('networkidle'); + expect(requests).toEqual([]); + + await load.focus(); + await load.press('Enter'); + await expectLoadedImage(root.getByRole('img', { name: 'Quarterly revenue chart' }), source); + expect(requests).toEqual([source]); + + const revised = source.replace('SYNTHETIC_NOTEBOOK_VALUE', 'UPDATED_NOTEBOOK_VALUE'); + await setContent(page, `The revised quarter includes final sales.\n\n![Quarterly revenue chart](${revised})`); + await expect(load).toHaveAccessibleDescription(includesText(revised)); + await expect(root.getByRole('img')).toHaveCount(0); + await page.waitForLoadState('networkidle'); + expect(requests).toEqual([source]); + await load.focus(); + await load.press('Space'); + await expectLoadedImage(root.getByRole('img', { name: 'Quarterly revenue chart' }), revised); + expect(requests).toEqual([source, revised]); +}); + +test('streaming reference images preserve approval on append and require approval for a changed destination', async ({ + page, + baseURL +}) => { + const requests = await interceptImages(page, baseURL); + await page.goto('/iframe.html?id=assistant-markdownanswer--streaming-images&viewMode=story'); + const root = page.locator('#storybook-root'); + const load = root.getByRole('button', { name: /load image/i }); + await expect(load).toBeVisible(); + const descriptionId = await load.getAttribute('aria-describedby'); + const disclosed = await page.locator(`[id="${descriptionId}"]`).textContent(); + await expect(root.getByRole('img')).toHaveCount(0); + await page.waitForLoadState('networkidle'); + expect(requests).toEqual([]); + await load.press('Enter'); + const image = root.getByRole('img', { name: 'Quarterly revenue chart' }); + await expect.poll(() => image.evaluate(element => element.complete && element.naturalWidth > 0)).toBe(true); + const sourceA = await image.getAttribute('src'); + expect(disclosed).toContain(sourceA); + expect(requests).toEqual([sourceA]); + + const initialProgress = await root.getByRole('status').textContent(); + await root.getByRole('button', { name: 'Append next chunk', exact: true }).click(); + await expect(root.getByRole('status')).not.toHaveText(initialProgress); + await expectLoadedImage(image, sourceA); + await expect(load).toHaveCount(0); + await page.waitForLoadState('networkidle'); + expect(requests).toEqual([sourceA]); + + const revised = new URL(sourceA); + revised.searchParams.set('version', 'B'); + const sourceB = revised.href; + await root.getByRole('button', { name: 'Append next chunk', exact: true }).click(); + await expect(load).toHaveAccessibleDescription(includesText(sourceB)); + await expect(image).toHaveCount(0); + await page.waitForLoadState('networkidle'); + expect(requests).toEqual([sourceA]); + await load.press('Space'); + await expectLoadedImage(image, sourceB); + expect(requests).toEqual([sourceA, sourceB]); +});