index2.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. <template>
  2. <div
  3. class="thumbnails"
  4. @mousedown="() => setThumbnailsFocus(true)"
  5. v-click-outside="() => setThumbnailsFocus(false)"
  6. v-contextmenu="contextmenusThumbnails"
  7. >
  8. <div class="add-slide">
  9. <div class="btn" @click="createSlide()"><IconPlus class="icon" /></div>
  10. <!-- 添加幻灯片 <Popover trigger="click" placement="bottom-start" v-model:value="presetLayoutPopoverVisible" center>
  11. <template #content>
  12. <Templates
  13. @select="slide => { createSlideByTemplate(slide); presetLayoutPopoverVisible = false }"
  14. @selectAll="slides => { insertAllTemplates(slides); presetLayoutPopoverVisible = false }"
  15. />
  16. </template>
  17. <div class="select-btn"><IconDown /></div>
  18. </Popover> -->
  19. </div>
  20. <Draggable
  21. class="thumbnail-list"
  22. ref="thumbnailsRef"
  23. :modelValue="slides"
  24. :animation="200"
  25. :scroll="true"
  26. :scrollSensitivity="50"
  27. :disabled="editingSectionId"
  28. @end="handleDragEnd"
  29. itemKey="id"
  30. >
  31. <template #item="{ element, index }">
  32. <div class="thumbnail-container">
  33. <div class="section-title"
  34. :data-section-id="element?.sectionTag?.id || ''"
  35. v-if="element.sectionTag || (hasSection && index === 0)"
  36. v-contextmenu="contextmenusSection"
  37. >
  38. <input
  39. :id="`section-title-input-${element?.sectionTag?.id || 'default'}`"
  40. type="text"
  41. :value="element?.sectionTag?.title || ''"
  42. placeholder="输入节名称"
  43. @blur="$event => saveSection($event)"
  44. @keydown.enter.stop="$event => saveSection($event)"
  45. v-if="editingSectionId === element?.sectionTag?.id || (index === 0 && editingSectionId === 'default')"
  46. >
  47. <span class="text" v-else>
  48. <div class="text-content">{{ element?.sectionTag ? (element?.sectionTag?.title || '无标题节') : '默认节' }}</div>
  49. </span>
  50. </div>
  51. <div
  52. class="thumbnail-item"
  53. :class="{
  54. 'active': slideIndex === index,
  55. 'selected': selectedSlidesIndex.includes(index),
  56. }"
  57. @mousedown="$event => handleClickSlideThumbnail($event, index)"
  58. @dblclick="enterScreening()"
  59. v-contextmenu="contextmenusThumbnailItem"
  60. >
  61. <div class="label" :class="{ 'offset-left': index >= 99 }">{{ fillDigit(index + 1, 2) }}</div>
  62. <ThumbnailSlide class="thumbnail" :slide="element" :size="120" :visible="index < slidesLoadLimit" />
  63. <div class="note-flag" v-if="element.notes && element.notes.length" @click="openNotesPanel()">{{ element.notes.length }}</div>
  64. </div>
  65. </div>
  66. </template>
  67. </Draggable>
  68. <div class="page-number">幻灯片 {{slideIndex + 1}} / {{slides.length}}</div>
  69. </div>
  70. </template>
  71. <script lang="ts" setup>
  72. import { computed, nextTick, ref, watch, useTemplateRef } from 'vue'
  73. import { storeToRefs } from 'pinia'
  74. import { useMainStore, useSlidesStore, useKeyboardStore } from '@/store'
  75. import { fillDigit } from '@/utils/common'
  76. import { isElementInViewport } from '@/utils/element'
  77. import type { ContextmenuItem } from '@/components/Contextmenu/types'
  78. import useSlideHandler from '@/hooks/useSlideHandler'
  79. import useSectionHandler from '@/hooks/useSectionHandler'
  80. import useScreening from '@/hooks/useScreening'
  81. import useLoadSlides from '@/hooks/useLoadSlides'
  82. import useAddSlidesOrElements from '@/hooks/useAddSlidesOrElements'
  83. import type { Slide } from '@/types/slides'
  84. import ThumbnailSlide from '@/views/components/ThumbnailSlide/index.vue'
  85. import Templates from './Templates.vue'
  86. import Popover from '@/components/Popover.vue'
  87. import Draggable from 'vuedraggable'
  88. const mainStore = useMainStore()
  89. const slidesStore = useSlidesStore()
  90. const keyboardStore = useKeyboardStore()
  91. const { selectedSlidesIndex: _selectedSlidesIndex, thumbnailsFocus } = storeToRefs(mainStore)
  92. const { slides, slideIndex, currentSlide } = storeToRefs(slidesStore)
  93. const { ctrlKeyState, shiftKeyState } = storeToRefs(keyboardStore)
  94. const { slidesLoadLimit } = useLoadSlides()
  95. const selectedSlidesIndex = computed(() => [..._selectedSlidesIndex.value, slideIndex.value])
  96. const presetLayoutPopoverVisible = ref(false)
  97. const hasSection = computed(() => {
  98. return slides.value.some(item => item.sectionTag)
  99. })
  100. const { addSlidesFromData } = useAddSlidesOrElements()
  101. const {
  102. copySlide,
  103. pasteSlide,
  104. createSlide,
  105. createSlideByTemplate,
  106. copyAndPasteSlide,
  107. deleteSlide,
  108. cutSlide,
  109. selectAllSlide,
  110. sortSlides,
  111. isEmptySlide,
  112. } = useSlideHandler()
  113. const {
  114. createSection,
  115. removeSection,
  116. removeAllSection,
  117. removeSectionSlides,
  118. updateSectionTitle,
  119. } = useSectionHandler()
  120. // 页面被切换时
  121. const thumbnailsRef = useTemplateRef<InstanceType<typeof Draggable>>('thumbnailsRef')
  122. watch(() => slideIndex.value, () => {
  123. // 清除多选状态的幻灯片
  124. if (selectedSlidesIndex.value.length) {
  125. mainStore.updateSelectedSlidesIndex([])
  126. }
  127. // 检查当前页缩略图是否在可视范围,不在的话需要滚动到对应的位置
  128. nextTick(() => {
  129. const activeThumbnailRef: HTMLElement = thumbnailsRef.value?.$el?.querySelector('.thumbnail-item.active')
  130. if (thumbnailsRef.value && activeThumbnailRef && !isElementInViewport(activeThumbnailRef, thumbnailsRef.value.$el)) {
  131. setTimeout(() => {
  132. activeThumbnailRef.scrollIntoView({ behavior: 'smooth' })
  133. }, 100)
  134. }
  135. })
  136. }, { immediate: true })
  137. // 切换页面
  138. const changeSlideIndex = (index: number) => {
  139. mainStore.setActiveElementIdList([])
  140. if (slideIndex.value === index) return
  141. slidesStore.updateSlideIndex(index)
  142. }
  143. // 点击缩略图
  144. const handleClickSlideThumbnail = (e: MouseEvent, index: number) => {
  145. if (editingSectionId.value) return
  146. const isMultiSelected = selectedSlidesIndex.value.length > 1
  147. if (isMultiSelected && selectedSlidesIndex.value.includes(index) && e.button !== 0) return
  148. // 按住Ctrl键,点选幻灯片,再次点击已选中的页面则取消选中
  149. // 如果被取消选中的页面刚好是当前激活页面,则需要从其他被选中的页面中选择第一个作为当前激活页面
  150. if (ctrlKeyState.value) {
  151. if (slideIndex.value === index) {
  152. if (!isMultiSelected) return
  153. const newSelectedSlidesIndex = selectedSlidesIndex.value.filter(item => item !== index)
  154. mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
  155. changeSlideIndex(selectedSlidesIndex.value[0])
  156. }
  157. else {
  158. if (selectedSlidesIndex.value.includes(index)) {
  159. const newSelectedSlidesIndex = selectedSlidesIndex.value.filter(item => item !== index)
  160. mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
  161. }
  162. else {
  163. const newSelectedSlidesIndex = [...selectedSlidesIndex.value, index]
  164. mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
  165. }
  166. }
  167. }
  168. // 按住Shift键,选择范围内的全部幻灯片
  169. else if (shiftKeyState.value) {
  170. if (slideIndex.value === index && !isMultiSelected) return
  171. let minIndex = Math.min(...selectedSlidesIndex.value)
  172. let maxIndex = index
  173. if (index < minIndex) {
  174. maxIndex = Math.max(...selectedSlidesIndex.value)
  175. minIndex = index
  176. }
  177. const newSelectedSlidesIndex = []
  178. for (let i = minIndex; i <= maxIndex; i++) newSelectedSlidesIndex.push(i)
  179. mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
  180. }
  181. // 正常切换页面
  182. else {
  183. mainStore.updateSelectedSlidesIndex([])
  184. changeSlideIndex(index)
  185. }
  186. }
  187. // 设置缩略图工具栏聚焦状态(只有聚焦状态下,该部分的快捷键才能生效)
  188. const setThumbnailsFocus = (focus: boolean) => {
  189. if (thumbnailsFocus.value === focus) return
  190. mainStore.setThumbnailsFocus(focus)
  191. if (!focus) mainStore.updateSelectedSlidesIndex([])
  192. }
  193. // 拖拽调整顺序后进行数据的同步
  194. const handleDragEnd = (eventData: { newIndex: number; oldIndex: number }) => {
  195. const { newIndex, oldIndex } = eventData
  196. if (newIndex === undefined || oldIndex === undefined || newIndex === oldIndex) return
  197. sortSlides(newIndex, oldIndex)
  198. }
  199. // 打开批注面板
  200. const openNotesPanel = () => {
  201. mainStore.setNotesPanelState(true)
  202. }
  203. const editingSectionId = ref('')
  204. const editSection = (id: string) => {
  205. mainStore.setDisableHotkeysState(true)
  206. editingSectionId.value = id || 'default'
  207. nextTick(() => {
  208. const inputRef = document.querySelector(`#section-title-input-${id || 'default'}`) as HTMLInputElement
  209. inputRef.focus()
  210. })
  211. }
  212. const saveSection = (e: FocusEvent | KeyboardEvent) => {
  213. const title = (e.target as HTMLInputElement).value
  214. updateSectionTitle(editingSectionId.value, title)
  215. editingSectionId.value = ''
  216. mainStore.setDisableHotkeysState(false)
  217. }
  218. const insertAllTemplates = (slides: Slide[]) => {
  219. if (isEmptySlide.value) slidesStore.setSlides(slides)
  220. else addSlidesFromData(slides)
  221. }
  222. const contextmenusSection = (el: HTMLElement): ContextmenuItem[] => {
  223. const sectionId = el.dataset.sectionId!
  224. return [
  225. {
  226. text: '删除节',
  227. handler: () => removeSection(sectionId),
  228. },
  229. {
  230. text: '删除节和幻灯片',
  231. handler: () => {
  232. mainStore.setActiveElementIdList([])
  233. removeSectionSlides(sectionId)
  234. },
  235. },
  236. {
  237. text: '删除所有节',
  238. handler: removeAllSection,
  239. },
  240. {
  241. text: '重命名节',
  242. handler: () => editSection(sectionId),
  243. },
  244. ]
  245. }
  246. const { enterScreening, enterScreeningFromStart } = useScreening()
  247. const contextmenusThumbnails = (): ContextmenuItem[] => {
  248. return [
  249. {
  250. text: '粘贴',
  251. subText: 'Ctrl + V',
  252. handler: pasteSlide,
  253. },
  254. {
  255. text: '全选',
  256. subText: 'Ctrl + A',
  257. handler: selectAllSlide,
  258. },
  259. {
  260. text: '新建页面',
  261. subText: 'Enter',
  262. handler: createSlide,
  263. },
  264. {
  265. text: '幻灯片放映',
  266. subText: 'F5',
  267. handler: enterScreeningFromStart,
  268. },
  269. ]
  270. }
  271. const contextmenusThumbnailItem = (): ContextmenuItem[] => {
  272. return [
  273. {
  274. text: '剪切',
  275. subText: 'Ctrl + X',
  276. handler: cutSlide,
  277. },
  278. {
  279. text: '复制',
  280. subText: 'Ctrl + C',
  281. handler: copySlide,
  282. },
  283. {
  284. text: '粘贴',
  285. subText: 'Ctrl + V',
  286. handler: pasteSlide,
  287. },
  288. {
  289. text: '全选',
  290. subText: 'Ctrl + A',
  291. handler: selectAllSlide,
  292. },
  293. { divider: true },
  294. {
  295. text: '新建页面',
  296. subText: 'Enter',
  297. handler: createSlide,
  298. },
  299. {
  300. text: '复制页面',
  301. subText: 'Ctrl + D',
  302. handler: copyAndPasteSlide,
  303. },
  304. {
  305. text: '删除页面',
  306. subText: 'Delete',
  307. handler: () => deleteSlide(),
  308. },
  309. {
  310. text: '增加节',
  311. handler: createSection,
  312. disable: !!currentSlide.value.sectionTag,
  313. },
  314. { divider: true },
  315. {
  316. text: '从当前放映',
  317. subText: 'Shift + F5',
  318. handler: enterScreening,
  319. },
  320. ]
  321. }
  322. </script>
  323. <style lang="scss" scoped>
  324. .thumbnails {
  325. border-right: solid 1px $borderColor;
  326. background-color: #fff;
  327. display: flex;
  328. // flex-direction: column;
  329. user-select: none;
  330. width: 100%;
  331. position: relative;
  332. }
  333. .add-slide {
  334. height: 100%;
  335. width: 40px;
  336. font-size: 12px;
  337. display: flex;
  338. flex-shrink: 0;
  339. border-right: 1px solid $borderColor;
  340. cursor: pointer;
  341. .btn {
  342. flex: 1;
  343. display: flex;
  344. justify-content: center;
  345. align-items: center;
  346. writing-mode: vertical-rl;
  347. text-orientation: upright;
  348. letter-spacing: 4px;
  349. &:hover {
  350. background-color: $lightGray;
  351. }
  352. }
  353. .select-btn {
  354. width: 30px;
  355. height: 100%;
  356. display: flex;
  357. justify-content: center;
  358. align-items: center;
  359. border-left: 1px solid $borderColor;
  360. &:hover {
  361. background-color: $lightGray;
  362. }
  363. }
  364. .icon {
  365. margin-bottom: 5px;
  366. font-size: 14px;
  367. }
  368. }
  369. .thumbnail-list {
  370. padding: 0 10px;
  371. flex: 1;
  372. overflow: auto;
  373. display: flex;
  374. align-items: center;
  375. gap: 10px;
  376. }
  377. .thumbnail-item {
  378. display: flex;
  379. justify-content: center;
  380. align-items: center;
  381. padding: 5px 0;
  382. position: relative;
  383. .thumbnail {
  384. border-radius: $borderRadius;
  385. outline: 2px solid rgba($color: $themeColor, $alpha: .15);
  386. }
  387. &.active {
  388. .label {
  389. color: $themeColor;
  390. }
  391. .thumbnail {
  392. outline-color: $themeColor;
  393. }
  394. }
  395. &.selected {
  396. .thumbnail {
  397. outline-color: $themeColor;
  398. }
  399. .note-flag {
  400. background-color: $themeColor;
  401. &::after {
  402. border-top-color: $themeColor;
  403. }
  404. }
  405. }
  406. .note-flag {
  407. width: 16px;
  408. height: 12px;
  409. border-radius: 1px;
  410. position: absolute;
  411. left: 8px;
  412. top: 13px;
  413. font-size: 8px;
  414. background-color: rgba($color: $themeColor, $alpha: .75);
  415. color: #fff;
  416. text-align: center;
  417. line-height: 12px;
  418. cursor: pointer;
  419. &::after {
  420. content: '';
  421. width: 0;
  422. height: 0;
  423. position: absolute;
  424. top: 10px;
  425. left: 4px;
  426. border: 4px solid transparent;
  427. border-top-color: rgba($color: $themeColor, $alpha: .75);
  428. }
  429. }
  430. }
  431. .label {
  432. font-size: 12px;
  433. color: #999;
  434. width: 20px;
  435. cursor: grab;
  436. &.offset-left {
  437. position: relative;
  438. left: -4px;
  439. }
  440. &:active {
  441. cursor: grabbing;
  442. }
  443. }
  444. .page-number {
  445. /* height: 100%; */
  446. font-size: 12px;
  447. /* border-top: 1px solid #e5e7eb; */
  448. // line-height: 40px;
  449. text-align: center;
  450. color: #666;
  451. position: absolute;
  452. bottom: 10px;
  453. left: 50%;
  454. transform: translateX(-50%);
  455. }
  456. .section-title {
  457. height: 26px;
  458. font-size: 12px;
  459. padding: 6px 8px 2px 18px;
  460. color: #555;
  461. &.contextmenu-active {
  462. color: $themeColor;
  463. .text::before {
  464. border-bottom-color: $themeColor;
  465. border-right-color: $themeColor;
  466. }
  467. }
  468. .text {
  469. display: flex;
  470. align-items: center;
  471. position: relative;
  472. &::before {
  473. content: '';
  474. width: 0;
  475. height: 0;
  476. border-top: 3px solid transparent;
  477. border-left: 3px solid transparent;
  478. border-bottom: 3px solid #555;
  479. border-right: 3px solid #555;
  480. margin-right: 5px;
  481. }
  482. .text-content {
  483. display: inline-block;
  484. @include ellipsis-oneline();
  485. }
  486. }
  487. input {
  488. width: 100%;
  489. border: 0;
  490. outline: 0;
  491. padding: 0;
  492. font-size: 12px;
  493. }
  494. }
  495. </style>