1377 lines
38 KiB
JavaScript
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,
|
|
}
|
|
}
|