mirror of
https://github.com/Powerful-517/yys-editor.git
synced 2026-01-23 22:43:28 +00:00
实现图片节点
This commit is contained in:
@@ -20,7 +20,7 @@ import { register } from '@logicflow/vue-node-registry';
|
|||||||
import ShikigamiSelectNode from './nodes/yys/ShikigamiSelectNode.vue';
|
import ShikigamiSelectNode from './nodes/yys/ShikigamiSelectNode.vue';
|
||||||
import YuhunSelectNode from './nodes/yys/YuhunSelectNode.vue';
|
import YuhunSelectNode from './nodes/yys/YuhunSelectNode.vue';
|
||||||
import PropertySelectNode from './nodes/yys/PropertySelectNode.vue';
|
import PropertySelectNode from './nodes/yys/PropertySelectNode.vue';
|
||||||
// import ImageNode from './nodes/common/ImageNode.vue';
|
import ImageNode from './nodes/common/ImageNode.vue';
|
||||||
// import TextNode from './nodes/common/TextNode.vue';
|
// import TextNode from './nodes/common/TextNode.vue';
|
||||||
import PropertyPanel from './PropertyPanel.vue';
|
import PropertyPanel from './PropertyPanel.vue';
|
||||||
import { useFilesStore } from "@/ts/useStore";
|
import { useFilesStore } from "@/ts/useStore";
|
||||||
@@ -53,7 +53,7 @@ function registerNodes(lfInstance: LogicFlow) {
|
|||||||
register({ type: 'yuhunSelect', component: YuhunSelectNode }, lfInstance);
|
register({ type: 'yuhunSelect', component: YuhunSelectNode }, lfInstance);
|
||||||
register({ type: 'propertySelect', component: PropertySelectNode }, lfInstance);
|
register({ type: 'propertySelect', component: PropertySelectNode }, lfInstance);
|
||||||
|
|
||||||
// register({ type: 'imageNode', component: ImageNode }, lfInstance);
|
register({ type: 'imageNode', component: ImageNode }, lfInstance);
|
||||||
// register({ type: 'textNode', component: TextNode }, lfInstance);
|
// register({ type: 'textNode', component: TextNode }, lfInstance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,15 +77,15 @@ onMounted(() => {
|
|||||||
{
|
{
|
||||||
text: '置于顶层',
|
text: '置于顶层',
|
||||||
callback(node: NodeData) {
|
callback(node: NodeData) {
|
||||||
console.log(lfInstance.getNodeModelById(node.id).zIndex)
|
|
||||||
lfInstance.setElementZIndex(node.id, 'top');
|
lfInstance.setElementZIndex(node.id, 'top');
|
||||||
|
console.log("置顶"+lfInstance.getNodeModelById(node.id).zIndex)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: '置于底层',
|
text: '置于底层',
|
||||||
callback(node: NodeData) {
|
callback(node: NodeData) {
|
||||||
console.log(lfInstance.getNodeModelById(node.id).zIndex)
|
|
||||||
lfInstance.setElementZIndex(node.id, 'bottom');
|
lfInstance.setElementZIndex(node.id, 'bottom');
|
||||||
|
console.log("置底"+lfInstance.getNodeModelById(node.id).zIndex)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -215,4 +215,4 @@ function handlePaneContextMenu({ e }: { e: MouseEvent }) {
|
|||||||
background-color: #f5f7fa;
|
background-color: #f5f7fa;
|
||||||
color: #409eff;
|
color: #409eff;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, reactive, watch } from 'vue';
|
||||||
import type LogicFlow from '@logicflow/core';
|
|
||||||
// import { useVueFlow } from '@vue-flow/core';
|
|
||||||
import { QuillEditor } from '@vueup/vue-quill';
|
|
||||||
import '@vueup/vue-quill/dist/vue-quill.snow.css';
|
|
||||||
import { useDialogs } from '../../ts/useDialogs';
|
import { useDialogs } from '../../ts/useDialogs';
|
||||||
import { useFilesStore } from '@/ts/useStore';
|
|
||||||
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
|
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
|
||||||
|
|
||||||
|
type FitMode = 'contain' | 'cover' | 'fill';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
height: {
|
height: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -19,7 +16,6 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const filesStore = useFilesStore();
|
|
||||||
const { openDialog } = useDialogs();
|
const { openDialog } = useDialogs();
|
||||||
|
|
||||||
const selectedNode = computed(() => props.node);
|
const selectedNode = computed(() => props.node);
|
||||||
@@ -31,12 +27,60 @@ const nodeType = computed(() => {
|
|||||||
return selectedNode.value.type || 'default';
|
return selectedNode.value.type || 'default';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type ImageForm = {
|
||||||
|
url: string;
|
||||||
|
fit: FitMode;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageForm = reactive<ImageForm>({
|
||||||
|
url: '',
|
||||||
|
fit: 'contain',
|
||||||
|
width: 180,
|
||||||
|
height: 120
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseNumber = (value: any, fallback: number) => {
|
||||||
|
const num = Number(value);
|
||||||
|
return Number.isFinite(num) ? num : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImageProps = (node?: any): ImageForm => {
|
||||||
|
const props = node?.properties ?? {};
|
||||||
|
const style = props.style ?? {};
|
||||||
|
return {
|
||||||
|
url: props.image?.url ?? props.url ?? '',
|
||||||
|
fit: (props.image?.fit ?? props.fit ?? 'contain') as FitMode,
|
||||||
|
width: parseNumber(props.width ?? style.width ?? node?.width, 180),
|
||||||
|
height: parseNumber(props.height ?? style.height ?? node?.height, 120)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => selectedNode.value,
|
||||||
|
(node) => {
|
||||||
|
if (!node) {
|
||||||
|
imageForm.url = '';
|
||||||
|
imageForm.fit = 'contain';
|
||||||
|
imageForm.width = 180;
|
||||||
|
imageForm.height = 120;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = getImageProps(node);
|
||||||
|
imageForm.url = next.url || '';
|
||||||
|
imageForm.fit = next.fit;
|
||||||
|
imageForm.width = next.width;
|
||||||
|
imageForm.height = next.height;
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
// 通用的弹窗处理方法
|
// 通用的弹窗处理方法
|
||||||
const handleOpenDialog = (type: 'shikigami' | 'yuhun' | 'property') => {
|
const handleOpenDialog = (type: 'shikigami' | 'yuhun' | 'property') => {
|
||||||
const lf = getLogicFlowInstance();
|
const lf = getLogicFlowInstance();
|
||||||
if (selectedNode.value && lf) {
|
if (selectedNode.value && lf) {
|
||||||
const node = selectedNode.value;
|
const node = selectedNode.value;
|
||||||
// 取 properties 下的 type 字段
|
|
||||||
const currentData = node.properties && node.properties[type] ? node.properties[type] : undefined;
|
const currentData = node.properties && node.properties[type] ? node.properties[type] : undefined;
|
||||||
|
|
||||||
openDialog(
|
openDialog(
|
||||||
@@ -53,25 +97,62 @@ const handleOpenDialog = (type: 'shikigami' | 'yuhun' | 'property') => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageUpload = (e) => {
|
const applyImageChanges = (partial: Partial<ImageForm>) => {
|
||||||
const file = e.target.files[0];
|
const lf = getLogicFlowInstance();
|
||||||
|
const node = selectedNode.value;
|
||||||
|
if (!lf || !node) return;
|
||||||
|
|
||||||
|
const baseProps = node.properties || {};
|
||||||
|
const merged = { ...getImageProps(node), ...partial };
|
||||||
|
|
||||||
|
const nextProps = {
|
||||||
|
...baseProps,
|
||||||
|
...merged,
|
||||||
|
width: merged.width,
|
||||||
|
height: merged.height,
|
||||||
|
style: {
|
||||||
|
...(baseProps.style || {}),
|
||||||
|
width: merged.width,
|
||||||
|
height: merged.height
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
...(baseProps.image || {}),
|
||||||
|
url: merged.url,
|
||||||
|
fit: merged.fit
|
||||||
|
},
|
||||||
|
url: merged.url
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(imageForm, merged);
|
||||||
|
lf.setProperties(node.id, nextProps);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageUpload = (e: Event) => {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const file = input?.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (evt) => {
|
reader.onload = (evt) => {
|
||||||
// updateNodeData('url', evt.target.result);
|
const result = evt.target?.result as string;
|
||||||
|
if (result) {
|
||||||
|
applyImageChanges({ url: result });
|
||||||
|
}
|
||||||
|
if (input) input.value = '';
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImageUrlChange = () => {
|
||||||
|
applyImageChanges({ url: imageForm.url });
|
||||||
|
};
|
||||||
|
|
||||||
const quillToolbar = [
|
const handleSizeChange = () => {
|
||||||
[{ header: 1 }, { header: 2 }],
|
applyImageChanges({ width: imageForm.width, height: imageForm.height });
|
||||||
['bold', 'italic', 'underline', 'strike'],
|
};
|
||||||
[{ color: [] }, { background: [] }],
|
|
||||||
[{ list: 'bullet' }, { list: 'ordered' }],
|
const handleFitChange = (val: FitMode) => {
|
||||||
[{ align: [] }],
|
applyImageChanges({ fit: val });
|
||||||
['clean']
|
};
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -143,10 +224,71 @@ const quillToolbar = [
|
|||||||
<!-- 图片节点属性 -->
|
<!-- 图片节点属性 -->
|
||||||
<div v-if="nodeType === 'imageNode'" class="property-section">
|
<div v-if="nodeType === 'imageNode'" class="property-section">
|
||||||
<div class="section-header">图片设置</div>
|
<div class="section-header">图片设置</div>
|
||||||
|
|
||||||
<div class="property-item">
|
<div class="property-item">
|
||||||
<input type="file" accept="image/*" @change="handleImageUpload" />
|
<div class="property-label">图片 URL</div>
|
||||||
<div v-if="selectedNode.value.properties && selectedNode.value.properties.url" style="margin-top:8px;">
|
<div class="property-value">
|
||||||
<img :src="selectedNode.value.properties.url" alt="预览" style="max-width:100%;max-height:100px;" />
|
<el-input
|
||||||
|
v-model="imageForm.url"
|
||||||
|
size="small"
|
||||||
|
placeholder="输入图片链接或上传文件"
|
||||||
|
style="width: 100%;"
|
||||||
|
@change="handleImageUrlChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="property-item">
|
||||||
|
<div class="property-label">上传文件</div>
|
||||||
|
<div class="property-value upload-row">
|
||||||
|
<input class="upload-input" type="file" accept="image/*" @change="handleImageUpload" />
|
||||||
|
<span class="upload-hint">本地上传将以 base64 保存</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="property-item">
|
||||||
|
<div class="property-label">显示模式</div>
|
||||||
|
<div class="property-value">
|
||||||
|
<el-select
|
||||||
|
v-model="imageForm.fit"
|
||||||
|
size="small"
|
||||||
|
style="width: 100%;"
|
||||||
|
@change="handleFitChange"
|
||||||
|
>
|
||||||
|
<el-option label="自适应" value="contain" />
|
||||||
|
<el-option label="填充" value="cover" />
|
||||||
|
<el-option label="拉伸" value="fill" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="property-item size-item">
|
||||||
|
<div class="property-label">宽 / 高</div>
|
||||||
|
<div class="property-value size-inputs">
|
||||||
|
<el-input-number
|
||||||
|
v-model="imageForm.width"
|
||||||
|
:min="40"
|
||||||
|
:max="1000"
|
||||||
|
size="small"
|
||||||
|
style="width: 120px;"
|
||||||
|
@change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
<span class="size-divider">×</span>
|
||||||
|
<el-input-number
|
||||||
|
v-model="imageForm.height"
|
||||||
|
:min="40"
|
||||||
|
:max="1000"
|
||||||
|
size="small"
|
||||||
|
style="width: 120px;"
|
||||||
|
@change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="imageForm.url" class="property-item">
|
||||||
|
<div class="property-label">预览</div>
|
||||||
|
<div class="property-value image-preview">
|
||||||
|
<img :src="imageForm.url" alt="预览" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,4 +382,50 @@ const quillToolbar = [
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
.property-value.upload-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-item .property-value {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-inputs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-divider {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
background: #fafafa;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 140px;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,65 +1,94 @@
|
|||||||
<!--<script setup lang="ts">-->
|
<script setup lang="ts">
|
||||||
<!--import {ref, watch} from 'vue';-->
|
import { inject, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
<!--import {Handle, useVueFlow} from '@vue-flow/core';-->
|
import { EventType } from '@logicflow/core';
|
||||||
<!--import {NodeResizer} from '@vue-flow/node-resizer';-->
|
|
||||||
<!--import '@vue-flow/node-resizer/dist/style.css';-->
|
|
||||||
|
|
||||||
<!--const props = defineProps({-->
|
type FitMode = 'contain' | 'cover' | 'fill';
|
||||||
<!-- data: Object,-->
|
|
||||||
<!-- id: String,-->
|
|
||||||
<!-- selected: Boolean-->
|
|
||||||
<!--});-->
|
|
||||||
|
|
||||||
<!--const nodeWidth = ref(180);-->
|
const getNode = inject('getNode') as (() => any) | undefined;
|
||||||
<!--const nodeHeight = ref(120);-->
|
const getGraph = inject('getGraph') as (() => any) | undefined;
|
||||||
|
|
||||||
<!--// 监听props.data变化,支持外部更新图片-->
|
const imageUrl = ref('');
|
||||||
<!--watch(() => props.data, (newData) => {-->
|
const fit = ref<FitMode>('contain');
|
||||||
<!-- if (newData && newData.width) nodeWidth.value = newData.width;-->
|
|
||||||
<!-- if (newData && newData.height) nodeHeight.value = newData.height;-->
|
|
||||||
<!--}, {immediate: true});-->
|
|
||||||
|
|
||||||
<!--</script>-->
|
const refreshFromProps = (props?: any, node?: any) => {
|
||||||
<!--<template>-->
|
const targetProps = props ?? node?.properties ?? {};
|
||||||
<!-- <NodeResizer v-if="selected" :min-width="60" :min-height="60" :max-width="400" :max-height="400"/>-->
|
imageUrl.value = targetProps.image?.url ?? targetProps.url ?? '';
|
||||||
<!-- <div class="image-node">-->
|
fit.value = targetProps.image?.fit ?? targetProps.fit ?? 'contain';
|
||||||
<!-- <Handle type="target" position="left" :id="`${id}-target`"/>-->
|
};
|
||||||
<!-- <div class="image-content">-->
|
|
||||||
<!-- <img v-if="props.data && props.data.url" :src="props.data.url" alt="图片节点"-->
|
|
||||||
<!-- style="width:100%;height:100%;object-fit:contain;"/>-->
|
|
||||||
<!-- <div v-else class="image-placeholder">未上传图片</div>-->
|
|
||||||
<!-- </div>-->
|
|
||||||
<!-- <Handle type="source" position="right" :id="`${id}-source`"/>-->
|
|
||||||
<!-- </div>-->
|
|
||||||
<!--</template>-->
|
|
||||||
<!--<style scoped>-->
|
|
||||||
<!--.image-node {-->
|
|
||||||
<!-- background: #fff;-->
|
|
||||||
<!-- border: 1px solid #dcdfe6;-->
|
|
||||||
<!-- border-radius: 4px;-->
|
|
||||||
<!-- display: flex;-->
|
|
||||||
<!-- flex-direction: column;-->
|
|
||||||
<!-- align-items: center;-->
|
|
||||||
<!-- justify-content: center;-->
|
|
||||||
<!-- overflow: hidden;-->
|
|
||||||
<!-- position: relative;-->
|
|
||||||
<!-- width: 100%;-->
|
|
||||||
<!-- height: 100%;-->
|
|
||||||
<!-- min-width: 180px;-->
|
|
||||||
<!-- min-height: 180px;-->
|
|
||||||
<!--}-->
|
|
||||||
|
|
||||||
<!--.image-content {-->
|
let offChange: (() => void) | null = null;
|
||||||
<!-- position: relative;-->
|
|
||||||
<!-- width: 100%;-->
|
|
||||||
<!-- height: 100%;-->
|
|
||||||
<!-- display: flex;-->
|
|
||||||
<!-- align-items: center;-->
|
|
||||||
<!-- justify-content: center;-->
|
|
||||||
<!--}-->
|
|
||||||
|
|
||||||
<!--.image-placeholder {-->
|
onMounted(() => {
|
||||||
<!-- color: #bbb;-->
|
const node = getNode?.();
|
||||||
<!-- font-size: 14px;-->
|
refreshFromProps(node?.properties, node);
|
||||||
<!--}-->
|
|
||||||
<!--</style>-->
|
const graph = getGraph?.();
|
||||||
|
const handler = (eventData: any) => {
|
||||||
|
if (node && eventData.id === node.id) {
|
||||||
|
refreshFromProps(eventData.properties, node);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
graph?.eventCenter.on(EventType.NODE_PROPERTIES_CHANGE, handler);
|
||||||
|
offChange = () => graph?.eventCenter.off(EventType.NODE_PROPERTIES_CHANGE, handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
offChange?.();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="image-node">
|
||||||
|
<div class="image-wrapper">
|
||||||
|
<img v-if="imageUrl" :src="imageUrl" alt="图片节点" :style="{ objectFit: fit }" draggable="false" />
|
||||||
|
<div v-else class="placeholder">
|
||||||
|
<div class="placeholder-text">未上传图片</div>
|
||||||
|
<div class="placeholder-hint">在右侧属性面板上传或填写 URL</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.image-node {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 150px;
|
||||||
|
min-height: 120px;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-wrapper img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: #909399;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-text {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user