Browse Source

feat(editor): 添加新版编辑器组件及可折叠工具栏功能

新增Editor2组件用于新版编辑器界面,包含可折叠工具栏组件和优化后的缩略图面板。调整App.vue路由逻辑以支持新版编辑器模式,同时添加了相关样式和交互逻辑。

- 新增CollapsibleToolbar可折叠工具栏组件
- 创建Editor2新版编辑器视图组件
- 更新Thumbnails缩略图面板组件
- 修改App.vue路由逻辑支持editor2模式
lsc 1 day ago
parent
commit
3dee13cd48

+ 8 - 3
src/App.vue

@@ -1,7 +1,8 @@
 <template>
 <template>
   <template v-if="slides.length">
   <template v-if="slides.length">
-    <Screen v-if="viewMode !== 'student'"  v-show="screening"/>
-    <Editor v-if="viewMode !== 'student'" v-show="_isPC && !screening" :courseid="urlParams.courseid"/>
+    <Screen v-if="viewMode !== 'student'" v-show="screening"/>
+    <Editor v-if="viewMode === 'editor'" v-show="_isPC && !screening" :courseid="urlParams.courseid"/>
+    <Editor2 v-else-if="viewMode === 'editor2'" v-show="_isPC && !screening" :courseid="urlParams.courseid"/>
     <Student v-else-if="viewMode === 'student'" :courseid="urlParams.courseid" :type="urlParams.type" :userid="urlParams.userid" :oid="urlParams.oid" :org="urlParams.org" :cid="urlParams.cid" />
     <Student v-else-if="viewMode === 'student'" :courseid="urlParams.courseid" :type="urlParams.type" :userid="urlParams.userid" :oid="urlParams.oid" :org="urlParams.org" :cid="urlParams.cid" />
     <Mobile v-else />
     <Mobile v-else />
   </template>
   </template>
@@ -20,6 +21,7 @@ import { isPC } from '@/utils/common'
 import api from '@/services'
 import api from '@/services'
 
 
 import Editor from './views/Editor/index.vue'
 import Editor from './views/Editor/index.vue'
+import Editor2 from './views/Editor/index2.vue'
 import Screen from './views/Screen/index.vue'
 import Screen from './views/Screen/index.vue'
 import Mobile from './views/Mobile/index.vue'
 import Mobile from './views/Mobile/index.vue'
 import Student from './views/Student/index.vue'
 import Student from './views/Student/index.vue'
@@ -40,11 +42,14 @@ const getInitialViewMode = () => {
   // 检查URL参数
   // 检查URL参数
   const urlParams = new URLSearchParams(window.location.search)
   const urlParams = new URLSearchParams(window.location.search)
   const modeFromUrl = urlParams.get('mode')
   const modeFromUrl = urlParams.get('mode')
-  
+  console.log(modeFromUrl)
   if (modeFromUrl === 'student') {
   if (modeFromUrl === 'student') {
     return 'student'
     return 'student'
   }
   }
   
   
+  if (modeFromUrl === 'editor2') {
+    return 'editor2'
+  }
   // 检查localStorage
   // 检查localStorage
   const modeFromStorage = localStorage.getItem('viewMode')
   const modeFromStorage = localStorage.getItem('viewMode')
   if (modeFromStorage) {
   if (modeFromStorage) {

+ 160 - 0
src/components/CollapsibleToolbar/index.vue

@@ -0,0 +1,160 @@
+<template>
+  <div class="collapsible-toolbar" :class="{ collapsed: isCollapsed }">
+    <div class="toolbar-header" @click="toggleCollapse">
+      <div class="header-content">
+        <slot name="header">
+          <span class="default-title">工具栏</span>
+        </slot>
+        <div class="collapse-icon">
+          <IconRight v-if="isCollapsed" />
+          <IconLeft v-else />
+        </div>
+      </div>
+    </div>
+    <div class="toolbar-content" v-show="!isCollapsed">
+      <slot name="content">
+        <div class="sidebar-content">
+          <div class="sidebar-item" @click="handleToolClick('shape')">
+            <span>形状</span>
+          </div>
+          <div class="sidebar-item" @click="handleToolClick('text')">
+            <span>文本</span>
+          </div>
+          <div class="sidebar-item" @click="handleToolClick('image')">
+            <span>图片</span>
+          </div>
+          <div class="sidebar-item" @click="handleToolClick('chart')">
+            <span>图表</span>
+          </div>
+          <div class="sidebar-item" @click="handleToolClick('table')">
+            <span>表格</span>
+          </div>
+        </div>
+      </slot>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+
+const props = withDefaults(defineProps<{
+  defaultCollapsed?: boolean
+}>(), {
+  defaultCollapsed: false
+})
+
+const emit = defineEmits<{
+  (e: 'toggle', collapsed: boolean): void
+}>()
+
+const isCollapsed = ref(props.defaultCollapsed)
+
+const toggleCollapse = () => {
+  isCollapsed.value = !isCollapsed.value
+  emit('toggle', isCollapsed.value)
+}
+
+const handleToolClick = (tool: string) => {
+  console.log('点击工具:', tool)
+}
+</script>
+
+<style lang="scss" scoped>
+.collapsible-toolbar {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+  border-right: 1px solid #e8e8e8;
+  transition: width 0.3s ease;
+}
+
+.toolbar-header {
+  height: 48px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 16px;
+  border-bottom: 1px solid #f0f0f0;
+  cursor: pointer;
+  user-select: none;
+  background: #f8f9fa;
+  transition: all 0.3s ease;
+
+  &:hover {
+    background: #f0f0f0;
+  }
+}
+
+.collapsed .toolbar-header {
+  padding: 0;
+  justify-content: center;
+}
+
+.header-content {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 14px;
+  font-weight: 500;
+  color: #333;
+  transition: all 0.3s ease;
+}
+
+.collapsed .header-content {
+  gap: 0;
+}
+
+.default-title {
+  flex: 1;
+}
+
+.collapsed .default-title {
+  display: none;
+}
+
+.collapse-icon {
+  width: 20px;
+  height: 20px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #666;
+  transition: transform 0.3s ease;
+}
+
+.collapsed .collapse-icon {
+  transform: rotate(180deg);
+}
+
+.toolbar-content {
+  flex: 1;
+  overflow: hidden;
+  padding: 12px;
+}
+
+.sidebar-content {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.sidebar-item {
+  padding: 12px 16px;
+  border-radius: 6px;
+  cursor: pointer;
+  transition: background-color 0.2s ease;
+  font-size: 14px;
+  color: #333;
+}
+
+.sidebar-item:hover {
+  background-color: #f0f0f0;
+}
+
+.sidebar-item:active {
+  background-color: #e0e0e0;
+}
+</style>

+ 554 - 0
src/views/Editor/Thumbnails/index2.vue

@@ -0,0 +1,554 @@
+<template>
+  <div 
+    class="thumbnails"
+    @mousedown="() => setThumbnailsFocus(true)"
+    v-click-outside="() => setThumbnailsFocus(false)"
+    v-contextmenu="contextmenusThumbnails"
+  >
+    <div class="add-slide">
+      <div class="btn" @click="createSlide()"><IconPlus class="icon" />添加幻灯片</div>
+      <!-- <Popover trigger="click" placement="bottom-start" v-model:value="presetLayoutPopoverVisible" center>
+        <template #content>
+          <Templates 
+            @select="slide => { createSlideByTemplate(slide); presetLayoutPopoverVisible = false }"
+            @selectAll="slides => { insertAllTemplates(slides); presetLayoutPopoverVisible = false }"
+          />
+        </template>
+        <div class="select-btn"><IconDown /></div>
+      </Popover> -->
+    </div>
+
+    <Draggable 
+      class="thumbnail-list"
+      ref="thumbnailsRef"
+      :modelValue="slides"
+      :animation="200"
+      :scroll="true"
+      :scrollSensitivity="50"
+      :disabled="editingSectionId"
+      @end="handleDragEnd"
+      itemKey="id"
+    >
+      <template #item="{ element, index }">
+        <div class="thumbnail-container">
+          <div class="section-title"
+            :data-section-id="element?.sectionTag?.id || ''"
+            v-if="element.sectionTag || (hasSection && index === 0)" 
+            v-contextmenu="contextmenusSection"
+          >
+            <input 
+              :id="`section-title-input-${element?.sectionTag?.id || 'default'}`" 
+              type="text"
+              :value="element?.sectionTag?.title || ''"
+              placeholder="输入节名称"
+              @blur="$event => saveSection($event)"
+              @keydown.enter.stop="$event => saveSection($event)"
+              v-if="editingSectionId === element?.sectionTag?.id || (index === 0 && editingSectionId === 'default')"
+            >
+            <span class="text" v-else>
+              <div class="text-content">{{ element?.sectionTag ? (element?.sectionTag?.title || '无标题节') : '默认节' }}</div>
+            </span>
+          </div>
+          <div
+            class="thumbnail-item"
+            :class="{
+              'active': slideIndex === index,
+              'selected': selectedSlidesIndex.includes(index),
+            }"
+            @mousedown="$event => handleClickSlideThumbnail($event, index)"
+            @dblclick="enterScreening()"
+            v-contextmenu="contextmenusThumbnailItem"
+          >
+            <div class="label" :class="{ 'offset-left': index >= 99 }">{{ fillDigit(index + 1, 2) }}</div>
+            <ThumbnailSlide class="thumbnail" :slide="element" :size="120" :visible="index < slidesLoadLimit" />
+  
+            <div class="note-flag" v-if="element.notes && element.notes.length" @click="openNotesPanel()">{{ element.notes.length }}</div>
+          </div>
+        </div>
+      </template>
+    </Draggable>
+
+    <div class="page-number">幻灯片 {{slideIndex + 1}} / {{slides.length}}</div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, nextTick, ref, watch, useTemplateRef } from 'vue'
+import { storeToRefs } from 'pinia'
+import { useMainStore, useSlidesStore, useKeyboardStore } from '@/store'
+import { fillDigit } from '@/utils/common'
+import { isElementInViewport } from '@/utils/element'
+import type { ContextmenuItem } from '@/components/Contextmenu/types'
+import useSlideHandler from '@/hooks/useSlideHandler'
+import useSectionHandler from '@/hooks/useSectionHandler'
+import useScreening from '@/hooks/useScreening'
+import useLoadSlides from '@/hooks/useLoadSlides'
+import useAddSlidesOrElements from '@/hooks/useAddSlidesOrElements'
+import type { Slide } from '@/types/slides'
+
+import ThumbnailSlide from '@/views/components/ThumbnailSlide/index.vue'
+import Templates from './Templates.vue'
+import Popover from '@/components/Popover.vue'
+import Draggable from 'vuedraggable'
+
+const mainStore = useMainStore()
+const slidesStore = useSlidesStore()
+const keyboardStore = useKeyboardStore()
+const { selectedSlidesIndex: _selectedSlidesIndex, thumbnailsFocus } = storeToRefs(mainStore)
+const { slides, slideIndex, currentSlide } = storeToRefs(slidesStore)
+const { ctrlKeyState, shiftKeyState } = storeToRefs(keyboardStore)
+
+const { slidesLoadLimit } = useLoadSlides()
+
+const selectedSlidesIndex = computed(() => [..._selectedSlidesIndex.value, slideIndex.value])
+
+const presetLayoutPopoverVisible = ref(false)
+
+const hasSection = computed(() => {
+  return slides.value.some(item => item.sectionTag)
+})
+
+const { addSlidesFromData } = useAddSlidesOrElements()
+
+const {
+  copySlide,
+  pasteSlide,
+  createSlide,
+  createSlideByTemplate,
+  copyAndPasteSlide,
+  deleteSlide,
+  cutSlide,
+  selectAllSlide,
+  sortSlides,
+  isEmptySlide,
+} = useSlideHandler()
+
+const {
+  createSection,
+  removeSection,
+  removeAllSection,
+  removeSectionSlides,
+  updateSectionTitle,
+} = useSectionHandler()
+
+// 页面被切换时
+const thumbnailsRef = useTemplateRef<InstanceType<typeof Draggable>>('thumbnailsRef')
+watch(() => slideIndex.value, () => {
+
+  // 清除多选状态的幻灯片
+  if (selectedSlidesIndex.value.length) {
+    mainStore.updateSelectedSlidesIndex([])
+  }
+
+  // 检查当前页缩略图是否在可视范围,不在的话需要滚动到对应的位置
+  nextTick(() => {
+    const activeThumbnailRef: HTMLElement = thumbnailsRef.value?.$el?.querySelector('.thumbnail-item.active')
+    if (thumbnailsRef.value && activeThumbnailRef && !isElementInViewport(activeThumbnailRef, thumbnailsRef.value.$el)) {
+      setTimeout(() => {
+        activeThumbnailRef.scrollIntoView({ behavior: 'smooth' })
+      }, 100)
+    }
+  })
+}, { immediate: true })
+
+// 切换页面
+const changeSlideIndex = (index: number) => {
+  mainStore.setActiveElementIdList([])
+
+  if (slideIndex.value === index) return
+  slidesStore.updateSlideIndex(index)
+}
+
+// 点击缩略图
+const handleClickSlideThumbnail = (e: MouseEvent, index: number) => {
+  if (editingSectionId.value) return
+
+  const isMultiSelected = selectedSlidesIndex.value.length > 1
+
+  if (isMultiSelected && selectedSlidesIndex.value.includes(index) && e.button !== 0) return
+
+  // 按住Ctrl键,点选幻灯片,再次点击已选中的页面则取消选中
+  // 如果被取消选中的页面刚好是当前激活页面,则需要从其他被选中的页面中选择第一个作为当前激活页面
+  if (ctrlKeyState.value) {
+    if (slideIndex.value === index) {
+      if (!isMultiSelected) return
+
+      const newSelectedSlidesIndex = selectedSlidesIndex.value.filter(item => item !== index)
+      mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
+      changeSlideIndex(selectedSlidesIndex.value[0])
+    }
+    else {
+      if (selectedSlidesIndex.value.includes(index)) {
+        const newSelectedSlidesIndex = selectedSlidesIndex.value.filter(item => item !== index)
+        mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
+      }
+      else {
+        const newSelectedSlidesIndex = [...selectedSlidesIndex.value, index]
+        mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
+      }
+    }
+  }
+  // 按住Shift键,选择范围内的全部幻灯片
+  else if (shiftKeyState.value) {
+    if (slideIndex.value === index && !isMultiSelected) return
+
+    let minIndex = Math.min(...selectedSlidesIndex.value)
+    let maxIndex = index
+
+    if (index < minIndex) {
+      maxIndex = Math.max(...selectedSlidesIndex.value)
+      minIndex = index
+    }
+
+    const newSelectedSlidesIndex = []
+    for (let i = minIndex; i <= maxIndex; i++) newSelectedSlidesIndex.push(i)
+    mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
+  }
+  // 正常切换页面
+  else {
+    mainStore.updateSelectedSlidesIndex([])
+    changeSlideIndex(index)
+  }
+}
+
+// 设置缩略图工具栏聚焦状态(只有聚焦状态下,该部分的快捷键才能生效)
+const setThumbnailsFocus = (focus: boolean) => {
+  if (thumbnailsFocus.value === focus) return
+  mainStore.setThumbnailsFocus(focus)
+
+  if (!focus) mainStore.updateSelectedSlidesIndex([])
+}
+
+// 拖拽调整顺序后进行数据的同步
+const handleDragEnd = (eventData: { newIndex: number; oldIndex: number }) => {
+  const { newIndex, oldIndex } = eventData
+  if (newIndex === undefined || oldIndex === undefined || newIndex === oldIndex) return
+  sortSlides(newIndex, oldIndex)
+}
+
+// 打开批注面板
+const openNotesPanel = () => {
+  mainStore.setNotesPanelState(true)
+}
+
+const editingSectionId = ref('')
+
+const editSection = (id: string) => {
+  mainStore.setDisableHotkeysState(true)
+  editingSectionId.value = id || 'default'
+
+  nextTick(() => {
+    const inputRef = document.querySelector(`#section-title-input-${id || 'default'}`) as HTMLInputElement
+    inputRef.focus()
+  })
+}
+
+const saveSection = (e: FocusEvent | KeyboardEvent) => {
+  const title = (e.target as HTMLInputElement).value
+  updateSectionTitle(editingSectionId.value, title)
+
+  editingSectionId.value = ''
+  mainStore.setDisableHotkeysState(false)
+}
+
+const insertAllTemplates = (slides: Slide[]) => {
+  if (isEmptySlide.value) slidesStore.setSlides(slides)
+  else addSlidesFromData(slides)
+}
+
+const contextmenusSection = (el: HTMLElement): ContextmenuItem[] => {
+  const sectionId = el.dataset.sectionId!
+
+  return [
+    {
+      text: '删除节',
+      handler: () => removeSection(sectionId),
+    },
+    {
+      text: '删除节和幻灯片',
+      handler: () => {
+        mainStore.setActiveElementIdList([])
+        removeSectionSlides(sectionId)
+      },
+    },
+    {
+      text: '删除所有节',
+      handler: removeAllSection,
+    },
+    {
+      text: '重命名节',
+      handler: () => editSection(sectionId),
+    },
+  ]
+}
+
+const { enterScreening, enterScreeningFromStart } = useScreening()
+
+const contextmenusThumbnails = (): ContextmenuItem[] => {
+  return [
+    {
+      text: '粘贴',
+      subText: 'Ctrl + V',
+      handler: pasteSlide,
+    },
+    {
+      text: '全选',
+      subText: 'Ctrl + A',
+      handler: selectAllSlide,
+    },
+    {
+      text: '新建页面',
+      subText: 'Enter',
+      handler: createSlide,
+    },
+    {
+      text: '幻灯片放映',
+      subText: 'F5',
+      handler: enterScreeningFromStart,
+    },
+  ]
+}
+
+const contextmenusThumbnailItem = (): ContextmenuItem[] => {
+  return [
+    {
+      text: '剪切',
+      subText: 'Ctrl + X',
+      handler: cutSlide,
+    },
+    {
+      text: '复制',
+      subText: 'Ctrl + C',
+      handler: copySlide,
+    },
+    {
+      text: '粘贴',
+      subText: 'Ctrl + V',
+      handler: pasteSlide,
+    },
+    {
+      text: '全选',
+      subText: 'Ctrl + A',
+      handler: selectAllSlide,
+    },
+    { divider: true },
+    {
+      text: '新建页面',
+      subText: 'Enter',
+      handler: createSlide,
+    },
+    {
+      text: '复制页面',
+      subText: 'Ctrl + D',
+      handler: copyAndPasteSlide,
+    },
+    {
+      text: '删除页面',
+      subText: 'Delete',
+      handler: () => deleteSlide(),
+    },
+    {
+      text: '增加节',
+      handler: createSection,
+      disable: !!currentSlide.value.sectionTag,
+    },
+    { divider: true },
+    {
+      text: '从当前放映',
+      subText: 'Shift + F5',
+      handler: enterScreening,
+    },
+  ]
+}
+</script>
+
+<style lang="scss" scoped>
+.thumbnails {
+  border-right: solid 1px $borderColor;
+  background-color: #fff;
+  display: flex;
+  // flex-direction: column;
+  user-select: none;
+  width: 100%;
+  position: relative;
+}
+.add-slide {
+  height: 100%;
+  width: 50px;
+  font-size: 12px;
+  display: flex;
+  flex-shrink: 0;
+  border-right: 1px solid $borderColor;
+  cursor: pointer;
+
+  .btn {
+    flex: 1;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    writing-mode: vertical-rl;
+    text-orientation: upright;
+    letter-spacing: 4px;
+
+    &:hover {
+      background-color: $lightGray;
+    }
+  }
+  .select-btn {
+    width: 30px;
+    height: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    border-left: 1px solid $borderColor;
+
+    &:hover {
+      background-color: $lightGray;
+    }
+  }
+
+  .icon {
+    margin-bottom: 5px;
+    font-size: 14px;
+  }
+}
+.thumbnail-list {
+  padding: 0 10px;
+  flex: 1;
+  overflow: auto;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+.thumbnail-item {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  padding: 5px 0;
+  position: relative;
+
+  .thumbnail {
+    border-radius: $borderRadius;
+    outline: 2px solid rgba($color: $themeColor, $alpha: .15);
+  }
+
+  &.active {
+    .label {
+      color: $themeColor;
+    }
+    .thumbnail {
+      outline-color: $themeColor;
+    }
+  }
+  &.selected {
+    .thumbnail {
+      outline-color: $themeColor;
+    }
+    .note-flag {
+      background-color: $themeColor;
+
+      &::after {
+        border-top-color: $themeColor;
+      }
+    }
+  }
+
+  .note-flag {
+    width: 16px;
+    height: 12px;
+    border-radius: 1px;
+    position: absolute;
+    left: 8px;
+    top: 13px;
+    font-size: 8px;
+    background-color: rgba($color: $themeColor, $alpha: .75);
+    color: #fff;
+    text-align: center;
+    line-height: 12px;
+    cursor: pointer;
+
+    &::after {
+      content: '';
+      width: 0;
+      height: 0;
+      position: absolute;
+      top: 10px;
+      left: 4px;
+      border: 4px solid transparent;
+      border-top-color: rgba($color: $themeColor, $alpha: .75);
+    }
+  }
+}
+.label {
+  font-size: 12px;
+  color: #999;
+  width: 20px;
+  cursor: grab;
+
+  &.offset-left {
+    position: relative;
+    left: -4px;
+  }
+
+  &:active {
+    cursor: grabbing;
+  }
+}
+.page-number {
+  /* height: 100%; */
+  font-size: 12px;
+  /* border-top: 1px solid #e5e7eb; */
+  // line-height: 40px;
+  text-align: center;
+  color: #666;
+  position: absolute;
+  bottom: 10px;
+  left: 50%;
+  transform: translateX(-50%);
+}
+.section-title {
+  height: 26px;
+  font-size: 12px;
+  padding: 6px 8px 2px 18px;
+  color: #555;
+
+  &.contextmenu-active {
+    color: $themeColor;
+
+    .text::before {
+      border-bottom-color: $themeColor;
+      border-right-color: $themeColor;
+    }
+  }
+
+  .text {
+    display: flex;
+    align-items: center;
+    position: relative;
+
+    &::before {
+      content: '';
+      width: 0;
+      height: 0;
+      border-top: 3px solid transparent;
+      border-left: 3px solid transparent;
+      border-bottom: 3px solid #555;
+      border-right: 3px solid #555;
+      margin-right: 5px;
+    }
+
+    .text-content {
+      display: inline-block;
+      @include ellipsis-oneline();
+    }
+  }
+
+  input {
+    width: 100%;
+    border: 0;
+    outline: 0;
+    padding: 0;
+    font-size: 12px;
+  }
+}
+</style>

+ 138 - 0
src/views/Editor/index2.vue

@@ -0,0 +1,138 @@
+<template>
+  <div class="pptist-editor">
+    <EditorHeader class="layout-header" />
+    <div class="layout-content">
+      <CollapsibleToolbar class="layout-sidebar" @toggle="handleToolbarToggle" />
+      <div class="layout-content-center" :style="{ width: centerWidth }">
+        <CanvasTool class="center-top" />
+        <Canvas class="center-body" :style="{ height: `calc(100% - ${remarkHeight + 40}px  - 120px)` }" :courseid="props.courseid"/>
+        <!-- <Remark
+          class="center-bottom" 
+          v-model:height="remarkHeight" 
+          :style="{ height: `${remarkHeight}px` }"
+           v-show="false"
+        /> -->
+        <Thumbnails class="layout-content-left" />
+      </div>
+      <Toolbar class="layout-content-right" v-show="false"/>
+    </div>
+  </div>
+
+  <SelectPanel v-if="showSelectPanel" />
+  <SearchPanel v-if="showSearchPanel" />
+  <NotesPanel v-if="showNotesPanel" />
+  <MarkupPanel v-if="showMarkupPanel" />
+
+  <Modal
+    :visible="!!dialogForExport" 
+    :width="680"
+    @closed="closeExportDialog()"
+  >
+    <ExportDialog />
+  </Modal>
+
+  <Modal
+    :visible="showAIPPTDialog" 
+    :width="720"
+    :closeOnClickMask="false"
+    :closeOnEsc="false"
+    closeButton
+    @closed="closeAIPPTDialog()"
+  >
+    <AIPPTDialog />
+  </Modal>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue'
+import { storeToRefs } from 'pinia'
+import { useMainStore } from '@/store'
+import useGlobalHotkey from '@/hooks/useGlobalHotkey'
+import usePasteEvent from '@/hooks/usePasteEvent'
+
+import EditorHeader from './EditorHeader/index.vue'
+import Canvas from './Canvas/index.vue'
+import CanvasTool from './CanvasTool/index.vue'
+import Thumbnails from './Thumbnails/index2.vue'
+import Toolbar from './Toolbar/index.vue'
+import Remark from './Remark/index.vue'
+import ExportDialog from './ExportDialog/index.vue'
+import SelectPanel from './SelectPanel.vue'
+import SearchPanel from './SearchPanel.vue'
+import NotesPanel from './NotesPanel.vue'
+import MarkupPanel from './MarkupPanel.vue'
+import AIPPTDialog from './AIPPTDialog.vue'
+import Modal from '@/components/Modal.vue'
+import CollapsibleToolbar from '@/components/CollapsibleToolbar/index.vue'
+
+interface Props {
+  courseid?: string | null
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  courseid: null,
+})
+
+const mainStore = useMainStore()
+const { dialogForExport, showSelectPanel, showSearchPanel, showNotesPanel, showMarkupPanel, showAIPPTDialog } = storeToRefs(mainStore)
+const closeExportDialog = () => mainStore.setDialogForExport('')
+const closeAIPPTDialog = () => mainStore.setAIPPTDialogState(false)
+
+const remarkHeight = ref(0)
+const sidebarCollapsed = ref(false)
+
+const handleToolbarToggle = (collapsed: boolean) => {
+  sidebarCollapsed.value = collapsed
+}
+
+const centerWidth = computed(() => {
+  return sidebarCollapsed.value ? 'calc(100% - 48px)' : 'calc(100% - 200px)'
+})
+
+useGlobalHotkey()
+usePasteEvent()
+</script>
+
+<style lang="scss" scoped>
+.pptist-editor {
+  height: 100%;
+}
+.layout-header {
+  height: 40px;
+}
+.layout-content {
+  height: calc(100% - 40px);
+  display: flex;
+}
+.layout-sidebar {
+  width: 200px;
+  height: 100%;
+  flex-shrink: 0;
+  transition: width 0.3s ease;
+}
+
+.layout-sidebar.collapsed {
+  width: 48px;
+}
+
+.layout-content-left {
+  width: 100%;
+  height: 120px;
+  flex-shrink: 0;
+}
+
+.layout-content-center {
+  // width: calc(100% - 200px - 160px);
+  width: calc(100%);
+  transition: width 0.3s ease;
+}
+
+.layout-content-center .center-top {
+  height: 40px;
+}
+
+.layout-content-right {
+  width: 260px;
+  height: 100%;
+}
+</style>