feat(style): stylePanel add style selector, write css to global styles (#41)

* feat(style): stylePanel add style selector, write css to global styles

* feat(style): classNameSelector support edit and delete

* fix(build): fix setting-style plugin build error

* fix(chore): fix review comment
This commit is contained in:
chilingling 2023-12-07 23:34:43 -08:00 committed by GitHub
parent 0478258c87
commit fd5baf1660
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1366 additions and 183 deletions

View File

@ -410,6 +410,8 @@ export const clearSelect = () => {
canvasState.current = null
canvasState.parent = null
Object.assign(selectState, initialRectState)
// 临时借用 remote 事件出发 currentSchema 更新
canvasState?.emit?.('remove')
}
export const querySelectById = (id, type = '') => {

View File

@ -10,11 +10,13 @@
*
*/
import { toRaw, nextTick, shallowReactive } from 'vue'
import { toRaw, nextTick, shallowReactive, ref } from 'vue'
import { getNode, setState, updateRect } from '@opentiny/tiny-engine-canvas'
import useCanvas from './useCanvas'
import useResource from './useResource'
import useTranslate from './useTranslate'
import { getNode, setState, updateRect } from '@opentiny/tiny-engine-canvas'
const propsUpdateKey = ref(0)
const otherBaseKey = {
className: {
@ -168,6 +170,10 @@ const getProps = (schema, parent) => {
}
const setProp = (name, value) => {
if (!properties.schema) {
return
}
properties.schema.props = properties.schema.props || {}
if (value === '' || value === undefined || value === null) {
@ -178,6 +184,7 @@ const setProp = (name, value) => {
// 没有父级或者不在节点上面要更新内容。就用setState
getNode(properties.schema.id, true).parent || setState(useCanvas().getPageSchema().state)
propsUpdateKey.value++
nextTick(updateRect)
}
@ -188,6 +195,7 @@ const getProp = (key) => {
const delProp = (name) => {
const props = properties.schema.props || {}
delete props[name]
propsUpdateKey.value++
}
const setProps = (schema) => {
@ -205,6 +213,7 @@ export default function () {
translateProp,
getSchema(parent) {
return parent ? properties : properties.schema
}
},
propsUpdateKey
}
}

View File

@ -28,8 +28,11 @@
"@opentiny/tiny-engine-common": "workspace:^1.0.0",
"@opentiny/tiny-engine-controller": "workspace:^1.0.0",
"@opentiny/tiny-engine-http": "workspace:^1.0.0",
"@opentiny/tiny-engine-utils": "workspace:^1.0.0",
"@opentiny/vue": "~3.10.0",
"@opentiny/vue-renderless": "~3.10.0",
"@vueuse/core": "^9.6.0",
"postcss": "^8.4.31",
"vue": "3.2.45"
},
"devDependencies": {

View File

@ -17,7 +17,7 @@
@save="save(CSS_TYPE.Style, $event)"
/>
</div>
<class-names-container></class-names-container>
<tiny-collapse v-model="activeNames">
<tiny-collapse-item title="布局" name="layout">
<layout-group :display="state.style.display" @update="updateStyle" />
@ -58,23 +58,26 @@
<script>
import { ref } from 'vue'
import { Collapse, CollapseItem } from '@opentiny/vue'
import { useHistory, useCanvas, useProperties } from '@opentiny/tiny-engine-controller'
import { setPageCss, getSchema as getCanvasPageSchema } from '@opentiny/tiny-engine-canvas'
import { MetaCodeEditor } from '@opentiny/tiny-engine-common'
import SizeGroup from './components/size/SizeGroup.vue'
import LayoutGroup from './components/layout/LayoutGroup.vue'
import FlexBox from './components/layout/FlexBox.vue'
import GridBox from './components/layout/GridBox.vue'
import PositionGroup from './components/position/PositionGroup.vue'
import BorderGroup from './components/border/BorderGroup.vue'
import SpacingGroup from './components/spacing/SpacingGroup.vue'
import BackgroundGroup from './components/background/BackgroundGroup.vue'
import EffectGroup from './components/effects/EffectGroup.vue'
// import BoxShadowGroup from './components/shadow/BoxShadowGroup.vue'
import TypographyGroup from './components/typography/TypographyGroup.vue'
import { formatString } from '@opentiny/tiny-engine-common/js/ast'
import {
SizeGroup,
LayoutGroup,
FlexBox,
GridBox,
PositionGroup,
BorderGroup,
SpacingGroup,
BackgroundGroup,
EffectGroup,
TypographyGroup,
ClassNamesContainer
} from './components'
import { CSS_TYPE } from './js/cssType'
import useStyle from './js/useStyle'
import { styleStrRemoveRoot } from './js/cssConvert'
import { useHistory, useCanvas, useProperties } from '@opentiny/tiny-engine-controller'
import { setPageCss, getSchema as getCanvasPageSchema } from '@opentiny/tiny-engine-canvas'
export default {
components: {
@ -87,9 +90,9 @@ export default {
BorderGroup,
SpacingGroup,
BackgroundGroup,
// BoxShadowGroup,
TypographyGroup,
EffectGroup,
ClassNamesContainer,
TinyCollapse: Collapse,
TinyCollapseItem: CollapseItem
},
@ -106,7 +109,7 @@ export default {
])
const { getCurrentSchema, getPageSchema } = useCanvas()
// style
const { state, updateStyle, setStyle } = useStyle() // updateStyle
const { state, updateStyle } = useStyle() // updateStyle
const { addHistory } = useHistory()
const { getSchema } = useProperties()
@ -117,7 +120,7 @@ export default {
if (type === CSS_TYPE.Style) {
const pageSchema = getCanvasPageSchema()
const schema = getSchema() || pageSchema
const styleString = styleStrRemoveRoot(content)
const styleString = formatString(styleStrRemoveRoot(content), 'css')
const currentSchema = getCurrentSchema() || pageSchema
state.styleContent = content
@ -132,13 +135,13 @@ export default {
delete currentSchema.props.style
}
setStyle(styleString)
addHistory()
} else if (type === CSS_TYPE.Css) {
const cssString = content.replace(/"/g, "'")
const cssString = formatString(content.replace(/"/g, "'"), 'css')
getPageSchema().css = cssString
getCanvasPageSchema().css = cssString
setPageCss(cssString)
state.schemaUpdateKey++
addHistory()
}
}

View File

@ -144,7 +144,6 @@ export default {
})
const { getProperty, getSettingFlag } = useProperties({
props,
names: Object.values(BACKGROUND_PROPERTY),
parseNumber: true
})

View File

@ -235,7 +235,6 @@ import useEvent from '../../js/useEvent'
import { useProperties } from '../../js/useStyle'
import { RADIUS_SETTING, BORDER_SETTING, BORDER_STYLE_TYPE } from '../../js/cssType'
import { BORDER_PROPERTY, BORDER_RADIUS_PROPERTY } from '../../js/styleProperty'
// import { hyphenate } from '@opentiny/tiny-engine-controller/utils'
const BORDER_STYLE = {
[BORDER_SETTING.All]: BORDER_PROPERTY.BorderStyle,
@ -299,7 +298,6 @@ export default {
const { setPosition } = useModal()
const { getProperty, getSettingFlag, getPropertyValue } = useProperties({
props,
names: Object.values({ ...BORDER_RADIUS_PROPERTY, ...BORDER_PROPERTY }),
parseNumber: true
})

View File

@ -0,0 +1,636 @@
<template>
<div class="className-container">
<h6 class="title">样式选择器</h6>
<div class="selector-container">
<div class="className-selector-wrap">
<div
:class="['className-selector-container', { 'has-error': classNameState.selectorHasError }]"
@click="handleFocusInput"
>
<div v-if="classNameState.curSelector || classNameState.curSelectorIsEditing" class="current-selector">
<div class="current-selector-label">
<span
ref="selectorTextRef"
:contenteditable="classNameState.curSelectorIsEditing"
:class="['selector-label-text', { 'text-editing': classNameState.curSelectorIsEditing }]"
:key="classNameState.curSelectorIsEditing"
@click.stop="handleEditCurSelector"
@input="handleCurSelectorChange"
@blur="handleCompleteEditCurSelector"
@keyup.enter="handleCompleteEditCurSelector"
@keyup.esc="handleCompleteEditCurSelector"
>
{{ classNameState.curSelector }}
</span>
<div v-if="!classNameState.curSelectorIsEditing && classNameState.curSelectorEditable" class="edit-wrap">
<svg-icon name="edit" title="编辑" class="edit-btn" @click.stop="handleEditCurSelector"></svg-icon>
<tiny-popover
v-model="classNameState.showDelConfirm"
trigger="manual"
class="del-selector-popover"
popper-class="del-selector-popper-wrapper"
>
<div class="popper-confirm" @mousedown.stop="">
<div class="popper-confirm-header">
<svg-icon class="icon" name="warning"></svg-icon>
<span class="title">您确定删除该选择器吗</span>
</div>
<div class="popper-confirm-footer">
<tiny-button class="confirm-btn" size="small" type="primary" @click="handleDelSelector">
确定
</tiny-button>
<tiny-button size="small" class="cancel-btn" @click="handleTriggerDelConfirm(false)">
取消
</tiny-button>
</div>
</div>
<template #reference>
<svg-icon
name="delete"
title="删除"
class="delete-btn"
@click.stop="handleTriggerDelConfirm(true)"
></svg-icon>
</template>
</tiny-popover>
</div>
</div>
</div>
<span v-else class="empty-tips">请选择或创建类名</span>
<input
ref="newSelectorInputRef"
type="text"
v-model="classNameState.newSelector"
class="selector-input"
@change="handleInputChange"
@blur="handleCreateNewClass"
@keyup.enter="handleCreateNewClass"
/>
<div v-if="classNameState.showDropdownList" class="selector-drop-down-list">
<span class="selector-dropdown-list-tips">输入并回车创建新选择器</span>
<span v-if="currentSelectorList.length" class="selector-dropdown-list-tips">选择已有选择器编辑</span>
<ul class="exist-class-list">
<li
v-for="item in currentSelectorList"
:key="item"
:title="item"
class="exist-class-item"
@mousedown="handleSelectExistingClass(item)"
>
<span>{{ item }}</span>
</li>
</ul>
<span v-if="state.selectors.length" class="selector-dropdown-list-tips add-global-class-tips">
添加全局类到当前组件并编辑
</span>
<ul class="exist-class-list">
<li
v-for="item in state.selectors"
:key="item"
:title="item"
class="exist-class-item"
@mousedown="handleSelectExistingClass(item)"
>
<span>{{ item }}</span>
</li>
</ul>
</div>
</div>
<div v-if="classNameState.selectorHasError" class="error-tips">{{ classNameState.selectorHasError }}</div>
</div>
<tiny-select v-model="state.className.mouseState" :options="stateOptions" class="state-selector"> </tiny-select>
</div>
</div>
</template>
<script setup>
import { computed, reactive, ref, nextTick, watch, watchEffect } from 'vue'
import { Select as TinySelect, Popover as TinyPopover, Button as TinyButton } from '@opentiny/vue'
import { getSchema as getCanvasPageSchema } from '@opentiny/tiny-engine-canvas'
import { useProperties } from '@opentiny/tiny-engine-controller'
import useStyle, { updateGlobalStyleStr } from '../../js/useStyle'
import { stringify, getSelectorArr } from '../../js/parser'
const { getSchema, propsUpdateKey } = useProperties()
const stateOptions = [
{ label: 'None', value: '' },
{ label: 'hover', value: 'hover' },
{ label: 'pressed', value: 'pressed' },
{ label: 'focused', value: 'focused' },
{ label: 'disabled', value: 'disabled' }
]
const SELECTOR_TYPE = {
CLASS_NAME: 'className',
ID: 'id'
}
const OPTION_TYPE = {
ADD: 'add',
REMOVE: 'remove',
EDIT: 'edit'
}
const classNameState = reactive({
curSelector: '',
curSelectorEditable: false,
newSelector: '',
curSelectorIsEditing: false,
preSelector: '',
isSelectorValid: true,
showDropdownList: false,
showDelConfirm: false,
selectorHasError: ''
})
const selectorTextRef = ref(null)
const newSelectorInputRef = ref(null)
const state = useStyle().state
const getCurSelectorEditable = (selector) => {
const selArr = getSelectorArr(selector)
return selArr.length < 2
}
watch(
() => state.className.classNameList,
(className) => {
classNameState.showDelConfirm = false
classNameState.selectorHasError = ''
if (classNameState.curSelectorIsEditing) {
classNameState.curSelectorIsEditing = false
}
classNameState.curSelector = className
// .test1.test2
classNameState.curSelectorEditable = getCurSelectorEditable(className)
}
)
const setSelectorProps = (type, value) => {
const schema = getSchema() || getCanvasPageSchema()
if (!schema.props) {
schema.props = {}
}
schema.props[type] = value
propsUpdateKey.value++
}
// className
const editClassName = (curClassName, optionType = OPTION_TYPE.ADD, oldSelector = '') => {
const schema = getSchema() || getCanvasPageSchema()
const type = curClassName.startsWith('.') ? SELECTOR_TYPE.CLASS_NAME : SELECTOR_TYPE.ID
const classNames = schema.props.className || ''
const ids = schema.props.id || ''
const typeMap = {
[SELECTOR_TYPE.CLASS_NAME]: classNames,
[SELECTOR_TYPE.ID]: ids
}
let newClassNames = curClassName.slice(1)
//
if (typeof typeMap[type] !== 'string') {
return
}
const editSelectorHandler = () => {
const oldSelType = oldSelector.startsWith('.') ? SELECTOR_TYPE.CLASS_NAME : SELECTOR_TYPE.ID
let oldSelSymbol = oldSelector.slice(1)
let res = newClassNames
//
if (oldSelType !== type) {
const selArr = typeMap[oldSelType].split(' ').filter((item) => item !== oldSelSymbol) || []
setSelectorProps(oldSelType, selArr.join(' '))
res = `${typeMap[type] ?? ''} ${newClassNames}`
} else {
//
res = typeMap[type]
.split(' ')
.map((item) => {
if (item === oldSelSymbol) {
return newClassNames
}
return item
})
.join(' ')
}
return res
}
const addSelectorHandler = () => {
return `${typeMap[type] ?? ''} ${newClassNames}`
}
const removeSelectorHandler = () => {
const leftSelectors = typeMap[type].split(' ').filter((item) => item !== newClassNames && Boolean(item)) || []
return leftSelectors.join(' ')
}
const handlersMap = {
[OPTION_TYPE.ADD]: addSelectorHandler,
[OPTION_TYPE.REMOVE]: removeSelectorHandler,
[OPTION_TYPE.EDIT]: editSelectorHandler
}
newClassNames = handlersMap[optionType]?.()
setSelectorProps(type, newClassNames)
}
//
const currentSelectorList = computed(() => [...state.currentClassNameList, ...state.currentIdList])
/**
* 选择器简单校验规则
* 必须以下划线连字符 - 或字符 a-z 开头不能是数字
* @param {string} selector
*/
const selectorValidator = (selector) => {
let sel = selector.trim()
classNameState.selectorHasError = ''
if (sel.startsWith('.') || sel.startsWith('#')) {
sel = sel.slice(1)
}
//
if (/^[0-9]/.test(sel)) {
classNameState.selectorHasError = '开头不能是数字'
return false
}
//
if (sel.includes('.') || sel.includes('#')) {
classNameState.selectorHasError = '单次只能添加一个类名'
return false
}
//
if (/[\s>~+]/.test(sel)) {
classNameState.selectorHasError = "不能包含空格 '>' '~' '+' 等符号"
return false
}
return true
}
// class id
const handleSelectExistingClass = (selector) => {
if (!state.selectorOptionLists.find(({ value }) => value === selector)) {
editClassName(selector, OPTION_TYPE.ADD)
}
state.className.classNameList = selector
state.className.mouseState = ''
}
//
const handleCreateNewClass = () => {
//
classNameState.showDropdownList = false
let newSelector = classNameState.newSelector
const isValid = selectorValidator(newSelector)
classNameState.selectorHasError = ''
//
classNameState.newSelector = ''
newSelectorInputRef.value?.blur?.()
if (!isValid) {
return
}
if (!newSelector.startsWith('.') && !newSelector.startsWith('#')) {
newSelector = `.${newSelector}`
}
if (newSelector.length <= 1) {
return
}
//
if (!state.selectorOptionLists.find(({ value }) => value === newSelector)) {
editClassName(newSelector, OPTION_TYPE.ADD)
}
state.className.classNameList = newSelector
state.className.mouseState = ''
}
//
const handleEditCurSelector = async () => {
if (!classNameState.curSelectorEditable) {
return
}
classNameState.curSelectorIsEditing = true
classNameState.preSelector = classNameState.curSelector
await nextTick()
if (!selectorTextRef.value) {
return
}
//
const range = document.createRange()
range.setStart(selectorTextRef.value.childNodes[0], 0)
range.setEnd(selectorTextRef.value.childNodes[0], selectorTextRef.value.textContent.length)
const selection = window.getSelection()
selection.removeAllRanges()
selection.addRange(range)
selectorTextRef.value.focus()
}
// esc
const handleCompleteEditCurSelector = () => {
if (!selectorTextRef.value) {
return
}
const curValue = selectorTextRef.value.textContent
let textValue = curValue
if (textValue.startsWith('#') || textValue.startsWith('.')) {
textValue = textValue.slice(1)
}
classNameState.curSelectorIsEditing = false
classNameState.showDropdownList = false
classNameState.selectorHasError = ''
//
if (!selectorValidator(textValue) || textValue.length < 1) {
classNameState.curSelector = classNameState.preSelector
classNameState.preSelector = ''
return
}
classNameState.curSelector = curValue
//
editClassName(curValue, OPTION_TYPE.EDIT, classNameState.preSelector)
//
const newStyleStr = stringify(state.cssParseList, state.styleObject, {
originSelector: classNameState.preSelector,
newSelector: curValue
})
updateGlobalStyleStr(newStyleStr)
}
const listener = () => {
classNameState.showDelConfirm = false
}
//
const handleTriggerDelConfirm = (visible) => {
classNameState.showDelConfirm = visible
if (visible) {
window.addEventListener('click', listener)
} else {
window.removeEventListener('click', listener)
}
}
const handleDelSelector = () => {
// , css css
// 使
editClassName(classNameState.curSelector, OPTION_TYPE.REMOVE)
}
const handleFocusInput = () => {
classNameState.showDropdownList = true
if (newSelectorInputRef.value) {
newSelectorInputRef.value.focus()
}
}
//
const handleCurSelectorChange = (event) => {
const newValue = event.target?.textContent
selectorValidator(newValue)
}
//
watchEffect(() => {
selectorValidator(classNameState.newSelector)
})
</script>
<style lang="less" scoped>
.className-container {
padding: 10px;
}
.selector-container {
display: flex;
margin-top: 10px;
color: var(--ti-lowcode-className-selector-container-color);
.className-selector-wrap {
.error-tips {
margin-top: 8px;
font-size: 12px;
color: var(--ti-lowcode-className-selector-error-tips-color);
}
}
.className-selector-container {
flex: 7;
border: 1px solid var(--ti-lowcode-className-selector-container-border-color);
border-radius: 6px;
padding: 2px 10px;
display: flex;
max-width: 180px;
row-gap: 2px;
align-items: center;
position: relative;
overflow: visible;
flex-wrap: wrap;
&:hover {
border-color: var(--ti-lowcode-className-selector-container-hover-border-color);
}
&.has-error {
border-color: var(--ti-lowcode-className-selector-container-error-border-color);
background-color: var(--ti-lowcode-className-selector-container-error-bg-color);
.selector-drop-down-list {
top: calc(100% + 30px);
}
}
&:has(.selector-input:focus) {
.empty-tips {
display: none;
}
}
.empty-tips {
position: absolute;
font-size: 14px;
color: var(--ti-lowcode-className-selector-container-empty-tips-color);
z-index: 0;
}
.current-selector {
max-width: 100%;
.current-selector-label {
display: flex;
align-items: center;
background-color: var(--ti-lowcode-className-selector-container-label-bg-color);
color: #fff;
padding: 1px 4px;
border-radius: 2px;
line-height: 30px;
.selector-label-text {
outline: none;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
&.text-editing {
text-overflow: unset;
}
}
.edit-wrap {
display: flex;
.del-selector-popover {
display: inline-flex;
.svg-icon {
outline: none;
}
:deep(.reference-wrapper) {
display: inline-flex;
}
}
}
.edit-btn,
.delete-btn {
color: var(--ti-lowcode-className-selector-container-option-btn-color);
margin-left: 4px;
cursor: pointer;
}
}
}
.selector-input {
color: var(--ti-lowcode-className-selector-container-color);
min-width: 0;
flex: 0 0 0;
line-height: 30px;
z-index: 1;
border: none;
outline: none;
background-color: transparent;
padding: 0;
&:focus {
flex: 1 0 46px;
padding: 1px 2px;
}
}
.selector-drop-down-list {
box-sizing: border-box;
position: absolute;
display: flex;
width: 100%;
max-height: 200px;
top: calc(100% + 10px);
left: 0;
padding: 8px 0;
background-color: var(--ti-lowcode-className-selector-dropdown-list-bg-color);
border: 1px solid transparent;
z-index: 1;
flex-direction: column;
overflow: scroll;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
.selector-dropdown-list-tips {
font-size: 12px;
padding: 0 10px;
}
.selector-dropdown-list-tips + .selector-dropdown-list-tips {
margin-top: 10px;
}
.add-global-class-tips {
margin-top: 10px;
}
.exist-class-item {
cursor: pointer;
height: 32px;
padding: 0 16px;
display: flex;
align-items: center;
font-size: 14px;
> span {
overflow: hidden;
text-overflow: ellipsis;
}
&.active,
&:hover {
background-color: var(--ti-lowcode-className-selector-dropdown-list-item-active-bg-color);
color: var(--ti-lowcode-className-selector-dropdown-list-item-color);
}
}
}
}
.state-selector {
flex: 4;
flex-shrink: 0;
margin-left: 4px;
}
}
.title {
margin: 0;
color: var(--ti-lowcode-className-selector-title-color);
}
</style>
<style lang="less">
.tiny-popover.tiny-popper.del-selector-popper-wrapper {
width: 220px;
height: 108px;
box-sizing: border-box;
background-color: var(--ti-lowcode-className-selector-del-popover-bg-color);
padding: 6px;
.popper-confirm {
padding: 20px;
}
.popper-confirm-header {
font-size: 12px;
color: var(--ti-lowcode-className-selector-del-popover-title-color);
.icon {
color: var(--ti-lowcode-warning-color);
width: 16px;
height: 16px;
}
.title {
margin-left: 4px;
}
}
.popper-confirm-footer {
text-align: center;
margin-top: 22px;
}
}
</style>

View File

@ -388,7 +388,6 @@ export default {
const { setPosition } = useModal()
const { getSettingFlag, getProperty } = useProperties({
props,
names: Object.values(EFFECTS_PROPERTY),
parseNumber: true
})

View File

@ -10,32 +10,19 @@
*
*/
import BackgroundGroup from './background/BackgroundGroup.vue'
import BorderGroup from './border/BorderGroup.vue'
import EffectGroup from './effects/EffectGroup.vue'
import ImageSelect from './inputs/ImageSelect.vue'
import ResetButton from './inputs/ResetButton.vue'
import LayoutGroup from './layout/LayoutGroup.vue'
import PositionGroup from './position/PositionGroup.vue'
import BoxShadowGroup from './shadow/BoxShadowGroup.vue'
import SizeGroup from './size/SizeGroup.vue'
import SpacingGroup from './spacing/SpacingGroup.vue'
import SpacingSetting from './spacing/SpacingSetting.vue'
import TypographyGroup from './typography/TypographyGroup.vue'
import TypographyMore from './typography/TypographyMore.vue'
export default {
BackgroundGroup,
BorderGroup,
EffectGroup,
ImageSelect,
ResetButton,
LayoutGroup,
PositionGroup,
BoxShadowGroup,
SizeGroup,
SpacingGroup,
SpacingSetting,
TypographyGroup,
TypographyMore
}
export { default as BackgroundGroup } from './background/BackgroundGroup.vue'
export { default as BorderGroup } from './border/BorderGroup.vue'
export { default as EffectGroup } from './effects/EffectGroup.vue'
export { default as ImageSelect } from './inputs/ImageSelect.vue'
export { default as ResetButton } from './inputs/ResetButton.vue'
export { default as LayoutGroup } from './layout/LayoutGroup.vue'
export { default as PositionGroup } from './position/PositionGroup.vue'
export { default as BoxShadowGroup } from './shadow/BoxShadowGroup.vue'
export { default as SizeGroup } from './size/SizeGroup.vue'
export { default as SpacingGroup } from './spacing/SpacingGroup.vue'
export { default as SpacingSetting } from './spacing/SpacingSetting.vue'
export { default as TypographyGroup } from './typography/TypographyGroup.vue'
export { default as TypographyMore } from './typography/TypographyMore.vue'
export { default as FlexBox } from './layout/FlexBox.vue'
export { default as GridBox } from './layout/GridBox.vue'
export { default as ClassNamesContainer } from './classNamesContainer/index.vue'

View File

@ -176,7 +176,6 @@ export default {
const showModal = ref(false)
const { getSettingFlag } = useProperties({
props,
names: Object.values(FLEX_PROPERTY),
parseNumber: true
})

View File

@ -361,7 +361,6 @@ export default {
})
const { getProperty, getSettingFlag } = useProperties({
props,
names: Object.values(GRID_PROPERTY),
parseNumber: true
})

View File

@ -221,7 +221,6 @@
import { reactive, watchEffect } from 'vue'
import { Tooltip } from '@opentiny/vue'
import { MetaSelect } from '@opentiny/tiny-engine-common'
import { camelize } from '@opentiny/tiny-engine-controller/utils'
import { push } from '@opentiny/vue-renderless/common/array'
import ModalMask, { useModal } from '../inputs/ModalMask.vue'
import SpacingSetting from '../spacing/SpacingSetting.vue'
@ -368,7 +367,6 @@ export default {
}
const { getProperty, getSettingFlag, getPropertyValue, getPropertyText } = useProperties({
props,
names: Object.values(POSITION_PROPERTY),
parseNumber: true
})
@ -413,7 +411,6 @@ export default {
}
const openDirectionSetting = (type, styleName) => {
styleName = camelize(styleName)
state.property = {
type,

View File

@ -339,7 +339,6 @@ export default {
})
const { getProperty, getSettingFlag, getPropertyValue } = useProperties({
props,
names: Object.values(SIZE_PROPERTY),
parseNumber: true
})

View File

@ -397,7 +397,6 @@
<script>
import { computed, reactive } from 'vue'
import { camelize } from '@opentiny/tiny-engine-controller/utils'
import SpacingSetting from './SpacingSetting.vue'
import ModalMask, { useModal } from '../inputs/ModalMask.vue'
import useEvent from '../../js/useEvent'
@ -433,8 +432,7 @@ export default {
const spacing = computed(() => {
const properties = {}
Object.values(SPACING_PROPERTY).forEach((str) => {
const name = camelize(str)
Object.values(SPACING_PROPERTY).forEach((name) => {
const value = props.style[name]
properties[name] = {
@ -447,6 +445,21 @@ export default {
return reactive(properties)
})
const getSettingFlag = (styleName) => Boolean(spacing.value[styleName]?.setting)
const getPropertyText = (styleName) => spacing.value[styleName]?.text || 0
const getPropertyValue = (styleName) => spacing.value[styleName]?.value
//
const openSetting = (type, styleName) => {
state.property = {
type,
name: styleName,
value: getPropertyValue(styleName)
}
state.showModal = true
}
const clickMargin = (styleName, event) => {
state.className = styleName
state.show = true
@ -463,28 +476,11 @@ export default {
openSetting(SPACING_PROPERTY.Padding, styleName)
}
//
const openSetting = (type, styleName) => {
styleName = camelize(styleName)
state.property = {
type,
name: styleName,
value: getPropertyValue(styleName)
}
state.showModal = true
}
const closeModal = () => {
state.show = false
state.showModal = false
}
const getSettingFlag = (styleName) => Boolean(spacing.value[camelize(styleName)]?.setting)
const getPropertyText = (styleName) => spacing.value[camelize(styleName)]?.text || 0
const getPropertyValue = (styleName) => spacing.value[camelize(styleName)]?.value
// style
const update = (property) => {
//

View File

@ -239,7 +239,6 @@ export default {
let activedName = ''
const showModal = ref(false)
const { getProperty, getSettingFlag, getPropertyValue } = useProperties({
props,
names: Object.values(TYPO_PROPERTY),
parseNumber: true
})

View File

@ -0,0 +1,274 @@
import postcss from 'postcss'
const handleRules = (node) => {
const declarations = node.nodes || []
const style = {}
let selectors = node.selectors || ''
let commentIndex = 0
if (Array.isArray(selectors)) {
selectors = selectors.join(',')
}
declarations.forEach(({ prop, value, important, type, text }) => {
if (type === 'decl') {
style[prop] = {
type,
value: `${value}${important ? '!important' : ''}`
}
} else if (type === 'comment') {
style[`comment${commentIndex}`] = {
type,
value: `/*${text}*/`
}
commentIndex++
}
})
return {
selectors,
style
}
}
const handleAtRules = (node) => {
// 这里我们不处理 at rules(如 @media、@keyframe 等规则), 直接转换成字符串
const { source = {}, type } = node
const { start, end, input } = source
const rawString = input.css.slice(start.offset, end.offset)
return {
type,
style: {
type,
value: rawString
}
}
}
const handleComments = (node) => {
const { type, text } = node
return {
type,
style: {
type,
value: `/*${text}*/`
}
}
}
const nodeHandlerMap = {
rule: handleRules,
atrule: handleAtRules,
comment: handleComments
}
/**
* css 字符串解析成 css 对象
* @param {string} css css 字符串
* @returns
*/
export const parser = (css) => {
const parseList = []
const selectors = []
const styleObject = {}
if (!css) {
return {
parseList,
selectors,
styleObject
}
}
const ast = postcss().process(css).sync().root
ast.nodes.forEach((node) => {
const { type } = node
const result = nodeHandlerMap[type](node)
parseList.push(result)
})
parseList.forEach((item) => {
if (!item.selectors) {
return
}
// 不支持属性选择器,以及组合选择器
if (/[,[\]>~+]/.test(item.selectors)) {
return
}
let selector = item.selectors
let mouseState = ''
if (selector.includes(':')) {
const [pureSelector, innerMouseState] = selector.split(':')
// 仅支持部分伪类选择器
if (!['hover', 'pressed', 'focused', 'disabled'].includes(innerMouseState)) {
return
}
selector = pureSelector
mouseState = innerMouseState
}
selectors.push(selector)
styleObject[item.selectors] = {
mouseState,
pureSelector: selector
}
const rules = {}
Object.entries(item.style).forEach(([key, value]) => {
if (value.type !== 'decl') {
return
}
rules[key] = value.value
})
styleObject[item.selectors].rules = rules
})
return {
parseList,
selectors,
styleObject
}
}
/**
* 拿到组合选择器的数组比如 .test1.test2 得到 ['.test1', '.test2']
* @param {string} selector
* @returns
*/
export const getSelectorArr = (selector) => {
const res = []
if (!selector || typeof selector !== 'string') {
return res
}
const separator = ['.', '#']
for (let i = 0; i < selector.length; i++) {
let str = selector[i]
i++
while (!separator.includes(selector[i]) && i < selector.length) {
str += selector[i]
i++
}
res.push(str)
i--
}
return res
}
// 根据配置替换选择器
const getFinalSelector = (config = {}) => {
const { selectorStr, originSelector, newSelector } = config
if (!originSelector || !newSelector) {
return selectorStr
}
const { pureSelector, mouseState } = config
const selectorArr = getSelectorArr(pureSelector)
let finalSelector = selectorArr
.map((item) => {
if (item === originSelector) {
return newSelector
}
return item
})
.join('')
if (mouseState) {
finalSelector += `:${mouseState}`
}
return finalSelector
}
/**
* 序列化对象成 css 字符串
* @param {object} originParseList 原解析对象
* @param {object} styleObject 可能被编辑过的 styleobject
* @param {object} config 配置可以配置替换制定选择器
* @returns string
*/
export const stringify = (originParseList, styleObject, config = {}) => {
let str = ''
const originSelectors = []
// 配置需要替换的选择器
const { originSelector, newSelector } = config
originParseList.forEach((item) => {
if (['comment', 'atrule'].includes(item.type) || !item.selectors) {
str += `\n${item.style.value}\n`
return
}
originSelectors.push(item.selectors)
if (!styleObject[item.selectors]) {
str += `${item.selectors} {\n`
for (const [key, value] of Object.entries(item.style)) {
if (key.includes('comment')) {
str += `${value.value}\n`
} else {
str += `${key}: ${value.value};\n`
}
}
} else {
const { mouseState, pureSelector } = styleObject[item.selectors]
const sel = getFinalSelector({
selectorStr: item.selectors,
originSelector,
newSelector,
pureSelector,
mouseState
})
str += `${sel} {\n`
// 在 styleObject 的,可能有改动,所以需要用 styleObject 拼接
for (const [key, value] of Object.entries(styleObject[item.selectors].rules)) {
str += `${key}: ${value};\n`
}
}
str += '}\n'
})
// 需要找出 styleObject 新增的选择器,然后写入到 str 中
Object.entries(styleObject).forEach(([selector, value]) => {
if (originSelectors.includes(selector)) {
return
}
// 这里是新增的选择器,需要写入
str += `${selector} {\n`
for (const [declKey, declValue] of Object.entries(value.rules)) {
str += `${declKey}: ${declValue};\n`
}
str += '}\n'
})
return str
}

View File

@ -11,26 +11,17 @@
*/
import { computed, reactive, watch } from 'vue'
import { useBroadcastChannel } from '@vueuse/core'
import { getSchema as getCanvasPageSchema, updateRect, setPageCss } from '@opentiny/tiny-engine-canvas'
import { useCanvas, useHistory, useProperties as useProps } from '@opentiny/tiny-engine-controller'
import { camelize } from '@opentiny/tiny-engine-controller/utils'
import { obj2StyleStr } from '@opentiny/tiny-engine-common/js/css'
import { styleStr2Obj, styleStrRemoveRoot } from './cssConvert'
import { updateRect, getSchema as getCanvasPageSchema } from '@opentiny/tiny-engine-canvas'
import { formatString } from '@opentiny/tiny-engine-common/js/ast'
import { constants, utils } from '@opentiny/tiny-engine-utils'
import { parser, stringify, getSelectorArr } from './parser'
const getStyleObj = (styleStr) => {
let obj = {}
const { BROADCAST_CHANNEL, EXPRESSION_TYPE } = constants
const { generateRandomLetters, parseExpression } = utils
if (typeof styleStr === 'string') {
obj = styleStr2Obj(styleStr)
}
return obj
}
export default () => {
const { getPageSchema, getCurrentSchema } = useCanvas()
const { getSchema } = useProps()
const { addHistory } = useHistory()
const { data: schemaLength } = useBroadcastChannel({ name: BROADCAST_CHANNEL.SchemaLength })
const state = reactive({
// 当前选中节点的 style解析成对象返回
@ -38,33 +29,267 @@ export default () => {
// 编辑器显示的行内样式字符串
styleContent: '',
// 编辑器显示的全局样式字符串
cssContent: ''
cssContent: '',
pageCssObject: {},
currentClassSelector: '',
existClassSelectors: [],
className: {
classNameList: '',
mouseState: ''
},
cssParseList: [],
selectors: [],
styleObject: {},
currentClassNameList: [],
currentIdList: [],
selectorOptionLists: [],
schemaUpdateKey: 0
})
const getCurrentClassSelector = () => {
let res = `${state.className.classNameList}`
const mouseState = state.className.mouseState
if (mouseState) {
res += `:${mouseState}`
}
return res
}
// 根据当前选中的组件,随机生成一个 css 类名
export const genRandomClassNames = (componentName) => {
return `.${componentName}-${generateRandomLetters(5)}`.toLowerCase()
}
const getPropsFromExpression = (propValue) => {
let res = []
try {
const expressRes = parseExpression(propValue?.value)
if (Array.isArray(expressRes)) {
res = expressRes
.map((item) => {
if (typeof item === 'string') {
return item
}
if (typeof item === 'object') {
return Object.keys(item)
}
return null
})
.flat()
.filter(Boolean)
} else if (typeof expressRes === 'string' && expressRes) {
res = [expressRes]
}
} catch (e) {
// 不做处理
}
return res
}
const parseClassOrIdProps = (propValue) => {
if (typeof propValue === 'string' && propValue) {
return propValue.split(' ').filter(Boolean)
}
let res = []
if (propValue?.type === EXPRESSION_TYPE.JS_EXPRESSION) {
return getPropsFromExpression(propValue)
}
return res
}
const getClassNameAndIdList = (schema) => {
let classNameList = []
let idList = []
if (!schema) {
return {
classNameList,
idList
}
}
const classNameStr = schema?.props?.className
const idStr = schema?.props?.id
classNameList = parseClassOrIdProps(classNameStr)
idList = parseClassOrIdProps(idStr)
return {
classNameList,
idList
}
}
const { getPageSchema, getCurrentSchema } = useCanvas()
const { getSchema, propsUpdateKey } = useProps()
const { addHistory } = useHistory()
watch(
() => [getCurrentSchema(), state.schemaUpdateKey, propsUpdateKey.value, getCanvasPageSchema(), schemaLength],
([curSchema], [oldCurSchema] = []) => {
let schema = getCurrentSchema()
if (!schema || Object.keys(schema).length === 0) {
schema = getCanvasPageSchema()
}
if (!schema) {
return
}
// 获取当前选中组件的类名以及 id 列表
const { classNameList, idList } = getClassNameAndIdList(schema)
state.currentClassNameList = classNameList.map((item) => `.${item}`)
state.currentIdList = idList.map((item) => `#${item}`)
// 变化了相当于重新选中了,需要重置当前选中的 className 以及样式面板的样式
if (curSchema !== oldCurSchema) {
state.className = {
classNameList: '',
mouseState: ''
}
state.style = {}
}
state.styleContent = `:root {\n ${schema?.props?.style || ''}\n}`
},
{
immediate: true,
deep: true
}
)
// 监听全局样式的变化,重新解析
watch(
() => getPageSchema()?.css,
(value) => {
state.cssContent = value || ''
// 解析css
const { parseList, selectors, styleObject } = parser(value)
state.cssParseList = parseList
state.selectors = selectors
state.styleObject = styleObject
}
)
const setStyle = (styleString) => {
state.style = styleStr2Obj(styleString)
// 计算当前类名下拉列表
watch(
() => [state.currentClassNameList, state.currentIdList, state.styleObject],
() => {
let list = []
const classNameListOptions = state.currentClassNameList.map((item) => ({ label: item, value: item }))
const idListOptions = state.currentIdList.map((item) => ({ label: item, value: item }))
list = list.concat(classNameListOptions, idListOptions)
Object.values(state.styleObject).forEach((value) => {
const selectorArr = getSelectorArr(value.pureSelector)
if (selectorArr.length <= 1) {
return
}
const isComboSelector = selectorArr.every(
(item) => state.currentClassNameList.includes(item) || state.currentIdList.includes(item)
)
if (isComboSelector) {
list.push({ label: value.pureSelector, value: value.pureSelector })
}
})
// 默认选择的类
let defaultSelector = ''
let defaultMouseState = ''
const curClassName = state.className.classNameList
if (list.find(({ value }) => value === curClassName)) {
defaultSelector = curClassName
defaultMouseState = state.className.mouseState
} else if (list.length) {
defaultSelector = list.at(-1).value
}
state.selectorOptionLists = list
state.className = {
classNameList: defaultSelector,
mouseState: defaultMouseState
}
}
)
// 计算当前样式面板展示的样式
watch(
[() => getCurrentSchema(), () => getCanvasPageSchema()],
() => state.className,
() => {
const schema = getCurrentSchema() || getCanvasPageSchema()
const styleString = schema?.props?.style
state.styleContent = obj2StyleStr(getStyleObj(styleString))
setStyle(styleString)
const { classNameList, mouseState } = state.className
if (!classNameList) {
return
}
const matchStyles = Object.values(state.styleObject).filter(
(value) => value.pureSelector === classNameList && value.mouseState === mouseState
)
const style = matchStyles.length ? matchStyles[0].rules : {}
state.style = style
},
{
immediate: true
deep: true
}
)
export const updateGlobalStyleStr = (styleStr) => {
const pageSchema = getPageSchema()
pageSchema.css = styleStr
getCanvasPageSchema().css = styleStr
setPageCss(styleStr)
state.schemaUpdateKey++
}
const updateGlobalStyle = (newSelector) => {
let currentSelector = getCurrentClassSelector()
const mouseState = state.className.mouseState
if (newSelector) {
currentSelector = newSelector
if (mouseState) {
currentSelector += `:${mouseState}`
}
}
state.styleObject[currentSelector] = {
...(state.styleObject[currentSelector] || {}),
rules: state.style
}
if (!Object.keys(state.style).length) {
delete state.styleObject[currentSelector]
}
const styleStr = formatString(stringify(state.cssParseList, state.styleObject), 'css')
updateGlobalStyleStr(styleStr)
}
// 更新 style 对象到 schema
const updateStyle = (properties) => {
const schema = getSchema() || getCanvasPageSchema()
@ -72,30 +297,55 @@ export default () => {
if (properties) {
Object.entries(properties).forEach(([key, value]) => {
state.style[camelize(key)] = value
state.style[key] = value
})
}
state.styleContent = obj2StyleStr(state.style)
const newStyleStr = styleStrRemoveRoot(state.styleContent)
const currentSelector = getCurrentClassSelector()
let randomClassName = ''
if (newStyleStr) {
schema.props.style = styleStrRemoveRoot(state.styleContent)
} else {
delete schema.props.style
const classNames = schema.props.className || ''
// 不存在选择器,需要生成一个随机类名,添加到当前选中组件中,然后写入到全局样式
if (!currentSelector && typeof classNames === 'string') {
randomClassName = genRandomClassNames(schema?.componentName || 'component')
let newClassNames = randomClassName.slice(1)
if (classNames) {
newClassNames = `${classNames} ${newClassNames}`
}
schema.props.className = newClassNames
state.className.classNameList = randomClassName
}
// 更新到全局样式
updateGlobalStyle(randomClassName)
addHistory()
updateRect()
}
export default () => {
return {
state,
setStyle,
updateStyle
}
}
const getTextOfValue = (value) => {
const basicValueMap = {
auto: 'auto',
none: 'none'
}
if (basicValueMap[value] || /^\d+(\.\d+)?%$/.test(value)) {
return value
}
return String(Number.parseInt(value) || '')
}
/**
* 根据 style 对象生成样式属性对象 properties
* styleName: {
@ -105,28 +355,20 @@ export default () => {
* setting // 属性是否已设置值
* }
*/
export const useProperties = ({ props, names, parseNumber }) => {
export const useProperties = ({ names, parseNumber }) => {
const properties = computed(() => {
const properties = {}
if (Array.isArray(names) && props.style) {
const newProperties = {}
if (Array.isArray(names) && state.style) {
names.forEach((name) => {
name = camelize(name)
const value = props.style[name]
const value = state.style[name]
let text = value || ''
if (parseNumber) {
if (value === 'auto') {
text = 'auto'
} else if (value === 'none') {
text = 'none'
} else if (/^\d+(\.\d+)?%$/.test(value)) {
text = value
} else {
text = String(Number.parseInt(value) || '')
}
text = getTextOfValue(value)
}
properties[name] = {
newProperties[name] = {
name, // 属性名
text, // 界面显示的值
value, // 属性原始值
@ -135,13 +377,13 @@ export const useProperties = ({ props, names, parseNumber }) => {
})
}
return reactive(properties)
return newProperties
})
const getProperty = (styleName) => properties.value[camelize(styleName)]
const getSettingFlag = (styleName) => Boolean(properties.value[camelize(styleName)]?.setting)
const getPropertyText = (styleName) => properties.value[camelize(styleName)]?.text
const getPropertyValue = (styleName) => properties.value[camelize(styleName)]?.value
const getProperty = (styleName) => properties.value[styleName]
const getSettingFlag = (styleName) => Boolean(properties.value[styleName]?.setting)
const getPropertyText = (styleName) => properties.value[styleName]?.text
const getPropertyValue = (styleName) => properties.value[styleName]?.value
return {
properties,

View File

@ -22,3 +22,24 @@
--ti-lowcode-block-link-field-link-icon-color: var(--ti-lowcode-base-gray-0);
--ti-lowcode-block-link-field-link-icon-bg-color: var(--ti-lowcode-base-success-color);
}
.className-container {
--ti-lowcode-className-selector-container-color: var(--ti-lowcode-base-text-color);
--ti-lowcode-className-selector-container-error-border-color: var(--ti-lowcode-base-error-color);
--ti-lowcode-className-selector-container-error-bg-color: rgba(242, 48, 48, 0.1);
--ti-lowcode-className-selector-error-tips-color: var(--ti-lowcode-base-error-color);
--ti-lowcode-className-selector-container-border-color: var(--ti-lowcode-base-gray-40);
--ti-lowcode-className-selector-container-hover-border-color: var(--ti-lowcode-base-primary-color-2);
--ti-lowcode-className-selector-container-empty-tips-color: var(--ti-lowcode-base-text-color-1);
--ti-lowcode-className-selector-container-label-bg-color: var(--ti-lowcode-base-blue-6);
--ti-lowcode-className-selector-container-option-btn-color: var(--ti-lowcode-base-gray-0);
--ti-lowcode-className-selector-dropdown-list-bg-color: #202020;
--ti-lowcode-className-selector-dropdown-list-item-color: var(--ti-lowcode-base-text-color);
--ti-lowcode-className-selector-dropdown-list-item-active-bg-color: var(--ti-lowcode-base-bg-2);
--ti-lowcode-className-selector-title-color: var(--ti-lowcode-base-text-color);
}
:root {
--ti-lowcode-className-selector-del-popover-bg-color: var(--ti-lowcode-base-bg-5);
--ti-lowcode-className-selector-del-popover-title-color: var(--ti-lowcode-base-text-color);
}

View File

@ -22,3 +22,24 @@
--ti-lowcode-block-link-field-link-icon-color: var(--ti-lowcode-base-gray-0);
--ti-lowcode-block-link-field-link-icon-bg-color: var(--ti-lowcode-base-success-color);
}
.className-container {
--ti-lowcode-className-selector-container-color: var(--ti-lowcode-base-text-color);
--ti-lowcode-className-selector-container-error-border-color: var(--ti-lowcode-base-error-color);
--ti-lowcode-className-selector-container-error-bg-color: rgba(242, 48, 48, 0.1);
--ti-lowcode-className-selector-error-tips-color: var(--ti-lowcode-base-error-color);
--ti-lowcode-className-selector-container-border-color: var(--ti-lowcode-base-gray-40);
--ti-lowcode-className-selector-container-hover-border-color: var(--ti-lowcode-base-gray-90);
--ti-lowcode-className-selector-container-empty-tips-color: var(--ti-lowcode-base-text-color-1);
--ti-lowcode-className-selector-container-label-bg-color: var(--ti-lowcode-base-blue-6);
--ti-lowcode-className-selector-container-option-btn-color: var(--ti-lowcode-base-gray-0);
--ti-lowcode-className-selector-dropdown-list-bg-color: var(--ti-lowcode-base-gray-0);
--ti-lowcode-className-selector-dropdown-list-item-color: var(--ti-lowcode-base-text-color);
--ti-lowcode-className-selector-dropdown-list-item-active-bg-color: var(--ti-lowcode-base-bg-2);
--ti-lowcode-className-selector-title-color: var(--ti-lowcode-base-text-color);
}
:root {
--ti-lowcode-className-selector-del-popover-bg-color: var(--ti-lowcode-base-bg-5);
--ti-lowcode-className-selector-del-popover-title-color: var(--ti-lowcode-base-text-color);
}