feat: persist LinkSub topology references
This commit is contained in:
@@ -365,6 +365,7 @@ function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; fa
|
||||
const activeDocument = facade.app.document.getActive()
|
||||
const booleanCommand = activeCommand === 'union' || activeCommand === 'cut' || activeCommand === 'intersection'
|
||||
const shapeObjects = activeDocument.objects.filter((object) => object.typeId.startsWith('Part::') || ['PartDesign::Feature', 'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer', 'PartDesign::LinearPattern', 'PartDesign::PolarPattern', 'PartDesign::Hole'].includes(object.typeId))
|
||||
const objectLabel = (object: DocumentSnapshot['objects'][number]) => { const value = object.properties.find((property) => property.name === 'Label')?.value; return typeof value === 'string' || typeof value === 'number' ? String(value) : object.id }
|
||||
const draftLink = (name: string) => typeof activeTask?.draft[name] === 'string' ? String(activeTask.draft[name]) : ''
|
||||
const acceptTask = () => {
|
||||
if (!activeTask || activeTask.status !== 'preview') { showNotice('No active task'); return }
|
||||
@@ -389,8 +390,8 @@ function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; fa
|
||||
{primitiveFields.map((field) => <label className="field-label" key={field}>{primitiveLabel(field)} <span className="field-unit">{primitiveUnits[field]}</span><input className="field-input" type="number" min={field === 'radius2' ? 0 : 0.001} max={primitiveUnits[field] === 'deg' ? 360 : undefined} step={primitiveUnits[field] === 'deg' ? 1 : 0.1} value={typeof activeTask?.draft[field] === 'number' ? Number(activeTask.draft[field]) : primitiveFallbacks[field]} onChange={(event) => facade.task.update({ [field]: Number(event.target.value) })} /></label>)}
|
||||
</>}
|
||||
{booleanCommand && <>
|
||||
<label className="field-label">Base<select className="field-input" value={draftLink('base')} onChange={(event) => facade.task.update({ base: event.target.value })}><option value="">Select base</option>{shapeObjects.map((object) => <option value={object.id} key={object.id}>{object.properties.find((property) => property.name === 'Label')?.value || object.id}</option>)}</select></label>
|
||||
<label className="field-label">Tool<select className="field-input" value={draftLink('tool')} onChange={(event) => facade.task.update({ tool: event.target.value })}><option value="">Select tool</option>{shapeObjects.map((object) => <option value={object.id} key={object.id}>{object.properties.find((property) => property.name === 'Label')?.value || object.id}</option>)}</select></label>
|
||||
<label className="field-label">Base<select className="field-input" value={draftLink('base')} onChange={(event) => facade.task.update({ base: event.target.value })}><option value="">Select base</option>{shapeObjects.map((object) => <option value={object.id} key={object.id}>{objectLabel(object)}</option>)}</select></label>
|
||||
<label className="field-label">Tool<select className="field-input" value={draftLink('tool')} onChange={(event) => facade.task.update({ tool: event.target.value })}><option value="">Select tool</option>{shapeObjects.map((object) => <option value={object.id} key={object.id}>{objectLabel(object)}</option>)}</select></label>
|
||||
</>}
|
||||
{currentFeatureField && <label className="field-label">{currentFeatureField.label} <span className="field-unit">{currentFeatureField.unit}</span><input className="field-input" type="number" min={0.001} max={currentFeatureField.unit === 'deg' ? 360 : undefined} step={currentFeatureField.unit === 'deg' ? 1 : 0.1} value={typeof activeTask?.draft[currentFeatureField.key] === 'number' ? Number(activeTask.draft[currentFeatureField.key]) : currentFeatureField.fallback} onChange={(event) => facade.task.update({ [currentFeatureField.key]: Number(event.target.value) })} /></label>}
|
||||
{activeCommand === 'linear-pattern' && <>
|
||||
@@ -449,6 +450,9 @@ function PropertyEditor({ facade, objectId, property, showNotice }: { facade: Bi
|
||||
const document = facade.app.document.getActive()
|
||||
const ids = [...new Set(document.tree.flatMap((item) => [item.id, ...(item.children ?? [])]))]
|
||||
editor = <select className="property-control property-link-control" value={String(property.value ?? '')} aria-label={property.label} onChange={(event) => commit(event.target.value || null)}><option value="">None</option>{ids.filter((id) => id !== objectId).map((id) => <option value={id} key={id}>{document.tree.find((item) => item.id === id)?.label ?? id}</option>)}</select>
|
||||
} else if (property.type === 'App::PropertyLinkSub') {
|
||||
const ref = property.value && typeof property.value === 'object' ? property.value : null
|
||||
editor = ref ? <div className="property-link-sub"><span title={ref.persistentId}>{ref.objectId} / {ref.kind} · {ref.status}</span><IconButton icon={X} label="Clear subshape reference" onClick={() => commit(null)} /></div> : <span className="property-readonly">None</span>
|
||||
} else if (property.type === 'App::PropertyColor') editor = <label className="property-color"><input type="color" value={String(property.value)} aria-label={property.label} onChange={(event) => commit(event.target.value)} /><span>{String(property.value).toUpperCase()}</span></label>
|
||||
else if (property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat' || property.type === 'App::PropertyInteger') editor = <label className="property-number"><input className="property-control" type="number" defaultValue={Number(property.value)} min={property.name === 'Occurrences' ? 2 : property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' ? 0 : undefined} max={property.name === 'Occurrences' ? 100 : property.type === 'App::PropertyAngle' ? 360 : property.type === 'App::PropertyPercent' ? 100 : undefined} step={property.type === 'App::PropertyLength' ? 0.1 : 1} aria-label={property.label} onBlur={(event) => { if (!commit(Number(event.target.value))) event.target.value = String(property.value) }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }} /><span>{property.unit}</span></label>
|
||||
else editor = <input className="property-control" defaultValue={String(property.value ?? '')} aria-label={property.label} onBlur={(event) => { if (!commit(event.target.value)) event.target.value = String(property.value ?? '') }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }} />
|
||||
|
||||
@@ -4,7 +4,7 @@ export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveS
|
||||
export { ThreeViewportAdapter } from './threeViewport'
|
||||
export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validatePocketInput, validateRevolutionInput, validateSphereInput } from './geometryRuntime'
|
||||
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema'
|
||||
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot } from './types'
|
||||
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot, TopoRefValue } from './types'
|
||||
export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming'
|
||||
export { createPersistedTopoRef, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from './topologyReferences'
|
||||
export type { PersistedTopoRef, TopoRefResolution, TopologyMigration } from './topologyReferences'
|
||||
|
||||
@@ -31,6 +31,7 @@ import { cloneSketch, createSketch, solveSketch, type SketchConstraint, type Ske
|
||||
import { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine'
|
||||
import { inspectFcstdArchive } from './fcstd'
|
||||
import { buildDiagnosticTree, buildRecomputeDiagnostics, cloneDiagnostic, replaceRecomputeDiagnostics } from './diagnostics'
|
||||
import { parseTopoRef } from './topologyReferences'
|
||||
|
||||
const initialTree: ModelTreeItem[] = [
|
||||
{ id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] },
|
||||
@@ -96,6 +97,7 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
|
||||
{ name: 'Length', label: 'Length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 18, unit: 'mm', recompute: true },
|
||||
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true },
|
||||
{ name: 'Base', label: 'Base', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'pad', recompute: true },
|
||||
{ name: 'UpToFace', label: 'Up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
|
||||
{ name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
|
||||
]
|
||||
if (item.id.startsWith('revolution')) return [
|
||||
@@ -141,10 +143,12 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
|
||||
|
||||
const createObjectSnapshot = (item: ModelTreeItem): DocumentObjectSnapshot => ({ id: item.id, typeId: typeIdForItem(item), properties: [...commonProperties(item), ...featureProperties(item), ...viewProperties()], sketch: item.type === 'sketch' ? createSketch(item.id) : undefined })
|
||||
|
||||
const clonePropertyValue = (value: PropertyValue): PropertyValue => value && typeof value === 'object' ? { ...value, candidates: value.candidates ? [...value.candidates] : undefined } : value
|
||||
|
||||
const cloneDocumentSnapshot = (document: DocumentSnapshot): DocumentSnapshot => ({
|
||||
...document,
|
||||
tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })),
|
||||
objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })),
|
||||
objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value), options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })),
|
||||
dependencies: document.dependencies?.map((edge) => ({ ...edge })),
|
||||
recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined,
|
||||
})
|
||||
@@ -155,6 +159,7 @@ const collectDependencyEdges = (document: Pick<DocumentSnapshot, 'objects'>): De
|
||||
for (const object of document.objects) {
|
||||
for (const property of object.properties) {
|
||||
if (property.type === 'App::PropertyLink' && typeof property.value === 'string' && objectIds.has(property.value)) edges.push({ sourceId: object.id, targetId: property.value, relation: 'link', propertyName: property.name })
|
||||
if (property.type === 'App::PropertyLinkSub' && property.value && typeof property.value === 'object' && objectIds.has(property.value.objectId)) edges.push({ sourceId: object.id, targetId: property.value.objectId, relation: 'topo-ref', propertyName: property.name, reference: property.value.persistentId })
|
||||
if (property.expression) {
|
||||
for (const reference of expressionReferences(property.expression)) {
|
||||
const separator = reference.lastIndexOf('.')
|
||||
@@ -260,6 +265,13 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope
|
||||
const knownIds = new Set(document.tree.flatMap((item) => [item.id, ...(item.children ?? [])]))
|
||||
if (value !== null && !knownIds.has(value)) throw new RangeError(`${property.label} target does not exist in this document.`)
|
||||
}
|
||||
if (property.type === 'App::PropertyLinkSub') {
|
||||
if (value !== null && (typeof value !== 'object' || Array.isArray(value))) throw new TypeError(`${property.label} requires a TopoRef value.`)
|
||||
if (value !== null) {
|
||||
const topoRef = parseTopoRef(JSON.stringify(value))
|
||||
if (!document.objects.some((object) => object.id === topoRef.objectId)) throw new RangeError(`${property.label} target does not exist in this document.`)
|
||||
}
|
||||
}
|
||||
if (property.name === 'Label' && !(value as string).trim()) throw new RangeError('Label cannot be empty.')
|
||||
}
|
||||
|
||||
@@ -383,7 +395,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
if (Number(objectSnapshot.properties.find((property) => property.name === 'Diameter')?.value) <= 0) throw new RangeError('Hole diameter must be greater than zero.')
|
||||
if (Number(objectSnapshot.properties.find((property) => property.name === 'Depth')?.value) <= 0) throw new RangeError('Hole depth must be greater than zero.')
|
||||
}
|
||||
const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), objectSnapshot]
|
||||
const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value) })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), objectSnapshot]
|
||||
const tipObject = type === 'feature' && partDesignCommands.has(commandId) && commandId !== 'create-sketch' ? objects.find((object) => object.id === 'body') : undefined
|
||||
if (tipObject) {
|
||||
const tip = tipObject.properties.find((property) => property.name === 'Tip')
|
||||
@@ -407,7 +419,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
|
||||
const document = cloneDocumentSnapshot(state.document)
|
||||
const object = document.objects[objectIndex]
|
||||
object.properties[propertyIndex] = { ...object.properties[propertyIndex], value, expression: undefined, expressionError: undefined }
|
||||
object.properties[propertyIndex] = { ...object.properties[propertyIndex], value: clonePropertyValue(value), expression: undefined, expressionError: undefined }
|
||||
const treeItem = document.tree.find((item) => item.id === objectId)
|
||||
if (propertyName === 'Label' && treeItem) treeItem.label = String(value)
|
||||
document.dependencies = collectDependencyEdges(document)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, Unsubscribe } from './types'
|
||||
import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, PropertyValue, Unsubscribe } from './types'
|
||||
import { cloneSketch } from './sketcher'
|
||||
|
||||
type WorkerRequest = { id: number; type: 'initialize' | 'dispose' | 'list-projects' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document' | 'recovery-report'; documentId: string } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'release-resource'; hash: string }
|
||||
@@ -6,7 +6,8 @@ type WorkerInput = { type: 'initialize' | 'dispose' | 'list-projects' } | { type
|
||||
type WorkerResponse = { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } | { id: number; ok: true; type: 'projects-listed'; projects: ProjectSummary[] } | { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: 'loaded'; document: DocumentSnapshot | null } | { id: number; ok: true; type: 'recovery-report'; report: ProjectRecoveryReport } | { id: number; ok: true; type: 'resource-put'; resource: ProjectResource } | { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null } | { id: number; ok: true; type: 'resource-released' } | { id: number; ok: true; type: 'disposed' } | { id: number; ok: false; error: string }
|
||||
|
||||
const unavailable: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: 0, reason: 'Persistence Worker is unavailable in this environment.' }
|
||||
const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined })
|
||||
const clonePropertyValue = (value: PropertyValue): PropertyValue => value && typeof value === 'object' ? { ...value, candidates: value.candidates ? [...value.candidates] : undefined } : value
|
||||
const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value), options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined })
|
||||
|
||||
export interface ProjectPersistenceClient {
|
||||
initialize(): Promise<PersistenceCapabilities>
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import type { SubshapeRef } from './types'
|
||||
import type { SubshapeRef, TopoRefValue } from './types'
|
||||
import { matchSubshapes, type SubshapeMatch, type SubshapeSignature } from './topologyNaming'
|
||||
|
||||
export type PersistedTopoRef = {
|
||||
schemaVersion: 1
|
||||
objectId: string
|
||||
kind: SubshapeRef['kind']
|
||||
persistentId: string
|
||||
topologyVersion: number
|
||||
generation: number
|
||||
status: NonNullable<SubshapeRef['status']>
|
||||
signature?: string
|
||||
candidates?: string[]
|
||||
}
|
||||
export type PersistedTopoRef = TopoRefValue
|
||||
|
||||
export type TopoRefResolution = {
|
||||
status: 'resolved' | 'ambiguous' | 'deleted'
|
||||
|
||||
@@ -14,14 +14,26 @@ export type ModelTreeItem = {
|
||||
children?: string[]
|
||||
}
|
||||
|
||||
export type PropertyValue = string | number | boolean | null
|
||||
export type TopoRefValue = {
|
||||
schemaVersion: 1
|
||||
objectId: string
|
||||
kind: 'face' | 'edge' | 'vertex'
|
||||
persistentId: string
|
||||
topologyVersion: number
|
||||
generation: number
|
||||
status: 'stable' | 'ambiguous' | 'new' | 'deleted'
|
||||
signature?: string
|
||||
candidates?: string[]
|
||||
}
|
||||
|
||||
export type PropertyValue = string | number | boolean | TopoRefValue | null
|
||||
|
||||
export type ObjectPropertySnapshot = {
|
||||
name: string
|
||||
label: string
|
||||
group: string
|
||||
scope: 'data' | 'view'
|
||||
type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyAngle' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' | 'App::PropertyInteger'
|
||||
type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyAngle' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyLinkSub' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' | 'App::PropertyInteger'
|
||||
value: PropertyValue
|
||||
unit?: string
|
||||
readOnly?: boolean
|
||||
|
||||
@@ -377,6 +377,9 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s
|
||||
.property-number { width: 100%; display: flex; align-items: center; gap: 4px; color: var(--text-muted); font-size: 9px; }
|
||||
.property-number .property-control { min-width: 0; }
|
||||
.property-link-control { color: var(--cyan); }
|
||||
.property-link-sub { display: flex; min-width: 0; align-items: center; gap: 4px; color: var(--cyan); font-size: 10px; }
|
||||
.property-link-sub > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.property-link-sub .icon-button { width: 24px; height: 24px; flex: 0 0 24px; }
|
||||
.property-color { display: flex; align-items: center; gap: 5px; color: var(--text-muted); font-size: 9px; }
|
||||
.property-color input { width: 22px; height: 18px; padding: 0; border: 1px solid var(--line); border-radius: 2px; background: transparent; }
|
||||
.property-expression { min-height: 20px; display: flex; align-items: center; justify-content: flex-end; gap: 5px; color: var(--cyan); font-size: 9px; }
|
||||
|
||||
Reference in New Issue
Block a user