Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions spx-gui/src/apps/xbuilder/pages/editor/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import ProjectEditor from '@/components/editor/ProjectEditor.vue'
import { CodeEditorProvider, loadMonaco } from '@/components/editor/spx-code-editor'
import { usePublishProject } from '@/components/project'
import { EditingMode, type ILocalCache } from '@/components/editor/editing'
import { editorLeaveConfirm } from '@/components/editor/leave-confirm'
import { EditorState } from '@/components/editor/editor-state'
import { cloudHelpers } from '@/models/common/cloud'
import { localHelpers, type LocalHelpers } from '@/models/common/local'
Expand Down Expand Up @@ -213,17 +214,20 @@ onBeforeRouteLeave(async () => {
* If it is OK to leave, return true, otherwise return false.
*/
async function checkChangesNotToBeSaved(es: EditorState) {
if (editorLeaveConfirm.consumeSkipOnce()) return true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

通过单独的机制来避免跟 editor 跟 tutorial 逻辑的耦合,这个思路我觉得没问题。不过现在这个 editor-leave-confirm 稍微有点重,我在想能不能借用已有的 editing-dirty 来达到这次的目的;比如 class Editing 增加个方法来重置 dirty 状态,现在 requestSkipOnce 的地方通过重置 dirty 状态来跳过检查?

const hasEdits = es.editing.mode === EditingMode.EffectFree && es.editing.dirty
if (!hasEdits) return true
return confirm({
title: t({
en: 'Leave editor',
zh: '离开编辑器'
}),
content: t({
en: `Project edits will not be saved if you leave now. Are you sure to leave?`,
zh: `若现在离开,对项目的修改将不会被保存。确定要离开吗?`
}),
content: t(
editorLeaveConfirm.messageOverride ?? {
en: `Project edits will not be saved if you leave now. Are you sure to leave?`,
zh: `若现在离开,对项目的修改将不会被保存。确定要离开吗?`
}
),
cancelText: t({
en: 'Keep editing',
zh: '继续编辑'
Expand Down
47 changes: 47 additions & 0 deletions spx-gui/src/components/editor/leave-confirm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { shallowRef } from 'vue'

import type { LocaleMessage } from '@/utils/i18n'

// Max age (ms) for a skip request to still suppress the leave confirmation. Time-bound so a
// request that is never consumed cannot get "stuck": some navigations (e.g. editor -> editor
// route updates, or starting a course from a non-editor page) never reach the editor's
// `onBeforeRouteLeave`, and an un-expiring request would otherwise suppress a later, genuine
// leave confirmation.
const skipConfirmTimeout = 1000

/**
* Controls the editor's "leave editor" confirmation.
*
* This is an editor-owned extension point: other features (e.g. tutorials) can drive the
* editor's leave behavior without the editor depending on them. The editor only exposes
* generic capabilities here and stays ignorant of who is driving it.
*
* - `messageOverride`: replace the confirmation message (e.g. tutorial-specific copy).
* - `requestSkipOnce` / `consumeSkipOnce`: skip the confirmation for a single, expected
* navigation (e.g. starting a course, or returning to the course series).
*/
class EditorLeaveConfirm {
private messageOverrideRef = shallowRef<LocaleMessage | null>(null)

get messageOverride(): LocaleMessage | null {
return this.messageOverrideRef.value
}
setMessageOverride(message: LocaleMessage | null) {
this.messageOverrideRef.value = message
}

private skipRequestedAt = 0
requestSkipOnce() {
this.skipRequestedAt = Date.now()
}
consumeSkipOnce() {
const skip = Date.now() - this.skipRequestedAt < skipConfirmTimeout
this.skipRequestedAt = 0
return skip
}
}

// App-level single instance: there is only ever one editor leave confirmation in play, and
// the drivers (tutorial, success modal) live outside the editor component tree, so they reach
// it by import rather than provide/inject.
export const editorLeaveConfirm = new EditorLeaveConfirm()
22 changes: 16 additions & 6 deletions spx-gui/src/components/tutorials/TutorialCourseSuccessModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { CourseSeries } from '@/apis/course-series'
import { useI18n } from '@/utils/i18n'

import { UIButton, UIImg, UIModal, UIModalClose } from '@/components/ui'
import { editorLeaveConfirm } from '@/components/editor/leave-confirm'
import { DefaultException, useMessageHandle } from '@/utils/exception'
import successImg from './success.png'

Expand Down Expand Up @@ -37,10 +38,19 @@ function handleCancel() {
emit('cancelled')
}

function handleBrowseTutorials() {
emit('cancelled')
router.push('/tutorials')
}
const { fn: handleBackToCourseSeries } = useMessageHandle(
async () => {
// Request the skip before anything else so the time-bound window isn't shortened
// by work in the `cancelled` handler.
editorLeaveConfirm.requestSkipOnce()
emit('cancelled')
await router.push(`/course-series/${props.series.id}`)
},
{
en: 'Failed to go back to course series',
zh: '返回系列课程失败'
}
)

const hasNextCourse = computed(() => {
const currentCourse = props.course
Expand Down Expand Up @@ -96,8 +106,8 @@ const { fn: handleStartNextCourse } = useMessageHandle(
<div class="mt-2 text-base">{{ courseCompleteMessage }}</div>

<div class="mt-10 w-full flex flex-col gap-5">
<UIButton type="neutral" size="large" @click="handleBrowseTutorials">
{{ $t({ zh: '浏览所有课程', en: 'Browse all courses' }) }}
<UIButton type="neutral" size="large" @click="handleBackToCourseSeries">
{{ $t({ zh: '返回系列课程', en: 'Back to series courses' }) }}
</UIButton>
<UIButton v-if="hasNextCourse" size="large" @click="handleStartNextCourse">
{{ $t({ zh: '学习下一个课程', en: 'Learn next course' }) }}
Expand Down
9 changes: 9 additions & 0 deletions spx-gui/src/components/tutorials/TutorialRoot.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useIsRouteLoaded } from '@/utils/route-loading'
import { isTutorialTopic, provideTutorial, Tutorial } from './tutorial'

import { useCopilot } from '@/components/copilot/context'
import { editorLeaveConfirm } from '@/components/editor/leave-confirm'
import * as tutorialCourseSuccess from './TutorialCourseSuccess.vue'
import * as tutorialCourseExitLink from './TutorialCourseExitLink'
import * as tutorialStateIndicator from './TutorialStateIndicator.vue'
Expand All @@ -27,7 +28,15 @@ watch(
(currentCourse, _, onCleanup) => {
if (currentCourse == null) return

// Drive the editor's leave confirmation with tutorial-specific copy while a course is
// active; the editor exposes the override and stays unaware of tutorials.
editorLeaveConfirm.setMessageOverride({
en: 'Tutorial edits will not be saved if you leave now. Are you sure to leave?',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个文案跟默认文案表达的信息差别并不大,有没有可能优化默认文案来让它在不同场景下都比较合适,而不是针对 tutorial 使用不一样的文案?

zh: '教程中的修改不会被保存,确认要离开吗?'
})

const disposers = [
() => editorLeaveConfirm.setMessageOverride(null),
copilot.registerCustomElement({
tagName: tutorialCourseSuccess.tagName,
description: tutorialCourseSuccess.detailedDescription,
Expand Down
5 changes: 5 additions & 0 deletions spx-gui/src/components/tutorials/tutorial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { timeout, until } from '@/utils/utils'
import { userSessionStorageRef } from '@/utils/user-storage'
import type { Copilot, Topic } from '@/components/copilot/copilot'
import { tagName as highlightLinkTagName } from '@/components/copilot/markdown-elements/HighlightLink.vue'
import { editorLeaveConfirm } from '@/components/editor/leave-confirm'
import type { Course } from '@/apis/course'
import type { CourseSeries } from '@/apis/course-series'

Expand Down Expand Up @@ -71,6 +72,10 @@ export class Tutorial {
const { entrypoint } = course

if (entrypoint) {
// A course entrypoint may point to a non-editor route. Navigating away from the
// editor would otherwise trigger its leave confirmation, but starting a course is
// an explicit, expected action, so skip the confirmation for this navigation.
editorLeaveConfirm.requestSkipOnce()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

跳过 confirm 的逻辑放到 TutorialCourseSuccessModal.vue 中调用 startCourse 的地方做可能更合适?按我们讨论的结论,不是所有的 startCourse 都应该跳过 confirm 的,只是 TutorialCourseSuccessModal 中那个 startCourse 行为才应该跳过

await this.router.push(entrypoint)
await until(this.isRouteLoaded)
await timeout(100) // Wait for detailed UI rendering
Expand Down