mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 00:00:46 +00:00
04b47bf359
After merging main's UserDraft PR (#9121) into the sessions branch, two integration issues surfaced: 1. AppEditor.svelte calls `UserDraft.use<App>('app', path)` at the component level — keyed by ($workspaceStore, 'app', path). Sessions that haven't materialized a fork yet stay at the user's main workspace, so a session targeting an app at the same path as a regular /apps/edit tab shared the same LS key. The session would read the regular tab's autosave and write its fork-edits back over it. Gate UserDraft.use on `!getContext('aiChatManager')` — sessions inject the manager via setContext, so inside a session pane the handle is `undefined`, stateApp falls through to the `app` prop the session loaded, and the auto-save $effect bails. Same gate on the four UserDraft.remove call sites in AppEditorHeader and RawAppEditorHeader so save/deploy from a session pane doesn't wipe the LS draft of a non-session tab at the same path. 2. sessionUnread.svelte.ts called useLocalStorageValue at module scope. Main's PR added a deep-mutation $effect inside that helper, which now requires component-initialization context — every page crashed at import time with `Svelte error: effect_orphan`. Replaced with a plain module-level $state + manual localStorage persist; same reactivity contract for callers. 3. ScriptEditorView.svelte was passing a `replaceStateFn` prop that ScriptBuilder dropped on main. Removed. Verified end-to-end with Playwright: - /flows/edit/{path} regression: UserDraft handle still created, no console errors - /sessions loads, sessionUnread doesn't crash - Session targeting non-raw app `u/admin/userdraft_collision_test` displays the fork content (FORK_ONLY_MARKER) even with an LS poison at `userdraft/w/local/app/{path}` containing a POISONED_BY_REGULAR_TAB_AUTOSAVE marker; poison remains untouched after the session loads and renders Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1342 lines
37 KiB
Svelte
1342 lines
37 KiB
Svelte
<script lang="ts">
|
|
import { createBubbler, stopPropagation } from 'svelte/legacy'
|
|
|
|
const bubble = createBubbler()
|
|
import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte'
|
|
import { getContext, onMount, setContext, untrack } from 'svelte'
|
|
import { twMerge } from 'tailwind-merge'
|
|
|
|
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
|
import { writable, type Writable } from 'svelte/store'
|
|
import { buildWorld } from '../rx'
|
|
import type {
|
|
App,
|
|
AppEditorContext,
|
|
AppEditorProps,
|
|
AppViewerContext,
|
|
ConnectingInput,
|
|
ContextPanelContext,
|
|
EditorBreakpoint,
|
|
EditorMode,
|
|
FocusedGrid
|
|
} from '../types'
|
|
import AppEditorHeader from './AppEditorHeader.svelte'
|
|
import GridEditor from './GridEditor.svelte'
|
|
|
|
import { Alert, Button, Tab } from '$lib/components/common'
|
|
import TabContent from '$lib/components/common/tabs/TabContent.svelte'
|
|
import Tabs from '$lib/components/common/tabs/TabsV2.svelte'
|
|
import { userStore, workspaceStore } from '$lib/stores'
|
|
import {
|
|
classNames,
|
|
getModifierKey,
|
|
readFieldsRecursively,
|
|
sendUserToast,
|
|
urlParamsToObject
|
|
} from '$lib/utils'
|
|
import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte'
|
|
import AppPreview from './AppPreview.svelte'
|
|
import ComponentList from './componentsPanel/ComponentList.svelte'
|
|
import ContextPanel from './contextPanel/ContextPanel.svelte'
|
|
|
|
import ItemPicker from '$lib/components/ItemPicker.svelte'
|
|
import VariableEditor from '$lib/components/VariableEditor.svelte'
|
|
import { VariableService } from '$lib/gen'
|
|
import { initHistory } from '$lib/history.svelte'
|
|
import { Component, Minus, Paintbrush, Plus, Smartphone, Scan, Hand, Grab } from 'lucide-svelte'
|
|
import { animateTo, findGridItemParentGrid } from './appUtils'
|
|
import ComponentNavigation from './component/ComponentNavigation.svelte'
|
|
import CssSettings from './componentsPanel/CssSettings.svelte'
|
|
import SettingsPanel from './SettingsPanel.svelte'
|
|
import {
|
|
SecondaryMenu,
|
|
secondaryMenuLeft,
|
|
secondaryMenuLeftStore,
|
|
secondaryMenuRightStore
|
|
} from './settingsPanel/secondaryMenu'
|
|
import Popover from '../../Popover.svelte'
|
|
import { migrateApp } from '../utils'
|
|
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
|
|
import { getTheme } from './componentsPanel/themeUtils'
|
|
import StylePanel from './settingsPanel/StylePanel.svelte'
|
|
import HideButton from './settingsPanel/HideButton.svelte'
|
|
import AppEditorBottomPanel from './AppEditorBottomPanel.svelte'
|
|
import panzoom from 'panzoom'
|
|
import { BG_PREFIX, findGridItem } from './appUtilsCore'
|
|
|
|
let {
|
|
app,
|
|
path,
|
|
policy,
|
|
summary,
|
|
fromHub = false,
|
|
diffDrawer = undefined,
|
|
savedApp = $bindable(undefined),
|
|
version = undefined,
|
|
newApp = false,
|
|
newPath = undefined,
|
|
replaceStateFn = (path: string) => window.history.replaceState(null, '', path),
|
|
gotoFn = (path: string, opt?: Record<string, any>) => window.history.pushState(null, '', path),
|
|
unsavedConfirmationModal,
|
|
onSavedNewAppPath,
|
|
onNavigate,
|
|
initialRevs
|
|
}: AppEditorProps = $props()
|
|
|
|
migrateApp(untrack(() => app))
|
|
|
|
// Inside a session pane the AIChatManager is injected via context. Sessions
|
|
// have their own state machinery (sessionRuntime + per-fork backend), and
|
|
// the user-facing $workspaceStore stays on the main workspace even when
|
|
// the session is editing in a fork — so a UserDraft handle here would
|
|
// share its LS key with the regular /apps/edit route and clobber both
|
|
// sides' autosaves. Skip UserDraft entirely in that case.
|
|
const inSessionPane = !!getContext('aiChatManager')
|
|
|
|
const appDraftPath = newApp ? '' : (path ?? '')
|
|
const appDraftHandle = inSessionPane ? undefined : UserDraft.use<App>('app', appDraftPath)
|
|
// Prefer the persisted autosave over the prop when both exist (e.g.
|
|
// /apps/add reload: the route always initializes `app` to an empty
|
|
// template, but the user's last session is sitting in LS under the
|
|
// empty-path entry). The route is responsible for wiping the entry
|
|
// (`UserDraft.remove`) when it wants to force a fresh start —
|
|
// `?nodraft=true`, template/hub loads, etc.
|
|
const stateApp = $state(untrack(() => appDraftHandle?.draft ?? app))
|
|
const appStore = writable<App>(stateApp)
|
|
// Captured once on mount: the load-time revs are only used as the
|
|
// seed meta on the very first persist of this entry. After that the
|
|
// handle's own meta wins.
|
|
const capturedInitialRevs = untrack(() => initialRevs)
|
|
// `useLocalStorageValue`'s `saveInitialValue: false` skips the first
|
|
// `set val` that DIFFERS from the loaded LS state — meant to absorb a
|
|
// route's "load baseline" write. In AppEditor's $effect-mirror pattern
|
|
// the loaded baseline always matches LS (stateApp is initialised from
|
|
// the handle's draft), so the skip slot survives until the user's
|
|
// FIRST edit and silently swallows it. Consume the slot up-front with
|
|
// a wipe-then-restore pair: the wipe sets state.val = undefined
|
|
// in-memory (the consumption side-effect of skipNextWrite, which
|
|
// suppresses the localStorage delete the wipe would otherwise schedule),
|
|
// and the restore immediately puts the value+meta back. Net effect: LS
|
|
// gets re-written once on mount and user edits persist normally.
|
|
let firstMirror = true
|
|
$effect(() => {
|
|
readFieldsRecursively(stateApp)
|
|
if (!appDraftHandle) return
|
|
untrack(() => {
|
|
// Resolve the meta to attach BEFORE the wipe — the wipe clears
|
|
// in-memory meta and would otherwise force-seed `initialRevs`
|
|
// even when the handle had real meta.
|
|
const currentMeta = appDraftHandle.meta
|
|
const hasMeta =
|
|
currentMeta.remoteRev !== undefined || currentMeta.remoteDraftRev !== undefined
|
|
const meta: UserDraftMeta = hasMeta ? currentMeta : (capturedInitialRevs ?? {})
|
|
if (firstMirror) {
|
|
firstMirror = false
|
|
appDraftHandle.setDraftAndMeta(undefined, {})
|
|
}
|
|
appDraftHandle.setDraftAndMeta(stateApp, meta)
|
|
})
|
|
})
|
|
const selectedComponent = writable<string[] | undefined>(undefined)
|
|
|
|
// $: selectedComponent.subscribe((s) => {
|
|
// console.log('selectedComponent', s)
|
|
// })
|
|
const mode = writable<EditorMode>('dnd')
|
|
const breakpoint = writable<EditorBreakpoint>('lg')
|
|
const summaryStore = writable(untrack(() => summary))
|
|
const connectingInput = writable<ConnectingInput>({
|
|
opened: false,
|
|
input: undefined,
|
|
hoveredComponent: undefined
|
|
})
|
|
|
|
const cssEditorOpen = writable<boolean>(false)
|
|
|
|
const history = initHistory(untrack(() => app))
|
|
|
|
const jobsById = writable<
|
|
Record<
|
|
string,
|
|
{
|
|
job: string
|
|
component: string
|
|
result?: string
|
|
transformer?: { result?: string; error?: string }
|
|
started_at?: number
|
|
duration_ms?: number
|
|
}
|
|
>
|
|
>({})
|
|
const focusedGrid = writable<FocusedGrid | undefined>(undefined)
|
|
const pickVariableCallback: Writable<((path: string) => void) | undefined> = writable(undefined)
|
|
let context = $state({
|
|
email: $userStore?.email,
|
|
groups: $userStore?.groups,
|
|
username: $userStore?.username,
|
|
name: $userStore?.name,
|
|
query: urlParamsToObject(new URL(window.location.href).searchParams),
|
|
hash: window.location.hash.substring(1),
|
|
workspace: $workspaceStore,
|
|
mode: 'editor',
|
|
summary: $summaryStore,
|
|
author: untrack(() => policy).on_behalf_of_email
|
|
})
|
|
const darkMode: Writable<boolean> = writable(document.documentElement.classList.contains('dark'))
|
|
|
|
const worldStore = buildWorld(untrack(() => context))
|
|
const previewTheme: Writable<string | undefined> = writable(undefined)
|
|
const initialized = writable({
|
|
initialized: false,
|
|
initializedComponents: [],
|
|
runnableInitialized: {}
|
|
})
|
|
const panzoomActive = writable(false)
|
|
|
|
summaryStore.subscribe((s) => {
|
|
$worldStore?.outputsById['ctx'].summary.set(s)
|
|
})
|
|
|
|
$secondaryMenuRightStore.isOpen = false
|
|
$secondaryMenuLeftStore.isOpen = false
|
|
|
|
let writablePath = writable(untrack(() => path))
|
|
|
|
function onPathChange() {
|
|
writablePath.set(path)
|
|
}
|
|
|
|
setContext<AppViewerContext>('AppViewerContext', {
|
|
worldStore,
|
|
app: appStore,
|
|
summary: summaryStore,
|
|
initialized: initialized,
|
|
selectedComponent,
|
|
mode,
|
|
connectingInput,
|
|
bgRuns: writable([]),
|
|
breakpoint,
|
|
runnableComponents: writable({}),
|
|
appPath: writablePath,
|
|
workspace: $workspaceStore ?? '',
|
|
onchange: undefined,
|
|
isEditor: true,
|
|
jobs: writable([]),
|
|
staticExporter: writable({}),
|
|
noBackend: false,
|
|
errorByComponent: writable({}),
|
|
jobsById,
|
|
openDebugRun: writable(undefined),
|
|
focusedGrid,
|
|
stateId: writable(0),
|
|
parentWidth: writable(0),
|
|
state: writable({}),
|
|
componentControl: writable({}),
|
|
hoverStore: writable(undefined),
|
|
allIdsInPath: writable([]),
|
|
darkMode,
|
|
cssEditorOpen,
|
|
previewTheme,
|
|
debuggingComponents: writable({}),
|
|
replaceStateFn: untrack(() => replaceStateFn),
|
|
policy: untrack(() => policy),
|
|
recomputeAllContext: writable({
|
|
loading: false,
|
|
componentNumber: 0,
|
|
refreshing: [],
|
|
progress: 100
|
|
}),
|
|
panzoomActive
|
|
})
|
|
|
|
let scale = writable(100)
|
|
|
|
const componentActive = writable(false)
|
|
|
|
let yTop = writable(0)
|
|
|
|
let runnableJob = writable({ focused: false, jobs: {}, frontendJobs: {}, width: 100 })
|
|
setContext<AppEditorContext>('AppEditorContext', {
|
|
yTop,
|
|
runnableJobEditorPanel: runnableJob,
|
|
evalPreview: writable({}),
|
|
componentActive,
|
|
dndItem: writable({}),
|
|
refreshComponents: writable(undefined),
|
|
history,
|
|
pickVariableCallback,
|
|
movingcomponents: writable(undefined),
|
|
selectedComponentInEditor: writable(undefined),
|
|
jobsDrawerOpen: writable(false),
|
|
scale,
|
|
stylePanel: () => StylePanel
|
|
})
|
|
|
|
function hashchange(e: HashChangeEvent) {
|
|
context.hash = e.newURL.split('#')[1]
|
|
context = context
|
|
}
|
|
|
|
let selectedTab: 'insert' | 'settings' | 'css' = $state('insert')
|
|
|
|
let befSelected: string | undefined = $state(undefined)
|
|
|
|
function onSelectedComponentChange() {
|
|
selectedTab = 'settings'
|
|
if (befSelected) {
|
|
if (!['ctx', 'state'].includes(befSelected) && !befSelected?.startsWith(BG_PREFIX)) {
|
|
let item = findGridItem($appStore, befSelected)
|
|
if (
|
|
item?.data.type === 'containercomponent' ||
|
|
item?.data.type === 'listcomponent' ||
|
|
item?.data.type === 'modalcomponent'
|
|
) {
|
|
$focusedGrid = {
|
|
parentComponentId: befSelected,
|
|
subGridIndex: 0
|
|
}
|
|
} else if (item?.data.type === 'steppercomponent') {
|
|
$focusedGrid = {
|
|
parentComponentId: befSelected,
|
|
subGridIndex:
|
|
($worldStore.outputsById?.[befSelected]?.currentStepIndex?.peak() as number) ?? 0
|
|
}
|
|
} else if (
|
|
item?.data.type === 'tabscomponent' ||
|
|
item?.data.type === 'conditionalwrapper'
|
|
) {
|
|
$focusedGrid = {
|
|
parentComponentId: befSelected,
|
|
subGridIndex:
|
|
($worldStore.outputsById?.[befSelected]?.selectedTabIndex?.peak() as number) ?? 0
|
|
}
|
|
} else {
|
|
let subgrid = findGridItemParentGrid($appStore, befSelected)
|
|
if (subgrid) {
|
|
try {
|
|
$focusedGrid = {
|
|
parentComponentId: subgrid.split('-')[0],
|
|
subGridIndex: parseInt(subgrid.split('-')[1])
|
|
}
|
|
} catch {}
|
|
} else {
|
|
if ($focusedGrid?.parentComponentId !== befSelected) {
|
|
$focusedGrid = undefined
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let itemPicker: ItemPicker | undefined = $state(undefined)
|
|
|
|
let variableEditor: VariableEditor | undefined = $state(undefined)
|
|
|
|
setContext<ContextPanelContext>('ContextPanel', {
|
|
search: writable<string>(''),
|
|
manuallyOpened: writable<Record<string, boolean>>({}),
|
|
hasResult: writable<Record<string, boolean>>({})
|
|
})
|
|
|
|
function onThemeChange() {
|
|
$darkMode = document.documentElement.classList.contains('dark')
|
|
}
|
|
|
|
let runnablePanelSize = $state(30)
|
|
let gridPanelSize = $state(70)
|
|
|
|
let leftPanelSize = $state(22)
|
|
let centerPanelSize = $state(56)
|
|
let rightPanelSize = $state(22)
|
|
|
|
let tmpRunnablePanelSize = -1
|
|
let tmpGridPanelSize = -1
|
|
|
|
let tmpLeftPanelSize = -1
|
|
let tmpCenterPanelSize = -1
|
|
let tmpRightPanelSize = -1
|
|
|
|
let toggled = false
|
|
let cssToggled = false
|
|
|
|
function animateCssInput(cssEditorOpen: boolean) {
|
|
if (cssEditorOpen && !cssToggled) {
|
|
cssToggled = true
|
|
|
|
tmpLeftPanelSize = leftPanelSize
|
|
tmpCenterPanelSize = centerPanelSize
|
|
tmpRightPanelSize = rightPanelSize
|
|
|
|
animateTo(leftPanelSize, 20, (newValue: number) => (leftPanelSize = newValue))
|
|
animateTo(centerPanelSize, 55, (newValue: number) => (centerPanelSize = newValue))
|
|
animateTo(rightPanelSize, 25, (newValue: number) => (rightPanelSize = newValue))
|
|
|
|
tmpRunnablePanelSize = runnablePanelSize
|
|
tmpGridPanelSize = gridPanelSize
|
|
|
|
animateTo(runnablePanelSize, 0, (newValue: number) => (runnablePanelSize = newValue))
|
|
animateTo(gridPanelSize, 100, (newValue: number) => (gridPanelSize = newValue))
|
|
} else if (!cssEditorOpen && cssToggled) {
|
|
cssToggled = false
|
|
|
|
animateTo(leftPanelSize, tmpLeftPanelSize, (newValue: number) => (leftPanelSize = newValue))
|
|
animateTo(
|
|
centerPanelSize,
|
|
tmpCenterPanelSize,
|
|
(newValue: number) => (centerPanelSize = newValue)
|
|
)
|
|
animateTo(
|
|
rightPanelSize,
|
|
tmpRightPanelSize,
|
|
(newValue: number) => (rightPanelSize = newValue)
|
|
)
|
|
|
|
tmpLeftPanelSize = -1
|
|
tmpCenterPanelSize = -1
|
|
tmpRightPanelSize = -1
|
|
|
|
animateTo(
|
|
runnablePanelSize,
|
|
tmpRunnablePanelSize,
|
|
(newValue: number) => (runnablePanelSize = newValue)
|
|
)
|
|
animateTo(gridPanelSize, tmpGridPanelSize, (newValue: number) => (gridPanelSize = newValue))
|
|
|
|
tmpRunnablePanelSize = -1
|
|
tmpGridPanelSize = -1
|
|
}
|
|
}
|
|
|
|
function selectCss() {
|
|
selectedTab !== 'css' && (selectedTab = 'css')
|
|
}
|
|
|
|
const cssId = 'wm-global-style'
|
|
|
|
let css: string | undefined = $state(undefined)
|
|
|
|
let lastTheme: string | undefined = undefined
|
|
appStore.subscribe(async (currentAppStore) => {
|
|
if (!currentAppStore.theme) {
|
|
return
|
|
}
|
|
|
|
if (JSON.stringify(currentAppStore.theme) != lastTheme) {
|
|
if (currentAppStore.theme.type === 'inlined') {
|
|
css = currentAppStore.theme.css
|
|
} else if (currentAppStore.theme.type === 'path' && currentAppStore.theme?.path) {
|
|
let loadedCss = await getTheme($workspaceStore!, currentAppStore.theme.path)
|
|
if (loadedCss) {
|
|
css = loadedCss.value
|
|
}
|
|
}
|
|
lastTheme = JSON.stringify(currentAppStore.theme)
|
|
}
|
|
})
|
|
|
|
function addOrRemoveCss(isPremium: boolean, isPreview: boolean = false) {
|
|
const existingElement = document.getElementById(cssId)
|
|
|
|
if (!isPremium && isPreview) {
|
|
if (existingElement) {
|
|
existingElement.remove()
|
|
}
|
|
} else {
|
|
if (!existingElement) {
|
|
const head = document.head
|
|
const link = document.createElement('style')
|
|
link.id = cssId
|
|
link.innerHTML = css ?? ''
|
|
head.appendChild(link)
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateCssContent(cssString: string | undefined, previewTheme: string | undefined) {
|
|
const theme = previewTheme ?? cssString ?? ''
|
|
|
|
const existingElement = document.getElementById(cssId)
|
|
if (existingElement && theme !== existingElement.innerHTML) {
|
|
existingElement.innerHTML = theme
|
|
}
|
|
}
|
|
|
|
let appEditorHeader: AppEditorHeader | undefined = $state(undefined)
|
|
|
|
export function triggerTutorial() {
|
|
const urlParams = new URLSearchParams(window.location.search)
|
|
const tutorial = urlParams.get('tutorial')
|
|
|
|
if (tutorial) {
|
|
appEditorHeader?.runTutorialById(tutorial)
|
|
}
|
|
}
|
|
|
|
let box: HTMLElement | undefined = $state(undefined)
|
|
function parseScroll() {
|
|
$yTop = box?.scrollTop ?? 0
|
|
// console.log('parse scroll', $yTop)
|
|
}
|
|
|
|
let mounted = false
|
|
onMount(() => {
|
|
mounted = true
|
|
|
|
setTimeout(() => {
|
|
if ($initialized?.initialized === false) {
|
|
sendUserToast(
|
|
'App is not yet initialized, please check the Troubleshoot Panel for more information',
|
|
true,
|
|
[
|
|
{
|
|
label: 'Open troubleshoot panel',
|
|
callback: () => {
|
|
appEditorHeader?.openTroubleshootPanel()
|
|
}
|
|
}
|
|
]
|
|
)
|
|
}
|
|
}, 10000)
|
|
|
|
parseScroll()
|
|
})
|
|
|
|
let lastComponentActive = false
|
|
|
|
$effect(() => {
|
|
if ($componentActive != lastComponentActive) {
|
|
if (box) {
|
|
box.scrollTop = $yTop
|
|
}
|
|
lastComponentActive = $componentActive
|
|
}
|
|
})
|
|
|
|
function setGridPanelSize(x: boolean) {
|
|
if (mounted) {
|
|
if (x) {
|
|
gridPanelSize = 100
|
|
} else {
|
|
gridPanelSize = 100 - runnablePanelSize
|
|
}
|
|
}
|
|
}
|
|
|
|
let runnableJobEnterTimeout: number | undefined = $state(undefined)
|
|
let stillInJobEnter = $state(false)
|
|
let storedLeftPanelSize = 0
|
|
let storedRightPanelSize = 0
|
|
let storedBottomPanelSize = 0
|
|
|
|
let centerPanelWidth = $state(0)
|
|
|
|
function hideLeftPanel(animate: boolean = false) {
|
|
storedLeftPanelSize = leftPanelSize
|
|
if (animate) {
|
|
animateTo(leftPanelSize, 0, (newValue: number) => (leftPanelSize = newValue))
|
|
} else {
|
|
leftPanelSize = 0
|
|
}
|
|
centerPanelSize = centerPanelSize + storedLeftPanelSize
|
|
if ($connectingInput.opened) {
|
|
$connectingInput.opened = false
|
|
}
|
|
}
|
|
|
|
function hideRightPanel() {
|
|
storedRightPanelSize = rightPanelSize
|
|
rightPanelSize = 0
|
|
centerPanelSize = centerPanelSize + storedRightPanelSize
|
|
if ($connectingInput.opened) {
|
|
$connectingInput.opened = false
|
|
}
|
|
}
|
|
|
|
function hideBottomPanel(animate: boolean = false) {
|
|
if (runnablePanelSize === 0) {
|
|
return
|
|
}
|
|
storedBottomPanelSize = runnablePanelSize
|
|
if (animate) {
|
|
tmpRunnablePanelSize = runnablePanelSize
|
|
tmpGridPanelSize = gridPanelSize
|
|
|
|
animateTo(runnablePanelSize, 0, (newValue: number) => (runnablePanelSize = newValue))
|
|
animateTo(gridPanelSize, 100, (newValue: number) => (gridPanelSize = newValue))
|
|
} else {
|
|
runnablePanelSize = 0
|
|
gridPanelSize = 99
|
|
}
|
|
}
|
|
|
|
function showLeftPanel(animate: boolean = false) {
|
|
if (leftPanelSize !== 0) {
|
|
return
|
|
}
|
|
if (animate) {
|
|
animateTo(
|
|
leftPanelSize,
|
|
storedLeftPanelSize,
|
|
(newValue: number) => (leftPanelSize = newValue)
|
|
)
|
|
} else {
|
|
leftPanelSize = storedLeftPanelSize
|
|
}
|
|
}
|
|
|
|
function showRightPanel() {
|
|
rightPanelSize = storedRightPanelSize
|
|
centerPanelSize = centerPanelSize - storedRightPanelSize
|
|
}
|
|
|
|
function showBottomPanel(animate: boolean = false) {
|
|
if (runnablePanelSize !== 0) {
|
|
return
|
|
}
|
|
if (animate) {
|
|
animateTo(
|
|
runnablePanelSize,
|
|
storedBottomPanelSize,
|
|
(newValue: number) => (runnablePanelSize = newValue)
|
|
)
|
|
animateTo(
|
|
gridPanelSize,
|
|
gridPanelSize - storedBottomPanelSize,
|
|
(newValue: number) => (gridPanelSize = newValue)
|
|
)
|
|
} else {
|
|
runnablePanelSize = storedBottomPanelSize
|
|
gridPanelSize = gridPanelSize - storedBottomPanelSize
|
|
}
|
|
}
|
|
|
|
function keydown(event: KeyboardEvent) {
|
|
let classes = event.target?.['className']
|
|
if (
|
|
(typeof classes === 'string' && classes.includes('inputarea')) ||
|
|
['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName!)
|
|
) {
|
|
return
|
|
}
|
|
|
|
isModifierKeyPressed = event.ctrlKey || event.metaKey
|
|
|
|
switch (event.key) {
|
|
case 'b': {
|
|
if (event.ctrlKey || event.metaKey) {
|
|
event.preventDefault()
|
|
|
|
if (leftPanelSize !== 0) {
|
|
hideLeftPanel()
|
|
} else {
|
|
showLeftPanel()
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'u': {
|
|
if (event.ctrlKey || event.metaKey) {
|
|
event.preventDefault()
|
|
|
|
if (rightPanelSize !== 0) {
|
|
hideRightPanel()
|
|
} else {
|
|
showRightPanel()
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'l': {
|
|
if (event.ctrlKey || event.metaKey) {
|
|
event.preventDefault()
|
|
|
|
if (runnablePanelSize !== 0) {
|
|
hideBottomPanel()
|
|
} else {
|
|
showBottomPanel()
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'Escape': {
|
|
if ($connectingInput.opened) {
|
|
$connectingInput.opened = false
|
|
}
|
|
if (handMode) {
|
|
handMode = false
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
let previousLeftPanelHidden: boolean = false
|
|
let previousBottomPanelHidden: boolean = false
|
|
|
|
async function updatePannelInConnecting() {
|
|
if ($connectingInput.opened && !toggled) {
|
|
previousLeftPanelHidden = leftPanelSize === 0
|
|
previousBottomPanelHidden = runnablePanelSize === 0
|
|
if (previousLeftPanelHidden) {
|
|
showLeftPanel(true)
|
|
}
|
|
if (!previousBottomPanelHidden) {
|
|
hideBottomPanel(true)
|
|
}
|
|
secondaryMenuLeft.close()
|
|
toggled = true
|
|
} else if (!$connectingInput.opened && toggled) {
|
|
if (previousLeftPanelHidden) {
|
|
hideLeftPanel(true)
|
|
}
|
|
if (!previousBottomPanelHidden) {
|
|
showBottomPanel(true)
|
|
}
|
|
toggled = false
|
|
}
|
|
}
|
|
|
|
function updateCursorStyle(disabled: boolean) {
|
|
if (disabled) {
|
|
// Select all elements that don't have data-connection-button and aren't children of elements with data-connection-button
|
|
const elements = document.querySelectorAll(
|
|
':not([data-connection-button]):not([data-connection-button] *)'
|
|
)
|
|
elements.forEach((element) => {
|
|
;(element as HTMLElement).style.cursor = 'not-allowed'
|
|
})
|
|
} else {
|
|
// Reset cursor style for all elements
|
|
const elements = document.querySelectorAll('*')
|
|
elements.forEach((element) => {
|
|
;(element as HTMLElement).style.removeProperty('cursor')
|
|
})
|
|
}
|
|
}
|
|
|
|
let instance: any
|
|
let isModifierKeyPressed = $state(false)
|
|
function resetView() {
|
|
if (instance && box) {
|
|
instance.moveTo(0.5, 0)
|
|
instance.zoomAbs(0.5, 0, 1)
|
|
box.scrollTop = 0
|
|
}
|
|
}
|
|
|
|
function zoomTo(x: number, y: number, scale: number) {
|
|
if (instance) {
|
|
instance.zoomAbs(x, y, scale)
|
|
}
|
|
}
|
|
|
|
function initPanzoom(node: HTMLElement) {
|
|
instance = panzoom(node, {
|
|
bounds: true,
|
|
boundsPadding: 0.1,
|
|
maxZoom: 1.5,
|
|
minZoom: 0.3,
|
|
zoomDoubleClickSpeed: 1,
|
|
smoothScroll: false,
|
|
initialZoom: $scale / 100.0,
|
|
beforeMouseDown: (e) => {
|
|
if (e.ctrlKey || e.metaKey || handMode) {
|
|
// Prevent event propagation to children when panning
|
|
e.stopPropagation()
|
|
return false
|
|
}
|
|
return true
|
|
},
|
|
beforeWheel: (e) => {
|
|
if (e.ctrlKey || e.metaKey || handMode) {
|
|
// Prevent event propagation to children when zooming
|
|
e.stopPropagation()
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
})
|
|
|
|
// Handle pointerdown when connecting
|
|
node.addEventListener('pointerdown_connecting', (e) => {
|
|
instance.handleDown(e)
|
|
})
|
|
|
|
// Update scale store when zoom changes
|
|
instance.on('zoom', (e) => {
|
|
const currentScale = e.getTransform().scale * 100
|
|
if (currentScale !== $scale) {
|
|
$scale = currentScale
|
|
}
|
|
})
|
|
|
|
return {
|
|
destroy() {
|
|
instance.dispose()
|
|
node.removeEventListener('pointerdown_connecting', instance.handleDown)
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleKeyUp(e: KeyboardEvent) {
|
|
isModifierKeyPressed = false
|
|
}
|
|
|
|
let mouseOverGridView = $state(false)
|
|
let handMode = $state(false)
|
|
let forceDeactivatePanzoom = $state(false)
|
|
|
|
$effect(() => {
|
|
path && untrack(() => onPathChange())
|
|
})
|
|
$effect(() => {
|
|
context.mode = $mode == 'dnd' ? 'editor' : 'viewer'
|
|
})
|
|
let width = $derived(
|
|
$breakpoint === 'sm' && $appStore?.mobileViewOnSmallerScreens !== false
|
|
? 'min-w-[400px] max-w-[656px]'
|
|
: `min-w-[710px] ${$appStore.fullscreen ? 'w-full' : 'max-w-7xl'}`
|
|
)
|
|
$effect(() => {
|
|
if ($selectedComponent?.[0] != befSelected) {
|
|
befSelected = $selectedComponent?.[0]
|
|
if ($selectedComponent?.[0] != undefined) {
|
|
untrack(() => onSelectedComponentChange())
|
|
}
|
|
}
|
|
})
|
|
$effect(() => {
|
|
if ($pickVariableCallback) {
|
|
itemPicker?.openDrawer()
|
|
}
|
|
})
|
|
// Animation logic for cssInput
|
|
$effect(() => {
|
|
;[$cssEditorOpen]
|
|
untrack(() => animateCssInput($cssEditorOpen))
|
|
})
|
|
$effect(() => {
|
|
$cssEditorOpen &&
|
|
secondaryMenuLeft?.open(StylePanel, {
|
|
type: 'style'
|
|
})
|
|
})
|
|
$effect(() => {
|
|
$cssEditorOpen && untrack(() => selectCss())
|
|
})
|
|
$effect(() => {
|
|
;[$mode]
|
|
untrack(() => addOrRemoveCss(true, $mode === 'preview'))
|
|
})
|
|
$effect(() => {
|
|
;[css, $previewTheme]
|
|
untrack(() => updateCssContent(css, $previewTheme))
|
|
})
|
|
$effect(() => {
|
|
;[$componentActive]
|
|
untrack(() => setGridPanelSize($componentActive))
|
|
})
|
|
$effect(() => {
|
|
;($connectingInput.opened, untrack(() => updatePannelInConnecting()))
|
|
})
|
|
$effect(() => {
|
|
forceDeactivatePanzoom = isModifierKeyPressed && handMode
|
|
})
|
|
$effect(() => {
|
|
$panzoomActive =
|
|
(isModifierKeyPressed || handMode) &&
|
|
!forceDeactivatePanzoom &&
|
|
!$componentActive &&
|
|
mouseOverGridView
|
|
})
|
|
$effect(() => {
|
|
;[!!$connectingInput.opened, !$panzoomActive]
|
|
untrack(() => updateCursorStyle(!!$connectingInput.opened && !$panzoomActive))
|
|
})
|
|
|
|
const unsavedConfirmationModal_render = $derived(unsavedConfirmationModal)
|
|
</script>
|
|
|
|
<svelte:head></svelte:head>
|
|
|
|
<DarkModeObserver on:change={onThemeChange} />
|
|
|
|
<svelte:window
|
|
onhashchange={hashchange}
|
|
onkeydown={keydown}
|
|
onkeyup={handleKeyUp}
|
|
onfocus={() => {
|
|
if (isModifierKeyPressed) {
|
|
isModifierKeyPressed = false
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<!-- {$focusedGrid?.parentComponentId} -->
|
|
{#if !$userStore?.operator}
|
|
{#if $appStore}
|
|
<AppEditorHeader
|
|
{newPath}
|
|
{newApp}
|
|
on:restore
|
|
{policy}
|
|
{fromHub}
|
|
bind:this={appEditorHeader}
|
|
{diffDrawer}
|
|
bind:savedApp
|
|
{version}
|
|
leftPanelHidden={leftPanelSize === 0}
|
|
rightPanelHidden={rightPanelSize === 0}
|
|
bottomPanelHidden={runnablePanelSize === 0}
|
|
{onSavedNewAppPath}
|
|
{onNavigate}
|
|
onShowLeftPanel={() => showLeftPanel()}
|
|
onShowRightPanel={() => showRightPanel()}
|
|
onShowBottomPanel={() => showBottomPanel()}
|
|
onHideLeftPanel={() => hideLeftPanel()}
|
|
onHideRightPanel={() => hideRightPanel()}
|
|
onHideBottomPanel={() => hideBottomPanel()}
|
|
>
|
|
{#snippet unsavedConfirmationModal({
|
|
diffDrawer,
|
|
additionalExitAction,
|
|
getInitialAndModifiedValues
|
|
})}
|
|
{@render unsavedConfirmationModal_render?.({
|
|
diffDrawer,
|
|
additionalExitAction,
|
|
getInitialAndModifiedValues
|
|
})}
|
|
{/snippet}
|
|
</AppEditorHeader>
|
|
{#if $mode === 'preview'}
|
|
<SplitPanesWrapper class="border-t">
|
|
<div
|
|
class={twMerge(
|
|
'h-full w-full relative',
|
|
$appStore.css?.['app']?.['viewer']?.class,
|
|
'wm-app-viewer'
|
|
)}
|
|
style={$appStore.css?.['app']?.['viewer']?.style}
|
|
>
|
|
<AppPreview
|
|
workspace={$workspaceStore ?? ''}
|
|
summary={$summaryStore}
|
|
app={$appStore}
|
|
appPath={path}
|
|
{breakpoint}
|
|
{policy}
|
|
isEditor
|
|
{context}
|
|
noBackend={false}
|
|
{replaceStateFn}
|
|
{gotoFn}
|
|
/>
|
|
</div>
|
|
</SplitPanesWrapper>
|
|
{:else}
|
|
{#if $componentActive}
|
|
<div
|
|
class="absolute z-50 inset-0 h-full w-full bg-surface-secondary [background-size:16px_16px]"
|
|
>
|
|
<div class="w-min whitespace-nowrap mx-auto pt-0.5 z-50">
|
|
<Alert
|
|
title={`Press ${getModifierKey()} to drop component inside a container.`}
|
|
size="xs"
|
|
class="h-10 py-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if $connectingInput.opened}
|
|
<div class="absolute z-50 inset-0 w-min h-min whitespace-nowrap mx-auto pt-0.5">
|
|
<Alert title="Press Esc to exit connection mode." size="xs" class="h-10 py-1" />
|
|
</div>
|
|
{/if}
|
|
|
|
<SplitPanesWrapper class="border-t">
|
|
<Splitpanes id="o1" class="max-w-full overflow-hidden">
|
|
<Pane bind:size={leftPanelSize} minSize={5} maxSize={33}>
|
|
<div
|
|
class={twMerge(
|
|
'w-full h-full relative',
|
|
$secondaryMenuLeftStore.isOpen ? 'overflow-hidden' : ''
|
|
)}
|
|
>
|
|
<!-- {yTop} -->
|
|
|
|
<SecondaryMenu right={false} />
|
|
<ContextPanel on:hidePanel={() => hideLeftPanel()} />
|
|
</div>
|
|
</Pane>
|
|
<Pane bind:size={centerPanelSize}>
|
|
<Splitpanes id="o2" horizontal class="!overflow-visible">
|
|
<Pane bind:size={gridPanelSize} class="ovisible">
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
onpointerdown={(e) => {
|
|
$selectedComponent = undefined
|
|
$focusedGrid = undefined
|
|
}}
|
|
class={twMerge(
|
|
'bg-surface-secondary h-full w-full relative',
|
|
$appStore.css?.['app']?.['viewer']?.class,
|
|
'wm-app-viewer h-full overflow-visible',
|
|
$panzoomActive ? 'cursor-grab' : ''
|
|
)}
|
|
style={$appStore.css?.['app']?.['viewer']?.style}
|
|
bind:clientWidth={centerPanelWidth}
|
|
>
|
|
{#if leftPanelSize === 0}
|
|
<div class="absolute top-0.5 left-0.5 z-50">
|
|
<HideButton
|
|
on:click={() => showLeftPanel()}
|
|
direction="left"
|
|
hidden
|
|
btnClasses="border bg-surface"
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{#if rightPanelSize === 0}
|
|
<div class="absolute top-0.5 right-0.5 z-50">
|
|
<HideButton
|
|
on:click={() => showRightPanel()}
|
|
direction="right"
|
|
hidden
|
|
btnClasses="border bg-surface"
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{#if runnablePanelSize === 0}
|
|
<div class="absolute bottom-0.5 right-0.5 z-50">
|
|
<HideButton
|
|
on:click={() => showBottomPanel()}
|
|
direction="bottom"
|
|
hidden
|
|
btnClasses="border bg-surface"
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="absolute bottom-2 left-2 z-50 border bg-surface"
|
|
data-connection-button
|
|
onpointerdown={stopPropagation(bubble('pointerdown'))}
|
|
>
|
|
<div class="flex flex-row gap-2 text-xs items-center h-8 px-1">
|
|
<Button
|
|
color="light"
|
|
size="xs2"
|
|
disabled={$scale <= 30}
|
|
on:click={() => {
|
|
zoomTo(0.5, 0, Math.round(($scale - 10) / 10) / 10)
|
|
}}
|
|
>
|
|
<Minus size={14} />
|
|
</Button>
|
|
<div class="w-8 flex justify-center text-2xs h-full items-center">
|
|
{Math.round($scale)}%
|
|
</div>
|
|
<Button
|
|
color="light"
|
|
size="xs2"
|
|
disabled={$scale >= 150}
|
|
on:click={() => {
|
|
zoomTo(0.5, 0, Math.round(($scale + 10) / 10) / 10)
|
|
}}
|
|
>
|
|
<Plus size={14} />
|
|
</Button>
|
|
<Button color="light" size="xs2" disabled={false} on:click={resetView}>
|
|
<Scan size={14} />
|
|
</Button>
|
|
<Popover disappearTimeout={0} notClickable placement="bottom">
|
|
{#snippet text()}
|
|
<div class="flex flex-row gap-2">
|
|
<div class="flex-1">
|
|
Hand Mode
|
|
<br />
|
|
<span class="ml-2">Pan</span>
|
|
<br />
|
|
<span class="ml-2">Zoom</span>
|
|
|
|
<br />
|
|
<span class="ml-2">Exit</span>
|
|
</div>
|
|
<div>
|
|
<span class="float-left text-primary-inverse"
|
|
>hold {getModifierKey()}</span
|
|
>
|
|
<br />
|
|
<span class="float-left text-primary-inverse">click & drag</span>
|
|
<br />
|
|
<span class="float-left text-primary-inverse">scroll</span>
|
|
<br />
|
|
<span class="float-left text-primary-inverse">esc</span>
|
|
</div>
|
|
</div>
|
|
{/snippet}
|
|
<Button
|
|
color="light"
|
|
size="xs2"
|
|
disabled={false}
|
|
on:click={() => (handMode = !handMode)}
|
|
btnClasses={handMode ? 'bg-surface-hover' : ''}
|
|
>
|
|
{#if $panzoomActive}
|
|
<Grab size={14} />
|
|
{:else}
|
|
<Hand size={14} />
|
|
{/if}
|
|
</Button>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="app-editor-top-level-drawer"></div>
|
|
<div
|
|
class="absolute pointer-events-none inset-0 h-full w-full surface-secondary bg-[radial-gradient(#dbdbdb_1px,transparent_1px)] dark:bg-[radial-gradient(#374457_1px,transparent_1px)] [background-size:16px_16px]"
|
|
></div>
|
|
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
bind:this={box}
|
|
onscroll={parseScroll}
|
|
onmouseenter={() => {
|
|
mouseOverGridView = true
|
|
}}
|
|
onmouseleave={() => {
|
|
mouseOverGridView = false
|
|
}}
|
|
class={classNames(
|
|
'mx-auto w-full h-full z-50',
|
|
$componentActive ? 'absolute right-0 left-0' : 'overflow-auto'
|
|
)}
|
|
style={$componentActive ? `top: -${$yTop}px;` : ''}
|
|
>
|
|
{#if $appStore.grid}
|
|
{#if !$connectingInput?.opened}
|
|
<ComponentNavigation />
|
|
{/if}
|
|
|
|
<div
|
|
class={twMerge(
|
|
width,
|
|
'mx-auto',
|
|
'z-10000',
|
|
$panzoomActive ? 'pointer-events-none' : ''
|
|
)}
|
|
use:initPanzoom
|
|
>
|
|
<GridEditor {policy} />
|
|
</div>
|
|
{/if}
|
|
{#if !$appStore?.mobileViewOnSmallerScreens && $breakpoint === 'sm'}
|
|
<div
|
|
class="absolute inset-0 flex bg-surface center-center z-10000 bg-opacity-60"
|
|
>
|
|
<div
|
|
class="bg-surface shadow-md rounded-md p-4 max-w-sm flex flex-col gap-2"
|
|
>
|
|
<div class="text-sm font-semibold"> Mobile View </div>
|
|
<div class="text-xs">
|
|
Enabling mobile view allows you to adjust component placement for
|
|
smaller screens.
|
|
</div>
|
|
<Button
|
|
variant="default"
|
|
size="xs"
|
|
on:click={() => {
|
|
$appStore.mobileViewOnSmallerScreens = true
|
|
}}
|
|
startIcon={{
|
|
icon: Smartphone
|
|
}}
|
|
>
|
|
Add mobile view on smaller screens
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</Pane>
|
|
{#if $connectingInput?.opened == false && !$componentActive}
|
|
<Pane bind:size={runnablePanelSize}>
|
|
<AppEditorBottomPanel
|
|
on:mouseenter={() => {
|
|
runnableJobEnterTimeout && clearTimeout(runnableJobEnterTimeout)
|
|
stillInJobEnter = true
|
|
runnableJobEnterTimeout = setTimeout(() => {
|
|
if (stillInJobEnter) {
|
|
$runnableJob.focused = true
|
|
}
|
|
}, 200)
|
|
}}
|
|
on:hidePanel={() => hideBottomPanel()}
|
|
on:mouseleave={() => {
|
|
stillInJobEnter = false
|
|
runnableJobEnterTimeout = setTimeout(
|
|
() => ($runnableJob.focused = false),
|
|
200
|
|
)
|
|
}}
|
|
{rightPanelSize}
|
|
{centerPanelWidth}
|
|
{runnablePanelSize}
|
|
/>
|
|
</Pane>
|
|
{/if}
|
|
</Splitpanes>
|
|
</Pane>
|
|
{#if rightPanelSize === 0}
|
|
<div class="relative flex flex-col h-full"></div>
|
|
{:else}
|
|
<Pane bind:size={rightPanelSize} minSize={15} maxSize={33}>
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
bind:clientWidth={$runnableJob.width}
|
|
class="relative flex flex-col h-full"
|
|
onkeydown={(e) => e.stopPropagation()}
|
|
>
|
|
<Tabs bind:selected={selectedTab} wrapperClass="!min-h-[42px]" class="!h-full">
|
|
<Popover disappearTimeout={0} notClickable placement="bottom">
|
|
{#snippet text()}
|
|
Component library
|
|
{/snippet}
|
|
<Tab
|
|
value="insert"
|
|
class="h-full"
|
|
on:pointerdown={() => {
|
|
if ($cssEditorOpen) {
|
|
$cssEditorOpen = false
|
|
selectedTab = 'insert'
|
|
}
|
|
}}
|
|
id="app-editor-component-library-tab"
|
|
icon={Plus}
|
|
></Tab>
|
|
</Popover>
|
|
<Popover disappearTimeout={0} notClickable placement="bottom">
|
|
{#snippet text()}
|
|
Component settings
|
|
{/snippet}
|
|
<Tab
|
|
value="settings"
|
|
class="h-full"
|
|
on:pointerdown={() => {
|
|
if ($cssEditorOpen) {
|
|
$cssEditorOpen = false
|
|
selectedTab = 'settings'
|
|
}
|
|
}}
|
|
icon={Component}
|
|
/>
|
|
</Popover>
|
|
<Popover disappearTimeout={0} notClickable placement="bottom">
|
|
{#snippet text()}
|
|
Global styling
|
|
{/snippet}
|
|
<Tab
|
|
value="css"
|
|
class="h-full"
|
|
on:pointerdown={() => {
|
|
if (!$cssEditorOpen) {
|
|
$cssEditorOpen = true
|
|
selectedTab = 'css'
|
|
}
|
|
}}
|
|
icon={Paintbrush}
|
|
/>
|
|
</Popover>
|
|
<div class="h-full w-full flex justify-end px-1">
|
|
<HideButton on:click={() => hideRightPanel()} direction="right" />
|
|
</div>
|
|
{#snippet content()}
|
|
<div class="h-full overflow-y-auto">
|
|
<TabContent class="overflow-auto h-full" value="settings">
|
|
{#if $selectedComponent !== undefined}
|
|
<SettingsPanel
|
|
on:delete={() => {
|
|
befSelected = undefined
|
|
selectedTab = 'insert'
|
|
}}
|
|
/>
|
|
<SecondaryMenu right />
|
|
{:else}
|
|
<div class="min-w-[150px] text-sm !text-secondary text-center py-8 px-2">
|
|
Select a component to see the settings for it
|
|
</div>
|
|
{/if}
|
|
</TabContent>
|
|
<TabContent value="insert">
|
|
<ComponentList />
|
|
</TabContent>
|
|
<TabContent value="css" class="h-full">
|
|
<CssSettings />
|
|
</TabContent>
|
|
</div>
|
|
{/snippet}
|
|
</Tabs>
|
|
</div>
|
|
</Pane>
|
|
{/if}
|
|
</Splitpanes>
|
|
</SplitPanesWrapper>
|
|
{/if}
|
|
{:else}
|
|
App is null
|
|
{/if}
|
|
{:else}
|
|
App editor not available to operators
|
|
{/if}
|
|
|
|
<ItemPicker
|
|
bind:this={itemPicker}
|
|
pickCallback={(path, _) => {
|
|
$pickVariableCallback?.(path)
|
|
}}
|
|
itemName="Variable"
|
|
extraField="path"
|
|
loadItems={async () =>
|
|
(await VariableService.listVariable({ workspace: $workspaceStore ?? '' })).map((x) => ({
|
|
name: x.path,
|
|
...x
|
|
}))}
|
|
>
|
|
{#snippet submission()}
|
|
<div
|
|
class="flex flex-row-reverse w-full bg-surface border-t border-gray-200 rounded-bl-lg rounded-br-lg"
|
|
>
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
startIcon={{ icon: Plus }}
|
|
on:click={() => {
|
|
variableEditor?.initNew?.()
|
|
}}
|
|
>
|
|
New variable
|
|
</Button>
|
|
</div>
|
|
{/snippet}
|
|
</ItemPicker>
|
|
|
|
<VariableEditor bind:this={variableEditor} />
|
|
|
|
<style global>
|
|
#o1 > .splitpanes__pane {
|
|
overflow: visible !important;
|
|
}
|
|
#o2 > .splitpanes__pane {
|
|
overflow: visible !important;
|
|
}
|
|
</style>
|