first commit
This commit is contained in:
705
frontend/src/module/editor/components/EditorCanvasFrame.vue
Normal file
705
frontend/src/module/editor/components/EditorCanvasFrame.vue
Normal file
@@ -0,0 +1,705 @@
|
||||
<template>
|
||||
<div class="editor-canvas-frame">
|
||||
<div v-if="baseUrl" class="editor-canvas-frame__viewport" :class="viewportClass">
|
||||
<iframe
|
||||
ref="frameRef"
|
||||
class="editor-canvas-frame__iframe"
|
||||
:src="baseUrl"
|
||||
title="editor-canvas"
|
||||
loading="lazy"
|
||||
@load="handleFrameLoad"
|
||||
/>
|
||||
<div
|
||||
class="editor-canvas-frame__status-pill"
|
||||
:class="`editor-canvas-frame__status-pill--${canvasStatusTag.type}`"
|
||||
>
|
||||
<span class="editor-canvas-frame__status-dot" />
|
||||
{{ canvasStatusTag.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="editor-canvas-frame__empty">
|
||||
<p class="editor-canvas-frame__empty-title">先填入要編輯的頁面網址</p>
|
||||
<p class="editor-canvas-frame__empty-body">
|
||||
畫布會直接載入目標頁面,之後所有可視化操作都會從這裡發生。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
|
||||
import {
|
||||
createStatusSummaryMessage,
|
||||
createFocusSelectorMessage,
|
||||
createInspectSelectorMessage,
|
||||
createStartInlineEditMessage,
|
||||
createMoveBlockMessage,
|
||||
createToggleBlockVisibilityMessage,
|
||||
createDuplicateBlockMessage,
|
||||
createDeleteBlockMessage,
|
||||
createInsertBlockMessage,
|
||||
createRestoreCanvasStateMessage,
|
||||
createPreviewOperationsMessage,
|
||||
editorBridgeEventType,
|
||||
normalizeCanvasSelectionPayload,
|
||||
} from "@/module/editor/model/editor-bridge-protocol"
|
||||
|
||||
const props = defineProps({
|
||||
baseUrl: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
deviceMode: {
|
||||
type: String,
|
||||
default: "desktop",
|
||||
},
|
||||
candidates: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedSelector: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
previewStatus: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
title: "",
|
||||
status: "idle",
|
||||
}),
|
||||
},
|
||||
previewOperations: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
feedbackSummary: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
title: "",
|
||||
selectorLabel: "",
|
||||
detail: "",
|
||||
helper: "",
|
||||
status: "info",
|
||||
}),
|
||||
},
|
||||
bridgeCommand: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
"select-candidate",
|
||||
"hover-candidate",
|
||||
"hover-clear",
|
||||
"hotspot-action",
|
||||
"inline-text-change",
|
||||
"inline-attribute-change",
|
||||
"inline-style-change",
|
||||
"canvas-ready",
|
||||
"bridge-outbound",
|
||||
])
|
||||
|
||||
const frameRef = ref(null)
|
||||
const canvasState = ref("idle")
|
||||
const canvasOrigin = ref("")
|
||||
const lastHoverSelector = ref("")
|
||||
const lastIncomingSelector = ref("")
|
||||
const lastFocusedSelector = ref("")
|
||||
const lastOutboundCommand = ref("")
|
||||
const viewportClass = computed(
|
||||
() => `editor-canvas-frame__viewport--${props.deviceMode || "desktop"}`
|
||||
)
|
||||
|
||||
const canvasStatusTag = computed(() => {
|
||||
switch (canvasState.value) {
|
||||
case "connected":
|
||||
return {
|
||||
type: "success",
|
||||
label: "已連線",
|
||||
}
|
||||
case "loaded":
|
||||
return {
|
||||
type: "info",
|
||||
label: "頁面已載入",
|
||||
}
|
||||
default:
|
||||
return {
|
||||
type: "warning",
|
||||
label: props.baseUrl ? "等待編輯橋接" : "尚未啟動",
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const handleFrameLoad = () => {
|
||||
canvasState.value = "loaded"
|
||||
canvasOrigin.value = ""
|
||||
pushSelectedSelectorToCanvas()
|
||||
}
|
||||
|
||||
const postBridgeMessage = (message, meta = {}) => {
|
||||
if (!frameRef.value?.contentWindow || !message?.type) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
[
|
||||
editorBridgeEventType.FOCUS_SELECTOR,
|
||||
editorBridgeEventType.INSPECT_SELECTOR,
|
||||
editorBridgeEventType.START_INLINE_EDIT,
|
||||
editorBridgeEventType.MOVE_BLOCK,
|
||||
editorBridgeEventType.TOGGLE_BLOCK_VISIBILITY,
|
||||
editorBridgeEventType.DUPLICATE_BLOCK,
|
||||
editorBridgeEventType.DELETE_BLOCK,
|
||||
editorBridgeEventType.INSERT_BLOCK,
|
||||
].includes(message.type) &&
|
||||
!message?.selector
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
frameRef.value.contentWindow.postMessage(message, canvasOrigin.value || "*")
|
||||
if (message.selector) {
|
||||
lastFocusedSelector.value = message.selector
|
||||
}
|
||||
lastOutboundCommand.value = meta.label || message.type
|
||||
emit("bridge-outbound", {
|
||||
type: meta.logType || message.type,
|
||||
value: message.selector,
|
||||
source: meta.source || "editor-workspace",
|
||||
})
|
||||
}
|
||||
|
||||
const pushSelectedSelectorToCanvas = () => {
|
||||
if (!props.selectedSelector || !frameRef.value?.contentWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
postBridgeMessage(
|
||||
createFocusSelectorMessage({
|
||||
selector: props.selectedSelector,
|
||||
}),
|
||||
{
|
||||
label: "已同步焦點到畫布",
|
||||
logType: "focus-selector",
|
||||
source: "editor-workspace",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const runBridgeCommand = (command) => {
|
||||
if (!command?.type) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
command.type !== editorBridgeEventType.RESTORE_CANVAS_STATE &&
|
||||
!command?.selector
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (command.type === editorBridgeEventType.INSPECT_SELECTOR) {
|
||||
postBridgeMessage(
|
||||
createInspectSelectorMessage({
|
||||
selector: command.selector,
|
||||
source: command.source,
|
||||
}),
|
||||
{
|
||||
label: "已要求畫布檢查區塊",
|
||||
logType: "inspect-selector",
|
||||
source: command.source || "editor-workspace",
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (command.type === editorBridgeEventType.START_INLINE_EDIT) {
|
||||
postBridgeMessage(
|
||||
createStartInlineEditMessage({
|
||||
selector: command.selector,
|
||||
editKind: command.editKind,
|
||||
attributeName: command.attributeName,
|
||||
attributeValue: command.attributeValue,
|
||||
styleProperty: command.styleProperty,
|
||||
styleValue: command.styleValue,
|
||||
source: command.source,
|
||||
}),
|
||||
{
|
||||
label: "已要求畫布進入頁內文字編輯",
|
||||
logType: "start-inline-edit",
|
||||
source: command.source || "editor-workspace",
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (command.type === editorBridgeEventType.MOVE_BLOCK) {
|
||||
postBridgeMessage(
|
||||
createMoveBlockMessage({
|
||||
selector: command.selector,
|
||||
direction: command.direction,
|
||||
source: command.source,
|
||||
}),
|
||||
{
|
||||
label: command.direction === "up" ? "已要求區塊上移" : "已要求區塊下移",
|
||||
logType: "move-block",
|
||||
source: command.source || "editor-workspace",
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (command.type === editorBridgeEventType.TOGGLE_BLOCK_VISIBILITY) {
|
||||
postBridgeMessage(
|
||||
createToggleBlockVisibilityMessage({
|
||||
selector: command.selector,
|
||||
source: command.source,
|
||||
}),
|
||||
{
|
||||
label: "已切換區塊顯示狀態",
|
||||
logType: "toggle-block-visibility",
|
||||
source: command.source || "editor-workspace",
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (command.type === editorBridgeEventType.DUPLICATE_BLOCK) {
|
||||
postBridgeMessage(
|
||||
createDuplicateBlockMessage({
|
||||
selector: command.selector,
|
||||
source: command.source,
|
||||
}),
|
||||
{
|
||||
label: "已要求複製區塊",
|
||||
logType: "duplicate-block",
|
||||
source: command.source || "editor-workspace",
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (command.type === editorBridgeEventType.DELETE_BLOCK) {
|
||||
postBridgeMessage(
|
||||
createDeleteBlockMessage({
|
||||
selector: command.selector,
|
||||
source: command.source,
|
||||
}),
|
||||
{
|
||||
label: "已要求刪除區塊",
|
||||
logType: "delete-block",
|
||||
source: command.source || "editor-workspace",
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (command.type === editorBridgeEventType.INSERT_BLOCK) {
|
||||
postBridgeMessage(
|
||||
createInsertBlockMessage({
|
||||
selector: command.selector,
|
||||
position: command.position,
|
||||
blockType: command.blockType,
|
||||
source: command.source,
|
||||
}),
|
||||
{
|
||||
label:
|
||||
command.position === "before"
|
||||
? `已要求前面插入${command.blockTypeLabel || "區塊"}`
|
||||
: `已要求後面插入${command.blockTypeLabel || "區塊"}`,
|
||||
logType: "insert-block",
|
||||
source: command.source || "editor-workspace",
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (command.type === editorBridgeEventType.RESTORE_CANVAS_STATE) {
|
||||
postBridgeMessage(
|
||||
createRestoreCanvasStateMessage({
|
||||
pageHtml: command.pageHtml,
|
||||
selector: command.selector,
|
||||
source: command.source,
|
||||
}),
|
||||
{
|
||||
label: "已還原畫布狀態",
|
||||
logType: "restore-canvas-state",
|
||||
source: command.source || "editor-workspace",
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
postBridgeMessage(
|
||||
createFocusSelectorMessage({
|
||||
selector: command.selector,
|
||||
source: command.source,
|
||||
}),
|
||||
{
|
||||
label: "已同步焦點到畫布",
|
||||
logType: "focus-selector",
|
||||
source: command.source || "editor-workspace",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const pushFeedbackSummaryToCanvas = () => {
|
||||
if (!frameRef.value?.contentWindow || !props.feedbackSummary?.title) {
|
||||
return
|
||||
}
|
||||
|
||||
postBridgeMessage(
|
||||
createStatusSummaryMessage({
|
||||
mode: props.feedbackSummary.mode,
|
||||
modeLabel: props.feedbackSummary.modeLabel,
|
||||
status: props.feedbackSummary.status,
|
||||
title: props.feedbackSummary.title,
|
||||
selector: props.feedbackSummary.selectorLabel,
|
||||
detail: props.feedbackSummary.detail,
|
||||
helper: props.feedbackSummary.helper,
|
||||
primaryActionLabel: props.feedbackSummary.primaryActionLabel,
|
||||
secondaryActionLabel: props.feedbackSummary.secondaryActionLabel,
|
||||
}),
|
||||
{
|
||||
label: "已同步畫布回饋摘要",
|
||||
logType: "status-summary",
|
||||
source: "editor-workspace",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const pushPreviewOperationsToCanvas = () => {
|
||||
if (!frameRef.value?.contentWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
postBridgeMessage(
|
||||
createPreviewOperationsMessage({
|
||||
operations: props.previewOperations,
|
||||
}),
|
||||
{
|
||||
label: "已同步預覽結果到畫布",
|
||||
logType: "preview-operations",
|
||||
source: "editor-workspace",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleMessage = (event) => {
|
||||
if (!props.baseUrl || !frameRef.value?.contentWindow || event.source !== frameRef.value.contentWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = event.data || {}
|
||||
|
||||
if (payload?.type === editorBridgeEventType.READY) {
|
||||
canvasState.value = "connected"
|
||||
canvasOrigin.value = event.origin || ""
|
||||
emit("canvas-ready", payload)
|
||||
pushPreviewOperationsToCanvas()
|
||||
return
|
||||
}
|
||||
|
||||
if (payload?.type === editorBridgeEventType.HOVER && payload.selector) {
|
||||
const normalizedPayload = normalizeCanvasSelectionPayload(payload)
|
||||
canvasState.value = "connected"
|
||||
lastHoverSelector.value = normalizedPayload.selector
|
||||
emit("hover-candidate", normalizedPayload)
|
||||
return
|
||||
}
|
||||
|
||||
if (payload?.type === editorBridgeEventType.HOVER_CLEAR) {
|
||||
lastHoverSelector.value = ""
|
||||
emit("hover-clear")
|
||||
return
|
||||
}
|
||||
|
||||
if (payload?.type === editorBridgeEventType.HOTSPOT_ACTION) {
|
||||
emit("hotspot-action", {
|
||||
action: payload.action || "",
|
||||
selector: payload.selector || "",
|
||||
tag: payload.tag || "",
|
||||
text: payload.text || "",
|
||||
href: payload.href || "",
|
||||
src: payload.src || "",
|
||||
intentChangeType: payload.intent_change_type || "",
|
||||
intentField: payload.intent_field || "",
|
||||
intentAttributeName: payload.intent_attribute_name || "",
|
||||
intentAttributeValue: payload.intent_attribute_value || "",
|
||||
source: payload.source || "canvas-hotspot",
|
||||
})
|
||||
emit("bridge-outbound", {
|
||||
type: payload.action || payload.type,
|
||||
value: payload.selector || "",
|
||||
source: payload.source || "canvas-hotspot",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (payload?.type === editorBridgeEventType.INLINE_TEXT_CHANGE && payload.selector) {
|
||||
emit("inline-text-change", {
|
||||
selector: payload.selector || "",
|
||||
text: payload.text || "",
|
||||
pageHtml: payload.page_html || "",
|
||||
source: payload.source || "canvas-inline-edit",
|
||||
})
|
||||
emit("bridge-outbound", {
|
||||
type: payload.type,
|
||||
value: payload.selector || "",
|
||||
source: payload.source || "canvas-inline-edit",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (payload?.type === editorBridgeEventType.INLINE_ATTRIBUTE_CHANGE && payload.selector) {
|
||||
emit("inline-attribute-change", {
|
||||
selector: payload.selector || "",
|
||||
attributeName: payload.attribute_name || "",
|
||||
attributeValue: payload.attribute_value || "",
|
||||
pageHtml: payload.page_html || "",
|
||||
source: payload.source || "canvas-inline-edit",
|
||||
})
|
||||
emit("bridge-outbound", {
|
||||
type: payload.type,
|
||||
value: payload.selector || "",
|
||||
source: payload.source || "canvas-inline-edit",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (payload?.type === editorBridgeEventType.INLINE_STYLE_CHANGE && payload.selector) {
|
||||
emit("inline-style-change", {
|
||||
selector: payload.selector || "",
|
||||
styleProperty: payload.style_property || "",
|
||||
styleValue: payload.style_value || "",
|
||||
pageHtml: payload.page_html || "",
|
||||
source: payload.source || "canvas-inline-edit",
|
||||
})
|
||||
emit("bridge-outbound", {
|
||||
type: payload.type,
|
||||
value: payload.selector || "",
|
||||
source: payload.source || "canvas-inline-edit",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (payload?.type === editorBridgeEventType.BLOCK_VISIBILITY_CHANGE && payload.selector) {
|
||||
emit("bridge-outbound", {
|
||||
type: payload.type,
|
||||
value: payload.selector || "",
|
||||
source: payload.source || "canvas-bridge",
|
||||
hidden: Boolean(payload.hidden),
|
||||
pageHtml: payload.page_html || "",
|
||||
})
|
||||
emit("select-candidate", normalizeCanvasSelectionPayload(payload))
|
||||
return
|
||||
}
|
||||
|
||||
if (payload?.type === editorBridgeEventType.BLOCK_STRUCTURE_CHANGE) {
|
||||
emit("bridge-outbound", {
|
||||
type: payload.type,
|
||||
value: payload.selector || "",
|
||||
source: payload.source || "canvas-bridge",
|
||||
action: payload.action || "",
|
||||
nextSelector: payload.next_selector || "",
|
||||
blockType: payload.block_type || "",
|
||||
tag: payload.tag || "",
|
||||
text: payload.text || "",
|
||||
href: payload.href || "",
|
||||
src: payload.src || "",
|
||||
hidden: Boolean(payload.hidden),
|
||||
pageHtml: payload.page_html || "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (payload?.type === editorBridgeEventType.SELECTION && payload.selector) {
|
||||
const normalizedPayload = normalizeCanvasSelectionPayload(payload)
|
||||
canvasState.value = "connected"
|
||||
lastIncomingSelector.value = normalizedPayload.selector
|
||||
emit("select-candidate", normalizedPayload)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.baseUrl,
|
||||
(value) => {
|
||||
canvasState.value = value ? "idle" : "idle"
|
||||
lastHoverSelector.value = ""
|
||||
lastIncomingSelector.value = ""
|
||||
lastFocusedSelector.value = ""
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.selectedSelector,
|
||||
() => {
|
||||
pushSelectedSelectorToCanvas()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.previewOperations,
|
||||
() => {
|
||||
pushPreviewOperationsToCanvas()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.feedbackSummary,
|
||||
() => {
|
||||
pushFeedbackSummaryToCanvas()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.bridgeCommand,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
runBridgeCommand(value)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("message", handleMessage)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("message", handleMessage)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor-canvas-frame {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.editor-canvas-frame__viewport {
|
||||
position: relative;
|
||||
min-height: calc(100vh - 72px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
overflow: hidden;
|
||||
background-color: #e2eaf4;
|
||||
background-image: radial-gradient(rgba(100, 116, 139, 0.18) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
}
|
||||
|
||||
.editor-canvas-frame__iframe {
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 72px);
|
||||
border: 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.editor-canvas-frame__viewport--desktop .editor-canvas-frame__iframe {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-canvas-frame__viewport--tablet .editor-canvas-frame__iframe {
|
||||
width: min(820px, 100%);
|
||||
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.1), 0 8px 32px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.editor-canvas-frame__viewport--mobile .editor-canvas-frame__iframe {
|
||||
width: min(430px, 100%);
|
||||
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.12), 0 12px 36px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
.editor-canvas-frame__empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 360px;
|
||||
padding: 24px;
|
||||
border: 1px dashed rgba(148, 163, 184, 0.42);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(241, 245, 249, 0.98));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.editor-canvas-frame__empty-title {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editor-canvas-frame__empty-body {
|
||||
max-width: 460px;
|
||||
margin: 10px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.editor-canvas-frame__status-pill {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
right: 14px;
|
||||
z-index: 10;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px 5px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(6px);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.editor-canvas-frame__status-pill--success {
|
||||
background: rgba(220, 252, 231, 0.88);
|
||||
color: #15803d;
|
||||
border: 1px solid rgba(134, 239, 172, 0.5);
|
||||
}
|
||||
|
||||
.editor-canvas-frame__status-pill--info {
|
||||
background: rgba(224, 242, 254, 0.88);
|
||||
color: #0369a1;
|
||||
border: 1px solid rgba(147, 197, 253, 0.5);
|
||||
}
|
||||
|
||||
.editor-canvas-frame__status-pill--warning {
|
||||
background: rgba(254, 249, 195, 0.88);
|
||||
color: #92400e;
|
||||
border: 1px solid rgba(253, 224, 71, 0.5);
|
||||
}
|
||||
|
||||
.editor-canvas-frame__status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-canvas-frame__status-pill--success .editor-canvas-frame__status-dot {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.editor-canvas-frame__status-pill--info .editor-canvas-frame__status-dot {
|
||||
background: #0284c7;
|
||||
}
|
||||
|
||||
.editor-canvas-frame__status-pill--warning .editor-canvas-frame__status-dot {
|
||||
background: #d97706;
|
||||
animation: status-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes status-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
</style>
|
||||
335
frontend/src/module/editor/components/EditorTopToolbar.vue
Normal file
335
frontend/src/module/editor/components/EditorTopToolbar.vue
Normal file
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<header class="editor-top-toolbar">
|
||||
<div class="editor-top-toolbar__actions">
|
||||
|
||||
<!-- 裝置切換 -->
|
||||
<div class="editor-top-toolbar__group">
|
||||
<button
|
||||
v-for="dev in deviceOptions"
|
||||
:key="dev.value"
|
||||
class="editor-top-toolbar__icon-btn"
|
||||
:class="{ 'is-active': deviceMode === dev.value }"
|
||||
:title="dev.label"
|
||||
@click="$emit('set-device-mode', dev.value)"
|
||||
>
|
||||
<component :is="dev.icon" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 結構操作 -->
|
||||
<div class="editor-top-toolbar__group">
|
||||
<button
|
||||
class="editor-top-toolbar__text-btn"
|
||||
:disabled="!selectedSelector"
|
||||
:title="selectedHidden ? '顯示區塊' : '隱藏區塊'"
|
||||
@click="$emit('toggle-visibility')"
|
||||
>
|
||||
<EyeOffIcon v-if="!selectedHidden" />
|
||||
<EyeIcon v-else />
|
||||
<span>{{ selectedHidden ? "顯示" : "隱藏" }}</span>
|
||||
</button>
|
||||
|
||||
<div class="editor-top-toolbar__insert-row">
|
||||
<el-select
|
||||
:model-value="insertBlockType"
|
||||
class="editor-top-toolbar__insert-type"
|
||||
placeholder="插入類型"
|
||||
@change="$emit('update:insert-block-type', $event)"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in insertBlockTypeOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
<button
|
||||
class="editor-top-toolbar__text-btn"
|
||||
:disabled="!selectedSelector"
|
||||
title="前面插入"
|
||||
@click="$emit('insert-before')"
|
||||
>
|
||||
<InsertBeforeIcon />
|
||||
<span>前插</span>
|
||||
</button>
|
||||
<button
|
||||
class="editor-top-toolbar__text-btn"
|
||||
:disabled="!selectedSelector"
|
||||
title="後面插入"
|
||||
@click="$emit('insert-after')"
|
||||
>
|
||||
<InsertAfterIcon />
|
||||
<span>後插</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="editor-top-toolbar__text-btn"
|
||||
:disabled="!selectedSelector"
|
||||
title="複製區塊"
|
||||
@click="$emit('duplicate')"
|
||||
>
|
||||
<CopyIcon />
|
||||
<span>複製</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="editor-top-toolbar__text-btn editor-top-toolbar__text-btn--danger"
|
||||
:disabled="!selectedSelector"
|
||||
title="刪除區塊"
|
||||
@click="$emit('delete')"
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>刪除</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 歷史 -->
|
||||
<div class="editor-top-toolbar__group">
|
||||
<button
|
||||
class="editor-top-toolbar__icon-btn"
|
||||
:disabled="!canUndo"
|
||||
title="上一步(Ctrl+Z)"
|
||||
@click="$emit('undo')"
|
||||
>
|
||||
<UndoIcon />
|
||||
</button>
|
||||
<button
|
||||
class="editor-top-toolbar__icon-btn"
|
||||
:disabled="!canRedo"
|
||||
title="下一步(Ctrl+Y)"
|
||||
@click="$emit('redo')"
|
||||
>
|
||||
<RedoIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-top-toolbar__secondary">
|
||||
<button class="editor-top-toolbar__text-btn editor-top-toolbar__text-btn--ghost" title="載入示範頁" @click="$emit('load-demo')">
|
||||
示範
|
||||
</button>
|
||||
<button class="editor-top-toolbar__text-btn editor-top-toolbar__text-btn--ghost" title="返回" @click="$emit('back')">
|
||||
<BackIcon />
|
||||
<span>返回</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineComponent, h } from "vue"
|
||||
|
||||
defineProps({
|
||||
deviceMode: { type: String, default: "desktop" },
|
||||
selectedSelector: { type: String, default: "" },
|
||||
selectedHidden: { type: Boolean, default: false },
|
||||
insertBlockType: { type: String, default: "content-card" },
|
||||
insertBlockTypeOptions: { type: Array, default: () => [] },
|
||||
canUndo: { type: Boolean, default: false },
|
||||
canRedo: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
defineEmits([
|
||||
"set-device-mode",
|
||||
"toggle-visibility",
|
||||
"update:insert-block-type",
|
||||
"insert-before",
|
||||
"insert-after",
|
||||
"duplicate",
|
||||
"delete",
|
||||
"undo",
|
||||
"redo",
|
||||
"back",
|
||||
"load-demo",
|
||||
])
|
||||
|
||||
function svgIcon(pathD, vb) {
|
||||
return defineComponent({
|
||||
render() {
|
||||
return h("svg", { width: 18, height: 18, viewBox: vb || "0 0 20 20", fill: "none", "aria-hidden": "true" },
|
||||
[h("path", { d: pathD, fill: "currentColor" })]
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const DesktopIcon = svgIcon("M3 4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v9H3V4Zm1 11h12l1 2H3l1-2Zm3-1h6v1H7v-1Z")
|
||||
const TabletIcon = svgIcon("M5 3a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H5Zm5 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z")
|
||||
const MobileIcon = svgIcon("M7 2a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H7Zm3 14a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z")
|
||||
const UndoIcon = defineComponent({ render() { return h("svg", { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true" }, [h("path", { d: "M5 7H13a4 4 0 0 1 0 8H9", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round" }), h("path", { d: "M7 4 4 7l3 3", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round", "stroke-linejoin": "round" })]) } })
|
||||
const RedoIcon = defineComponent({ render() { return h("svg", { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true" }, [h("path", { d: "M15 7H7a4 4 0 0 0 0 8h4", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round" }), h("path", { d: "M13 4l3 3-3 3", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round", "stroke-linejoin": "round" })]) } })
|
||||
const EyeIcon = svgIcon("M3 10s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5Zm7 2a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z")
|
||||
const EyeOffIcon = defineComponent({ render() { return h("svg", { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true" }, [h("path", { d: "M3 10s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5Z", stroke: "currentColor", "stroke-width": "1.6" }), h("line", { x1: "4", y1: "4", x2: "16", y2: "16", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round" })]) } })
|
||||
const CopyIcon = svgIcon("M7 4h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2ZM4 7H3a1 1 0 0 0-1 1v8a2 2 0 0 0 2 2h9a1 1 0 0 0 1-1v-1")
|
||||
const TrashIcon = defineComponent({ render() { return h("svg", { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true" }, [h("path", { d: "M5 7h10l-1 10H6L5 7ZM3 7h14M8 7V4h4v3", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round", "stroke-linejoin": "round" })]) } })
|
||||
const InsertBeforeIcon = defineComponent({ render() { return h("svg", { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true" }, [h("rect", { x: "3", y: "3", width: "14", height: "5", rx: "1.5", stroke: "currentColor", "stroke-width": "1.5" }), h("path", { d: "M10 11v6M7 14l3-3 3 3", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" })]) } })
|
||||
const InsertAfterIcon = defineComponent({ render() { return h("svg", { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true" }, [h("rect", { x: "3", y: "12", width: "14", height: "5", rx: "1.5", stroke: "currentColor", "stroke-width": "1.5" }), h("path", { d: "M10 9V3M7 6l3 3 3-3", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" })]) } })
|
||||
const BackIcon = svgIcon("M10 4 4 10l6 6M4 10h12")
|
||||
|
||||
const deviceOptions = [
|
||||
{ value: "desktop", label: "桌機", icon: DesktopIcon },
|
||||
{ value: "tablet", label: "平板", icon: TabletIcon },
|
||||
{ value: "mobile", label: "手機", icon: MobileIcon },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor-top-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
backdrop-filter: blur(18px);
|
||||
background: rgba(250, 251, 253, 0.96);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.editor-top-toolbar__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.editor-top-toolbar__secondary {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 分組容器 */
|
||||
.editor-top-toolbar__group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 1px 0 rgba(255, 255, 255, 0.9) inset;
|
||||
}
|
||||
|
||||
/* 插入列 — 把 select 和兩個按鈕放一排 */
|
||||
.editor-top-toolbar__insert-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* icon-only 按鈕(裝置切換、undo/redo) */
|
||||
.editor-top-toolbar__icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-top-toolbar__icon-btn:hover:not(:disabled) {
|
||||
background: #f0f5ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.editor-top-toolbar__icon-btn.is-active {
|
||||
background: #e8f0ff;
|
||||
color: #1d4ed8;
|
||||
box-shadow: 0 1px 3px rgba(37, 99, 235, 0.14);
|
||||
}
|
||||
|
||||
.editor-top-toolbar__icon-btn:disabled {
|
||||
opacity: 0.38;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* text + icon 按鈕 */
|
||||
.editor-top-toolbar__text-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 34px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.editor-top-toolbar__text-btn:hover:not(:disabled) {
|
||||
background: #f0f5ff;
|
||||
border-color: rgba(59, 130, 246, 0.28);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.editor-top-toolbar__text-btn:disabled {
|
||||
opacity: 0.38;
|
||||
cursor: default;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
/* 危險按鈕 — 刪除 */
|
||||
.editor-top-toolbar__text-btn--danger:hover:not(:disabled) {
|
||||
background: #fff5f5;
|
||||
border-color: rgba(239, 68, 68, 0.32);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* 次要按鈕 — 返回/示範 */
|
||||
.editor-top-toolbar__text-btn--ghost {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.editor-top-toolbar__text-btn--ghost:hover:not(:disabled) {
|
||||
background: #f1f5f9;
|
||||
border-color: rgba(148, 163, 184, 0.24);
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
/* insert type select */
|
||||
.editor-top-toolbar__insert-type {
|
||||
width: 116px;
|
||||
}
|
||||
|
||||
.editor-top-toolbar :deep(.el-select__wrapper),
|
||||
.editor-top-toolbar :deep(.el-input__wrapper) {
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
border-radius: 9px;
|
||||
box-shadow: none;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.editor-top-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.editor-top-toolbar__actions {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
118
frontend/src/module/editor/model/editor-bridge-protocol.js
Normal file
118
frontend/src/module/editor/model/editor-bridge-protocol.js
Normal file
@@ -0,0 +1,118 @@
|
||||
export const editorBridgeEventType = {
|
||||
READY: "mkt-editor:ready",
|
||||
HOVER: "mkt-editor:hover",
|
||||
HOVER_CLEAR: "mkt-editor:hover-clear",
|
||||
SELECTION: "mkt-editor:selection",
|
||||
HOTSPOT_ACTION: "mkt-editor:hotspot-action",
|
||||
FOCUS_SELECTOR: "mkt-editor:focus-selector",
|
||||
INSPECT_SELECTOR: "mkt-editor:inspect-selector",
|
||||
START_INLINE_EDIT: "mkt-editor:start-inline-edit",
|
||||
MOVE_BLOCK: "mkt-editor:move-block",
|
||||
TOGGLE_BLOCK_VISIBILITY: "mkt-editor:toggle-block-visibility",
|
||||
DUPLICATE_BLOCK: "mkt-editor:duplicate-block",
|
||||
DELETE_BLOCK: "mkt-editor:delete-block",
|
||||
INSERT_BLOCK: "mkt-editor:insert-block",
|
||||
RESTORE_CANVAS_STATE: "mkt-editor:restore-canvas-state",
|
||||
INLINE_TEXT_CHANGE: "mkt-editor:inline-text-change",
|
||||
INLINE_ATTRIBUTE_CHANGE: "mkt-editor:inline-attribute-change",
|
||||
INLINE_STYLE_CHANGE: "mkt-editor:inline-style-change",
|
||||
BLOCK_VISIBILITY_CHANGE: "mkt-editor:block-visibility-change",
|
||||
BLOCK_STRUCTURE_CHANGE: "mkt-editor:block-structure-change",
|
||||
STATUS_SUMMARY: "mkt-editor:status-summary",
|
||||
PREVIEW_OPERATIONS: "mkt-editor:preview-operations",
|
||||
}
|
||||
|
||||
export const normalizeCanvasSelectionPayload = (payload = {}) => ({
|
||||
selector: payload.selector || "",
|
||||
tag: payload.tag || "",
|
||||
text: payload.text || "",
|
||||
href: payload.href || "",
|
||||
src: payload.src || "",
|
||||
hidden: Boolean(payload.hidden),
|
||||
source: payload.source || "canvas-bridge",
|
||||
})
|
||||
|
||||
export const createFocusSelectorMessage = (payload = {}) => ({
|
||||
type: editorBridgeEventType.FOCUS_SELECTOR,
|
||||
selector: payload.selector || "",
|
||||
source: payload.source || "editor-workspace",
|
||||
})
|
||||
|
||||
export const createInspectSelectorMessage = (payload = {}) => ({
|
||||
type: editorBridgeEventType.INSPECT_SELECTOR,
|
||||
selector: payload.selector || "",
|
||||
source: payload.source || "editor-workspace",
|
||||
})
|
||||
|
||||
export const createStartInlineEditMessage = (payload = {}) => ({
|
||||
type: editorBridgeEventType.START_INLINE_EDIT,
|
||||
selector: payload.selector || "",
|
||||
editKind: payload.editKind || "",
|
||||
attributeName: payload.attributeName || "",
|
||||
attributeValue: payload.attributeValue || "",
|
||||
styleProperty: payload.styleProperty || "",
|
||||
styleValue: payload.styleValue || "",
|
||||
source: payload.source || "editor-workspace",
|
||||
})
|
||||
|
||||
export const createMoveBlockMessage = (payload = {}) => ({
|
||||
type: editorBridgeEventType.MOVE_BLOCK,
|
||||
selector: payload.selector || "",
|
||||
direction: payload.direction || "down",
|
||||
source: payload.source || "editor-workspace",
|
||||
})
|
||||
|
||||
export const createToggleBlockVisibilityMessage = (payload = {}) => ({
|
||||
type: editorBridgeEventType.TOGGLE_BLOCK_VISIBILITY,
|
||||
selector: payload.selector || "",
|
||||
source: payload.source || "editor-workspace",
|
||||
})
|
||||
|
||||
export const createDuplicateBlockMessage = (payload = {}) => ({
|
||||
type: editorBridgeEventType.DUPLICATE_BLOCK,
|
||||
selector: payload.selector || "",
|
||||
source: payload.source || "editor-workspace",
|
||||
})
|
||||
|
||||
export const createDeleteBlockMessage = (payload = {}) => ({
|
||||
type: editorBridgeEventType.DELETE_BLOCK,
|
||||
selector: payload.selector || "",
|
||||
source: payload.source || "editor-workspace",
|
||||
})
|
||||
|
||||
export const createInsertBlockMessage = (payload = {}) => ({
|
||||
type: editorBridgeEventType.INSERT_BLOCK,
|
||||
selector: payload.selector || "",
|
||||
position: payload.position || "after",
|
||||
blockType: payload.blockType || "content-card",
|
||||
source: payload.source || "editor-workspace",
|
||||
})
|
||||
|
||||
export const createRestoreCanvasStateMessage = (payload = {}) => ({
|
||||
type: editorBridgeEventType.RESTORE_CANVAS_STATE,
|
||||
pageHtml: payload.pageHtml || "",
|
||||
selector: payload.selector || "",
|
||||
source: payload.source || "editor-workspace",
|
||||
})
|
||||
|
||||
export const createStatusSummaryMessage = (payload = {}) => ({
|
||||
type: editorBridgeEventType.STATUS_SUMMARY,
|
||||
mode: payload.mode || "idle",
|
||||
modeLabel: payload.modeLabel || "",
|
||||
status: payload.status || "info",
|
||||
title: payload.title || "",
|
||||
selector: payload.selector || "",
|
||||
detail: payload.detail || "",
|
||||
helper: payload.helper || "",
|
||||
primaryActionLabel: payload.primaryActionLabel || "",
|
||||
secondaryActionLabel: payload.secondaryActionLabel || "",
|
||||
source: payload.source || "editor-workspace",
|
||||
})
|
||||
|
||||
export const createPreviewOperationsMessage = (payload = {}) => ({
|
||||
type: editorBridgeEventType.PREVIEW_OPERATIONS,
|
||||
operations: Array.isArray(payload.operations)
|
||||
? JSON.parse(JSON.stringify(payload.operations))
|
||||
: [],
|
||||
source: payload.source || "editor-workspace",
|
||||
})
|
||||
1043
frontend/src/module/editor/model/editor-workspace-model.js
Normal file
1043
frontend/src/module/editor/model/editor-workspace-model.js
Normal file
File diff suppressed because it is too large
Load Diff
40
frontend/src/module/editor/service/editor-api.js
Normal file
40
frontend/src/module/editor/service/editor-api.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import fastapiClient from "@/services/api/fastapi-client";
|
||||
|
||||
const createEditorSession = async (payload) => {
|
||||
return await fastapiClient.request("/api/editor/sessions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
};
|
||||
|
||||
const getVariantChanges = async (variantId) => {
|
||||
const response = await fastapiClient.request(
|
||||
`/api/editor/variants/${variantId}/changes`
|
||||
);
|
||||
return response.items || [];
|
||||
};
|
||||
|
||||
const saveVariantChanges = async (variantId, items) => {
|
||||
const response = await fastapiClient.request(
|
||||
`/api/editor/variants/${variantId}/changes`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ items }),
|
||||
}
|
||||
);
|
||||
return response.items || [];
|
||||
};
|
||||
|
||||
const buildPreview = async (payload) => {
|
||||
return await fastapiClient.request("/api/editor/previews/build", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
};
|
||||
|
||||
export default {
|
||||
createEditorSession,
|
||||
getVariantChanges,
|
||||
saveVariantChanges,
|
||||
buildPreview,
|
||||
};
|
||||
1376
frontend/src/module/editor/service/use-editor-workspace-page.js
Normal file
1376
frontend/src/module/editor/service/use-editor-workspace-page.js
Normal file
File diff suppressed because it is too large
Load Diff
140
frontend/src/module/editor/view/workspace.vue
Normal file
140
frontend/src/module/editor/view/workspace.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<section class="fullscreen-editor">
|
||||
<EditorTopToolbar
|
||||
:device-mode="deviceMode"
|
||||
:selected-selector="selectedSelector"
|
||||
:selected-hidden="selectedElementMeta.hidden"
|
||||
:insert-block-type="insertBlockType"
|
||||
:insert-block-type-options="insertBlockTypeOptions"
|
||||
:can-undo="canUndo"
|
||||
:can-redo="canRedo"
|
||||
@set-device-mode="setDeviceMode"
|
||||
@toggle-visibility="toggleSelectedBlockVisibility"
|
||||
@update:insert-block-type="insertBlockType = $event"
|
||||
@insert-before="insertBlockNearSelection('before')"
|
||||
@insert-after="insertBlockNearSelection('after')"
|
||||
@duplicate="duplicateSelectedBlock"
|
||||
@delete="deleteSelectedBlock"
|
||||
@undo="undoHistory"
|
||||
@redo="redoHistory"
|
||||
@back="goBack"
|
||||
@load-demo="loadDemoPage"
|
||||
/>
|
||||
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
:closable="false"
|
||||
:title="errorMessage"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<section class="fullscreen-editor__stage">
|
||||
<div class="fullscreen-editor__canvas-shell">
|
||||
<EditorCanvasFrame
|
||||
:base-url="baseUrl"
|
||||
:device-mode="deviceMode"
|
||||
:candidates="canvasCandidates"
|
||||
:selected-selector="selectedSelector"
|
||||
:preview-status="selectedPreviewSummary"
|
||||
:preview-operations="previewOperations"
|
||||
:feedback-summary="canvasFeedbackSummary"
|
||||
:bridge-command="canvasBridgeCommand"
|
||||
@select-candidate="selectCanvasCandidate"
|
||||
@hover-candidate="handleCanvasHover"
|
||||
@hover-clear="keepCurrentSelection"
|
||||
@hotspot-action="handleCanvasHotspotAction"
|
||||
@inline-attribute-change="handleInlineAttributeChange"
|
||||
@inline-style-change="handleInlineStyleChange"
|
||||
@inline-text-change="handleInlineTextChange"
|
||||
@canvas-ready="handleCanvasReady"
|
||||
@bridge-outbound="handleCanvasOutbound"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted } from "vue"
|
||||
import EditorCanvasFrame from "@/module/editor/components/EditorCanvasFrame.vue";
|
||||
import EditorTopToolbar from "@/module/editor/components/EditorTopToolbar.vue";
|
||||
import { useEditorWorkspacePage } from "@/module/editor/service/use-editor-workspace-page";
|
||||
|
||||
const {
|
||||
errorMessage,
|
||||
previewOperations,
|
||||
canvasCandidates,
|
||||
selectedPreviewSummary,
|
||||
canvasFeedbackSummary,
|
||||
selectedSelector,
|
||||
selectedElementMeta,
|
||||
canvasBridgeCommand,
|
||||
baseUrl,
|
||||
deviceMode,
|
||||
setDeviceMode,
|
||||
insertBlockType,
|
||||
insertBlockTypeOptions,
|
||||
toggleSelectedBlockVisibility,
|
||||
insertBlockNearSelection,
|
||||
duplicateSelectedBlock,
|
||||
deleteSelectedBlock,
|
||||
canUndo,
|
||||
canRedo,
|
||||
undoHistory,
|
||||
redoHistory,
|
||||
selectCanvasCandidate,
|
||||
loadDemoPage,
|
||||
handleCanvasReady,
|
||||
handleCanvasHotspotAction,
|
||||
handleInlineAttributeChange,
|
||||
handleInlineStyleChange,
|
||||
handleCanvasHover,
|
||||
handleInlineTextChange,
|
||||
keepCurrentSelection,
|
||||
handleCanvasOutbound,
|
||||
goBack,
|
||||
} = useEditorWorkspacePage();
|
||||
|
||||
function onKeyDown(e) {
|
||||
const tag = document.activeElement?.tagName?.toLowerCase()
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") return
|
||||
if (document.activeElement?.isContentEditable) return
|
||||
|
||||
const mod = e.ctrlKey || e.metaKey
|
||||
if (!mod) return
|
||||
|
||||
if (e.key === "z" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (canUndo.value) undoHistory()
|
||||
return
|
||||
}
|
||||
if ((e.key === "y") || (e.key === "z" && e.shiftKey)) {
|
||||
e.preventDefault()
|
||||
if (canRedo.value) redoHistory()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener("keydown", onKeyDown))
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", onKeyDown))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fullscreen-editor {
|
||||
min-height: 100vh;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
.fullscreen-editor__stage {
|
||||
display: grid;
|
||||
min-height: calc(100vh - 72px);
|
||||
}
|
||||
|
||||
.fullscreen-editor__canvas-shell :deep(.editor-canvas-frame) {
|
||||
min-height: calc(100vh - 72px);
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user