Add full graph editor controls
This commit is contained in:
@@ -9,6 +9,7 @@ import '@vue-flow/controls/dist/style.css'
|
|||||||
import GraphFxNode from './components/GraphFxNode.vue'
|
import GraphFxNode from './components/GraphFxNode.vue'
|
||||||
import { countSavedPositions, graphFxToVueFlow, parseGraphFxJson, type GraphFxFlowWarning, type GraphFxSerializedGraph } from './lib/graphfxToVueFlow'
|
import { countSavedPositions, graphFxToVueFlow, parseGraphFxJson, type GraphFxFlowWarning, type GraphFxSerializedGraph } from './lib/graphfxToVueFlow'
|
||||||
import { layoutGraph } from './lib/layout'
|
import { layoutGraph } from './lib/layout'
|
||||||
|
import { coerceIoValue, createSerializedNode, displayIoValue, nodeCatalog, removeNodeAndConnections } from './lib/editorHelpers'
|
||||||
import { sampleGraph } from './lib/sampleGraph'
|
import { sampleGraph } from './lib/sampleGraph'
|
||||||
import apiExampleJson from '../examples/api.json?raw'
|
import apiExampleJson from '../examples/api.json?raw'
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +33,8 @@ const selectedSaveSlot = ref(localStorage.getItem('GRAPHFX_UI_SELECTED_SAVE_SLOT
|
|||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const sidebarCollapsed = ref(false)
|
const sidebarCollapsed = ref(false)
|
||||||
const activeTab = ref<'control' | 'data'>('control')
|
const activeTab = ref<'control' | 'data'>('control')
|
||||||
|
const selectedNodeId = ref<string | null>(null)
|
||||||
|
const nodeSearch = ref('')
|
||||||
const { fitView } = useVueFlow()
|
const { fitView } = useVueFlow()
|
||||||
|
|
||||||
const runtimeState = ref<RuntimeState | null>(null)
|
const runtimeState = ref<RuntimeState | null>(null)
|
||||||
@@ -49,6 +52,13 @@ const liveFrames = new Map<string, number>()
|
|||||||
|
|
||||||
const labeledImageInputs = computed(() => runtimeState.value?.inputs || [])
|
const labeledImageInputs = computed(() => runtimeState.value?.inputs || [])
|
||||||
const labeledImageOutputs = computed(() => runtimeState.value?.outputs || [])
|
const labeledImageOutputs = computed(() => runtimeState.value?.outputs || [])
|
||||||
|
const selectedGraphNode = computed(() => graph.value.find((item) => item.node.id === selectedNodeId.value) || null)
|
||||||
|
const selectedFlowNode = computed(() => nodes.value.find((node) => node.id === selectedNodeId.value) || null)
|
||||||
|
const filteredNodeCatalog = computed(() => {
|
||||||
|
const query = nodeSearch.value.trim().toLowerCase()
|
||||||
|
return nodeCatalog.filter((item) => !query || item.name.toLowerCase().includes(query)).slice(0, 80)
|
||||||
|
})
|
||||||
|
const firstCatalogMatch = computed(() => filteredNodeCatalog.value[0] || null)
|
||||||
|
|
||||||
const stats = computed(() => ({
|
const stats = computed(() => ({
|
||||||
nodes: nodes.value.length,
|
nodes: nodes.value.length,
|
||||||
@@ -62,6 +72,7 @@ function refreshFlow() {
|
|||||||
const parsed = parseGraphFxJson(editorText.value)
|
const parsed = parseGraphFxJson(editorText.value)
|
||||||
const model = graphFxToVueFlow(parsed)
|
const model = graphFxToVueFlow(parsed)
|
||||||
graph.value = parsed
|
graph.value = parsed
|
||||||
|
if (selectedNodeId.value && !parsed.some((item) => item.node.id === selectedNodeId.value)) selectedNodeId.value = null
|
||||||
nodes.value = layoutGraph(model.nodes, model.edges, {
|
nodes.value = layoutGraph(model.nodes, model.edges, {
|
||||||
direction: 'LR',
|
direction: 'LR',
|
||||||
useSavedPositions: useSavedPositions.value,
|
useSavedPositions: useSavedPositions.value,
|
||||||
@@ -79,6 +90,106 @@ function syncEditorFromGraph() {
|
|||||||
editorText.value = JSON.stringify(graph.value, null, 2)
|
editorText.value = JSON.stringify(graph.value, null, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshFromGraph(options: { fit?: boolean } = {}) {
|
||||||
|
syncEditorFromGraph()
|
||||||
|
refreshFlow()
|
||||||
|
if (options.fit) nextTick(() => fitView({ padding: 0.18, duration: 250 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultNewNodePosition() {
|
||||||
|
const maxX = Math.max(0, ...graph.value.map((item) => typeof item.x === 'number' ? item.x : 0))
|
||||||
|
const maxY = Math.max(0, ...graph.value.map((item) => typeof item.y === 'number' ? item.y : 0))
|
||||||
|
return { x: maxX + 340, y: Math.max(40, maxY + 40) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNode(name: string) {
|
||||||
|
try {
|
||||||
|
const node = createSerializedNode(name, defaultNewNodePosition())
|
||||||
|
graph.value = [...graph.value, node]
|
||||||
|
selectedNodeId.value = node.node.id
|
||||||
|
refreshFromGraph({ fit: true })
|
||||||
|
activeTab.value = 'control'
|
||||||
|
} catch (error) {
|
||||||
|
parseError.value = error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFirstCatalogMatch() {
|
||||||
|
if (firstCatalogMatch.value) addNode(firstCatalogMatch.value.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSelectedNode() {
|
||||||
|
if (!selectedNodeId.value) return
|
||||||
|
graph.value = removeNodeAndConnections(graph.value, selectedNodeId.value)
|
||||||
|
selectedNodeId.value = null
|
||||||
|
refreshFromGraph()
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateSelectedNode() {
|
||||||
|
const source = selectedGraphNode.value
|
||||||
|
if (!source) return
|
||||||
|
const clone = JSON.parse(JSON.stringify(source)) as typeof source
|
||||||
|
clone.node.id = crypto.randomUUID()
|
||||||
|
clone.x = (typeof source.x === 'number' ? source.x : 0) + 60
|
||||||
|
clone.y = (typeof source.y === 'number' ? source.y : 0) + 60
|
||||||
|
for (const input of Object.values(clone.node.options?.in || {})) input.output = null
|
||||||
|
graph.value = [...graph.value, clone]
|
||||||
|
selectedNodeId.value = clone.node.id
|
||||||
|
refreshFromGraph({ fit: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameSelectedNodeId(nextId: string) {
|
||||||
|
const item = selectedGraphNode.value
|
||||||
|
const oldId = item?.node.id
|
||||||
|
const clean = nextId.trim()
|
||||||
|
if (!item || !oldId || !clean || clean === oldId) return
|
||||||
|
if (graph.value.some((entry) => entry.node.id === clean)) {
|
||||||
|
parseError.value = `A node with id "${clean}" already exists.`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.node.id = clean
|
||||||
|
const oldPrefix = `${oldId}-out-`
|
||||||
|
for (const entry of graph.value) {
|
||||||
|
for (const input of Object.values(entry.node.options?.in || {})) {
|
||||||
|
if (input.output?.startsWith(oldPrefix)) input.output = input.output.replace(oldPrefix, `${clean}-out-`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selectedNodeId.value = clean
|
||||||
|
refreshFromGraph()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIoLabel(direction: 'in' | 'out', ioName: string, label: string) {
|
||||||
|
const io = selectedGraphNode.value?.node.options?.[direction]?.[ioName]
|
||||||
|
if (!io) return
|
||||||
|
io.label = label.trim() || null
|
||||||
|
refreshFromGraph()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInputValue(ioName: string, rawValue: string) {
|
||||||
|
const input = selectedGraphNode.value?.node.options?.in?.[ioName]
|
||||||
|
const port = selectedFlowNode.value?.data?.inputs?.find((item) => item.name === ioName)
|
||||||
|
if (!input || !port) return
|
||||||
|
input.value = coerceIoValue(rawValue, port.type)
|
||||||
|
input.output = null
|
||||||
|
refreshFromGraph()
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectInput(ioName: string) {
|
||||||
|
const input = selectedGraphNode.value?.node.options?.in?.[ioName]
|
||||||
|
if (!input) return
|
||||||
|
input.output = null
|
||||||
|
refreshFromGraph()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedNodeId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNodeClick(payload: unknown, maybeNode?: Node) {
|
||||||
|
const node = (maybeNode || (payload as { node?: Node })?.node || payload) as Node | undefined
|
||||||
|
if (node?.id) selectedNodeId.value = node.id
|
||||||
|
}
|
||||||
|
|
||||||
function parseOutputHandle(handle?: string | null) {
|
function parseOutputHandle(handle?: string | null) {
|
||||||
const match = handle?.match(/^(.*):out:(.*)$/)
|
const match = handle?.match(/^(.*):out:(.*)$/)
|
||||||
return match ? { nodeId: match[1], outputName: match[2] } : null
|
return match ? { nodeId: match[1], outputName: match[2] } : null
|
||||||
@@ -339,6 +450,94 @@ refreshFlow()
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card editor-card">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Add node</h2>
|
||||||
|
<button type="button" class="secondary" :disabled="!firstCatalogMatch" @click.stop.prevent="addFirstCatalogMatch">Add first</button>
|
||||||
|
</div>
|
||||||
|
<input v-model="nodeSearch" class="editor-input" type="search" placeholder="Filter node types" />
|
||||||
|
<div class="node-catalog">
|
||||||
|
<button
|
||||||
|
v-for="item in filteredNodeCatalog"
|
||||||
|
:key="item.name"
|
||||||
|
type="button"
|
||||||
|
class="secondary catalog-item"
|
||||||
|
:title="item.runnable ? 'Runnable in this UI' : 'Visual/edit only until optional runtime support is added'"
|
||||||
|
@click.stop.prevent="addNode(item.name)"
|
||||||
|
>
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
<small>{{ item.runnable ? 'run' : 'view' }}</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedGraphNode && selectedFlowNode" class="panel-card editor-card">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>{{ selectedGraphNode.node.name }}</h2>
|
||||||
|
<div class="mini-actions">
|
||||||
|
<button type="button" class="secondary" @click.stop.prevent="duplicateSelectedNode">Duplicate</button>
|
||||||
|
<button type="button" class="danger" @click.stop.prevent="deleteSelectedNode">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="editor-field">
|
||||||
|
Node id
|
||||||
|
<input class="editor-input" :value="selectedGraphNode.node.id" @change="renameSelectedNodeId(($event.target as HTMLInputElement).value)" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div v-if="selectedFlowNode.data.inputs.length" class="io-editor">
|
||||||
|
<h3>Inputs</h3>
|
||||||
|
<div v-for="input in selectedFlowNode.data.inputs" :key="`in-${input.name}`" class="io-editor-row">
|
||||||
|
<div class="io-editor-row__head">
|
||||||
|
<strong>{{ input.name }}</strong>
|
||||||
|
<span class="io-type" :data-type="input.type">{{ input.type }}</span>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
label
|
||||||
|
<input class="editor-input" :value="selectedGraphNode.node.options?.in?.[input.name]?.label || ''" @change="updateIoLabel('in', input.name, ($event.target as HTMLInputElement).value)" />
|
||||||
|
</label>
|
||||||
|
<label v-if="input.type !== 'Image'">
|
||||||
|
value
|
||||||
|
<input
|
||||||
|
v-if="input.type !== 'Boolean'"
|
||||||
|
class="editor-input"
|
||||||
|
:type="input.type === 'Number' ? 'number' : input.type === 'Color' ? 'color' : 'text'"
|
||||||
|
:value="displayIoValue(selectedGraphNode.node.options?.in?.[input.name]?.value)"
|
||||||
|
@change="updateInputValue(input.name, ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
v-else
|
||||||
|
class="editor-input"
|
||||||
|
:value="String(Boolean(selectedGraphNode.node.options?.in?.[input.name]?.value))"
|
||||||
|
@change="updateInputValue(input.name, ($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option value="false">false</option>
|
||||||
|
<option value="true">true</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button v-if="input.output" type="button" class="secondary" @click="disconnectInput(input.name)">Disconnect</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedFlowNode.data.outputs.length" class="io-editor">
|
||||||
|
<h3>Outputs</h3>
|
||||||
|
<div v-for="output in selectedFlowNode.data.outputs" :key="`out-${output.name}`" class="io-editor-row">
|
||||||
|
<div class="io-editor-row__head">
|
||||||
|
<strong>{{ output.name }}</strong>
|
||||||
|
<span class="io-type" :data-type="output.type">{{ output.type }}</span>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
label
|
||||||
|
<input class="editor-input" :value="selectedGraphNode.node.options?.out?.[output.name]?.label || ''" @change="updateIoLabel('out', output.name, ($event.target as HTMLInputElement).value)" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="panel-card editor-card muted">
|
||||||
|
Select a node to edit labels, values, duplicate, or delete it.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<button type="button" class="run-button" @click="runGraph">Run graph</button>
|
<button type="button" class="run-button" @click="runGraph">Run graph</button>
|
||||||
<p class="status-line">{{ runtimeStatus }}</p>
|
<p class="status-line">{{ runtimeStatus }}</p>
|
||||||
@@ -428,6 +627,8 @@ refreshFlow()
|
|||||||
:max-zoom="1.6"
|
:max-zoom="1.6"
|
||||||
fit-view-on-init
|
fit-view-on-init
|
||||||
@connect="onConnect"
|
@connect="onConnect"
|
||||||
|
@node-click="onNodeClick"
|
||||||
|
@pane-click="clearSelection"
|
||||||
@node-drag-stop="onNodeDragStop"
|
@node-drag-stop="onNodeDragStop"
|
||||||
@edge-double-click="onEdgeDoubleClick"
|
@edge-double-click="onEdgeDoubleClick"
|
||||||
>
|
>
|
||||||
|
|||||||
95
graphfx-ui/src/lib/editorHelpers.ts
Normal file
95
graphfx-ui/src/lib/editorHelpers.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { graphFxNodeDefinitions, type GraphFxIoType, type GraphFxNodeDefinition } from './nodeDefinitions'
|
||||||
|
import type { GraphFxIoValue, GraphFxSerializedGraph, GraphFxSerializedNode } from './graphfxToVueFlow'
|
||||||
|
|
||||||
|
export type NodeCatalogItem = {
|
||||||
|
name: string
|
||||||
|
runnable: boolean
|
||||||
|
definition: GraphFxNodeDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeSupported = new Set([
|
||||||
|
'Resize', 'Compose', 'Fill', 'Flip', 'EmptySpace', 'Text', 'Rotate', 'Webcam', 'Api', 'ApiJson', 'Eval',
|
||||||
|
'QRCodeDetector', 'MarkerDetector', 'WaitForAll', 'QRCodeGenerator', 'Base64ToImg', 'ImgToBase64', 'Font',
|
||||||
|
'Disable', 'Numbers', 'NumberBinaryOperation', 'BrightnessContrast', 'HSV', 'Sepia', 'GreenScreen', 'Channels',
|
||||||
|
'GradientMap', 'ColorLookupTable', 'Vignette', 'Blur', 'SoftEdge', 'Edge', 'KernelPreset', 'Levels', 'LevelsAuto',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const nodeCatalog: NodeCatalogItem[] = Object.entries(graphFxNodeDefinitions)
|
||||||
|
.map(([name, definition]) => ({ name, definition, runnable: runtimeSupported.has(name) }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
|
||||||
|
export function defaultValueForType(type: GraphFxIoType): unknown {
|
||||||
|
switch (type) {
|
||||||
|
case 'Number': return 0
|
||||||
|
case 'String': return ''
|
||||||
|
case 'Boolean': return false
|
||||||
|
case 'Color': return '#ffffff'
|
||||||
|
case 'Font': return null
|
||||||
|
case 'Image': return null
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeInput(type: GraphFxIoType): GraphFxIoValue {
|
||||||
|
return { label: null, value: defaultValueForType(type), output: null, type }
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeOutput(type: GraphFxIoType): GraphFxIoValue {
|
||||||
|
return { label: null, type }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSerializedNode(name: string, position = { x: 0, y: 0 }): GraphFxSerializedNode {
|
||||||
|
const definition = graphFxNodeDefinitions[name]
|
||||||
|
if (!definition) throw new Error(`Unknown node type "${name}".`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
node: {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name,
|
||||||
|
options: {
|
||||||
|
in: Object.fromEntries(Object.entries(definition.in || {}).map(([io, type]) => [io, makeInput(type)])),
|
||||||
|
out: Object.fromEntries(Object.entries(definition.out || {}).map(([io, type]) => [io, makeOutput(type)])),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
x: Math.round(position.x),
|
||||||
|
y: Math.round(position.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coerceIoValue(value: string, type: GraphFxIoType): unknown {
|
||||||
|
switch (type) {
|
||||||
|
case 'Number': {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
|
}
|
||||||
|
case 'Boolean': return value === 'true'
|
||||||
|
case 'Image': return null
|
||||||
|
case 'Font': return value ? value : null
|
||||||
|
case 'Color':
|
||||||
|
case 'String':
|
||||||
|
case 'Unknown':
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayIoValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return ''
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeNodeAndConnections(graph: GraphFxSerializedGraph, nodeId: string): GraphFxSerializedGraph {
|
||||||
|
const removedOutputPrefix = `${nodeId}-out-`
|
||||||
|
return graph
|
||||||
|
.filter((item) => item.node.id !== nodeId)
|
||||||
|
.map((item) => {
|
||||||
|
for (const input of Object.values(item.node.options?.in || {})) {
|
||||||
|
if (input.output?.startsWith(removedOutputPrefix) || input.output?.startsWith(`${nodeId}-`)) {
|
||||||
|
input.output = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -254,6 +254,131 @@ code {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.editor-card {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-input {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgb(255 255 255 / 12%);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.5rem 0.62rem;
|
||||||
|
color: #f5f8ff;
|
||||||
|
background: #101827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-field,
|
||||||
|
.io-editor-row label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.28rem;
|
||||||
|
color: #9eb0cf;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 850;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-catalog {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.42rem;
|
||||||
|
max-height: 190px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.48rem 0.55rem;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-item span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-item small {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #7f93b7;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.38rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-actions button,
|
||||||
|
.io-editor-row > button {
|
||||||
|
padding: 0.42rem 0.56rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: linear-gradient(135deg, #ff6b6b, #ff9f68);
|
||||||
|
color: #170707;
|
||||||
|
box-shadow: 0 12px 28px rgb(255 107 107 / 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.io-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.io-editor h3 {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: #d8e3fb;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.io-editor-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.58rem;
|
||||||
|
border: 1px solid rgb(255 255 255 / 8%);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgb(5 9 19 / 58%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.io-editor-row__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.io-editor-row__head strong {
|
||||||
|
color: #f8fbff;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.io-type {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.12rem 0.42rem;
|
||||||
|
color: #06111c;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 950;
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.io-type[data-type="Image"] { background: #4dd7ff; }
|
||||||
|
.io-type[data-type="Number"] { background: #f6c85f; }
|
||||||
|
.io-type[data-type="String"] { background: #9ee66f; }
|
||||||
|
.io-type[data-type="Boolean"] { background: #c084fc; }
|
||||||
|
.io-type[data-type="Color"] { background: #ff78b7; }
|
||||||
|
.io-type[data-type="Font"] { background: #fb923c; }
|
||||||
|
|
||||||
.status-line,
|
.status-line,
|
||||||
.muted {
|
.muted {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user