diff --git a/.changeset/nasty-lizards-refuse.md b/.changeset/nasty-lizards-refuse.md new file mode 100644 index 0000000000..80adf28a82 --- /dev/null +++ b/.changeset/nasty-lizards-refuse.md @@ -0,0 +1,20 @@ +--- +"@lynx-js/web-elements": patch +"@lynx-js/web-core": patch +--- + +feat: add x-markdown support + +Add opt-in support for the `x-markdown` element on Lynx Web, including +Markdown rendering together with its related styling, interaction, animation, +truncation, range rendering, and effect capabilities exposed through the +component API. + +Update the `web-core`, `web-core-wasm`, and `web-mainthread-apis` runtime +paths to use the shared property-or-attribute setter from `web-constants`, so +custom elements such as `x-markdown` can receive structured property values +correctly instead of being forced through string-only attribute updates. + +```javascript +import '@lynx-js/web-elements/XMarkdown'; +``` diff --git a/packages/web-platform/web-core-e2e/tests/reactlynx.spec.ts b/packages/web-platform/web-core-e2e/tests/reactlynx.spec.ts index 063ffce42e..46611245b6 100644 --- a/packages/web-platform/web-core-e2e/tests/reactlynx.spec.ts +++ b/packages/web-platform/web-core-e2e/tests/reactlynx.spec.ts @@ -3199,12 +3199,16 @@ test.describe('reactlynx3 tests', () => { // input/bindinput test-case start test('basic-element-x-input-bindinput', async ({ page }, { title }) => { await goto(page, title); - await page.locator('input').press('Enter'); - await wait(200); - await page.locator('input').fill('foobar'); - await wait(200); - const result = await page.locator('.result').first().innerText(); - expect(result).toBe('foobar-6-6'); + const input = page.locator('input'); + const result = page.locator('.result').first(); + + // Firefox CI can be slower to finish mounting/binding handlers; wait for initial render first. + await expect(input).toBeVisible(); + await expect(input).toHaveValue('bindinput'); + + await input.press('Enter'); + await input.fill('foobar'); + await expect(result).toHaveText('foobar-6-6'); }); // input/bindinput test-case start for test('basic-element-input-bindinput', async ({ page }, { title }) => { diff --git a/packages/web-platform/web-core/ts/client/mainthread/crossThreadHandlers/registerSetNativePropsHandler.ts b/packages/web-platform/web-core/ts/client/mainthread/crossThreadHandlers/registerSetNativePropsHandler.ts index bc684fa4db..18997903a8 100644 --- a/packages/web-platform/web-core/ts/client/mainthread/crossThreadHandlers/registerSetNativePropsHandler.ts +++ b/packages/web-platform/web-core/ts/client/mainthread/crossThreadHandlers/registerSetNativePropsHandler.ts @@ -1,14 +1,15 @@ // Copyright 2023 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { Rpc } from '@lynx-js/web-worker-rpc'; +import { Rpc } from '@lynx-js/web-worker-rpc'; import { queryNodes } from './queryNodes.js'; -import { setNativePropsEndpoint } from '../../endpoints.js'; import type { LynxViewInstance } from '../LynxViewInstance.js'; +import { setElementPropertyOrAttribute } from '../utils/setElementPropertyOrAttribute.js'; +import { setNativePropsEndpoint } from '../../endpoints.js'; function applyNativeProps(element: Element, nativeProps: Record) { for (const key in nativeProps) { - const value = nativeProps[key] as string; + const value = nativeProps[key]; if (key === 'text' && element?.tagName === 'X-TEXT') { if ( element.firstElementChild @@ -23,7 +24,7 @@ function applyNativeProps(element: Element, nativeProps: Record) { ) { (element as HTMLElement).style.setProperty(key, value); } else { - element.setAttribute(key, value); + setElementPropertyOrAttribute(element, key, value); } } } diff --git a/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/createElementAPI.ts b/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/createElementAPI.ts index a5b3bc549f..6645046f6c 100644 --- a/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/createElementAPI.ts +++ b/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/createElementAPI.ts @@ -1,4 +1,5 @@ import { wasmInstance } from '../../wasm.js'; +import { setElementPropertyOrAttribute } from '../utils/setElementPropertyOrAttribute.js'; import { AnimationOperation, @@ -447,11 +448,7 @@ export function createElementAPI( } }); } else { - if (value == null) { - element.removeAttribute(name); - } else { - element.setAttribute(name, value.toString()); - } + setElementPropertyOrAttribute(element, name, value); if (name === 'exposure-id') { if (value != null) { mtsBinding.markExposureRelatedElementByUniqueId( diff --git a/packages/web-platform/web-core/ts/client/mainthread/utils/setElementPropertyOrAttribute.ts b/packages/web-platform/web-core/ts/client/mainthread/utils/setElementPropertyOrAttribute.ts new file mode 100644 index 0000000000..a710331933 --- /dev/null +++ b/packages/web-platform/web-core/ts/client/mainthread/utils/setElementPropertyOrAttribute.ts @@ -0,0 +1,25 @@ +// Copyright 2023 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export const setElementPropertyOrAttribute = ( + element: Element, + key: string, + value: unknown, +) => { + if (value == null) { + element.removeAttribute(key); + return; + } + + if ( + key in element + && typeof value !== 'string' + && typeof value !== 'number' + && typeof value !== 'boolean' + ) { + (element as unknown as Record)[key] = value; + } else { + element.setAttribute(key, String(value)); + } +}; diff --git a/packages/web-platform/web-elements/index.css b/packages/web-platform/web-elements/index.css index dcdf435448..cd2d2f04a2 100644 --- a/packages/web-platform/web-elements/index.css +++ b/packages/web-platform/web-elements/index.css @@ -13,6 +13,7 @@ @import url("./src/elements/XSvg/x-svg.css"); @import url("./src/elements/XImage/x-image.css"); @import url("./src/elements/XInput/x-input.css"); +@import url("./src/elements/XMarkdown/x-markdown.css"); @import url("./src/elements/XOverlayNg/x-overlay-ng.css"); @import url("./src/elements/XRefreshView/x-refresh-view.css"); @import url("./src/elements/XSwiper/x-swiper.css"); diff --git a/packages/web-platform/web-elements/package.json b/packages/web-platform/web-elements/package.json index d916941f84..e2dae0a045 100644 --- a/packages/web-platform/web-elements/package.json +++ b/packages/web-platform/web-elements/package.json @@ -59,6 +59,11 @@ "types": "./dist/elements/XInput/index.d.ts", "default": "./dist/elements/XInput/index.js" }, + "./XMarkdown": { + "@lynx-js/source-field": "./src/elements/XMarkdown/index.ts", + "types": "./dist/elements/XMarkdown/index.d.ts", + "default": "./dist/elements/XMarkdown/index.js" + }, "./XOverlayNg": { "@lynx-js/source-field": "./src/elements/XOverlayNg/index.ts", "types": "./dist/elements/XOverlayNg/index.d.ts", @@ -135,11 +140,16 @@ "test:install": "playwright install --with-deps chromium firefox webkit", "test:update": "playwright test --ui --update-snapshots" }, + "dependencies": { + "dompurify": "^3.3.1", + "markdown-it": "^14.1.0" + }, "devDependencies": { "@lynx-js/playwright-fixtures": "workspace:*", "@playwright/test": "^1.58.2", "@rsbuild/core": "catalog:rsbuild", "@rsbuild/plugin-source-build": "1.0.4", + "@types/markdown-it": "^14.1.1", "nyc": "^17.1.0", "tslib": "^2.8.1" }, diff --git a/packages/web-platform/web-elements/src/elements/XMarkdown/XMarkdown.ts b/packages/web-platform/web-elements/src/elements/XMarkdown/XMarkdown.ts new file mode 100644 index 0000000000..859e75245c --- /dev/null +++ b/packages/web-platform/web-elements/src/elements/XMarkdown/XMarkdown.ts @@ -0,0 +1,425 @@ +/* +// Copyright 2024 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +*/ +import { Component } from '../../element-reactive/index.js'; +import { CommonEventsAndMethods } from '../common/CommonEventsAndMethods.js'; +import { templateXMarkdown } from '../htmlTemplates.js'; +import { + parseMarkdownStyle, + serializeMarkdownStyle, + XMarkdownAttributes, +} from './XMarkdownAttributes.js'; +import type { MarkdownStyleConfig } from './XMarkdownAttributes.js'; + +type SelectionRoot = ShadowRoot & { getSelection?: () => Selection | null }; +type SelectionWithComposedRanges = Selection & { + getComposedRanges?: (...args: unknown[]) => StaticRange[]; +}; + +const getComposedRange = ( + selection: Selection, + shadowRoot: ShadowRoot | null, +): StaticRange | null => { + const getComposedRanges = (selection as SelectionWithComposedRanges) + .getComposedRanges; + if (!shadowRoot || typeof getComposedRanges !== 'function') return null; + try { + return getComposedRanges.call(selection, { + shadowRoots: [shadowRoot], + })[0] ?? null; + } catch { + try { + return getComposedRanges.call(selection, shadowRoot)[0] ?? null; + } catch { + return null; + } + } +}; + +const createRangeByOffsets = ( + doc: Document, + root: HTMLElement, + start: number, + end: number, +): Range | null => { + const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let pos = 0; + let startNode: Text | null = null; + let startOffset = 0; + let endNode: Text | null = null; + let endOffset = 0; + let node = walker.nextNode() as Text | null; + + while (node) { + const len = node.nodeValue?.length ?? 0; + if (!startNode && pos + len >= start) { + startNode = node; + startOffset = start - pos; + } + if (pos + len >= end) { + endNode = node; + endOffset = end - pos; + break; + } + pos += len; + node = walker.nextNode() as Text | null; + } + + if (!startNode) return null; + if (!endNode) { + endNode = startNode; + endOffset = startOffset; + } + + const range = doc.createRange(); + range.setStart( + startNode, + Math.max(0, Math.min(startOffset, startNode.length)), + ); + range.setEnd(endNode, Math.max(0, Math.min(endOffset, endNode.length))); + return range; +}; + +const getRenderedPrefixLengthForSourceOffset = ( + source: string, + rendered: string, + sourceOffset: number, +) => { + let renderedIndex = 0; + const limit = Math.max(0, Math.min(sourceOffset, source.length)); + + for (let sourceIndex = 0; sourceIndex < limit; sourceIndex += 1) { + if (renderedIndex >= rendered.length) break; + if (source[sourceIndex] === rendered[renderedIndex]) { + renderedIndex += 1; + } + } + + return renderedIndex; +}; + +const mapSourceRangeToCharRange = ( + source: string, + rendered: string, + start: number, + end: number, +): { start: number; end: number } => { + const renderedStart = getRenderedPrefixLengthForSourceOffset( + source, + rendered, + start, + ); + const renderedEnd = getRenderedPrefixLengthForSourceOffset( + source, + rendered, + end, + ); + return { + start: renderedStart, + end: Math.max(renderedStart, renderedEnd), + }; +}; + +const isSelectionNodeInsideHost = ( + dom: XMarkdown, + shadowRoot: ShadowRoot | null, + node: Node | null, +) => + !!node + && ( + node === dom + || node === shadowRoot + || dom.contains(node) + || !!shadowRoot?.contains(node) + ); + +const getRangeInRoot = ( + dom: XMarkdown, + root: HTMLElement, + selection: Selection | null, +): Range | null => { + if (!selection) return null; + const shadowRoot = dom.shadowRoot as SelectionRoot | null; + const sourceRange = getComposedRange(selection, shadowRoot) + ?? (selection.rangeCount > 0 ? selection.getRangeAt(0) : null); + if (!sourceRange) return null; + if ( + !root.contains(sourceRange.startContainer) + || !root.contains(sourceRange.endContainer) + ) { + if ( + !isSelectionNodeInsideHost(dom, shadowRoot, sourceRange.startContainer) + || !isSelectionNodeInsideHost(dom, shadowRoot, sourceRange.endContainer) + || !isSelectionNodeInsideHost(dom, shadowRoot, selection.anchorNode) + || !isSelectionNodeInsideHost(dom, shadowRoot, selection.focusNode) + ) { + return null; + } + const selectedText = selection.toString(); + if (!selectedText) { + return null; + } + const start = root.textContent?.indexOf(selectedText) ?? -1; + if (start < 0) return null; + return createRangeByOffsets( + dom.ownerDocument, + root, + start, + start + selectedText.length, + ); + } + const range = dom.ownerDocument.createRange(); + range.setStart(sourceRange.startContainer, sourceRange.startOffset); + range.setEnd(sourceRange.endContainer, sourceRange.endOffset); + return range; +}; + +const getSelectionCandidates = (dom: XMarkdown): Selection[] => { + const shadowRoot = dom.shadowRoot as SelectionRoot | null; + const shadowSelection = + shadowRoot && typeof shadowRoot.getSelection === 'function' + ? shadowRoot.getSelection() + : null; + const documentSelection = dom.ownerDocument.getSelection(); + return [shadowSelection, documentSelection].filter( + (selection, index, selections): selection is Selection => + !!selection && selections.indexOf(selection) === index, + ); +}; + +const getSelectionForRoot = ( + dom: XMarkdown, + root: HTMLElement, +): { selection: Selection; range: Range } | null => { + for (const selection of getSelectionCandidates(dom)) { + const range = getRangeInRoot(dom, root, selection); + if (range) return { selection, range }; + } + return null; +}; + +const getPreferredSelectionTarget = (dom: XMarkdown): Selection | null => { + const candidates = getSelectionCandidates(dom); + return candidates[0] ?? null; +}; + +@Component( + 'x-markdown', + [CommonEventsAndMethods, XMarkdownAttributes], + templateXMarkdown, +) +export class XMarkdown extends HTMLElement { + static readonly notToFilterFalseAttributes = new Set(['content-complete']); + + #getMarkdownStyle() { + return parseMarkdownStyle(this.getAttribute('markdown-style')); + } + + #setMarkdownStyle(value: MarkdownStyleConfig | string | null) { + const serialized = serializeMarkdownStyle(value); + if (serialized === null) { + this.removeAttribute('markdown-style'); + } else { + this.setAttribute('markdown-style', serialized); + } + } + + get markdownStyle(): MarkdownStyleConfig { + return this.#getMarkdownStyle(); + } + + set markdownStyle(value: MarkdownStyleConfig | string | null) { + this.#setMarkdownStyle(value); + } + + get ['markdown-style'](): MarkdownStyleConfig { + return this.#getMarkdownStyle(); + } + + set ['markdown-style'](value: MarkdownStyleConfig | string | null) { + this.#setMarkdownStyle(value); + } + + /** + * 获取当前渲染内容中的所有图片 URL。 + */ + getImages(): string[] { + const root = this.shadowRoot?.querySelector( + '#markdown-root', + ) as HTMLElement | null; + if (!root) return []; + return Array.from(root.querySelectorAll('img')) + .map((img) => img.getAttribute('src') || '') + .filter((v) => !!v) as string[]; + } + + getContent(params?: { start?: number; end?: number }): { content: string } { + const content = this.getAttribute('content') ?? ''; + const s = Math.max(0, params?.start ?? 0); + const eInclusive = params?.end ?? (content.length - 1); + const e = Math.min(content.length, eInclusive + 1); + const slice = e >= s ? content.slice(s, e) : ''; + return { content: slice }; + } + + pauseAnimation() { + this.setAttribute('animation-paused', 'true'); + } + + resumeAnimation(params?: { animationStep?: number }) { + if (params?.animationStep !== undefined) { + this.setAttribute('initial-animation-step', String(params.animationStep)); + } + if (this.getAttribute('animation-type') !== 'typewriter') { + this.setAttribute('animation-type', 'typewriter'); + } + const velocity = this.getAttribute('animation-velocity'); + if (!velocity || Number(velocity) <= 0) { + this.setAttribute('animation-velocity', '40'); + } + this.removeAttribute('animation-paused'); + } + + getSelectedText(): string { + const root = this.shadowRoot?.querySelector('#markdown-root') as + | HTMLElement + | null; + if (!root) return ''; + const result = getSelectionForRoot(this, root); + return result?.range.toString() ?? ''; + } + + getTextBoundingRect( + params?: { start?: number; end?: number; indexType?: 'char' | 'source' }, + ): { boundingRect: DOMRect } | null { + const root = this.shadowRoot?.querySelector('#markdown-root') as + | HTMLElement + | null; + if (!root) return null; + const doc = this.ownerDocument; + const createRangeByChar = (s: number, e: number): Range | null => { + const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let pos = 0; + let startNode: Text | null = null; + let startOffset = 0; + let endNode: Text | null = null; + let endOffset = 0; + let node = walker.nextNode() as Text | null; + while (node) { + const len = node.nodeValue?.length ?? 0; + if (!startNode && pos + len >= s) { + startNode = node; + startOffset = s - pos; + } + if (pos + len >= e) { + endNode = node; + endOffset = e - pos; + break; + } + pos += len; + node = walker.nextNode() as Text | null; + } + if (!startNode) return null; + if (!endNode) { + endNode = startNode; + endOffset = startOffset; + } + const r = doc.createRange(); + r.setStart( + startNode, + Math.max(0, Math.min(startOffset, startNode.length)), + ); + r.setEnd(endNode, Math.max(0, Math.min(endOffset, endNode.length))); + return r; + }; + if (params?.start !== undefined || params?.end !== undefined) { + const s = Math.max(0, params?.start ?? 0); + const e = Math.max(s, params?.end ?? s); + const rangeOffsets = params?.indexType === 'source' + ? mapSourceRangeToCharRange( + this.getAttribute('content') ?? '', + root.textContent ?? '', + s, + e, + ) + : { start: s, end: e }; + if (!rangeOffsets) return null; + const r = createRangeByChar(rangeOffsets.start, rangeOffsets.end); + if (!r) return null; + return { boundingRect: r.getBoundingClientRect() }; + } + const result = getSelectionForRoot(this, root); + if (!result) return null; + return { boundingRect: result.range.getBoundingClientRect() }; + } + + setTextSelection( + params: { startX: number; startY: number; endX: number; endY: number }, + ) { + const doc = this.ownerDocument as any; + const getRangeAtPoint = (x: number, y: number): Range | null => { + if (doc.caretRangeFromPoint) return doc.caretRangeFromPoint(x, y); + if (doc.caretPositionFromPoint) { + const pos = doc.caretPositionFromPoint(x, y); + if (!pos) return null; + const r = this.ownerDocument.createRange(); + r.setStart(pos.offsetNode, pos.offset); + r.collapse(true); + return r; + } + return null; + }; + const r1 = getRangeAtPoint(params.startX, params.startY); + const r2 = getRangeAtPoint(params.endX, params.endY); + if (r1 && r2) { + const sel = getPreferredSelectionTarget(this); + if (!sel) return; + sel.removeAllRanges(); + const range = this.ownerDocument.createRange(); + range.setStart(r1.startContainer, r1.startOffset); + range.setEnd(r2.startContainer, r2.startOffset); + sel.addRange(range); + } + } + + getParseResult( + params: { tags: string[] }, + ): Record { + const root = this.shadowRoot?.querySelector('#markdown-root') as + | HTMLElement + | null; + if (!root) return {}; + const text = root.textContent || ''; + const result: Record = {}; + const doc = this.ownerDocument; + const calcOffset = (node: Node): { start: number; end: number } => { + const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let pos = 0; + let start = -1; + let end = -1; + let current = walker.nextNode(); + while (current) { + const len = (current.nodeValue || '').length; + if (current === node || node.contains(current)) { + if (start < 0) { + start = pos; + } + end = pos + len; + } + pos += len; + current = walker.nextNode(); + } + return { + start: Math.max(0, Math.min(start, text.length)), + end: Math.max(0, Math.min(end, text.length)), + }; + }; + for (const tag of params.tags) { + const nodes = Array.from(root.querySelectorAll(tag)); + result[tag] = nodes.map((el) => calcOffset(el)); + } + return result; + } +} diff --git a/packages/web-platform/web-elements/src/elements/XMarkdown/XMarkdownAttributes.ts b/packages/web-platform/web-elements/src/elements/XMarkdown/XMarkdownAttributes.ts new file mode 100644 index 0000000000..dfa7b1a1bb --- /dev/null +++ b/packages/web-platform/web-elements/src/elements/XMarkdown/XMarkdownAttributes.ts @@ -0,0 +1,1422 @@ +/* +// Copyright 2024 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +*/ +import type MarkdownIt from 'markdown-it'; +import type createDOMPurify from 'dompurify'; +import { + boostedQueueMicrotask, + genDomGetter, + registerAttributeHandler, +} from '../../element-reactive/index.js'; +import type { XMarkdown } from './XMarkdown.js'; + +type MarkdownItCtor = typeof MarkdownIt; +type DOMPurifyCtor = typeof createDOMPurify; + +let MarkdownItLoaded: MarkdownItCtor | undefined; +let DOMPurifyLoaded: DOMPurifyCtor | undefined; +let depsLoading: Promise | undefined; +let depsLoaded = false; +let depsError: unknown; + +const loadDeps = () => { + if (depsLoaded) return Promise.resolve(); + if (depsLoading) return depsLoading; + depsLoading = import( + /* webpackChunkName: "xmarkdown-deps" */ + './XMarkdownDeps.js' + ).then((deps) => { + MarkdownItLoaded = deps.MarkdownIt; + DOMPurifyLoaded = deps.createDOMPurify; + depsLoaded = true; + }).catch((err) => { + depsError = err; + }); + return depsLoading; +}; + +const escapeHtml = (value: string) => + value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>'); + +const escapeHtmlAttr = (value: string) => + value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll('\'', '''); + +const decodeHtmlEntities = (value: string) => + value + .replaceAll('"', '"') + .replaceAll('"', '"') + .replaceAll(''', '\'') + .replaceAll(''', '\'') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>'); + +const normalizeClassList = (value: string) => { + const parts = value.trim().split(/\s+/).filter(Boolean); + const safe = parts.filter((part) => /^[A-Za-z0-9_-]+$/.test(part)); + return safe.join(' '); +}; + +const sanitizeAllowedHtml = (value: string) => { + if (!value) return value; + if ( + value.includes(' + + + + web playground + + + + + + + + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/events.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/events.html new file mode 100644 index 0000000000..157f4a1aef --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/events.html @@ -0,0 +1,45 @@ + + + + + + web playground + + + + + + + + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/html-tags.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/html-tags.html new file mode 100644 index 0000000000..518ebfb199 --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/html-tags.html @@ -0,0 +1,52 @@ + + + + + + web playground + + + + + + + + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/image.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/image.html new file mode 100644 index 0000000000..f7c901fe91 --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/image.html @@ -0,0 +1,39 @@ + + + + + + web playground + + + + + + + + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/incremental.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/incremental.html new file mode 100644 index 0000000000..5c1e32a7f7 --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/incremental.html @@ -0,0 +1,34 @@ + + + + + + web playground + + + + + + + + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/inlineview-class.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/inlineview-class.html new file mode 100644 index 0000000000..f6be0c5dc3 --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/inlineview-class.html @@ -0,0 +1,30 @@ + + + + + inlineview class + + + + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/inlineview.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/inlineview.html new file mode 100644 index 0000000000..03259fbc6c --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/inlineview.html @@ -0,0 +1,26 @@ + + + + + inlineview + + + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/menu.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/menu.html new file mode 100644 index 0000000000..2dda35b2a9 --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/menu.html @@ -0,0 +1,203 @@ + + + + + + web playground + + + + + + + + + + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/style.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/style.html new file mode 100644 index 0000000000..2013fa7eb8 --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/style.html @@ -0,0 +1,50 @@ + + + + + + web playground + + + + + + + + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/table.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/table.html new file mode 100644 index 0000000000..e50ff616e8 --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/table.html @@ -0,0 +1,42 @@ + + + + + + web playground + + + + + + + + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/text-selection.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/text-selection.html new file mode 100644 index 0000000000..ba0862b7e1 --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/text-selection.html @@ -0,0 +1,144 @@ + + + + + + web playground + + + + + + + + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/truncate.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/truncate.html new file mode 100644 index 0000000000..adda9a1c90 --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/truncate.html @@ -0,0 +1,25 @@ + + + + + truncate + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-cursor.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-cursor.html new file mode 100644 index 0000000000..820b4cae9f --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-cursor.html @@ -0,0 +1,39 @@ + + + + + typewriter cursor + + + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-effect.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-effect.html new file mode 100644 index 0000000000..b263dd9077 --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-effect.html @@ -0,0 +1,52 @@ + + + + + typewriter-effect + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-keep-cursor.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-keep-cursor.html new file mode 100644 index 0000000000..0bfa1d16c4 --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-keep-cursor.html @@ -0,0 +1,44 @@ + + + + + typewriter-keep-cursor + + + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-pause-resume.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-pause-resume.html new file mode 100644 index 0000000000..969c4183e0 --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-pause-resume.html @@ -0,0 +1,26 @@ + + + + + typewriter pause resume + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-reset-content.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-reset-content.html new file mode 100644 index 0000000000..adaaad49d4 --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-reset-content.html @@ -0,0 +1,60 @@ + + + + + typewriter reset content + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-trailing-text.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-trailing-text.html new file mode 100644 index 0000000000..b395166d5e --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-trailing-text.html @@ -0,0 +1,26 @@ + + + + + typewriter trailing text + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter.html new file mode 100644 index 0000000000..36e3692b45 --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter.html @@ -0,0 +1,30 @@ + + + + + typewriter + + + + + + + + + + + diff --git a/packages/web-platform/web-elements/tests/x-markdown.spec.ts b/packages/web-platform/web-elements/tests/x-markdown.spec.ts new file mode 100644 index 0000000000..050e128a41 --- /dev/null +++ b/packages/web-platform/web-elements/tests/x-markdown.spec.ts @@ -0,0 +1,846 @@ +import { test, expect } from '@lynx-js/playwright-fixtures'; +import type { Page } from '@playwright/test'; + +const goto = async (page: Page, fixtureName: string) => { + await page.goto(`tests/fixtures/${fixtureName}.html`, { waitUntil: 'load' }); + await page.evaluate(() => document.fonts.ready); +}; + +const getShadowText = async (page: Page, selector: string) => + page.evaluate((value) => { + const element = document.querySelector('x-markdown'); + return element?.shadowRoot?.querySelector(value)?.textContent ?? ''; + }, selector); + +const getShadowCount = async (page: Page, selector: string) => + page.evaluate((value) => { + const element = document.querySelector('x-markdown'); + return element?.shadowRoot?.querySelectorAll(value).length ?? 0; + }, selector); + +const appendContent = async (page: Page, suffix: string) => { + await page.evaluate((value) => { + const element = document.querySelector('x-markdown'); + const content = element?.getAttribute('content') ?? ''; + element?.setAttribute('content', `${content}${value}`); + }, suffix); +}; + +const captureFirstChild = async (page: Page) => { + await page.evaluate(() => { + const element = document.querySelector('x-markdown'); + const root = element?.shadowRoot?.querySelector('#markdown-root'); + (window as any).__markdown_first_child = root?.firstElementChild ?? null; + }); +}; + +const isFirstChildSame = async (page: Page) => + page.evaluate(() => { + const element = document.querySelector('x-markdown'); + const root = element?.shadowRoot?.querySelector('#markdown-root'); + return root?.firstElementChild === (window as any).__markdown_first_child; + }); + +const selectShadowText = async ( + page: Page, + selector: string, + startOffset: number, + endOffset: number, +) => { + await page.evaluate( + ({ value, start, end }) => { + (window as any)._selectShadowText(value, start, end); + }, + { value: selector, start: startOffset, end: endOffset }, + ); +}; + +const selectShadowTextByMouseup = async ( + page: Page, + selector: string, + startOffset: number, + endOffset: number, +) => { + await page.evaluate( + ({ value, start, end }) => { + (window as any)._selectShadowTextByMouseup(value, start, end); + }, + { value: selector, start: startOffset, end: endOffset }, + ); +}; + +const setTextSelection = async ( + page: Page, + selector: string, + startOffset: number, + endOffset: number, +) => { + await page.evaluate( + ({ value, start, end }) => { + (window as any)._setTextSelection(value, start, end); + }, + { value: selector, start: startOffset, end: endOffset }, + ); +}; + +const clearShadowText = async (page: Page) => { + await page.evaluate(() => { + (window as any)._clearShadowText?.(); + }); +}; + +test.describe('x-markdown', () => { + test('should render basic markdown', async ({ page }) => { + await goto(page, 'x-markdown/basic'); + const markdown = page.locator('x-markdown'); + + await expect(markdown.locator('h1')).toHaveText('Title'); + await expect(markdown.locator('strong')).toHaveText('bold'); + await expect(markdown.locator('em')).toHaveText('italic'); + await expect(markdown.locator('li')).toHaveCount(2); + await expect(markdown.locator('code').first()).toContainText('code'); + }); + + test('should apply markdown-style updates', async ({ page }) => { + await goto(page, 'x-markdown/style'); + const markdown = page.locator('x-markdown'); + + const link = markdown.locator('a'); + await expect(link).toHaveCSS('color', 'rgb(255, 0, 0)'); + + const inlineCode = markdown.locator('code').first(); + await expect(inlineCode).toHaveCSS('color', 'rgb(0, 0, 255)'); + await expect(inlineCode).toHaveCSS( + 'background-color', + 'rgb(238, 238, 238)', + ); + + await markdown.evaluate((el) => { + el.setAttribute( + 'markdown-style', + JSON.stringify({ link: { color: '0000ff' } }), + ); + }); + await expect(link).toHaveCSS('color', 'rgb(0, 0, 255)'); + }); + + test('should support markdown-style property updates', async ({ page }) => { + await goto(page, 'x-markdown/style'); + const markdown = page.locator('x-markdown'); + + const link = markdown.locator('a'); + const propertyValue = await markdown.evaluate((el: any) => { + el['markdown-style'] = { link: { color: '00ff00' } }; + return el.markdownStyle; + }); + + expect(propertyValue).toEqual({ link: { color: '00ff00' } }); + await expect(link).toHaveCSS('color', 'rgb(0, 255, 0)'); + await expect(markdown).toHaveAttribute( + 'markdown-style', + JSON.stringify({ link: { color: '00ff00' } }), + ); + }); + + test('should render image', async ({ page }) => { + await goto(page, 'x-markdown/image'); + const markdown = page.locator('x-markdown'); + + const image = markdown.locator('img'); + await expect(image).toHaveAttribute( + 'src', + '/tests/fixtures/resources/firefox-logo.png', + ); + }); + + test('should update content on attribute change', async ({ page }) => { + await goto(page, 'x-markdown/basic'); + const markdown = page.locator('x-markdown'); + + await markdown.evaluate((el) => { + el.setAttribute('content', '# Updated'); + }); + await expect(markdown.locator('h1')).toHaveText('Updated'); + }); + + test('should only allow span and p html tags in markdown content', async ({ page }) => { + await goto(page, 'x-markdown/html-tags'); + const markdown = page.locator('x-markdown'); + + const span = markdown.locator('span.mark-red'); + await expect(span).toHaveText('World'); + await expect(span).not.toHaveAttribute('style', /.+/); + await expect(span).not.toHaveAttribute('onclick', /.+/); + + const p = markdown.locator('p.mark-help'); + await expect(p).toHaveText('Paragraph'); + await expect(p).not.toHaveAttribute('style', /.+/); + + await expect(markdown.locator('div.not-allowed')).toHaveCount(0); + await expect(markdown).toContainText( + '
Not allowed
', + ); + }); + + test('should fire bindlink and bindimageTap events', async ({ page }) => { + await goto(page, 'x-markdown/events'); + const markdown = page.locator('x-markdown'); + + await markdown.locator('a').click(); + await page.waitForFunction(() => (window as any)._bindlink_detail !== null); + const linkDetail = await page.evaluate(() => + (window as any)._bindlink_detail + ); + expect(linkDetail.url).toBe('https://example.com'); + expect(linkDetail.content).toBe('link'); + expect(linkDetail.contentId).toBe('case-1'); + + await markdown.locator('img').click(); + await page.waitForFunction(() => + (window as any)._bindimage_detail !== null + ); + const imageDetail = await page.evaluate(() => + (window as any)._bindimage_detail + ); + expect(imageDetail.url).toContain( + '/tests/fixtures/resources/firefox-logo.png', + ); + expect(imageDetail.contentId).toBe('case-1'); + }); + + test.describe('programmatic shadow selection', () => { + test.beforeEach(async ({ browserName }) => { + test.skip( + browserName === 'webkit', + 'programmatic shadow selection is unsupported in webkit', + ); + }); + + test('should enable text selection and fire selectionchange', async ({ page }) => { + await goto(page, 'x-markdown/text-selection'); + await page.evaluate(() => { + (window as any)._resetSelectionState(); + document.querySelector('x-markdown')?.setAttribute( + 'text-selection', + 'true', + ); + }); + + const selectionStateBeforeSelect = await page.evaluate(() => + (window as any)._getSelectionState() + ); + expect(selectionStateBeforeSelect.styleText).toContain( + 'user-select: text;', + ); + + await selectShadowText(page, 'h1', 0, 5); + await page.waitForFunction(() => + (window as any)._selection_detail !== null + ); + + const selectionState = await page.evaluate(() => + (window as any)._getSelectionState() + ); + + expect(selectionState.detail).toMatchObject({ + start: 0, + end: 5, + direction: 'forward', + }); + expect(selectionState.selectedText).toBe('Title'); + expect(selectionState.rect?.width ?? 0).toBeGreaterThan(0); + expect(selectionState.rect?.height ?? 0).toBeGreaterThan(0); + }); + + test('should fire selectionchange on mouseup in web', async ({ page }) => { + await goto(page, 'x-markdown/text-selection'); + await page.evaluate(() => { + (window as any)._resetSelectionState(); + document.querySelector('x-markdown')?.setAttribute( + 'text-selection', + 'true', + ); + }); + + await selectShadowTextByMouseup(page, 'h1', 0, 5); + await page.waitForFunction(() => + (window as any)._selection_detail?.end === 5 + ); + + const selectionState = await page.evaluate(() => + (window as any)._getSelectionState() + ); + + expect(selectionState.detail).toMatchObject({ + start: 0, + end: 5, + direction: 'forward', + }); + expect(selectionState.selectedText).toBe('Title'); + }); + + test('should show and position menu on selectionchange', async ({ page }) => { + await goto(page, 'x-markdown/menu'); + + const initialMenuState = await page.evaluate(() => + (window as any)._getMenuState() + ); + expect(initialMenuState.visibility).toBe('hidden'); + expect(initialMenuState.labels).toEqual(['全选', '复制']); + + await selectShadowText(page, 'h1', 0, 5); + await page.waitForFunction(() => + (window as any)._getMenuState().visibility === 'visible' + ); + + const menuState = await page.evaluate(() => + (window as any)._getMenuState() + ); + expect(menuState.detail).toMatchObject({ + start: 0, + end: 5, + direction: 'forward', + }); + expect(Number.parseFloat(menuState.left)).toBeGreaterThan(0); + expect(Number.parseFloat(menuState.top)).toBeGreaterThan(0); + + await clearShadowText(page); + await page.waitForFunction(() => + (window as any)._getMenuState().visibility === 'hidden' + ); + }); + + test('should defer menu display until mouseup during pointer selection', async ({ page }) => { + await goto(page, 'x-markdown/menu'); + + await page.locator('x-markdown').dispatchEvent('mousedown'); + await selectShadowText(page, 'h1', 0, 5); + + const menuStateWhileSelecting = await page.evaluate(() => + (window as any)._getMenuState() + ); + expect(menuStateWhileSelecting.visibility).toBe('hidden'); + + await page.evaluate(() => { + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + }); + await page.waitForFunction(() => + (window as any)._getMenuState().visibility === 'visible' + ); + }); + + test('should clear selection when selection extends outside markdown', async ({ page }) => { + await goto(page, 'x-markdown/menu'); + + await selectShadowText(page, 'h1', 0, 5); + await page.waitForFunction(() => + (window as any)._getMenuState().visibility === 'visible' + ); + + await page.evaluate(() => { + (window as any)._selectShadowTextToMenu(); + }); + await page.waitForFunction(() => { + const menuState = (window as any)._getMenuState(); + return ( + menuState.visibility === 'hidden' + && menuState.detail?.start === -1 + && menuState.detail?.end === -1 + ); + }); + + const menuState = await page.evaluate(() => + (window as any)._getMenuState() + ); + expect(menuState.selectedText).toBe(''); + }); + }); + + test('should not fire selectionchange when text-selection is false', async ({ page }) => { + await goto(page, 'x-markdown/text-selection'); + await page.evaluate(() => { + (window as any)._resetSelectionState(); + document.querySelector('x-markdown')?.setAttribute( + 'text-selection', + 'false', + ); + }); + + const selectionStateBeforeSelect = await page.evaluate(() => + (window as any)._getSelectionState() + ); + expect(selectionStateBeforeSelect.styleText).not.toContain( + 'user-select: text;', + ); + + await selectShadowText(page, 'h1', 0, 5); + await page.waitForTimeout(50); + + const selectionState = await page.evaluate(() => + (window as any)._getSelectionState() + ); + expect(selectionState.count).toBe(0); + }); + + test.describe('selection methods', () => { + test.beforeEach(async ({ browserName }) => { + test.skip(browserName !== 'chromium', 'selection automation is flaky'); + }); + + test('should getSelectedText return current selection text', async ({ page }) => { + await goto(page, 'x-markdown/text-selection'); + await page.evaluate(() => { + (window as any)._resetSelectionState(); + }); + + await selectShadowText(page, 'h1', 0, 5); + await page.waitForFunction(() => + (window as any)._selection_detail?.end === 5 + ); + + const selectedText = await page.evaluate(() => { + const el = document.querySelector('x-markdown') as any; + return el.getSelectedText(); + }); + + expect(selectedText).toBe('Title'); + }); + + test('should setTextSelection update current selection', async ({ page }) => { + await goto(page, 'x-markdown/text-selection'); + await setTextSelection(page, 'h1', 0, 5); + await page.waitForFunction(() => { + const el = document.querySelector('x-markdown') as any; + return el?.getSelectedText() === 'Title'; + }); + + const selectionState = await page.evaluate(() => { + const el = document.querySelector('x-markdown') as any; + return { + selectedText: el.getSelectedText(), + nativeSelectedText: el.shadowRoot?.getSelection?.()?.toString() + ?? document.getSelection()?.toString() + ?? '', + }; + }); + + expect(selectionState.selectedText).toBe('Title'); + expect(selectionState.nativeSelectedText).toBe('Title'); + }); + }); + + test('should append content incrementally', async ({ page }) => { + await goto(page, 'x-markdown/basic'); + expect(await getShadowText(page, 'h1')).toBe('Title'); + await captureFirstChild(page); + await appendContent(page, '\n\n## More'); + await page.waitForFunction(() => { + const element = document.querySelector('x-markdown'); + const root = element?.shadowRoot; + return root?.querySelector('h2')?.textContent === 'More'; + }); + expect(await isFirstChildSame(page)).toBe(true); + }); + + test('should re-render when content diverges', async ({ page }) => { + await goto(page, 'x-markdown/basic'); + await captureFirstChild(page); + await page.evaluate(() => { + const element = document.querySelector('x-markdown'); + element?.setAttribute('content', '# Title\n\nReplaced'); + }); + await page.waitForFunction(() => { + const element = document.querySelector('x-markdown'); + return element?.shadowRoot?.querySelector('p')?.textContent + === 'Replaced'; + }); + expect(await isFirstChildSame(page)).toBe(false); + }); + + test('should batch append by newline boundary', async ({ page }) => { + await goto(page, 'x-markdown/incremental'); + expect(await getShadowCount(page, 'p')).toBe(1); + await appendContent(page, 'Line 2'); + await page.waitForTimeout(20); + expect(await getShadowCount(page, 'p')).toBe(1); + await page.waitForTimeout(80); + expect(await getShadowCount(page, 'p')).toBe(2); + }); + + test('should render tables', async ({ page }) => { + await goto(page, 'x-markdown/table'); + const markdown = page.locator('x-markdown'); + await expect(markdown.locator('table')).toHaveCount(1); + }); + + test('should support basic methods', async ({ page }) => { + await goto(page, 'x-markdown/basic'); + const content = await page.evaluate(() => { + const el = document.querySelector('x-markdown') as any; + return el.getContent({ start: 0, end: 6 }).content; + }); + expect(content).toBe('# Title'); + const images = await page.evaluate(() => { + const el = document.querySelector('x-markdown') as any; + return el.getImages(); + }); + expect(Array.isArray(images)).toBeTruthy(); + }); + + test('should return full text ranges for nested markdown tags', async ({ page }) => { + await goto(page, 'x-markdown/basic'); + const content = [ + 'Hello nested only', + '', + 'Hello **bold** tail', + ].join('\n'); + + await page.evaluate((value) => { + const el = document.querySelector('x-markdown') as any; + el.setAttribute('content', value); + }, content); + await page.waitForFunction(() => { + const el = document.querySelector('x-markdown'); + const root = el?.shadowRoot?.querySelector('#markdown-root'); + return root?.textContent?.includes('Hello nested only') + && root?.textContent?.includes('Hello bold tail'); + }); + + const ranges = await page.evaluate(() => { + const el = document.querySelector('x-markdown') as any; + const rendered = el.shadowRoot?.querySelector('#markdown-root') + ?.textContent ?? ''; + return { + rendered, + result: el.getParseResult({ tags: ['p', 'strong', 'span'] }), + }; + }); + + const firstParagraphText = 'Hello nested only'; + const secondParagraphText = 'Hello bold tail'; + const boldText = 'bold'; + const nestedSpanText = 'nested only'; + + expect(ranges.result.p).toHaveLength(2); + expect( + ranges.rendered.slice(ranges.result.p[0].start, ranges.result.p[0].end), + ).toBe(firstParagraphText); + expect( + ranges.rendered.slice(ranges.result.p[1].start, ranges.result.p[1].end), + ).toBe(secondParagraphText); + + expect(ranges.result.strong).toHaveLength(1); + expect( + ranges.rendered.slice( + ranges.result.strong[0].start, + ranges.result.strong[0].end, + ), + ).toBe(boldText); + + expect(ranges.result.span).toHaveLength(1); + expect( + ranges.rendered.slice( + ranges.result.span[0].start, + ranges.result.span[0].end, + ), + ).toBe(nestedSpanText); + expect(ranges.result.span[0].end).toBeGreaterThan( + ranges.result.span[0].start, + ); + }); + + test('should support source indices in getTextBoundingRect', async ({ page }) => { + await goto(page, 'x-markdown/basic'); + const rects = await page.evaluate(() => { + const el = document.querySelector('x-markdown') as any; + const source = el.getAttribute('content') ?? ''; + const rendered = el.shadowRoot?.querySelector('#markdown-root') + ?.textContent ?? ''; + const sourceToken = '**bold**'; + const renderedToken = 'bold'; + const sourceStart = source.indexOf(sourceToken); + const charStart = rendered.indexOf(renderedToken); + const toPlainRect = (rect: DOMRect | undefined | null) => + rect + ? { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + } + : null; + + return { + sourceRect: toPlainRect( + el.getTextBoundingRect({ + start: sourceStart, + end: sourceStart + sourceToken.length, + indexType: 'source', + })?.boundingRect, + ), + charRect: toPlainRect( + el.getTextBoundingRect({ + start: charStart, + end: charStart + renderedToken.length, + indexType: 'char', + })?.boundingRect, + ), + }; + }); + + expect(rects.sourceRect).not.toBeNull(); + expect(rects.charRect).not.toBeNull(); + expect(rects.sourceRect?.width).toBeGreaterThan(0); + expect(rects.sourceRect?.height).toBeGreaterThan(0); + expect(rects.sourceRect?.left).toBeCloseTo(rects.charRect?.left ?? 0, 3); + expect(rects.sourceRect?.top).toBeCloseTo(rects.charRect?.top ?? 0, 3); + expect(rects.sourceRect?.width).toBeCloseTo(rects.charRect?.width ?? 0, 3); + expect(rects.sourceRect?.height).toBeCloseTo( + rects.charRect?.height ?? 0, + 3, + ); + }); + + test('should getImages return image sources', async ({ page }) => { + await goto(page, 'x-markdown/image'); + const images = await page.evaluate(() => { + const el = document.querySelector('x-markdown') as any; + return el.getImages(); + }); + expect(images).toEqual([ + '/tests/fixtures/resources/firefox-logo.png', + ]); + }); + + test('should inject inline view and keep vertical-align', async ({ page }) => { + await goto(page, 'x-markdown/inlineview'); + await page.waitForFunction(() => { + const el = document.querySelector('x-markdown'); + const root = el && (el as any).shadowRoot; + return !!root && !!root.querySelector('.md-inline-view'); + }); + const va = await page.evaluate(() => { + const el = document.querySelector('x-markdown')!; + const root = el.shadowRoot as ShadowRoot; + const inline = root.querySelector('.md-inline-view') as + | HTMLElement + | null; + return inline ? getComputedStyle(inline).verticalAlign : ''; + }); + expect(va).toBe('middle'); + }); + + test('should apply class styles to inline view', async ({ page }) => { + await goto(page, 'x-markdown/inlineview-class'); + await page.waitForFunction(() => { + const el = document.querySelector('x-markdown'); + const slot = el?.shadowRoot?.querySelector( + '.md-inline-view slot', + ) as HTMLSlotElement | null; + return slot?.assignedElements().some((node) => + (node as HTMLElement).id === 'content-view' + ) ?? false; + }); + + const inlineView = page.locator('#content-view'); + await expect(inlineView).toHaveCSS('background-color', 'rgb(255, 0, 0)'); + await expect(inlineView).toHaveCSS('width', '24px'); + await expect(inlineView).toHaveCSS('height', '24px'); + }); + + test('should clamp and append truncation marker', async ({ page }) => { + await goto(page, 'x-markdown/truncate'); + const markerText = await page.evaluate(() => { + const el = document.querySelector('x-markdown')!; + const root = el.shadowRoot as ShadowRoot; + const marker = root.querySelector('.md-truncation'); + return marker?.textContent || ''; + }); + expect(markerText).toBe('Read more'); + }); + + test('should fire typewriter drawStart/drawEnd', async ({ page }) => { + await goto(page, 'x-markdown/typewriter'); + await page.waitForFunction(() => (window as any)._drawStart === true); + await page.waitForFunction(() => (window as any)._drawEnd === true); + await expect(page.locator('x-markdown').locator('h1')).toHaveText('Title'); + }); + + test('should pause and resume typewriter animation', async ({ page }) => { + await goto(page, 'x-markdown/typewriter-pause-resume'); + await page.waitForFunction( + () => ((window as any)._animationSteps?.length ?? 0) >= 2, + ); + + const pausedStep = await page.evaluate(() => { + const el = document.querySelector('x-markdown') as + | (HTMLElement & { pauseAnimation: () => void }) + | null; + el?.pauseAnimation(); + const steps = (window as any)._animationSteps ?? []; + return steps[steps.length - 1] ?? 0; + }); + + await page.waitForTimeout(300); + + const stepAfterPause = await page.evaluate(() => { + const steps = (window as any)._animationSteps ?? []; + return steps[steps.length - 1] ?? 0; + }); + expect(stepAfterPause).toBe(pausedStep); + + await page.evaluate(() => { + const el = document.querySelector('x-markdown') as + | (HTMLElement & { resumeAnimation: () => void }) + | null; + el?.resumeAnimation(); + }); + + await page.waitForFunction( + (step) => { + const steps = (window as any)._animationSteps ?? []; + return (steps[steps.length - 1] ?? 0) > step; + }, + pausedStep, + ); + }); + + test('should reset typewriter state when content is cleared and reassigned', async ({ page }) => { + await goto(page, 'x-markdown/typewriter-reset-content'); + await page.waitForFunction(() => (window as any)._reassigned === true); + await page.waitForFunction( + () => ((window as any)._newAnimationSteps?.length ?? 0) >= 1, + ); + + const state = await page.evaluate(() => ({ + textAfterReassign: (window as any)._textAfterReassign, + firstNewStep: (window as any)._newAnimationSteps?.[0] ?? null, + })); + + expect(state.textAfterReassign).toBe(''); + expect(state.firstNewStep).toBe(1); + }); + + test('should render custom typewriter cursor', async ({ page }) => { + await goto(page, 'x-markdown/typewriter-cursor'); + await page.waitForFunction(() => (window as any)._drawStart === true); + await page.waitForTimeout(1500); + + const cursorRendered = await page.evaluate(() => { + const el = document.querySelector('x-markdown')!; + const root = el.shadowRoot as ShadowRoot; + const cursor = root.querySelector('#cursor'); + return !!cursor; + }); + expect(cursorRendered).toBe(true); + await page.waitForTimeout(1000); + const cursor = await page.evaluate(() => { + const el = document.querySelector('x-markdown')!; + const root = el.shadowRoot as ShadowRoot; + const cursor = root.querySelector('#cursor'); + return cursor; + }); + expect(cursor).toBeNull(); + }); + + test('should render typewriter cursor after trailing text node', async ({ page }) => { + await goto(page, 'x-markdown/typewriter-trailing-text'); + await page.waitForTimeout(500); + + const isCorrectParent = await page.evaluate(() => { + const el = document.querySelector('x-markdown')!; + const root = el.shadowRoot as ShadowRoot; + const cursor = root.querySelector('.md-typewriter-cursor'); + if (!cursor) return false; + + const parent = cursor.parentElement; + return parent && parent.tagName === 'P'; + }); + + expect(isCorrectParent).toBe(true); + }); + + test('should not hide cursor when content-complete is false', async ({ page }) => { + await goto(page, 'x-markdown/typewriter-keep-cursor'); + await page.waitForFunction(() => (window as any)._drawStart === true); + await page.waitForFunction(() => + (window as any)._animationComplete === true + ); + const isComplete = await page.evaluate(() => { + const el = document.querySelector('x-markdown')!; + return el.getAttribute('content-complete'); + }); + expect(isComplete).toBe('false'); + const cursorRendered = await page.evaluate(() => { + const el = document.querySelector('x-markdown')!; + const root = el.shadowRoot as ShadowRoot; + const cursor = root.querySelector('.md-typewriter-cursor'); + return !!cursor; + }); + expect(cursorRendered).toBe(true); + }); + + test('should render markdown effect', async ({ page }) => { + await goto(page, 'x-markdown/typewriter-effect'); + await page.waitForFunction(() => (window as any)._drawStart === true); + await page.waitForFunction(() => + (window as any)._animationComplete === true + ); + + const effectState = await page.evaluate(() => { + const el = document.querySelector('x-markdown')!; + const root = el.shadowRoot as ShadowRoot; + const effects = Array.from( + root.querySelectorAll('.md-text-mask-effect'), + ) as HTMLElement[]; + const overlays = Array.from( + root.querySelectorAll('.md-text-mask-effect-overlay'), + ) as HTMLElement[]; + const contents = Array.from( + root.querySelectorAll('.md-text-mask-effect-content'), + ) as HTMLElement[]; + return { + rendered: effects.length > 0, + effectCount: effects.length, + overlayText: overlays.map((overlay) => overlay.textContent ?? '').join( + '', + ), + contentText: contents.map((content) => content.textContent ?? '').join( + '', + ), + overlayBackgrounds: overlays.map((overlay) => { + const styles = getComputedStyle(overlay); + return { + backgroundImage: styles.backgroundImage, + backgroundSize: styles.backgroundSize, + backgroundPosition: styles.backgroundPosition, + }; + }), + }; + }); + expect(effectState.rendered).toBe(true); + expect(effectState.effectCount).toBeGreaterThan(0); + expect(effectState.overlayText).toBe('TTTT'); + expect(effectState.contentText).toBe('TTTT'); + expect(effectState.overlayBackgrounds.length).toBeGreaterThan(0); + expect(effectState.overlayBackgrounds[0]?.backgroundImage).toContain( + 'linear-gradient', + ); + if (effectState.overlayBackgrounds.length > 1) { + expect( + new Set( + effectState.overlayBackgrounds.map( + (overlay) => overlay.backgroundSize, + ), + ).size, + ).toBe(1); + expect( + new Set( + effectState.overlayBackgrounds.map( + (overlay) => overlay.backgroundPosition, + ), + ).size, + ).toBeGreaterThan(1); + } + }); +}); diff --git a/packages/web-platform/web-tests/tests/react.spec.ts b/packages/web-platform/web-tests/tests/react.spec.ts index c2e7965719..79a7dd30e5 100644 --- a/packages/web-platform/web-tests/tests/react.spec.ts +++ b/packages/web-platform/web-tests/tests/react.spec.ts @@ -4606,6 +4606,25 @@ test.describe('reactlynx3 tests', () => { }, ); }); + test.describe('x-markdown', () => { + test( + 'basic-element-x-markdown-markdown-style', + async ({ page }, { title }) => { + await goto(page, title); + await wait(200); + + const markdown = page.locator('x-markdown'); + await expect(markdown).toHaveAttribute( + 'markdown-style', + JSON.stringify({ link: { color: '00ff00' } }), + ); + await expect(markdown.locator('a')).toHaveCSS( + 'color', + 'rgb(0, 255, 0)', + ); + }, + ); + }); test.describe('x-audio-tt', () => { test('basic-element-x-audio-tt-play', async ({ page }, { title }) => { // test.skip(true, 'lynx.createSelectorQuery is not supported'); // FIXME diff --git a/packages/web-platform/web-tests/tests/react/basic-element-x-markdown-markdown-style/index.jsx b/packages/web-platform/web-tests/tests/react/basic-element-x-markdown-markdown-style/index.jsx new file mode 100644 index 0000000000..ce46e83fb5 --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/basic-element-x-markdown-markdown-style/index.jsx @@ -0,0 +1,24 @@ +// Copyright 2023 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { root } from '@lynx-js/react'; + +const markdownStyle = { + link: { + color: '00ff00', + }, +}; + +function App() { + return ( + + + + ); +} + +root.render(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38b6b6d39b..165902a068 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1467,6 +1467,13 @@ importers: version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(@vitest/ui@3.2.4)(jsdom@27.4.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6) packages/web-platform/web-elements: + dependencies: + dompurify: + specifier: ^3.3.1 + version: 3.3.1 + markdown-it: + specifier: ^14.1.0 + version: 14.1.0 devDependencies: '@lynx-js/playwright-fixtures': specifier: workspace:* @@ -1480,6 +1487,9 @@ importers: '@rsbuild/plugin-source-build': specifier: 1.0.4 version: 1.0.4(@rsbuild/core@1.7.5) + '@types/markdown-it': + specifier: ^14.1.1 + version: 14.1.1 nyc: specifier: ^17.1.0 version: 17.1.0 @@ -4492,12 +4502,21 @@ packages: '@types/jsonfile@6.1.4': resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/loader-utils@2.0.6': resolution: {integrity: sha512-cgu0Xefgq9O5FjFR78jgI6X31aPjDWCaJ6LCfRtlj6BtyVVWiXagysSYlPACwGKAzRwsFLjKXcj4iGfcVt6cLw==} + '@types/markdown-it@14.1.1': + resolution: {integrity: sha512-4NpsnpYl2Gt1ljyBGrKMxFYAYvpqbnnkgP/i/g+NLpjEUa3obn1XJCur9YbEXKDAkaXqsR1LbDnGEJ0MmKFxfg==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} @@ -4575,6 +4594,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/uglify-js@3.17.5': resolution: {integrity: sha512-TU+fZFBTBcXj/GpDpDaBmgWk/gn96kMZ+uocaFUlV2f8a6WdMzzI44QBCmGcCiYR0Y6ZlNRiyUyKKt5nl/lbzQ==} @@ -5894,6 +5916,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -7452,6 +7477,9 @@ packages: resolution: {integrity: sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + lint-staged@16.2.7: resolution: {integrity: sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==} engines: {node: '>=20.17'} @@ -7567,6 +7595,10 @@ packages: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + hasBin: true + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -7631,6 +7663,9 @@ packages: mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -8538,6 +8573,10 @@ packages: engines: {node: '>=18'} hasBin: true + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -9777,6 +9816,9 @@ packages: peerDependencies: typescript: '>=4.8.0 <5.10.0' + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -12980,15 +13022,24 @@ snapshots: dependencies: '@types/node': 24.10.13 + '@types/linkify-it@5.0.0': {} + '@types/loader-utils@2.0.6': dependencies: '@types/node': 24.10.13 '@types/webpack': 4.41.40 + '@types/markdown-it@14.1.1': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 + '@types/mdurl@2.0.0': {} + '@types/mdx@2.0.13': {} '@types/mime@1.3.5': {} @@ -13063,6 +13114,9 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/uglify-js@3.17.5': dependencies: source-map: 0.6.1 @@ -14448,6 +14502,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -16373,6 +16431,10 @@ snapshots: lines-and-columns@2.0.4: {} + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + lint-staged@16.2.7: dependencies: commander: 14.0.3 @@ -16489,6 +16551,15 @@ snapshots: markdown-extensions@2.0.0: {} + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + markdown-table@3.0.4: {} math-intrinsics@1.1.0: {} @@ -16671,6 +16742,8 @@ snapshots: mdn-data@2.12.2: {} + mdurl@2.0.0: {} + media-typer@0.3.0: {} media-typer@1.1.0: {} @@ -17690,6 +17763,8 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 + punycode.js@2.3.1: {} + punycode@2.3.1: {} qs@6.13.0: @@ -19118,6 +19193,8 @@ snapshots: randexp: 0.5.3 typescript: 5.9.3 + uc.micro@2.1.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4