index2.vue 14 KB

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