Files
mkt.ose.tw/frontend/src/module/editor/service/use-editor-workspace-page.js
2026-03-23 20:23:58 +08:00

1377 lines
38 KiB
JavaScript

import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue"
import { useRoute, useRouter } from "vue-router"
import editorApi from "@/module/editor/service/editor-api"
import { editorBridgeEventType } from "@/module/editor/model/editor-bridge-protocol"
import {
buildSelectedSelectorSnapshot,
buildSelectedElementSummary,
buildQuickEditActions,
buildRecommendedFlowSummary,
buildSelectedPreviewSummary,
buildCanvasCheckSummary,
buildCanvasFeedbackSummary,
buildPreviewFreshness,
buildCanvasCandidates,
buildDraftFormGuide,
buildInteractionStateSummary,
buildOperatorGuide,
buildWorkspaceConsistency,
buildInspectorSummary,
buildWorkspaceJourney,
buildWorkspaceNextAction,
buildWorkspaceReadiness,
createChangeDraft,
editorChangeTemplates,
formatChangesJson,
mapEditorSession,
mapPreviewOperations,
normalizeDraftByType,
parseChangesJson,
summarizeChanges,
} from "@/module/editor/model/editor-workspace-model"
export function useEditorWorkspacePage() {
const route = useRoute()
const router = useRouter()
const createEmptySelectedElementMeta = () => ({
tag: "",
text: "",
href: "",
src: "",
hidden: false,
source: "",
})
const variantId = computed(() => String(route.params.variantId || ""))
const loading = ref(false)
const errorMessage = ref("")
const session = ref(null)
const changes = ref([])
const changesJson = ref("[]")
const previewOperations = ref([])
const baseUrl = ref("")
const selectedSelector = ref("")
const changeDraft = ref(createChangeDraft())
const canvasBridgeState = ref("idle")
const canvasBridgePageUrl = ref("")
const selectedElementMeta = ref(createEmptySelectedElementMeta())
const hoveredElementMeta = ref({
selector: "",
tag: "",
text: "",
href: "",
src: "",
source: "",
})
const canvasEventLog = ref([])
const canvasBridgeCommand = ref(null)
const canvasDocumentHtml = ref("")
const draftFocusTarget = ref("")
const deviceMode = ref("desktop")
const historyStack = ref([])
const historyIndex = ref(-1)
const insertBlockType = ref("content-card")
let liveEditHistoryTimer = null
const insertBlockTypeOptions = [
{ value: "text-block", label: "文字區塊" },
{ value: "button-block", label: "按鈕區塊" },
{ value: "image-block", label: "圖片區塊" },
{ value: "content-card", label: "內容卡片" },
]
const activeEditSelector = computed(() => selectedSelector.value)
const snapshotEditorState = () => ({
changesJson: changesJson.value,
changeDraft: JSON.parse(JSON.stringify(changeDraft.value)),
previewOperations: JSON.parse(JSON.stringify(previewOperations.value)),
selectedSelector: selectedSelector.value,
selectedElementMeta: JSON.parse(JSON.stringify(selectedElementMeta.value || createEmptySelectedElementMeta())),
canvasDocumentHtml: canvasDocumentHtml.value,
})
const applySnapshot = (snapshot = {}) => {
changesJson.value = snapshot.changesJson || "[]"
changeDraft.value = normalizeDraftByType(snapshot.changeDraft || createChangeDraft())
previewOperations.value = Array.isArray(snapshot.previewOperations) ? snapshot.previewOperations : []
selectedSelector.value = snapshot.selectedSelector || ""
selectedElementMeta.value = {
...createEmptySelectedElementMeta(),
...(snapshot.selectedElementMeta || {}),
}
canvasDocumentHtml.value = snapshot.canvasDocumentHtml || ""
if (snapshot.canvasDocumentHtml) {
canvasBridgeCommand.value = {
type: editorBridgeEventType.RESTORE_CANVAS_STATE,
pageHtml: snapshot.canvasDocumentHtml,
selector: snapshot.selectedSelector || "",
source: "editor-workspace",
issuedAt: Date.now(),
}
}
}
const recordHistory = () => {
const snapshot = snapshotEditorState()
const nextStack = historyStack.value.slice(0, historyIndex.value + 1)
const previousSnapshot = nextStack[nextStack.length - 1]
if (previousSnapshot && JSON.stringify(previousSnapshot) === JSON.stringify(snapshot)) {
return
}
nextStack.push(snapshot)
historyStack.value = nextStack.slice(-20)
historyIndex.value = historyStack.value.length - 1
}
const invalidatePreviewOperations = () => {
if (!previewOperations.value.length) {
return
}
previewOperations.value = []
}
const scheduleLiveEditHistory = () => {
window.clearTimeout(liveEditHistoryTimer)
liveEditHistoryTimer = window.setTimeout(() => {
recordHistory()
}, 280)
}
const canUndo = computed(() => historyIndex.value > 0)
const canRedo = computed(() => historyIndex.value >= 0 && historyIndex.value < historyStack.value.length - 1)
const previewOperationsPreview = computed(() =>
JSON.stringify(previewOperations.value, null, 2)
)
const parsedChanges = computed(() => {
try {
return parseChangesJson(changesJson.value)
} catch (error) {
return []
}
})
const changeSummary = computed(() => summarizeChanges(parsedChanges.value))
const canvasCandidates = computed(() =>
buildCanvasCandidates(parsedChanges.value)
)
const inspectorSummary = computed(() =>
buildInspectorSummary({
selectedSelector: selectedSelector.value,
changes: parsedChanges.value,
previewOperations: previewOperations.value,
})
)
const interactionStateSummary = computed(() =>
buildInteractionStateSummary({
hoveredElementMeta: hoveredElementMeta.value,
selectedSelector: selectedSelector.value,
selectedElementMeta: selectedElementMeta.value,
})
)
const workspaceConsistency = computed(() =>
buildWorkspaceConsistency({
selectedSelector: selectedSelector.value,
draft: changeDraft.value,
changes: parsedChanges.value,
previewOperations: previewOperations.value,
})
)
const selectedSelectorSnapshot = computed(() =>
buildSelectedSelectorSnapshot({
selectedSelector: selectedSelector.value,
changes: parsedChanges.value,
previewOperations: previewOperations.value,
})
)
const selectedElementSummary = computed(() =>
buildSelectedElementSummary({
selectedSelector: selectedSelector.value,
selectedElementMeta: selectedElementMeta.value,
})
)
const quickEditActions = computed(() =>
buildQuickEditActions({
selectedSelector: selectedSelector.value,
selectedElementSummary: selectedElementSummary.value,
selectedElementMeta: selectedElementMeta.value,
})
)
const draftFormGuide = computed(() =>
buildDraftFormGuide({
draft: changeDraft.value,
selectedElementSummary: selectedElementSummary.value,
selectedElementMeta: selectedElementMeta.value,
})
)
const previewFreshness = computed(() =>
buildPreviewFreshness({
changes: parsedChanges.value,
previewOperations: previewOperations.value,
draft: changeDraft.value,
})
)
const recommendedFlowSummary = computed(() =>
buildRecommendedFlowSummary({
selectedSelector: selectedSelector.value,
selectedElementSummary: selectedElementSummary.value,
draft: changeDraft.value,
previewOperations: previewOperations.value,
})
)
const selectedPreviewSummary = computed(() =>
buildSelectedPreviewSummary({
selectedSelector: selectedSelector.value,
selectedSelectorSnapshot: selectedSelectorSnapshot.value,
})
)
const canvasCheckSummary = computed(() =>
buildCanvasCheckSummary({
selectedSelector: selectedSelector.value,
selectedElementSummary: selectedElementSummary.value,
selectedPreviewSummary: selectedPreviewSummary.value,
})
)
const canvasFeedbackSummary = computed(() =>
buildCanvasFeedbackSummary({
interactionStateSummary: interactionStateSummary.value,
selectedSelector: selectedSelector.value,
hoveredElementMeta: hoveredElementMeta.value,
selectedElementSummary: selectedElementSummary.value,
selectedPreviewSummary: selectedPreviewSummary.value,
previewFreshness: previewFreshness.value,
})
)
const workspaceReadiness = computed(() =>
buildWorkspaceReadiness({
baseUrl: baseUrl.value,
session: session.value,
changes: parsedChanges.value,
previewOperations: previewOperations.value,
})
)
const nextAction = computed(() =>
buildWorkspaceNextAction({
baseUrl: baseUrl.value,
session: session.value,
changes: parsedChanges.value,
previewOperations: previewOperations.value,
})
)
const workspaceJourney = computed(() =>
buildWorkspaceJourney({
baseUrl: baseUrl.value,
session: session.value,
changes: parsedChanges.value,
previewOperations: previewOperations.value,
})
)
const operatorGuide = computed(() =>
buildOperatorGuide({
baseUrl: baseUrl.value,
session: session.value,
selectedSelector: selectedSelector.value,
draft: changeDraft.value,
changes: parsedChanges.value,
previewOperations: previewOperations.value,
})
)
const applyTemplate = (templateKey) => {
const template = editorChangeTemplates.find((item) => item.key === templateKey)
if (!template) {
return
}
changesJson.value = formatChangesJson([...parsedChanges.value, template.item])
changeDraft.value = normalizeDraftByType({
...template.item,
selector_value: selectedSelector.value || template.item.selector_value || "",
})
}
const selectCanvasCandidate = (payload) => {
const nextSelector =
typeof payload === "string" ? payload : payload?.selector || ""
selectedSelector.value = nextSelector
changeDraft.value = normalizeDraftByType({
...changeDraft.value,
selector_value: nextSelector,
})
selectedElementMeta.value = {
tag: payload?.tag || "",
text: payload?.text || "",
href: payload?.href || "",
src: payload?.src || "",
hidden: Boolean(payload?.hidden),
source: payload?.source || "candidate-button",
}
if (nextSelector) {
canvasEventLog.value = [
{
id: `${Date.now()}-${nextSelector}`,
type: "selection",
value: nextSelector,
source: payload?.source || "candidate-button",
},
...canvasEventLog.value,
].slice(0, 8)
}
}
const patchDraft = (patch = {}) => {
changeDraft.value = normalizeDraftByType({
...changeDraft.value,
...patch,
payload: {
...changeDraft.value.payload,
...(patch.payload || {}),
},
})
}
const setDeviceMode = (mode = "desktop") => {
if (!["desktop", "tablet", "mobile"].includes(mode)) {
return
}
deviceMode.value = mode
}
const moveSelectedBlock = (direction = "down") => {
if (!selectedSelector.value || !["up", "down"].includes(direction)) {
return
}
sendCanvasCommand(editorBridgeEventType.MOVE_BLOCK, selectedSelector.value, { direction })
}
const toggleSelectedBlockVisibility = () => {
if (!selectedSelector.value) {
return
}
sendCanvasCommand(editorBridgeEventType.TOGGLE_BLOCK_VISIBILITY, selectedSelector.value)
selectedElementMeta.value = { ...selectedElementMeta.value, hidden: !selectedElementMeta.value.hidden }
}
const duplicateSelectedBlock = () => {
if (!selectedSelector.value) {
return
}
sendCanvasCommand(editorBridgeEventType.DUPLICATE_BLOCK, selectedSelector.value)
}
const deleteSelectedBlock = () => {
if (!selectedSelector.value) {
return
}
sendCanvasCommand(editorBridgeEventType.DELETE_BLOCK, selectedSelector.value)
}
const insertBlockNearSelection = (position = "after") => {
if (!selectedSelector.value || !["before", "after"].includes(position)) {
return
}
const selectedOption =
insertBlockTypeOptions.find((option) => option.value === insertBlockType.value) ||
insertBlockTypeOptions[insertBlockTypeOptions.length - 1]
sendCanvasCommand(editorBridgeEventType.INSERT_BLOCK, selectedSelector.value, {
position,
blockType: selectedOption.value,
blockTypeLabel: selectedOption.label,
})
}
const applySelectedElementToDraft = () => {
const targetSelector = activeEditSelector.value
if (!targetSelector) {
return
}
const textContent = selectedElementMeta.value.text || ""
if (changeDraft.value.change_type === "replace_text" && textContent) {
patchDraft({
selector_value: targetSelector,
payload: {
text: textContent,
},
})
return
}
if (changeDraft.value.change_type === "set_html" && textContent) {
patchDraft({
selector_value: targetSelector,
payload: {
html: textContent,
},
})
return
}
patchDraft({
selector_value: targetSelector,
})
}
const applyRecommendedChangeType = () => {
const targetSelector = activeEditSelector.value || selectedSelector.value
const recommendedChangeType =
selectedElementSummary.value?.recommendedChangeType || "replace_text"
patchDraft({
change_type: recommendedChangeType,
selector_value: targetSelector,
payload: {},
})
const textContent = selectedElementMeta.value.text || ""
if (recommendedChangeType === "replace_text" && textContent) {
patchDraft({
payload: {
text: textContent,
},
})
return
}
if (recommendedChangeType === "set_html" && textContent) {
patchDraft({
payload: {
html: textContent,
},
})
}
}
const resolveDraftFocusTarget = (changeType = "", selectedMeta = {}, preferredField = "") => {
if (preferredField) {
return preferredField
}
if (changeType === "replace_text" || changeType === "set_html") {
return "content"
}
if (changeType === "set_attribute") {
if (selectedMeta?.src) {
return "attribute-value"
}
return "attribute-name"
}
if (changeType === "set_style") {
return "style-property"
}
return ""
}
const queueDraftFocus = async (target = "") => {
if (!target) {
return
}
draftFocusTarget.value = target
await nextTick()
}
const clearDraftFocusTarget = () => {
draftFocusTarget.value = ""
}
const startDraftEditFlow = async (intent = "") => {
const targetSelector = activeEditSelector.value || selectedSelector.value
if (!targetSelector) {
return
}
if (!session.value?.id) {
await createSession()
if (!session.value?.id) {
return
}
}
loadSelectedSelectorIntoDraft()
syncSelectedSelectorToDraft()
const draftMatchesSelection =
changeDraft.value?.selector_value === targetSelector
const selectedMeta = selectedElementMeta.value || {}
const forcedIntent =
typeof intent === "string"
? {
changeType: intent,
focusTarget: "",
attributeName: "",
attributeValue: "",
}
: {
changeType: intent?.changeType || "",
focusTarget: intent?.focusTarget || "",
attributeName: intent?.attributeName || "",
attributeValue: intent?.attributeValue || "",
}
const targetChangeType =
forcedIntent.changeType ||
selectedElementSummary.value?.recommendedChangeType ||
"replace_text"
if (forcedIntent.changeType) {
patchDraft({
change_type: forcedIntent.changeType,
selector_value: targetSelector,
payload: {},
})
if (forcedIntent.changeType === "replace_text") {
patchDraft({
payload: {
text: selectedMeta.text || "新的主標題文案",
},
})
} else if (forcedIntent.changeType === "set_attribute") {
patchDraft({
payload: {
name:
forcedIntent.attributeName ||
(selectedMeta.src ? "src" : selectedMeta.href ? "href" : "href"),
value:
forcedIntent.attributeValue ||
selectedMeta.src ||
selectedMeta.href ||
"https://ose.tw",
},
})
} else if (forcedIntent.changeType === "set_style") {
patchDraft({
payload: {
property: "backgroundColor",
value: "#111827",
},
})
} else {
applyRecommendedChangeType()
}
} else if (
!draftMatchesSelection ||
!changeDraft.value?.change_type ||
!changeDraft.value?.payload ||
Object.keys(changeDraft.value.payload || {}).length === 0
) {
applyRecommendedChangeType()
}
scrollToSection("editor-draft-section")
await queueDraftFocus(
resolveDraftFocusTarget(targetChangeType, selectedMeta, forcedIntent.focusTarget)
)
if (targetChangeType === "replace_text") {
sendCanvasCommand("mkt-editor:start-inline-edit", targetSelector, {
editKind: "replace_text",
})
}
if (targetChangeType === "set_attribute") {
sendCanvasCommand("mkt-editor:start-inline-edit", targetSelector, {
editKind: "set_attribute",
attributeName:
forcedIntent.attributeName ||
changeDraft.value?.payload?.name ||
(selectedMeta.src ? "src" : "href"),
attributeValue:
forcedIntent.attributeValue ||
changeDraft.value?.payload?.value ||
selectedMeta.src ||
selectedMeta.href ||
"",
})
}
if (targetChangeType === "set_style") {
sendCanvasCommand("mkt-editor:start-inline-edit", targetSelector, {
editKind: "set_style",
styleProperty: changeDraft.value?.payload?.property || "backgroundColor",
styleValue: changeDraft.value?.payload?.value || "#111827",
})
}
}
const runRecommendedFlow = async () => {
if (!selectedSelector.value) {
return
}
const draftMatchesSelection =
changeDraft.value?.selector_value === selectedSelector.value
if (!draftMatchesSelection) {
applyRecommendedChangeType()
return
}
await buildPreview()
}
const runSelectedPreviewFlow = async () => {
if (!selectedSelector.value) {
return
}
if ((selectedSelectorSnapshot.value?.previewOperations || []).length === 0) {
await buildPreview()
return
}
scrollToSection("editor-canvas-section")
}
const runCanvasCheckFlow = async () => {
if (!selectedSelector.value) {
return
}
if (selectedPreviewSummary.value.status !== "ready") {
await runSelectedPreviewFlow()
return
}
focusSelectedOnCanvas()
scrollToSection("editor-canvas-section")
}
const runQuickEditAction = async (changeType = "") => {
if (!changeType) {
return
}
await startDraftEditFlow({
changeType,
focusTarget: resolveDraftFocusTarget(changeType, selectedElementMeta.value || {}),
})
}
const syncSelectedSelectorToDraft = () => {
const targetSelector = activeEditSelector.value || selectedSelector.value
if (!targetSelector) {
return
}
patchDraft({
selector_value: targetSelector,
})
}
const applyDraftToChanges = () => {
const nextDraft = normalizeDraftByType(changeDraft.value)
changesJson.value = formatChangesJson([...parsedChanges.value, nextDraft])
recordHistory()
}
const replaceChangesWithDraft = () => {
const nextDraft = normalizeDraftByType(changeDraft.value)
changesJson.value = formatChangesJson([nextDraft])
recordHistory()
}
const loadSelectedSelectorIntoDraft = () => {
const targetSelector = activeEditSelector.value || selectedSelector.value
if (!targetSelector) {
return
}
const matchedChange = parsedChanges.value.find(
(item) => item.selector_value === targetSelector
)
if (matchedChange) {
changeDraft.value = normalizeDraftByType(matchedChange)
return
}
changeDraft.value = normalizeDraftByType({
...changeDraft.value,
selector_value: targetSelector,
})
}
const createSession = async () => {
loading.value = true
errorMessage.value = ""
try {
const response = await editorApi.createEditorSession({
variant_id: variantId.value,
base_url: baseUrl.value || null,
mode: "edit",
})
session.value = mapEditorSession(response)
} catch (error) {
errorMessage.value = error?.message || "無法建立 editor session。"
} finally {
loading.value = false
}
}
const loadChanges = async () => {
loading.value = true
errorMessage.value = ""
try {
changes.value = await editorApi.getVariantChanges(variantId.value)
changesJson.value = formatChangesJson(changes.value)
recordHistory()
} catch (error) {
errorMessage.value = error?.message || "無法載入 variant changes。"
} finally {
loading.value = false
}
}
const saveChanges = async () => {
loading.value = true
errorMessage.value = ""
try {
const items = parseChangesJson(changesJson.value)
changes.value = await editorApi.saveVariantChanges(variantId.value, items)
changesJson.value = formatChangesJson(changes.value)
} catch (error) {
errorMessage.value = error?.message || "無法保存 variant changes。"
} finally {
loading.value = false
}
}
const buildPreview = async () => {
loading.value = true
errorMessage.value = ""
try {
const items = parseChangesJson(changesJson.value)
const preview = await editorApi.buildPreview({
variant_id: variantId.value,
items: items,
})
previewOperations.value = mapPreviewOperations(preview)
recordHistory()
} catch (error) {
errorMessage.value = error?.message || "無法 build preview。"
} finally {
loading.value = false
}
}
const undoHistory = () => {
if (!canUndo.value) {
return
}
historyIndex.value -= 1
applySnapshot(historyStack.value[historyIndex.value])
}
const redoHistory = () => {
if (!canRedo.value) {
return
}
historyIndex.value += 1
applySnapshot(historyStack.value[historyIndex.value])
}
const goBack = () => {
router.back()
}
const loadDemoPage = () => {
if (typeof window === "undefined") {
return
}
baseUrl.value = `${window.location.origin}/editor-demo.html`
}
const handleCanvasReady = (payload = {}) => {
canvasBridgeState.value = "ready"
canvasBridgePageUrl.value = payload.page_url || baseUrl.value || ""
if (payload.page_html) {
const shouldSeedHistory =
!canvasDocumentHtml.value ||
historyStack.value.length === 0 ||
!historyStack.value[historyStack.value.length - 1]?.canvasDocumentHtml
canvasDocumentHtml.value = payload.page_html
if (shouldSeedHistory) {
recordHistory()
}
}
canvasEventLog.value = [
{
id: `${Date.now()}-ready`,
type: "ready",
value: payload.page_url || baseUrl.value || "",
source: "canvas-bridge",
},
...canvasEventLog.value,
].slice(0, 8)
}
const applySelectedMetaFromPayload = (payload = {}, fallbackMeta = selectedElementMeta.value) => ({
...fallbackMeta,
tag: payload.tag || fallbackMeta.tag || "",
text: payload.text || fallbackMeta.text || "",
href: payload.href || fallbackMeta.href || "",
src: payload.src || fallbackMeta.src || "",
hidden: Boolean(payload.hidden),
source: payload.source || "canvas-bridge",
})
const syncDraftSelectorAfterStructureChange = (previousSelectedSelector = "", nextSelector = "") => {
if (
changeDraft.value?.selector_value &&
changeDraft.value.selector_value === previousSelectedSelector
) {
patchDraft({
selector_value: nextSelector || "",
})
}
if (!nextSelector) {
changeDraft.value = normalizeDraftByType({
...changeDraft.value,
selector_value: "",
})
clearDraftFocusTarget()
}
}
const handleCanvasOutbound = (payload = {}) => {
if (!payload?.type) {
return
}
if (
payload.type === editorBridgeEventType.BLOCK_VISIBILITY_CHANGE &&
payload.selector === selectedSelector.value
) {
selectedElementMeta.value = applySelectedMetaFromPayload(payload)
canvasDocumentHtml.value = payload.pageHtml || canvasDocumentHtml.value
invalidatePreviewOperations()
recordHistory()
}
if (payload.type === editorBridgeEventType.BLOCK_STRUCTURE_CHANGE) {
const previousSelectedSelector = selectedSelector.value
canvasDocumentHtml.value = payload.pageHtml || canvasDocumentHtml.value
invalidatePreviewOperations()
if (["move", "duplicate"].includes(payload.action) && payload.nextSelector) {
selectedSelector.value = payload.nextSelector
selectedElementMeta.value = applySelectedMetaFromPayload(payload)
}
if (payload.action === "delete") {
selectedSelector.value = payload.nextSelector || ""
if (!payload.nextSelector) {
selectedElementMeta.value = createEmptySelectedElementMeta()
} else {
selectedElementMeta.value = applySelectedMetaFromPayload(payload)
}
}
if (payload.action === "insert" && payload.nextSelector) {
selectedSelector.value = payload.nextSelector
selectedElementMeta.value = applySelectedMetaFromPayload(payload)
if (payload.blockType === "button-block") {
patchDraft({
change_type: "set_attribute",
selector_value: payload.nextSelector,
payload: {
name: "href",
value: payload.href || "https://ose.tw",
},
})
draftFocusTarget.value = "attribute-value"
sendCanvasCommand(editorBridgeEventType.START_INLINE_EDIT, payload.nextSelector, {
editKind: "set_attribute",
attributeName: "href",
attributeValue: payload.href || "https://ose.tw",
})
} else if (payload.blockType === "image-block") {
patchDraft({
change_type: "set_attribute",
selector_value: payload.nextSelector,
payload: {
name: "src",
value: payload.src || "",
},
})
draftFocusTarget.value = "attribute-value"
sendCanvasCommand(editorBridgeEventType.START_INLINE_EDIT, payload.nextSelector, {
editKind: "set_attribute",
attributeName: "src",
attributeValue: payload.src || "",
})
} else {
patchDraft({
change_type: "replace_text",
selector_value: payload.nextSelector,
payload: {
text: payload.text || "新的區塊標題",
},
})
draftFocusTarget.value = "content"
if (payload.blockType === "text-block") {
sendCanvasCommand(editorBridgeEventType.START_INLINE_EDIT, payload.nextSelector, {
editKind: "replace_text",
})
}
}
}
syncDraftSelectorAfterStructureChange(previousSelectedSelector, payload.nextSelector || "")
recordHistory()
}
canvasEventLog.value = [
{
id: `${Date.now()}-${payload.type}`,
type: payload.type,
value: payload.value || "",
source: payload.source || "editor-workspace",
},
...canvasEventLog.value,
].slice(0, 8)
}
const hydrateSelectionFromPayload = (payload = {}) => {
if (payload.selector) {
selectedSelector.value = payload.selector
}
selectedElementMeta.value = {
tag: payload.tag || selectedElementMeta.value.tag || "",
text: payload.text || selectedElementMeta.value.text || "",
href: payload.href || selectedElementMeta.value.href || "",
src: payload.src || selectedElementMeta.value.src || "",
hidden: Boolean(payload.hidden),
source: payload.source || selectedElementMeta.value.source || "canvas-hotspot",
}
}
const hydrateDraftIntentFromPayload = (payload = {}) => {
if (!payload.intentChangeType) {
return
}
patchDraft({
change_type: payload.intentChangeType,
selector_value: payload.selector || selectedSelector.value,
payload:
payload.intentChangeType === "set_attribute"
? {
name: payload.intentAttributeName || "href",
value: payload.intentAttributeValue || "",
}
: payload.intentChangeType === "set_style"
? {
property: "backgroundColor",
value: "#111827",
}
: payload.intentChangeType === "replace_text"
? {
text: payload.text || selectedElementMeta.value.text || "新的主標題文案",
}
: {},
})
}
const handleCanvasHotspotAction = async (payload = {}) => {
hydrateSelectionFromPayload(payload)
hydrateDraftIntentFromPayload(payload)
switch (payload.action) {
case "select-current":
loadSelectedSelectorIntoDraft()
syncSelectedSelectorToDraft()
scrollToSection("editor-inspector-section")
return
case "inspect-current":
scrollToSection("editor-inspector-section")
return
case "edit-current":
await startDraftEditFlow()
return
case "edit-text-current":
await startDraftEditFlow({
changeType: payload.intentChangeType || "replace_text",
focusTarget: payload.intentField || "content",
})
return
case "edit-link-current":
case "edit-image-current":
await startDraftEditFlow({
changeType: payload.intentChangeType || "set_attribute",
focusTarget: payload.intentField || "attribute-value",
attributeName: payload.intentAttributeName || "",
attributeValue: payload.intentAttributeValue || "",
})
return
case "edit-style-current":
await startDraftEditFlow({
changeType: payload.intentChangeType || "set_style",
focusTarget: payload.intentField || "style-property",
})
return
case "confirm-current":
await runCanvasCheckFlow()
return
default:
return
}
}
const handleCanvasHover = (payload = {}) => {
hoveredElementMeta.value = {
selector: payload.selector || "",
tag: payload.tag || "",
text: payload.text || "",
href: payload.href || "",
src: payload.src || "",
source: payload.source || "canvas-hover",
}
}
const handleInlineTextChange = (payload = {}) => {
if (!payload.selector) {
return
}
selectedSelector.value = payload.selector
selectedElementMeta.value = {
...selectedElementMeta.value,
text: payload.text || selectedElementMeta.value.text || "",
source: payload.source || "canvas-inline-edit",
}
canvasDocumentHtml.value = payload.pageHtml || canvasDocumentHtml.value
invalidatePreviewOperations()
if (changeDraft.value.change_type === "replace_text") {
patchDraft({
selector_value: payload.selector,
payload: {
text: payload.text || "",
},
})
}
if (payload.source === "canvas-inline-edit-live") {
scheduleLiveEditHistory()
} else {
recordHistory()
}
}
const clearHoveredCandidate = () => {
hoveredElementMeta.value = {
selector: "",
tag: "",
text: "",
href: "",
src: "",
hidden: false,
source: "",
}
}
const keepCurrentSelection = () => {
clearHoveredCandidate()
focusSelectedOnCanvas()
scrollToSection("editor-canvas-section")
}
const promoteHoveredToSelected = () => {
if (!hoveredElementMeta.value.selector) {
return
}
selectCanvasCandidate({
selector: hoveredElementMeta.value.selector,
tag: hoveredElementMeta.value.tag,
text: hoveredElementMeta.value.text,
href: hoveredElementMeta.value.href,
src: hoveredElementMeta.value.src,
source: "canvas-hover-promoted",
})
clearHoveredCandidate()
}
const inspectHoveredOnCanvas = () => {
if (!hoveredElementMeta.value.selector) {
return
}
sendCanvasCommand("mkt-editor:inspect-selector", hoveredElementMeta.value.selector)
scrollToSection("editor-canvas-section")
}
const sendCanvasCommand = (type, selector, extra = {}) => {
if (!selector) {
return
}
canvasBridgeCommand.value = {
id: `${Date.now()}-${type}-${selector}`,
type,
selector,
...extra,
source: "editor-workspace",
}
}
const handleInlineAttributeChange = (payload = {}) => {
if (!payload.selector || !payload.attributeName) {
return
}
selectedSelector.value = payload.selector
selectedElementMeta.value = {
...selectedElementMeta.value,
href:
payload.attributeName === "href"
? payload.attributeValue || ""
: selectedElementMeta.value.href || "",
src:
payload.attributeName === "src"
? payload.attributeValue || ""
: selectedElementMeta.value.src || "",
source: payload.source || "canvas-inline-edit",
}
canvasDocumentHtml.value = payload.pageHtml || canvasDocumentHtml.value
invalidatePreviewOperations()
if (changeDraft.value.change_type === "set_attribute") {
patchDraft({
selector_value: payload.selector,
payload: {
name: payload.attributeName,
value: payload.attributeValue || "",
},
})
}
if (payload.source === "canvas-inline-edit-live") {
scheduleLiveEditHistory()
} else {
recordHistory()
}
}
const handleInlineStyleChange = (payload = {}) => {
if (!payload.selector || !payload.styleProperty) {
return
}
selectedSelector.value = payload.selector
selectedElementMeta.value = {
...selectedElementMeta.value,
source: payload.source || "canvas-inline-edit",
}
canvasDocumentHtml.value = payload.pageHtml || canvasDocumentHtml.value
invalidatePreviewOperations()
if (changeDraft.value.change_type === "set_style") {
patchDraft({
selector_value: payload.selector,
payload: {
property: payload.styleProperty,
value: payload.styleValue || "",
},
})
}
if (payload.source === "canvas-inline-edit-live") {
scheduleLiveEditHistory()
} else {
recordHistory()
}
}
const focusSelectedOnCanvas = () => {
sendCanvasCommand("mkt-editor:focus-selector", selectedSelector.value)
}
const inspectSelectedOnCanvas = () => {
sendCanvasCommand("mkt-editor:inspect-selector", selectedSelector.value)
}
const runInteractionStateAction = async () => {
switch (interactionStateSummary.value.stage) {
case "idle":
scrollToSection("editor-canvas-section")
return
case "hover":
promoteHoveredToSelected()
return
case "compare":
keepCurrentSelection()
return
default:
await runCanvasCheckFlow()
}
}
const scrollToSection = (sectionId) => {
document
.getElementById(sectionId)
?.scrollIntoView({ behavior: "smooth", block: "start" })
}
const runNextAction = async () => {
switch (nextAction.value.key) {
case "create_session":
await createSession()
return
case "apply_template":
applyTemplate("replace_text")
return
case "build_preview":
await buildPreview()
return
case "canvas_next":
scrollToSection("editor-canvas-section")
return
default:
return
}
}
onMounted(async () => {
if (typeof route.query.base_url === "string" && route.query.base_url) {
baseUrl.value = route.query.base_url
}
await loadChanges()
if (canvasCandidates.value.length > 0) {
selectCanvasCandidate({
...canvasCandidates.value[0],
source: "initial-candidate",
})
changeDraft.value = normalizeDraftByType({
...changeDraft.value,
selector_value: selectedSelector.value,
})
}
if (historyStack.value.length === 0) {
recordHistory()
}
})
onBeforeUnmount(() => {
window.clearTimeout(liveEditHistoryTimer)
})
return {
variantId,
loading,
errorMessage,
session,
changes,
changesJson,
previewOperations,
previewOperationsPreview,
parsedChanges,
changeSummary,
canvasCandidates,
interactionStateSummary,
inspectorSummary,
workspaceConsistency,
selectedSelectorSnapshot,
selectedElementSummary,
quickEditActions,
previewFreshness,
recommendedFlowSummary,
selectedPreviewSummary,
canvasCheckSummary,
canvasFeedbackSummary,
selectedSelector,
changeDraft,
canvasBridgeState,
canvasBridgePageUrl,
selectedElementMeta,
hoveredElementMeta,
canvasEventLog,
canvasBridgeCommand,
workspaceReadiness,
nextAction,
workspaceJourney,
operatorGuide,
draftFormGuide,
changeTemplates: editorChangeTemplates,
baseUrl,
selectCanvasCandidate,
patchDraft,
applySelectedElementToDraft,
applyRecommendedChangeType,
runRecommendedFlow,
runSelectedPreviewFlow,
runCanvasCheckFlow,
runQuickEditAction,
startDraftEditFlow,
syncSelectedSelectorToDraft,
applyDraftToChanges,
replaceChangesWithDraft,
loadSelectedSelectorIntoDraft,
applyTemplate,
createSession,
loadChanges,
saveChanges,
buildPreview,
loadDemoPage,
runNextAction,
scrollToSection,
handleCanvasReady,
handleCanvasHotspotAction,
handleCanvasHover,
handleInlineAttributeChange,
handleInlineStyleChange,
handleInlineTextChange,
clearHoveredCandidate,
keepCurrentSelection,
handleCanvasOutbound,
promoteHoveredToSelected,
inspectHoveredOnCanvas,
focusSelectedOnCanvas,
inspectSelectedOnCanvas,
runInteractionStateAction,
draftFocusTarget,
clearDraftFocusTarget,
goBack,
deviceMode,
setDeviceMode,
insertBlockType,
insertBlockTypeOptions,
moveSelectedBlock,
toggleSelectedBlockVisibility,
duplicateSelectedBlock,
deleteSelectedBlock,
insertBlockNearSelection,
canUndo,
canRedo,
undoHistory,
redoHistory,
}
}