Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/architecture/11-element-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,15 @@ CanvasWorkspace 遍历 elements
每个物料包可提供 Viewer 渲染扩展:

```typescript
interface ViewerRenderContext {
readonly document: Document
data: Record<string, unknown>
resolvedProps: Record<string, unknown>
pageIndex: number
unit: string
zoom: number
}

interface MaterialViewerExtension {
render(node: MaterialNode, context: ViewerRenderContext): ViewerRenderOutput
measure?(node: MaterialNode, context: ViewerMeasureContext): ViewerMeasureResult
Expand All @@ -666,6 +675,7 @@ interface MaterialViewerExtension {
其中职责边界如下:

- `render()` 只负责生成物料内容 DOM/SVG。
- `context.document` 是当前 Viewer host 的文档;物料返回 DOM/SVG 节点时必须使用它创建节点,不能依赖全局 `document`,以兼容 iframe 和 custom host。
- `measure()` 负责参与运行时测量和回流,典型场景是 table-data 这类动态高度物料。
- `getRenderSize()` 负责声明最终渲染容器尺寸,适用于“运行态视觉尺寸不等于 schema 几何尺寸”的物料;例如线条物料可把 legacy `lineWidth` 或最小可见厚度提升为实际渲染高度。
- `viewer` 的 `RenderSurface` 不允许根据 `node.type` 推断这些差异,必须统一通过 `MaterialRendererRegistry -> MaterialViewerExtension` 获取。
Expand Down
2 changes: 1 addition & 1 deletion packages/builtin/src/basic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,7 @@ export const builtinViewerMaterialBundle: BuiltinViewerMaterialBundle = {
},
},
{ type: IMAGE_TYPE, binding: imageMaterialBinding, extension: { render: (node, ctx) => renderImage(node, ctx.unit) } },
{ type: BARCODE_TYPE, binding: barcodeMaterialBinding, extension: { render: node => renderBarcode(node) } },
{ type: BARCODE_TYPE, binding: barcodeMaterialBinding, extension: { render: (node, ctx) => renderBarcode(node, ctx) } },
{ type: QRCODE_TYPE, binding: qrcodeMaterialBinding, extension: { render: node => renderQrcode(node) } },
{ type: LINE_TYPE, binding: noMaterialBinding, extension: createLineViewerExtension() },
{ type: RECT_TYPE, binding: noMaterialBinding, extension: { render: (node, ctx) => renderRect(node, ctx.unit) } },
Expand Down
2 changes: 1 addition & 1 deletion packages/builtin/src/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const ALL_BUILTIN_VIEWER_MATERIAL_BUNDLE: BuiltinViewerMaterialBundle = {
},
},
{ type: IMAGE_TYPE, binding: imageMaterialBinding, extension: { render: (node, ctx) => renderImage(node, ctx.unit) } },
{ type: BARCODE_TYPE, binding: barcodeMaterialBinding, extension: { render: node => renderBarcode(node) } },
{ type: BARCODE_TYPE, binding: barcodeMaterialBinding, extension: { render: (node, ctx) => renderBarcode(node, ctx) } },
{ type: QRCODE_TYPE, binding: qrcodeMaterialBinding, extension: { render: node => renderQrcode(node) } },
{ type: LINE_TYPE, binding: noMaterialBinding, extension: createLineViewerExtension() },
{ type: RECT_TYPE, binding: noMaterialBinding, extension: { render: (node, ctx) => renderRect(node, ctx.unit) } },
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/material-viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export function readTrustedViewerHtml(html: TrustedViewerHtml): string {
* Provides document-level unit and zoom for physical unit calculations.
*/
export interface ViewerRenderContext {
/** Host document used to create material-owned DOM nodes. */
readonly document: Document
data: Record<string, unknown>
resolvedProps: Record<string, unknown>
pageIndex: number
Expand Down
3 changes: 3 additions & 0 deletions packages/materials/barcode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,8 @@
"@easyink/schema": "workspace:*",
"@easyink/shared": "workspace:*",
"jsbarcode": "catalog:framework"
},
"devDependencies": {
"@types/jsbarcode": "catalog:types"
}
}
136 changes: 136 additions & 0 deletions packages/materials/barcode/src/designer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import type { MaterialExtensionContext, NodeSignal } from '@easyink/core'
import type { MaterialNode } from '@easyink/schema'
import { createDefaultSchema } from '@easyink/schema'
import { describe, expect, it } from 'vitest'
import { createBarcodeExtension } from './designer'
import { createBarcodeNode } from './schema'
import { installCanvasTextMeasurement } from './test-utils'

installCanvasTextMeasurement()

function createContext(): MaterialExtensionContext {
const schema = createDefaultSchema()
return {
getSchema: () => schema,
getNode: () => undefined,
getSelection: () => ({ ids: [], count: 0, isEmpty: true }),
getBindingLabel: binding => binding.fieldLabel || binding.fieldPath,
commitCommand: () => {},
tx: {
run: () => {},
batch: fn => fn(),
},
requestPropertyPanel: () => {},
emit: () => {},
on: () => () => {},
getZoom: () => 1,
getPageEl: () => null,
t: key => key,
}
}

function createMutableNodeSignal(initialNode: MaterialNode): {
signal: NodeSignal
update: (node: MaterialNode) => void
} {
let currentNode = initialNode
let subscriber: ((node: MaterialNode) => void) | undefined
return {
signal: {
get: () => currentNode,
subscribe: (callback) => {
subscriber = callback
return () => {
subscriber = undefined
}
},
},
update: (node) => {
currentNode = node
subscriber?.(node)
},
}
}

describe('createBarcodeExtension', () => {
it('renders and repaints the official EAN-13 layout', () => {
const container = document.createElement('div')
const extension = createBarcodeExtension(createContext())
const nodeSignal = createMutableNodeSignal(createBarcodeNode({
props: {
value: '5901234123457',
format: 'EAN13',
showText: true,
},
}))
const cleanup = extension.renderContent(nodeSignal.signal, container)

expect(Array.from(container.querySelectorAll('text')).map(text => text.textContent).filter(Boolean))
.toEqual(['5', '901234', '123457'])

nodeSignal.update(createBarcodeNode({
props: {
value: '5901234123457',
format: 'EAN13',
showText: false,
},
}))

expect(container.querySelectorAll('text')).toHaveLength(0)
cleanup()
})

it('shows the configured format sample for an empty value', () => {
const container = document.createElement('div')
const extension = createBarcodeExtension(createContext())
const nodeSignal = createMutableNodeSignal(createBarcodeNode({
props: {
value: '',
format: 'EAN13',
},
}))

extension.renderContent(nodeSignal.signal, container)

expect(container.querySelector('svg')).not.toBeNull()
expect(container.querySelectorAll('text')).toHaveLength(0)
expect(container.textContent).toContain('EAN13')
})

it('writes binding labels through textContent', () => {
const container = document.createElement('div')
const extension = createBarcodeExtension(createContext())
const nodeSignal = createMutableNodeSignal(createBarcodeNode({
binding: {
sourceId: 'product',
fieldPath: 'ean',
fieldLabel: '<img src=x>',
},
props: {
value: 'EasyInk',
format: 'CODE128',
},
}))

extension.renderContent(nodeSignal.signal, container)

expect(container.textContent).toContain('{#<img src=x>}')
expect(container.querySelector('img')).toBeNull()
})

it('shows an error state for invalid values', () => {
const container = document.createElement('div')
const extension = createBarcodeExtension(createContext())
const nodeSignal = createMutableNodeSignal(createBarcodeNode({
props: {
value: 'invalid',
format: 'EAN13',
},
}))

extension.renderContent(nodeSignal.signal, container)

expect(container.querySelector('svg')).toBeNull()
expect(container.textContent).toBe('Invalid: invalid')
})
})
138 changes: 90 additions & 48 deletions packages/materials/barcode/src/designer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,84 +2,126 @@ import type { MaterialDesignerExtension, MaterialExtensionContext } from '@easyi
import type { MaterialNode } from '@easyink/schema'
import type { BarcodeProps } from './schema'
import { getBindingRefs } from '@easyink/schema'
import { escapeHtml } from '@easyink/shared'
import { generateBarcodeSvg } from './render'
import { createBarcodeSvgElement } from './render'
import { BARCODE_FORMATS, resolveBarcodeProps } from './schema'

function buildPlaceholder(p: BarcodeProps, label: string): string {
const sampleValue = BARCODE_FORMATS.find(format => format.value === p.format)?.sampleValue || 'EasyInk'
function applyFrameStyles(frame: HTMLDivElement, props: BarcodeProps, unit: string): void {
frame.style.position = 'relative'
frame.style.width = '100%'
frame.style.height = '100%'
frame.style.boxSizing = 'border-box'
if (props.borderWidth) {
frame.style.border = `${props.borderWidth}${unit} ${props.borderType} ${props.borderColor}`
}
}

function createErrorPlaceholder(document: Document, props: BarcodeProps, value: string): HTMLDivElement {
const error = document.createElement('div')
error.style.width = '100%'
error.style.height = '100%'
error.style.display = 'flex'
error.style.alignItems = 'center'
error.style.justifyContent = 'center'
error.style.boxSizing = 'border-box'
error.style.background = props.backgroundColor
error.style.color = '#e53e3e'
error.style.fontSize = '11px'
error.style.border = '1px dashed #e53e3e'
error.textContent = `Invalid: ${value}`
return error
}

function appendLabelOverlay(document: Document, frame: HTMLElement, label: string, color: string): void {
const overlay = document.createElement('div')
overlay.style.position = 'absolute'
overlay.style.inset = '0'
overlay.style.display = 'flex'
overlay.style.alignItems = 'center'
overlay.style.justifyContent = 'center'

const text = document.createElement('span')
text.style.maxWidth = '90%'
text.style.overflow = 'hidden'
text.style.padding = '1px 4px'
text.style.borderRadius = '2px'
text.style.background = 'rgba(255, 255, 255, 0.8)'
text.style.color = color
text.style.fontSize = '10px'
text.style.textOverflow = 'ellipsis'
text.style.whiteSpace = 'nowrap'
text.textContent = label

overlay.appendChild(text)
frame.appendChild(overlay)
}

function createPlaceholder(document: Document, props: BarcodeProps, label: string): HTMLElement {
const placeholder = document.createElement('div')
placeholder.style.position = 'relative'
placeholder.style.width = '100%'
placeholder.style.height = '100%'
placeholder.style.opacity = '0.4'

let svg: string
const sampleValue = BARCODE_FORMATS.find(format => format.value === props.format)?.sampleValue || 'EasyInk'
try {
svg = generateBarcodeSvg(sampleValue, {
format: p.format,
lineWidth: p.lineWidth,
lineColor: p.lineColor,
backgroundColor: p.backgroundColor,
placeholder.appendChild(createBarcodeSvgElement(sampleValue, {
format: props.format,
lineWidth: props.lineWidth,
lineColor: props.lineColor,
backgroundColor: props.backgroundColor,
showText: false,
})
}, document))
}
catch {
return buildErrorPlaceholder(p, label)
return createErrorPlaceholder(document, props, label)
}

return `<div style="position:relative;width:100%;height:100%;opacity:0.4">${svg}<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center"><span style="background:rgba(255,255,255,0.8);padding:1px 4px;font-size:10px;color:${p.lineColor};border-radius:2px">${label}</span></div></div>`
appendLabelOverlay(document, placeholder, label, props.lineColor)
return placeholder
}

function buildErrorPlaceholder(p: BarcodeProps, value: string): string {
return `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:${p.backgroundColor};color:#e53e3e;font-size:11px;border:1px dashed #e53e3e;box-sizing:border-box">Invalid: ${escapeHtml(value)}</div>`
}
function buildElement(node: MaterialNode, context: MaterialExtensionContext, document: Document): HTMLElement {
const props = resolveBarcodeProps(node)
const frame = document.createElement('div')
applyFrameStyles(frame, props, context.getSchema().unit)

function buildHtml(node: MaterialNode, context: MaterialExtensionContext): string {
const p = resolveBarcodeProps(node)
const unit = context.getSchema().unit
const DASH_MAP: Record<string, string> = { dashed: 'dashed', dotted: 'dotted' }
const borderStyle = p.borderWidth ? `border:${p.borderWidth}${unit} ${DASH_MAP[p.borderType] || 'solid'} ${p.borderColor};box-sizing:border-box;` : ''

let label: string | undefined
const b = getBindingRefs(node.binding)[0]
if (b) {
label = `{#${escapeHtml(context.getBindingLabel(b))}}`
}

const value = p.value || ''
const binding = getBindingRefs(node.binding)[0]
const label = binding ? `{#${context.getBindingLabel(binding)}}` : undefined
const value = props.value == null ? '' : String(props.value)

if (!value) {
const inner = buildPlaceholder(p, label || p.format)
return borderStyle ? `<div style="width:100%;height:100%;${borderStyle}">${inner}</div>` : inner
frame.appendChild(createPlaceholder(document, props, label || props.format))
return frame
}

let svg: string
try {
svg = generateBarcodeSvg(value, {
format: p.format,
lineWidth: p.lineWidth,
lineColor: p.lineColor,
backgroundColor: p.backgroundColor,
showText: p.showText,
})
frame.appendChild(createBarcodeSvgElement(value, {
format: props.format,
lineWidth: props.lineWidth,
lineColor: props.lineColor,
backgroundColor: props.backgroundColor,
showText: props.showText,
}, document))
}
catch {
const inner = buildErrorPlaceholder(p, value)
return borderStyle ? `<div style="width:100%;height:100%;${borderStyle}">${inner}</div>` : inner
frame.appendChild(createErrorPlaceholder(document, props, value))
return frame
}

if (label) {
return `<div style="position:relative;width:100%;height:100%;${borderStyle}">${svg}<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center"><span style="background:rgba(255,255,255,0.8);padding:1px 4px;font-size:10px;color:${p.lineColor};border-radius:2px;max-width:90%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${label}</span></div></div>`
}
if (label)
appendLabelOverlay(document, frame, label, props.lineColor)

return borderStyle ? `<div style="width:100%;height:100%;${borderStyle}">${svg}</div>` : svg
return frame
}

export function createBarcodeExtension(context: MaterialExtensionContext): MaterialDesignerExtension {
return {
renderContent(nodeSignal, container) {
function render() {
container.innerHTML = buildHtml(nodeSignal.get(), context)
container.replaceChildren(buildElement(nodeSignal.get(), context, container.ownerDocument))
}
render()
const unsub = nodeSignal.subscribe(render)
return unsub
return nodeSignal.subscribe(render)
},
}
}
Loading
Loading