diff --git a/multi-platform/lynx-vrender/src/App.tsx b/multi-platform/lynx-vrender/src/App.tsx index a3e146146..72fc7f92e 100644 --- a/multi-platform/lynx-vrender/src/App.tsx +++ b/multi-platform/lynx-vrender/src/App.tsx @@ -31,7 +31,7 @@ const LIFECYCLE_MODES: Array<{ key: LifecycleMode; label: string }> = [ let activeController: ReturnType | null = null; let activeControllerSceneKey: SceneKey | null = null; -let requestedSceneKey: SceneKey = 'primitives'; +let requestedSceneKey: SceneKey = 'tooltip'; let lastCanvasTouchAt = 0; let canvasTouchActive = false; let canvasMouseActive = false; @@ -285,7 +285,7 @@ function getLifecycleModeLabel(mode: LifecycleMode) { } export function App() { - const [sceneKey, setSceneKey] = useState('primitives'); + const [sceneKey, setSceneKey] = useState('tooltip'); const [lifecycleMode, setLifecycleMode] = useState('reuse-stage'); const [hasCanvasBridge, setHasCanvasBridge] = useState( diff --git a/multi-platform/lynx-vrender/src/smoke-scenes.ts b/multi-platform/lynx-vrender/src/smoke-scenes.ts index e5bb94bb1..122c6b56a 100644 --- a/multi-platform/lynx-vrender/src/smoke-scenes.ts +++ b/multi-platform/lynx-vrender/src/smoke-scenes.ts @@ -43,6 +43,8 @@ export const SCENES = [ { key: 'states', label: '状态' }, { key: 'transform', label: '变换' }, { key: 'components', label: '组件' }, + { key: 'tooltip', label: 'Tooltip' }, + { key: 'baseline', label: '基准矩阵' }, { key: 'events', label: '事件' }, { key: 'stress', label: '批量' }, { key: 'geometry', label: '几何' }, @@ -81,6 +83,14 @@ export const SCENE_META: Record = { title: 'VRender Components', hint: '首屏挂载 Tag、Segment、Axis、Legend、Slider、DataZoom、ScrollBar 和输入控件,失败组件显示 fallback。' }, + tooltip: { + title: 'Tooltip 文字对齐', + hint: '红色横线是内容行中心,应同时穿过 symbol 中心和文字视觉中心。' + }, + baseline: { + title: 'Baseline Matrix(PROTOTYPE)', + hint: '10/16/24px × 五种 correction;横线是传给 alphabetic baseline 的原始 y。' + }, events: { title: '事件拾取边界', hint: 'touch 转发到 stage.window.dispatchEvent 后,验证重叠、zIndex、旋转组和裁剪子节点拾取。' @@ -338,6 +348,10 @@ class LynxSmokeController { this.drawTransformScene(); } else if (sceneKey === 'components') { this.drawComponentsScene(); + } else if (sceneKey === 'tooltip') { + this.drawTooltipScene(); + } else if (sceneKey === 'baseline') { + this.drawBaselineMatrixScene(); } else if (sceneKey === 'events') { this.drawEventsScene(); } else if (sceneKey === 'stress') { @@ -1861,6 +1875,192 @@ class LynxSmokeController { this.mountDeferredComponentControls(); } + private drawTooltipScene() { + const tooltip = new (componentCtor(Tooltip))({ + x: 16, + y: 18, + visible: true, + autoMeasure: false, + autoCalculatePosition: false, + padding: [18, 20], + panel: { + visible: true, + width: 294, + height: 172, + fill: '#FFFFFF', + stroke: '#98A2B3', + lineWidth: 1, + cornerRadius: 10, + shadow: false + }, + title: { + visible: true, + height: 30, + value: { text: 'Tooltip alignment' } + }, + titleStyle: { + value: { + fill: '#101828', + fontFamily: 'Arial', + fontSize: 20, + lineHeight: 30, + textBaseline: 'middle' + }, + spaceRow: 8 + }, + content: [ + { + height: 18, + key: { text: 'Alpha' }, + value: { text: '12' }, + shape: { visible: true, fill: '#276EF1' } + }, + { + height: 18, + key: { text: 'Beta' }, + value: { text: '34' }, + shape: { visible: true, fill: '#11A579' } + } + ], + contentStyle: { + shape: { visible: true, size: 12, spacing: 12 }, + key: { + fill: '#101828', + fontFamily: 'Arial', + fontSize: 18, + lineHeight: 18, + spacing: 52 + }, + value: { + fill: '#101828', + fontFamily: 'Arial', + fontSize: 18, + lineHeight: 18, + spacing: 0 + }, + spaceRow: 8 + }, + hasContentShape: true, + keyWidth: 65, + valueWidth: 24 + }); + this.stage.defaultLayer.add(tooltip); + + for (const y of [83, 109]) { + this.stage.defaultLayer.add( + createLine({ + points: [ + { x: 30, y }, + { x: 296, y } + ], + stroke: '#D92D20', + lineWidth: 1, + lineDash: [4, 3], + zIndex: 501, + pickable: false + }) + ); + } + } + + private drawBaselineMatrixScene() { + const context = this.stage.window.getContext(); + const fontSizes = [10, 16, 24]; + const columns = [112, 210, 308]; + const variants = [ + { label: 'A none', offset: () => 0 }, + { label: 'B −0.5em', offset: (fontSize: number) => -0.5 * fontSize }, + { + label: 'C −fontAsc', + offset: (fontSize: number, metrics: any) => + -(metrics.fontBoundingBoxAscent ?? 0.79 * fontSize) + }, + { label: 'D −0.79em', offset: (fontSize: number) => -0.79 * fontSize }, + { + label: 'E −actualAsc', + offset: (fontSize: number, metrics: any) => + -(metrics.actualBoundingBoxAscent ?? 0.79 * fontSize) + } + ]; + + this.stage.defaultLayer.add( + createText({ + x: 18, + y: 24, + text: 'baseline matrix (PROTOTYPE)', + fontSize: 14, + fill: '#667085' + }) + ); + + fontSizes.forEach((fontSize, index) => { + this.stage.defaultLayer.add( + createText({ + x: columns[index], + y: 52, + text: `${fontSize}px`, + fontSize: 11, + fill: '#667085', + textAlign: 'center', + textBaseline: 'middle' + }) + ); + }); + + variants.forEach((variant, rowIndex) => { + const baselineY = 94 + rowIndex * 72; + this.stage.defaultLayer.add( + createText({ + x: 16, + y: baselineY - 20, + text: variant.label, + fontSize: 10, + fill: '#475467' + }) + ); + this.stage.defaultLayer.add( + createLine({ + points: [ + { x: 76, y: baselineY }, + { x: 348, y: baselineY } + ], + stroke: '#98A2B3', + lineWidth: 1, + pickable: false + }) + ); + + fontSizes.forEach((fontSize, columnIndex) => { + context.setTextStyleWithoutAlignBaseline({ + fontFamily: 'Arial', + fontSize + }); + const metrics = context.measureText('Hg') as any; + const x = columns[columnIndex]; + this.stage.defaultLayer.add( + createCircle({ + x, + y: baselineY, + radius: 3, + fill: '#D92D20', + pickable: false + }) + ); + this.stage.defaultLayer.add( + createText({ + x: x + 8, + y: baselineY + variant.offset(fontSize, metrics), + text: 'Hg', + fontFamily: 'Arial', + fontSize, + fill: '#101828', + textBaseline: 'alphabetic' + }) + ); + }); + }); + } + private mountDeferredComponentControls() { const nodes = this.componentNodes; if (!nodes || nodes.deferredControlsMounted) { diff --git a/packages/vrender-kits/__tests__/unit/lynx-context.test.ts b/packages/vrender-kits/__tests__/unit/lynx-context.test.ts new file mode 100644 index 000000000..3670de49f --- /dev/null +++ b/packages/vrender-kits/__tests__/unit/lynx-context.test.ts @@ -0,0 +1,32 @@ +import { application } from '@visactor/vrender-core/application'; +import { LynxContext2d } from '../../src/canvas/contributions/lynx/context'; + +test('lynx context normalizes device-scaled font metrics', () => { + const metrics = { + width: 80, + actualBoundingBoxAscent: 12, + actualBoundingBoxDescent: 4, + fontBoundingBoxAscent: 28, + fontBoundingBoxDescent: 10 + }; + const context = new LynxContext2d( + { + nativeCanvas: { + getContext: () => ({ measureText: () => metrics, setTransform: (): void => undefined }) + } + } as any, + 2 + ); + const global = application.global; + application.global = { devicePixelRatio: 1 } as any; + + try { + expect(context.measureText('VRender', 'native')).toEqual({ + ...metrics, + fontBoundingBoxAscent: 14, + fontBoundingBoxDescent: 5 + }); + } finally { + application.global = global; + } +}); diff --git a/packages/vrender-kits/src/canvas/contributions/lynx/context.ts b/packages/vrender-kits/src/canvas/contributions/lynx/context.ts index bf2df8658..46d6b80c6 100644 --- a/packages/vrender-kits/src/canvas/contributions/lynx/context.ts +++ b/packages/vrender-kits/src/canvas/contributions/lynx/context.ts @@ -75,15 +75,21 @@ export class LynxContext2d extends BrowserContext2d implements IContext2d { text: string, method: 'native' | 'simple' | 'quick' = application.global.measureTextMethod ): { width: number } { - this.setTransform(1, 0, 0, 1, 0, 0, true, application.global.devicePixelRatio); - const data: any = super.measureText(text, method); - // lynx环境中的fontBoundingBoxDescent和fontBoundingBoxAscent有严重偏移,暂时规避 + const dpr = this.dpr || 1; + this.setTransform(1, 0, 0, 1, 0, 0, true, dpr); + const metrics = super.measureText(text, method) as TextMetrics; + if (dpr === 1 || metrics.fontBoundingBoxAscent == null || metrics.fontBoundingBoxDescent == null) { + return metrics; + } return { - width: data.width, - fontBoundingBoxDescent: undefined, - fontBoundingBoxAscent: undefined, - actualBoundingBoxAscent: undefined, - actualBoundingBoxDescent: undefined + ...metrics, + width: metrics.width, + actualBoundingBoxLeft: metrics.actualBoundingBoxLeft, + actualBoundingBoxRight: metrics.actualBoundingBoxRight, + actualBoundingBoxAscent: metrics.actualBoundingBoxAscent, + actualBoundingBoxDescent: metrics.actualBoundingBoxDescent, + fontBoundingBoxAscent: metrics.fontBoundingBoxAscent / dpr, + fontBoundingBoxDescent: metrics.fontBoundingBoxDescent / dpr } as any; }