321 lines
8.4 KiB
TypeScript
321 lines
8.4 KiB
TypeScript
import type { IDomFacade } from 'fontoxpath'
|
|
|
|
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
|
|
|
|
const NODE_TYPES = {
|
|
ELEMENT_NODE: 1,
|
|
ATTRIBUTE_NODE: 2,
|
|
TEXT_NODE: 3,
|
|
DOCUMENT_NODE: 9
|
|
}
|
|
|
|
const QUERYABLE_ATTRS = [
|
|
'name',
|
|
'width',
|
|
'height',
|
|
'x',
|
|
'y',
|
|
'visible',
|
|
'opacity',
|
|
'cornerRadius',
|
|
'fontSize',
|
|
'fontFamily',
|
|
'fontWeight',
|
|
'textDirection',
|
|
'layoutMode',
|
|
'layoutDirection',
|
|
'itemSpacing',
|
|
'paddingTop',
|
|
'paddingBottom',
|
|
'paddingLeft',
|
|
'paddingRight',
|
|
'strokeWeight',
|
|
'rotation',
|
|
'locked',
|
|
'blendMode',
|
|
'text',
|
|
'lineHeight',
|
|
'letterSpacing'
|
|
]
|
|
|
|
interface XPathDocument {
|
|
nodeType: number
|
|
nodeName: string
|
|
documentElement: XPathNode | null
|
|
_children?: XPathNode[]
|
|
}
|
|
|
|
interface XPathAttr {
|
|
nodeType: number
|
|
nodeName: string
|
|
name: string
|
|
localName: string
|
|
namespaceURI: null
|
|
prefix: null
|
|
value: string
|
|
ownerElement: XPathNode
|
|
}
|
|
|
|
interface XPathNode {
|
|
nodeType: number
|
|
nodeName: string
|
|
localName: string
|
|
namespaceURI: null
|
|
prefix: null
|
|
_sceneNode: SceneNode
|
|
_attrs?: XPathAttr[]
|
|
_parent?: XPathNode | XPathDocument | null
|
|
_children?: XPathNode[]
|
|
}
|
|
|
|
function wrapNode(
|
|
_graph: SceneGraph,
|
|
node: SceneNode,
|
|
parent?: XPathNode | XPathDocument | null
|
|
): XPathNode {
|
|
const wrapped: XPathNode = {
|
|
nodeType: NODE_TYPES.ELEMENT_NODE,
|
|
nodeName: node.type,
|
|
localName: node.type,
|
|
namespaceURI: null,
|
|
prefix: null,
|
|
_sceneNode: node,
|
|
_parent: parent
|
|
}
|
|
return wrapped
|
|
}
|
|
|
|
function createDocument(graph: SceneGraph, rootNode: SceneNode): XPathDocument {
|
|
const doc: XPathDocument = {
|
|
nodeType: NODE_TYPES.DOCUMENT_NODE,
|
|
nodeName: '#document',
|
|
documentElement: null
|
|
}
|
|
const root = wrapNode(graph, rootNode, doc)
|
|
doc.documentElement = root
|
|
doc._children = [root]
|
|
return doc
|
|
}
|
|
|
|
function getAttrs(wrapped: XPathNode): XPathAttr[] {
|
|
if (wrapped._attrs) return wrapped._attrs
|
|
|
|
const node = wrapped._sceneNode
|
|
const attrs: XPathAttr[] = []
|
|
|
|
for (const attrName of QUERYABLE_ATTRS) {
|
|
if (attrName in node) {
|
|
const value = Reflect.get(node, attrName)
|
|
if (value === undefined || value === null || typeof value === 'symbol') continue
|
|
const stringValue =
|
|
typeof value === 'object'
|
|
? JSON.stringify(value)
|
|
: String(value as string | number | boolean)
|
|
attrs.push({
|
|
nodeType: NODE_TYPES.ATTRIBUTE_NODE,
|
|
nodeName: attrName,
|
|
name: attrName,
|
|
localName: attrName,
|
|
namespaceURI: null,
|
|
prefix: null,
|
|
value: stringValue,
|
|
ownerElement: wrapped
|
|
})
|
|
}
|
|
}
|
|
|
|
wrapped._attrs = attrs
|
|
return attrs
|
|
}
|
|
|
|
function getChildren(graph: SceneGraph, wrapped: XPathNode): XPathNode[] {
|
|
if (wrapped._children) return wrapped._children
|
|
|
|
const node = wrapped._sceneNode
|
|
wrapped._children = node.childIds
|
|
.map((id) => graph.getNode(id))
|
|
.filter((n): n is SceneNode => n !== undefined)
|
|
.map((child) => wrapNode(graph, child, wrapped))
|
|
|
|
return wrapped._children
|
|
}
|
|
|
|
function isDocument(node: unknown): node is XPathDocument {
|
|
return (node as XPathDocument).nodeType === NODE_TYPES.DOCUMENT_NODE
|
|
}
|
|
|
|
function siblingNode(
|
|
graph: SceneGraph,
|
|
node: XPathNode | XPathDocument,
|
|
offset: -1 | 1
|
|
): XPathNode | null {
|
|
if (isDocument(node)) return null
|
|
const parent = node._parent
|
|
if (!parent || isDocument(parent)) return null
|
|
const siblings = getChildren(graph, parent)
|
|
const index = siblings.indexOf(node)
|
|
if (index === -1) return null
|
|
return siblings[index + offset] ?? null
|
|
}
|
|
|
|
function createDomFacade(graph: SceneGraph) {
|
|
return {
|
|
getAllAttributes(node: XPathNode | XPathDocument): XPathAttr[] {
|
|
if (isDocument(node)) return []
|
|
return getAttrs(node)
|
|
},
|
|
|
|
getAttribute(node: XPathNode | XPathDocument, attributeName: string): string | null {
|
|
if (isDocument(node)) return null
|
|
const sceneNode = node._sceneNode
|
|
if (attributeName in sceneNode) {
|
|
const value = Reflect.get(sceneNode, attributeName)
|
|
if (value === undefined || value === null || typeof value === 'symbol') return null
|
|
return typeof value === 'object'
|
|
? JSON.stringify(value)
|
|
: String(value as string | number | boolean)
|
|
}
|
|
return null
|
|
},
|
|
|
|
getChildNodes(node: XPathNode | XPathDocument): XPathNode[] {
|
|
if (isDocument(node)) return node._children ?? []
|
|
return getChildren(graph, node)
|
|
},
|
|
|
|
getData(node: XPathAttr): string {
|
|
return node.value
|
|
},
|
|
|
|
getFirstChild(node: XPathNode | XPathDocument): XPathNode | null {
|
|
if (isDocument(node)) return node.documentElement
|
|
const children = getChildren(graph, node)
|
|
return children[0] ?? null
|
|
},
|
|
|
|
getLastChild(node: XPathNode | XPathDocument): XPathNode | null {
|
|
if (isDocument(node)) return node.documentElement
|
|
const children = getChildren(graph, node)
|
|
return children[children.length - 1] ?? null
|
|
},
|
|
|
|
getNextSibling(node: XPathNode | XPathDocument): XPathNode | null {
|
|
return siblingNode(graph, node, 1)
|
|
},
|
|
|
|
getParentNode(node: XPathNode | XPathDocument): XPathNode | XPathDocument | null {
|
|
if (isDocument(node)) return null
|
|
return node._parent ?? null
|
|
},
|
|
|
|
getPreviousSibling(node: XPathNode | XPathDocument): XPathNode | null {
|
|
return siblingNode(graph, node, -1)
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface XPathQueryOptions {
|
|
limit?: number
|
|
page?: string
|
|
}
|
|
|
|
export async function queryByXPath(
|
|
graph: SceneGraph,
|
|
selector: string,
|
|
options: XPathQueryOptions = {}
|
|
): Promise<SceneNode[]> {
|
|
const { limit = 1000 } = options
|
|
const pages = graph.getPages()
|
|
const targetPages = options.page ? pages.filter((p) => p.name === options.page) : pages
|
|
|
|
if (targetPages.length === 0) return []
|
|
|
|
const { evaluateXPathToNodes } = await import('fontoxpath')
|
|
const domFacade = createDomFacade(graph) as IDomFacade
|
|
const results: SceneNode[] = []
|
|
|
|
for (const page of targetPages) {
|
|
const doc = createDocument(graph, page)
|
|
const nodes = evaluateXPathToNodes(selector, doc, domFacade)
|
|
|
|
for (const node of nodes) {
|
|
if (results.length >= limit) break
|
|
const sceneNode = (node as XPathNode)._sceneNode
|
|
if (sceneNode.type !== 'CANVAS') {
|
|
results.push(sceneNode)
|
|
}
|
|
}
|
|
|
|
if (results.length >= limit) break
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
/**
|
|
* Build an XPath selector that uniquely identifies a node in its page.
|
|
*
|
|
* Strategy: walk from the node up to the page root, building path segments.
|
|
* Each segment uses the node type as the element name. If the name is
|
|
* unique among siblings of the same type, use `[@name='...']`.
|
|
* Otherwise fall back to a positional predicate `[n]`.
|
|
*/
|
|
export function nodeToXPath(graph: SceneGraph, nodeId: string): string | null {
|
|
const node = graph.getNode(nodeId)
|
|
if (!node) return null
|
|
|
|
const segments: string[] = []
|
|
let current = node
|
|
|
|
for (;;) {
|
|
if (current.type === 'CANVAS') break
|
|
const parentId: string | null = current.parentId
|
|
const parent: SceneNode | undefined = parentId ? graph.getNode(parentId) : undefined
|
|
|
|
segments.unshift(buildSegment(graph, current, parent))
|
|
if (!parent || parent.type === 'CANVAS') break
|
|
current = parent
|
|
}
|
|
|
|
return segments.length > 0 ? '//' + segments.join('/') : null
|
|
}
|
|
|
|
function buildSegment(graph: SceneGraph, node: SceneNode, parent: SceneNode | undefined): string {
|
|
const tag = node.type
|
|
const escaped = escapeXPathName(node.name)
|
|
|
|
if (!parent) return `${tag}[@name=${escaped}]`
|
|
|
|
const siblings = parent.childIds
|
|
.map((id) => graph.getNode(id))
|
|
.filter((n): n is SceneNode => n !== undefined && n.type === tag)
|
|
|
|
const sameNameCount = siblings.filter((s) => s.name === node.name).length
|
|
if (sameNameCount <= 1) return `${tag}[@name=${escaped}]`
|
|
|
|
const index = siblings.findIndex((s) => s.id === node.id) + 1
|
|
return `${tag}[${index}]`
|
|
}
|
|
|
|
function escapeXPathName(s: string): string {
|
|
if (!s.includes("'")) return `'${s}'`
|
|
if (!s.includes('"')) return `"${s}"`
|
|
const parts = s.split("'").map((p) => `'${p}'`)
|
|
return `concat(${parts.join(', "\'", ')})`
|
|
}
|
|
|
|
export async function matchByXPath(
|
|
graph: SceneGraph,
|
|
selector: string,
|
|
node: SceneNode
|
|
): Promise<boolean> {
|
|
const { evaluateXPathToBoolean } = await import('fontoxpath')
|
|
const domFacade = createDomFacade(graph) as IDomFacade
|
|
const wrapped = wrapNode(graph, node)
|
|
try {
|
|
return evaluateXPathToBoolean(`self::*[${selector}]`, wrapped, domFacade)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|