feat(frontend): Add quick style settings to app editor (#1308)

* feat(frontend): Add app secondary settings menu

* refactor(frontend): Separate color picker

* save

* feat(frontend): Add quick style options

* fix(frontend): Handle overflow

* feat(frontend): Add suggestions to quick styles

* save wip

* feat(frontend): Add UI for quick styling

* fix(frontend): Handle multi value properties

* fix(frontend): Convenience updates

* feat(frontend): Add styling properties to components

* fix(frontend): Parse inner colors

* fix(frontend): Multi value sync

* fix(frontend): Correct unit handling

* fix(frontend): Correct multivalue handling

* remove comments

* fix color picker label

* feat(frontend): Add box-shadow property

* feat(frontend): Add concise unit selector

* feat(frontend): Update app labels

* remove unused imports

* fix width

* fix(frontend): App freezing

* fix(frontend): Remove unused imports

* fix(frontend): Conditional chaining

* fix

* revert

* minor updates

* feat(frontend): Add 'apply style to all' button

* fix(frontend): Update styling properties

* fix(frontend): Styling menu typography update
This commit is contained in:
Ádám Kovács
2023-03-24 18:26:16 +01:00
committed by GitHub
parent da1167585c
commit f3c24ee3c6
24 changed files with 1342 additions and 99 deletions
+31
View File
@@ -66,6 +66,7 @@
"prettier": "^2.8.3",
"prettier-plugin-svelte": "^2.9.0",
"simple-svelte-autocomplete": "^2.5.1",
"style-to-object": "^0.4.1",
"stylelint-config-recommended": "^9.0.0",
"svelte": "^3.55.1",
"svelte-awesome": "^3.0.0",
@@ -4079,6 +4080,12 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"dev": true
},
"node_modules/inline-style-parser": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz",
"integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==",
"dev": true
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -6959,6 +6966,15 @@
"dev": true,
"peer": true
},
"node_modules/style-to-object": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.1.tgz",
"integrity": "sha512-HFpbb5gr2ypci7Qw+IOhnP2zOU7e77b+rzM+wTzXzfi1PrtBCX0E7Pk4wL4iTLnhzZ+JgEGAhX81ebTg/aYjQw==",
"dev": true,
"dependencies": {
"inline-style-parser": "0.1.1"
}
},
"node_modules/stylehacks": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz",
@@ -11127,6 +11143,12 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"dev": true
},
"inline-style-parser": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz",
"integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==",
"dev": true
},
"internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -13222,6 +13244,15 @@
"dev": true,
"peer": true
},
"style-to-object": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.1.tgz",
"integrity": "sha512-HFpbb5gr2ypci7Qw+IOhnP2zOU7e77b+rzM+wTzXzfi1PrtBCX0E7Pk4wL4iTLnhzZ+JgEGAhX81ebTg/aYjQw==",
"dev": true,
"requires": {
"inline-style-parser": "0.1.1"
}
},
"stylehacks": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz",
+1
View File
@@ -44,6 +44,7 @@
"prettier": "^2.8.3",
"prettier-plugin-svelte": "^2.9.0",
"simple-svelte-autocomplete": "^2.5.1",
"style-to-object": "^0.4.1",
"stylelint-config-recommended": "^9.0.0",
"svelte": "^3.55.1",
"svelte-awesome": "^3.0.0",
@@ -23,8 +23,7 @@
import TabContent from '$lib/components/common/tabs/TabContent.svelte'
import { Alert, Button, Tab } from '$lib/components/common'
import ComponentList from './componentsPanel/ComponentList.svelte'
import Icon from 'svelte-awesome'
import { faCode, faPlus, faSliders } from '@fortawesome/free-solid-svg-icons'
import { faPlus } from '@fortawesome/free-solid-svg-icons'
import ContextPanel from './contextPanel/ContextPanel.svelte'
import { classNames, encodeState } from '$lib/utils'
import AppPreview from './AppPreview.svelte'
@@ -41,6 +40,8 @@
import ComponentNavigation from './component/ComponentNavigation.svelte'
import ItemPicker from '$lib/components/ItemPicker.svelte'
import VariableEditor from '$lib/components/VariableEditor.svelte'
import { SecondaryMenu } from './settingsPanel/secondaryMenu'
import { Component, Paintbrush, Plus } from 'lucide-svelte'
export let app: App
export let path: string
@@ -192,7 +193,7 @@
<Pane size={15} minSize={5} maxSize={33}>
<ContextPanel />
</Pane>
<Pane size={64}>
<Pane size={63}>
<SplitPanesWrapper>
<Splitpanes horizontal>
<Pane size={70}>
@@ -233,31 +234,32 @@
</Splitpanes>
</SplitPanesWrapper>
</Pane>
<Pane size={21} minSize={5} maxSize={33}>
<Pane size={22} minSize={5} maxSize={33}>
<div class="relative flex flex-col h-full">
<Tabs bind:selected={selectedTab} wrapperClass="!h-[40px]" class="!h-full">
<Tab value="insert" size="xs">
<div class="m-1 center-center gap-2">
<Icon data={faPlus} />
<div class="m-1 center-center gap-1">
<Plus size={18} />
<span>Insert</span>
</div>
</Tab>
<Tab value="settings" size="xs">
<div class="m-1 center-center gap-2">
<Icon data={faSliders} />
<span>Settings</span>
<div class="m-1 center-center gap-1">
<Component size={18} />
<span>Component</span>
</div>
</Tab>
<Tab value="css" size="xs">
<div class="m-1 center-center gap-2">
<Icon data={faCode} />
<span>CSS</span>
<div class="m-1 center-center gap-1">
<Paintbrush size={18} />
<span>Styling</span>
</div>
</Tab>
<div slot="content" class="h-full overflow-y-auto">
<TabContent class="overflow-auto h-full" value="settings">
{#if $selectedComponent !== undefined}
<SettingsPanel />
<SecondaryMenu />
{:else}
<div class="min-w-[150px] text-sm text-gray-500 text-center py-8 px-2">
Select a component to see the settings&nbsp;for&nbsp;it
@@ -100,8 +100,7 @@
)}
>
<div
class="w-full sticky top-0 flex justify-between border-b bg-gray-50 px-4 py-1 items-center gap-4"
style="z-index: 1000;"
class="w-full sticky z-[1001] top-0 flex justify-between border-b bg-gray-50 px-4 py-1 items-center gap-4"
>
<h2 class="truncate">{summary}</h2>
<RecomputeAllComponents />
@@ -8,6 +8,10 @@
1. Add the component to one of the component sets: `layout`, `buttons`,
`inputs` or `display` _(this controls which group the component will be
placed in in the **Insert** menu)_
1. [**`quickStyleProperties.ts`**](../componentsPanel/quickStyleProperties.ts):
1. Add the component to the `quickStyleProperties` record
1. _(optional)_ Add the CSS properties that could be applied to the component
parts
1. [**`Component.svelte`**](./Component.svelte):
1. Add the new component in the Svelte `if` statement
1. [**`default-codes.ts`**](./default-codes.ts):
@@ -1,71 +1,119 @@
<script lang="ts">
import { X } from 'lucide-svelte'
import { Forward, Paintbrush2 } from 'lucide-svelte'
import { createEventDispatcher, getContext } from 'svelte'
import { fade } from 'svelte/transition'
import { addWhitespaceBeforeCapitals } from '../../../../utils'
import type { ComponentCssProperty } from '../../types'
import { addWhitespaceBeforeCapitals, sendUserToast } from '../../../../utils'
import { Button, ClearableInput } from '../../../common'
import Popover from '../../../Popover.svelte'
import type { AppViewerContext, ComponentCssProperty } from '../../types'
import type { AppComponent } from '../component/components'
import QuickStyleMenu from './QuickStyleMenu.svelte'
import type { StylePropertyKey } from './quickStyleProperties'
export let name: string
export let componentType: AppComponent['type'] | undefined = undefined
export let value: ComponentCssProperty = {}
export let forceStyle: boolean = false
export let forceClass: boolean = false
export let quickStyleProperties: StylePropertyKey[] | undefined = undefined
const { app } = getContext<AppViewerContext>('AppViewerContext')
const dispatch = createEventDispatcher()
let isQuickMenuOpen = false
function reset(property: keyof ComponentCssProperty) {
if (value) {
value[property] = ''
$: dispatch('change', value)
function toggleQuickMenu() {
isQuickMenuOpen = !isQuickMenuOpen
}
function applyToAllInstances() {
if (
componentType &&
componentType in ($app?.css || {}) &&
name in ($app?.css?.[componentType] || {})
) {
$app.css![componentType]![name].style = value.style
sendUserToast(
`Applied style to all instances of the ${componentType.replace('component', '')} component`
)
}
}
</script>
<div class="text-sm font-semibold text-gray-500 capitalize pt-2">
<div
class="sticky top-0 z-20 text-lg bg-white font-semibold [font-variant:small-caps] text-gray-700 pt-2 pb-1"
>
{addWhitespaceBeforeCapitals(name)}
</div>
{#if value}
<div class="border-l border-gray-400/80 py-1 pl-3.5 mt-1 ml-0.5">
<div class="border-l border-gray-400/80 py-1 pl-3.5 ml-0.5">
{#if value.style !== undefined || forceStyle}
<label class="block pb-2">
<div class="text-xs font-medium pb-0.5"> Style </div>
<div class="relative">
<input
on:keydown|stopPropagation
type="text"
class="!pr-7"
bind:value={value.style}
on:focus
/>
{#if value?.style}
<button
transition:fade|local={{ duration: 100 }}
class="absolute z-10 top-1.5 right-1 rounded-full p-1 duration-200 hover:bg-gray-200"
aria-label="Remove styles"
on:click|preventDefault|stopPropagation={() => reset('style')}
>
<X size={14} />
</button>
{/if}
</div>
</label>
<div class="pb-2">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="block">
<div class="text-sm font-medium text-gray-600 pb-0.5"> Style </div>
<div class="flex gap-1">
<div class="relative grow">
<ClearableInput
bind:value={value.style}
type="textarea"
wrapperClass="h-full min-h-[72px]"
inputClass="h-full"
/>
</div>
<div class="flex flex-col gap-1">
{#if componentType}
<Popover placement="bottom" notClickable disapperTimoout={0}>
<Button
variant="border"
color="light"
size="xs"
btnClasses="!p-1 !w-[34px] !h-[34px]"
aria-label="Apply to all instances of this component"
on:click={applyToAllInstances}
>
<Forward size={18} />
</Button>
<svelte:fragment slot="text">
Apply to all instances of this component
</svelte:fragment>
</Popover>
{/if}
{#if quickStyleProperties?.length}
<Popover placement="bottom" notClickable disapperTimoout={0}>
<Button
variant="border"
color="light"
size="xs"
btnClasses="!p-1 !w-[34px] !h-[34px] {isQuickMenuOpen
? '!bg-gray-200/60 hover:!bg-gray-200'
: ''}"
aria-label="{isQuickMenuOpen ? 'Close' : 'Open'} styling menu"
on:click={toggleQuickMenu}
>
<Paintbrush2 size={18} />
</Button>
<svelte:fragment slot="text">
{isQuickMenuOpen ? 'Close' : 'Open'} styling menu
</svelte:fragment>
</Popover>
{/if}
</div>
</div>
</label>
{#if quickStyleProperties?.length && isQuickMenuOpen}
<div transition:fade|local={{ duration: 200 }} class="w-full pt-1">
<QuickStyleMenu bind:value={value.style} properties={quickStyleProperties} />
</div>
{/if}
</div>
{/if}
{#if value.class !== undefined || forceClass}
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="block">
<div class="text-xs font-medium pb-0.5"> Tailwind classes </div>
<div class="text-sm font-medium text-gray-600 pb-0.5"> Tailwind classes </div>
<div class="relative">
<input
on:keydown|stopPropagation
type="text"
class="!pr-7"
bind:value={value.class}
on:focus
/>
{#if value?.class}
<button
transition:fade|local={{ duration: 100 }}
class="absolute z-10 top-1.5 right-1 rounded-full p-1 duration-200 hover:bg-gray-200"
aria-label="Remove classes"
on:click|preventDefault|stopPropagation={() => reset('class')}
>
<X size={14} />
</button>
{/if}
<ClearableInput bind:value={value.class} />
</div>
</label>
{/if}
@@ -0,0 +1,183 @@
<script lang="ts">
import { getContext, onMount, setContext } from 'svelte'
import parse from 'style-to-object'
import { isValidHexColor } from '../../../../utils'
import type { AppViewerContext } from '../../types'
import {
createStyleStore,
StylePropertyType,
StylePropertyUnits,
STYLE_STORE_KEY,
type StylePropertyKey,
type TopColors
} from './quickStyleProperties'
import QuickStyleProperty from './QuickStyleProperty.svelte'
export let value = ''
export let properties: StylePropertyKey[]
const { app } = getContext<AppViewerContext>('AppViewerContext')
const styleStore = createStyleStore(properties)
setContext(STYLE_STORE_KEY, styleStore)
let multiValues: Record<number, string[]> = initiateMultiValues()
let mounted = false
$: mounted && $styleStore && writeStyle()
$: mounted && (!value || value) && parseStyle()
$: $app && setTopColors()
function parseStyle() {
styleStore.resetStyle()
if (!value) {
multiValues = initiateMultiValues()
return
}
const current = parse(value) || {}
Object.entries(current).forEach(([k, v]) => {
styleStore.updatePropValue(k as StylePropertyKey, v)
const { prop, index } = styleStore.getProp(k as StylePropertyKey)
if (Array.isArray(prop?.prop?.value) && index !== undefined) {
const valueArray = v.split(' ')
multiValues[index] = multiValues[index].map((v, i) => valueArray[i] || v)
}
})
setTopColors()
}
function writeStyle() {
const current = parse(value) || {}
$styleStore.style.forEach((s) => {
current[s.prop.key] = convertValue(s.value)
})
const entries = Object.entries(current)
value = entries.reduce((style, [k, v]) => {
return v ? `${style} ${k}: ${v}; `.trim() : style
}, '')
}
function convertValue(value: any) {
switch (typeof value) {
case 'number':
return value + ''
case 'string':
return value
default:
return ''
}
}
function setTopColors() {
const styles = collectStyles()
const parsedStyles = styles.map((style) => parse(style) || {})
const usedColors: Record<string, number> = {}
parsedStyles.forEach((style) => {
Object.values(style).reduce((colors, v) => {
// TODO: support RGB and HSL colors as well
// Splitting is needed so colors can be extracted
// from values like '1px solid #000000'
v.split(' ').forEach((v) => {
if (isValidHexColor(v)) {
colors[v] = (colors[v] || 0) + 1
}
})
return colors
}, usedColors)
})
const colors = Object.entries(usedColors)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([color]) => color) as TopColors
styleStore.setTopColors(colors)
}
function collectStyles() {
const styles: string[] = []
// Getting global app styles
Object.values($app.css || {}).forEach((element) => {
Object.values(element).filter(({ style }) => style && styles.push(style))
})
// Getting styles from individual components
$app.grid.map((component) => {
Object.values(component.data.customCss || {}).forEach(({ style }) => {
style && styles.push(style)
})
})
// Getting style from subgrids
Object.values($app.subgrids || {}).forEach((grid) => {
grid.map((component) => {
Object.values(component.data.customCss || {}).forEach(({ style }) => {
style && styles.push(style)
})
})
})
return styles
}
function initiateMultiValues() {
return $styleStore.style.reduce((acc, curr, i) => {
if (Array.isArray(curr.prop.value)) {
acc[i] = Array.from({ length: curr.prop.value.length }, () => {
return ''
})
}
return acc
}, {})
}
function setMultiValueProperty(index: number) {
if (multiValues[index].every((v) => !v || +v === 0 || StylePropertyUnits.includes(v))) {
$styleStore.style[index].value = ''
return
}
const values = multiValues[index].map((v, i) => {
v = StylePropertyUnits.includes(v) ? '' : v
const type = $styleStore.style[index].prop.value[i].type
if (v) {
multiValues[index][i] = v
return v
} else if (type === StylePropertyType.color) {
return '#000000'
} else if (type === StylePropertyType.number) {
return 0
} else if (type === StylePropertyType.unit) {
return 0
} else if (type === StylePropertyType.text) {
const options = $styleStore.style[index].prop.value[i].options
return options ? options[0].text : ''
} else {
return ''
}
})
$styleStore.style[index].value = values.join(' ').trim()
}
onMount(() => {
parseStyle()
mounted = true
})
</script>
<div class="bg-gray-200/60 rounded-md p-2">
{#each $styleStore.style as { prop }, index}
<div class="pb-3 last:pb-0">
<div class="text-sm font-medium text-gray-600 pb-0.5"> {prop.key} </div>
<div class="flex items-center gap-1 w-full">
{#if Array.isArray(prop.value)}
<div class="flex justify-start items-center flex-wrap gap-x-4 gap-y-1">
{#each prop.value as value, i}
<QuickStyleProperty
prop={{ ...prop, value }}
inline
bind:value={multiValues[index][i]}
on:change={() => setMultiValueProperty(index)}
/>
{/each}
</div>
{:else}
<QuickStyleProperty {prop} bind:value={$styleStore.style[index].value} />
{/if}
</div>
</div>
{/each}
</div>
@@ -0,0 +1,141 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte'
import { Button, ClearableInput, Menu } from '../../../common'
import Popover from '../../../Popover.svelte'
import ColorInput from '../settingsPanel/inputEditor/ColorInput.svelte'
import {
StylePropertyType,
StylePropertyUnits,
STYLE_STORE_KEY,
type StyleStore,
type StyleStoreValue
} from './quickStyleProperties'
export let prop: StyleStoreValue['style'][number]['prop']
export let value: string | undefined
export let inline = false
const styleStore = getContext<StyleStore>(STYLE_STORE_KEY)
const dispatch = createEventDispatcher()
const key = prop.key
const type = prop.value?.['type']
let unit: (typeof StylePropertyUnits)[number] = StylePropertyUnits[0]
let internalValue: number | string
$: internalValue = value ? +value.replace(unit, '') : ''
$: dispatch('change', value)
function updateValue(next: number) {
value = next ? next + unit : ''
}
function updateUnit(next: (typeof StylePropertyUnits)[number]) {
value = value?.replace(unit, next) || ''
unit = next
}
</script>
<div class={inline && type !== StylePropertyType.color ? '' : 'w-full'}>
{#if prop.value['title']}
<div class="font-medium text-xs text-gray-500">
{prop.value['title']}
</div>
{/if}
<div class="flex gap-1">
{#if type === StylePropertyType.color}
<ColorInput bind:value />
{#each $styleStore.topColors as color}
<Popover placement="bottom" notClickable disapperTimoout={0} class="flex">
<Button
color="light"
size="xs"
variant="border"
btnClasses="!p-0 !w-[34px] !h-[34px]"
aria-label="Set {key} to {color}"
style={`background-color: ${color};`}
on:click={() => (value = color)}
/>
<svelte:fragment slot="text">{color}</svelte:fragment>
</Popover>
{/each}
{:else if type === StylePropertyType.number}
<ClearableInput
type="number"
bind:value
step={prop.value?.['step'] || 1}
min={prop.value?.['min']}
max={prop.value?.['max']}
/>
{:else if type === StylePropertyType.unit}
<ClearableInput
wrapperClass="flex items-center {inline ? '!grow-0' : ''}"
inputClass="!border-r-0 !rounded-r-none {inline ? '!w-[90px]' : ''}"
buttonClass="!right-9"
type="number"
value={internalValue}
on:change={({ detail }) => updateValue(detail)}
>
<Menu
let:close
noMinW
wrapperClasses="h-full bg-white rounded-r-md border-y border-r border-gray-300 pr-0.5"
popupClasses="!mt-0"
>
<button
slot="trigger"
type="button"
class="font-normal text-xs px-1 py-1.5 w-8 rounded mt-0.5 duration-200 hover:bg-gray-200/90"
>
{unit}
</button>
<ul class="bg-white rounded border py-1 overflow-auto">
{#each StylePropertyUnits as u}
<li class="w-full">
<Button
type="button"
color="light"
size="xs"
variant="contained"
btnClasses="!justify-start !rounded-none !w-full !px-3 !py-1.5"
on:click={() => {
updateUnit(u)
close()
}}
>
{u}
</Button>
</li>
{/each}
</ul>
</Menu>
</ClearableInput>
{:else if type === StylePropertyType.text}
{#each prop.value?.['options'] || [] as option}
<Popover placement="bottom" notClickable disapperTimoout={0}>
<Button
color={value === option.text ? 'dark' : 'light'}
size="xs"
variant={value === option.text ? 'contained' : 'border'}
btnClasses="!p-1 !min-w-[34px] !h-[34px]"
aria-label="Set {key} to {option.text}"
on:click={() => {
if (value === option.text) {
value = ''
} else {
value = option.text
}
}}
>
{#if typeof option.icon === 'string'}
{option.icon}
{:else}
<svelte:component this={option.icon} size={18} />
{/if}
</Button>
<svelte:fragment slot="text">{option.text}</svelte:fragment>
</Popover>
{:else}
<ClearableInput inputClass="min-w-[32px]" bind:value />
{/each}
{/if}
</div>
</div>
@@ -0,0 +1,644 @@
import { get, writable } from 'svelte/store'
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Asterisk,
Eye,
EyeOff,
Grid,
Italic,
Mouse,
MousePointer,
Pointer,
RectangleHorizontal,
Slash,
Square,
StretchVertical,
Strikethrough,
Type,
Underline
} from 'lucide-svelte'
import type { components } from '../component'
export const STYLE_STORE_KEY = 'style_store' as const
export type StyleStore = ReturnType<typeof createStyleStore>
export type StyleStoreValue = {
style: {
prop: (typeof StyleProperty)[number]
value: string | undefined
}[]
topColors: TopColors
}
export function createStyleStore(properties: StylePropertyKey[]) {
const style = StyleProperty.filter((p) => properties.includes(p.key)).map((p) => ({
prop: p,
value: '' as string | undefined
}))
const store = writable<StyleStoreValue>({
style: [...style],
topColors: [] as TopColors
})
return {
subscribe: store.subscribe,
set: store.set,
update: store.update,
updatePropValue: (key: StylePropertyKey, value?: string) => {
if (!key) {
return
}
store.update((s) => {
const index = s.style.findIndex((p) => p.prop.key === key)
if (index !== -1) {
s.style[index].value = value || ''
}
return s
})
},
getProp(key: StylePropertyKey) {
const s = get(store)
const index = s.style.findIndex((p) => p.prop.key === key)
return {
prop: s.style[index],
index: index === -1 ? undefined : index
}
},
resetStyle: () => {
store.update((s) => {
s.style = style.map((s) => ({ ...s, value: '' }))
return s
})
},
setTopColors: (colors: TopColors) => {
store.update((s) => {
s.topColors = colors
return s
})
}
}
}
export enum StylePropertyType {
'color' = 'color', // color value
'unit' = 'unit', // number with unit like px, em, rem, etc.
'number' = 'number', // bare number like the value of 'font-weight'
'text' = 'text' // text like the value of 'display'
}
export const StylePropertyUnits = ['px', 'em', 'rem', '%', 'vh', 'vw']
export type TopColors = [] | [string] | [string, string] | [string, string, string]
export type StylePropertyKey = (typeof StyleProperty)[number]['key']
// Using an array instead of an object to preserve the order of the properties
export const StyleProperty = [
{
key: 'display',
value: {
type: StylePropertyType.text,
options: [
{
text: 'block',
icon: Square
},
{
text: 'inline-block',
icon: RectangleHorizontal
},
{
text: 'flex',
icon: StretchVertical
},
{
text: 'grid',
icon: Grid
}
]
}
},
{
key: 'padding',
value: [
{
type: StylePropertyType.unit,
title: 'top'
},
{
type: StylePropertyType.unit,
title: 'right'
},
{
type: StylePropertyType.unit,
title: 'bottom'
},
{
type: StylePropertyType.unit,
title: 'left'
}
]
},
{
key: 'opacity',
value: {
type: StylePropertyType.number,
step: 0.1,
min: 0,
max: 1
}
},
{
key: 'cursor',
value: {
type: StylePropertyType.text,
options: [
{
text: 'default',
icon: MousePointer
},
{
text: 'pointer',
icon: Pointer
}
]
}
},
{
key: 'width',
value: {
type: StylePropertyType.unit
}
},
{
key: 'min-width',
value: {
type: StylePropertyType.unit
}
},
{
key: 'max-width',
value: {
type: StylePropertyType.unit
}
},
{
key: 'height',
value: {
type: StylePropertyType.unit
}
},
{
key: 'min-height',
value: {
type: StylePropertyType.unit
}
},
{
key: 'max-height',
value: {
type: StylePropertyType.unit
}
},
{
key: 'border',
value: [
{
type: StylePropertyType.unit,
title: 'width'
},
{
type: StylePropertyType.text,
title: 'style',
options: [
{
text: 'solid',
icon: '___'
},
{
text: 'dashed',
icon: '_ _'
},
{
text: 'dotted',
icon: '. .'
}
]
},
{
type: StylePropertyType.color,
title: 'color'
}
]
},
{
key: 'border-radius',
value: {
type: StylePropertyType.unit
}
},
{
key: 'box-shadow',
value: [
{
type: StylePropertyType.unit,
title: 'offset-x'
},
{
type: StylePropertyType.unit,
title: 'offset-y'
},
{
type: StylePropertyType.unit,
title: 'blur'
},
{
type: StylePropertyType.unit,
title: 'spread'
},
{
type: StylePropertyType.color,
title: 'color'
}
]
},
{
key: 'background-color',
value: {
type: StylePropertyType.color
}
},
{
key: 'color',
value: {
type: StylePropertyType.color
}
},
{
key: 'font-size',
value: {
type: StylePropertyType.unit
}
},
{
key: 'font-family',
value: {
type: StylePropertyType.text
}
},
{
key: 'font-weight',
value: {
type: StylePropertyType.number
}
},
{
key: 'font-style',
value: {
type: StylePropertyType.text,
options: [
{
text: 'normal',
icon: Type
},
{
text: 'italic',
icon: Italic
}
]
}
},
{
key: 'text-align',
value: {
type: StylePropertyType.text,
options: [
{
text: 'left',
icon: AlignLeft
},
{
text: 'center',
icon: AlignCenter
},
{
text: 'right',
icon: AlignRight
},
{
text: 'justify',
icon: AlignJustify
}
]
}
},
{
key: 'text-decoration',
value: {
type: StylePropertyType.text,
options: [
{
text: 'none',
icon: Slash
},
{
text: 'underline',
icon: Underline
},
{
text: 'line-through',
icon: Strikethrough
}
]
}
},
{
key: 'text-transform',
value: {
type: StylePropertyType.text,
options: [
{
text: 'none',
icon: Slash
},
{
text: 'capitalize',
icon: 'Aa'
},
{
text: 'uppercase',
icon: 'AA'
},
{
text: 'lowercase',
icon: 'aa'
}
]
}
},
{
key: 'line-height',
value: {
type: StylePropertyType.unit
}
},
{
key: 'letter-spacing',
value: {
type: StylePropertyType.unit
}
},
{
key: 'word-spacing',
value: {
type: StylePropertyType.unit
}
},
{
key: 'overflow',
value: {
type: StylePropertyType.text,
options: [
{
text: 'auto',
icon: Asterisk
},
{
text: 'visible',
icon: Eye
},
{
text: 'hidden',
icon: EyeOff
},
{
text: 'scroll',
icon: Mouse
}
]
}
}
] as const
const allDefaultProps = StyleProperty.map(({ key }) => key)
const containerDefaultProps: StylePropertyKey[] = [
'padding',
'opacity',
'border',
'border-radius',
'box-shadow',
'background-color',
'overflow'
]
const textDefaultProps: StylePropertyKey[] = [
'padding',
'opacity',
'background-color',
'color',
'font-size',
'font-family',
'font-weight',
'font-style',
'text-align',
'text-decoration',
'text-transform',
'line-height',
'letter-spacing',
'word-spacing'
]
const sizeDefaultProps: StylePropertyKey[] = [
'width',
'min-width',
'max-width',
'height',
'min-height',
'max-height'
]
const buttonDefaultProps: StylePropertyKey[] = [
'cursor',
'border',
'border-radius',
'box-shadow',
...textDefaultProps
]
const inputDefaultProps: StylePropertyKey[] = [
'cursor',
'border',
'border-radius',
...textDefaultProps
]
export const quickStyleProperties: Record<
keyof typeof components,
Record<string, StylePropertyKey[]>
> = {
mapcomponent: {
map: containerDefaultProps
},
pdfcomponent: {
container: containerDefaultProps
},
formcomponent: {
container: containerDefaultProps,
button: buttonDefaultProps
},
htmlcomponent: {
container: allDefaultProps
},
iconcomponent: {
container: containerDefaultProps,
icon: [
'padding',
'opacity',
'cursor',
'width',
'min-width',
'max-width',
'height',
'min-height',
'max-height',
'color'
]
},
tabscomponent: {
tabRow: containerDefaultProps,
allTabs: [...textDefaultProps, ...sizeDefaultProps],
selectedTab: [...textDefaultProps, ...sizeDefaultProps],
container: containerDefaultProps
},
textcomponent: {
text: textDefaultProps
},
imagecomponent: {
image: containerDefaultProps
},
rangecomponent: {
handles: ['opacity', 'cursor', 'border', 'border-radius', 'background-color'],
bar: ['opacity', 'cursor', 'border', 'border-radius', 'background-color'],
limits: textDefaultProps,
values: textDefaultProps
},
tablecomponent: {
tableHeader: containerDefaultProps,
tableBody: containerDefaultProps,
tableFooter: containerDefaultProps,
container: containerDefaultProps
},
aggridcomponent: {},
buttoncomponent: {
button: buttonDefaultProps
},
drawercomponent: {
container: containerDefaultProps
},
plotlycomponent: {},
selectcomponent: {
input: inputDefaultProps
},
slidercomponent: {
handles: ['opacity', 'cursor', 'border', 'border-radius', 'background-color'],
bar: ['opacity', 'cursor', 'border', 'border-radius', 'background-color'],
limits: textDefaultProps,
values: textDefaultProps
},
displaycomponent: {
header: [...containerDefaultProps, ...textDefaultProps],
container: containerDefaultProps
},
barchartcomponent: {
container: containerDefaultProps
},
checkboxcomponent: {
text: textDefaultProps
},
currencycomponent: {
input: inputDefaultProps
},
piechartcomponent: {
container: containerDefaultProps
},
vegalitecomponent: {},
containercomponent: {
container: containerDefaultProps
},
dateinputcomponent: {
input: inputDefaultProps
},
fileinputcomponent: {
container: containerDefaultProps
},
textinputcomponent: {
input: inputDefaultProps
},
emailinputcomponent: {
input: inputDefaultProps
},
formbuttoncomponent: {
button: buttonDefaultProps,
popup: containerDefaultProps
},
timeseriescomponent: {
container: containerDefaultProps
},
multiselectcomponent: {
input: inputDefaultProps
},
numberinputcomponent: {
input: inputDefaultProps
},
scatterchartcomponent: {
container: containerDefaultProps
},
passwordinputcomponent: {
input: inputDefaultProps
},
resourceselectcomponent: {
input: inputDefaultProps
},
verticaldividercomponent: {
divider: [
'padding',
'opacity',
'width',
'min-width',
'max-width',
'height',
'min-height',
'max-height',
'border',
'border-radius',
'background-color'
],
container: containerDefaultProps
},
horizontaldividercomponent: {
divider: [
'padding',
'opacity',
'width',
'min-width',
'max-width',
'height',
'min-height',
'max-height',
'border',
'border-radius',
'background-color'
],
container: containerDefaultProps
},
verticalsplitpanescomponent: {
container: containerDefaultProps
},
horizontalsplitpanescomponent: {
container: containerDefaultProps
}
}
@@ -1,6 +1,6 @@
<script lang="ts">
import Button from '$lib/components/common/button/Button.svelte'
import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight } from '@fortawesome/free-solid-svg-icons'
import { getContext } from 'svelte'
import type { AppEditorContext, AppViewerContext, GridItem, RichConfiguration } from '../../types'
import PanelSection from './common/PanelSection.svelte'
@@ -9,7 +9,7 @@
import StaticInputEditor from './inputEditor/StaticInputEditor.svelte'
import ConnectedInputEditor from './inputEditor/ConnectedInputEditor.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import { capitalize, classNames, getModifierKey } from '$lib/utils'
import { capitalize, classNames, getModifierKey, isMac } from '$lib/utils'
import { buildExtraLib, fieldTypeToTsType } from '../../utils'
import Recompute from './Recompute.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
@@ -17,7 +17,7 @@
import AlignmentEditor from './AlignmentEditor.svelte'
import RunnableInputEditor from './inputEditor/RunnableInputEditor.svelte'
import TemplateEditor from '$lib/components/TemplateEditor.svelte'
import { ccomponents, type AppComponent } from '../component'
import { ccomponents } from '../component'
import CssProperty from '../componentsPanel/CssProperty.svelte'
import GridTab from './GridTab.svelte'
import { clearErrorByComponentId, clearJobsByComponentId, deleteGridItem } from '../appUtils'
@@ -25,6 +25,9 @@
import { slide } from 'svelte/transition'
import { push } from '$lib/history'
import Kbd from '$lib/components/common/kbd/Kbd.svelte'
import { secondaryMenu } from './secondaryMenu'
import StylePanel from './StylePanel.svelte'
import { Delete } from 'lucide-svelte'
export let componentSettings: { item: GridItem; parent: string | undefined } | undefined =
undefined
@@ -90,12 +93,10 @@
: undefined
function keydown(event: KeyboardEvent) {
switch (event.key) {
case 'Delete': {
removeGridElement()
event.stopPropagation()
break
}
const { key, metaKey } = event
if (key === 'Delte' || (key === 'Backspace' && metaKey)) {
removeGridElement()
event.stopPropagation()
}
}
@@ -235,19 +236,20 @@
<div class="grow shrink" />
{#if Object.keys(ccomponents[component.type].customCss ?? {}).length > 0}
<PanelSection title="Custom CSS">
<PanelSection title="Styling">
<div slot="action">
<Button
color="light"
size="xs"
endIcon={{ icon: viewCssOptions ? faChevronUp : faChevronDown }}
on:click={() => (viewCssOptions = !viewCssOptions)}
variant="border"
endIcon={{ icon: faChevronRight }}
on:click={() => secondaryMenu.open(StylePanel, { component })}
>
{viewCssOptions ? 'Hide' : 'Show'}
Open
</Button>
</div>
{#if viewCssOptions}
<div transition:slide|local>
<div transition:slide|local class="w-full">
{#each Object.keys(ccomponents[component.type].customCss ?? {}) as name}
{#if componentSettings.item.data?.customCss != undefined}
<div class="w-full mb-2">
@@ -270,7 +272,15 @@
<div slot="action">
<Button size="xs" color="red" variant="border" on:click={removeGridElement}>
Delete&nbsp;&nbsp;
<Kbd>Del</Kbd>
{#if isMac()}
<Kbd kbdClass="center-center">
<span class="text-lg leading-none">⌘</span>
<span class="px-0.5">+</span>
<Delete size={16} />
</Kbd>
{:else}
<Kbd>Del</Kbd>
{/if}
</Button>
</div>
<div class="flex flex-col gap-1">
@@ -0,0 +1,26 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { AppViewerContext } from '../../types'
import { ccomponents, type AppComponent } from '../component'
import CssProperty from '../componentsPanel/CssProperty.svelte'
import { quickStyleProperties } from '../componentsPanel/quickStyleProperties'
export let component: AppComponent | undefined
const { app } = getContext<AppViewerContext>('AppViewerContext')
</script>
{#if component && component.customCss !== undefined}
{#each Object.keys(ccomponents[component.type].customCss ?? {}) as name}
<div class="w-full">
<CssProperty
quickStyleProperties={quickStyleProperties?.[component.type]?.[name]}
forceStyle={ccomponents[component.type].customCss[name].style !== undefined}
forceClass={ccomponents[component.type].customCss[name].class !== undefined}
{name}
componentType={component.type}
bind:value={component.customCss[name]}
on:change={() => app.set($app)}
/>
</div>
{/each}
{/if}
@@ -1,22 +1,21 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import ColorPicker, { ChromeVariant } from 'svelte-awesome-color-picker'
import type { StaticInput } from '../../../inputType'
import { ClearableInput } from '../../../../common'
export let componentInput: StaticInput<string>
export let value: string = '#fff'
const dispatch = createEventDispatcher()
let isOpen = false
$: dispatch('change', value)
</script>
<div class="color-picker-input">
<input
type="text"
readonly
value={componentInput.value}
on:focus|preventDefault|stopPropagation={() => (isOpen = true)}
/>
<div class="color-picker-input grow">
<ClearableInput readonly bind:value on:focus={() => (isOpen = true)} />
<ColorPicker
bind:isOpen
bind:hex={componentInput.value}
label={componentInput.value}
bind:hex={value}
label={value}
canChangeMode={false}
components={ChromeVariant}
toRight
@@ -25,7 +24,7 @@
</div>
<style global>
.color-picker-input span > label.svelte-rogbpz {
.color-picker-input span > label {
display: none;
}
@@ -33,6 +32,7 @@
box-shadow: 0 10px 40px -5px rgba(0, 0, 0, 0.25) !important;
border-style: none !important;
border-radius: 0.375rem !important;
z-index: 20;
}
.color-picker-input .slider-wrapper {
@@ -81,7 +81,7 @@
Inconsistent labeled resource object
{/if}
{:else if fieldType === 'color'}
<ColorInput bind:componentInput />
<ColorInput bind:value={componentInput.value} />
{:else if fieldType === 'object'}
{#if format?.startsWith('resource-')}
<ResourcePicker
@@ -0,0 +1,51 @@
<script lang="ts">
import { fly } from 'svelte/transition'
import { faChevronLeft } from '@fortawesome/free-solid-svg-icons'
import { Badge, Button } from '../../../../common'
import { secondaryMenu, SECONDARY_MENU_ID } from './'
import { getContext } from 'svelte'
import type { AppViewerContext } from '../../../types'
const { selectedComponent } = getContext<AppViewerContext>('AppViewerContext')
let width: number
let lastSelected = $selectedComponent
$: if (lastSelected !== $selectedComponent) {
secondaryMenu.close()
lastSelected = $selectedComponent
}
</script>
<div
bind:clientWidth={width}
class="absolute z-50 inset-0 overflow-hidden"
class:pointer-events-none={!$secondaryMenu.isOpen}
>
{#if $secondaryMenu.isOpen && $secondaryMenu.component}
<div
transition:fly|local={{ duration: 300, x: width, y: 0, opacity: 1 }}
id={SECONDARY_MENU_ID}
class="flex flex-col w-full h-full bg-white"
>
<div class="flex justify-between items-center bg-white gap-1 p-3">
<Button
color="light"
size="xs"
variant="border"
startIcon={{ icon: faChevronLeft }}
on:click={secondaryMenu.close}
>
Back
</Button>
<Badge color="blue">{$selectedComponent}</Badge>
</div>
<div class="relative h-full overflow-y-auto px-3 pb-3">
{#if typeof $secondaryMenu.component === 'string'}
{@html $secondaryMenu.component}
{:else}
<svelte:component this={$secondaryMenu.component} {...$secondaryMenu.props} />
{/if}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,2 @@
export { default as SecondaryMenu } from './SecondaryMenu.svelte'
export * from './menuStore'
@@ -0,0 +1,20 @@
import type { SvelteComponent } from 'svelte'
import { writable } from 'svelte/store'
export const SECONDARY_MENU_ID = 'app-secondary-menu' as const
export interface SecondaryMenuStore {
isOpen: boolean
component?: typeof SvelteComponent | string
props: Record<string, any>
}
const store = writable<SecondaryMenuStore>({ isOpen: false, component: undefined, props: {} })
export const secondaryMenu = {
subscribe: store.subscribe,
open: (component: SecondaryMenuStore['component'], props: SecondaryMenuStore['props'] = {}) => {
store.set({ isOpen: true, component, props })
},
close: () => store.set({ isOpen: false, component: undefined, props: {} })
} as const
+4 -3
View File
@@ -4,6 +4,7 @@ import type { History } from '$lib/history'
import type { Writable } from 'svelte/store'
import type { AppComponent, components } from './editor/component/components'
import type { StyleProperty, StylePropertyKey } from './editor/componentsPanel/quickStyleProperties'
import type {
AppInput,
ConnectedAppInput,
@@ -99,6 +100,8 @@ export type InlineScript = {
refreshOn?: { id: string; key: string }[]
}
export type AppCssItemName = 'viewer' | 'grid' | AppComponent['type']
export type App = {
grid: GridItem[]
fullscreen: boolean
@@ -114,9 +117,7 @@ export type App = {
autoRefresh?: boolean
doNotRecomputeOnInputChanged?: boolean
}>
css?: Partial<
Record<'viewer' | 'grid' | AppComponent['type'], Record<string, ComponentCssProperty>>
>
css?: Partial<Record<AppCssItemName, Record<string, ComponentCssProperty>>>
subgrids?: Record<string, GridItem[]>
}
@@ -9,6 +9,7 @@
export let variant: ButtonType.Variant = 'contained'
export let mainClasses: string = ''
export let toggleClasses: string = ''
export let listClasses: string = ''
export let disabled: boolean = false
export let href: string | undefined = undefined
export let target: ButtonType.Target = '_self'
@@ -64,14 +65,16 @@
{#if ref}
<Popup
{ref}
let:open
let:close
options={{
placement: $$slots.main ? 'bottom-end' : 'bottom',
strategy: 'absolute',
modifiers: [{ name: 'offset', options: { offset: [0, 0] } }]
}}
>
<ul class="bg-white rounded-t border pt-1 pb-2 max-h-40 overflow-auto">
<slot />
<ul class="bg-white rounded-t border pt-1 pb-2 max-h-40 overflow-auto {listClasses}">
<slot {open} {close} />
</ul>
</Popup>
{/if}
@@ -10,6 +10,7 @@
export let iconOnly: boolean = false
export let startIcon: ButtonType.Icon | undefined = undefined
export let endIcon: ButtonType.Icon | undefined = undefined
export let wrapperClasses = ''
const props = getContext<ButtonType.ItemProps | undefined>(ButtonType.ItemContextKey)
const iconWidthClass: Record<ButtonType.Size, string> = {
@@ -45,7 +46,7 @@
}
</script>
<li class="mt-1">
<li class="mt-1 {wrapperClasses}">
<Button {...buttonProps} on:click>
<slot />
</Button>
@@ -0,0 +1,61 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { fade } from 'svelte/transition'
import { X } from 'lucide-svelte'
export let value: any = ''
export let placeholder = ''
export let type: 'text' | 'textarea' | 'number' = 'text'
export let inputClass = ''
export let wrapperClass = ''
export let buttonClass = ''
const dispatch = createEventDispatcher()
$: isNumeric = ['number', 'range'].includes(type)
$: dispatch('change', value)
function handleInput(e) {
value = isNumeric ? +e.target.value : e.target.value
}
function clear() {
value = ''
}
</script>
<div class="relative grow {wrapperClass}">
{#if type === 'textarea'}
<textarea
{value}
{placeholder}
rows="1"
class="resize-y duration-200 {inputClass}"
{...$$restProps}
on:input={handleInput}
on:focus
on:blur
/>
{:else}
<input
{type}
{value}
{placeholder}
class="duration-200 {(type === 'number' && value ? '!pr-[26px] ' : '') + inputClass}"
{...$$restProps}
on:input={handleInput}
on:focus
on:blur
/>
{/if}
{#if value}
<button
transition:fade|local={{ duration: 100 }}
class="absolute z-10 top-1.5 right-1 rounded-full p-1 bg-white/60 duration-200 hover:bg-gray-200 {buttonClass}"
aria-label="Clear"
on:click|preventDefault|stopPropagation={clear}
>
<X size={14} />
</button>
{/if}
<slot />
</div>
@@ -5,6 +5,7 @@ export { default as Button } from './button/Button.svelte'
export { default as ButtonPopup } from './button/ButtonPopup.svelte'
export { default as ButtonPopupItem } from './button/ButtonPopupItem.svelte'
export { default as UndoRedo } from './button/UndoRedo.svelte'
export { default as ClearableInput } from './clearableInput/ClearableInput.svelte'
export { default as Drawer } from './drawer/Drawer.svelte'
export { default as DrawerContent } from './drawer/DrawerContent.svelte'
export { default as Kbd } from './kbd/Kbd.svelte'
@@ -1,7 +1,11 @@
<script lang="ts">
export let kbdClass = ''
</script>
<span
class="{$$props.class} rounded border bg-white/70 px-1.5 !text-xs text-gray-600 shadow-sm font-light transition-all group-hover:border-primary-500 group-hover:text-primary-500"
>
<kbd>
<kbd class={kbdClass}>
<slot />
</kbd>
</span>
@@ -5,9 +5,13 @@
<script lang="ts">
import { classNames } from '$lib/utils'
import { onMount } from 'svelte'
import { fade } from 'svelte/transition'
export let noMinW = false
export let show = false
export let wrapperClasses = ''
export let popupClasses = ''
export let transitionDuration = 100
let menu: HTMLDivElement
type Alignment = 'start' | 'end' | 'center'
@@ -53,7 +57,7 @@
}
</script>
<div class="relative" bind:this={menu}>
<div class="relative {wrapperClasses}" bind:this={menu}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
on:click|stopPropagation={() => {
@@ -69,10 +73,12 @@
</div>
{#if show}
<div
transition:fade|local={{ duration: transitionDuration }}
class={classNames(
'z-50 absolute mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none',
placementsClasses[placement],
noMinW ? 'min-w-0' : 'w-60'
noMinW ? 'min-w-0' : 'w-60',
popupClasses
)}
role="menu"
tabindex="-1"
+4
View File
@@ -713,3 +713,7 @@ export function isMac(): boolean {
export function getModifierKey(): string {
return isMac() ? '⌘' : 'CTRL'
}
export function isValidHexColor(color: string): boolean {
return /^#(([A-F0-9]{2}){3,4}|[A-F0-9]{3})$/i.test(color)
}