index2.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705
  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" v-show="false">
  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. <!-- :disabled="editingSectionId" -->
  21. <Draggable
  22. class="thumbnail-list"
  23. ref="thumbnailsRef"
  24. :modelValue="slides"
  25. :animation="200"
  26. :scroll="true"
  27. :scrollSensitivity="50"
  28. :disabled="isMobileDevice || editingSectionId"
  29. @end="handleDragEnd"
  30. itemKey="id"
  31. >
  32. <template #item="{ element, index }">
  33. <div class="thumbnail-container">
  34. <div class="section-title"
  35. :data-section-id="element?.sectionTag?.id || ''"
  36. v-if="element.sectionTag || (hasSection && index === 0)"
  37. v-contextmenu="contextmenusSection"
  38. >
  39. <input
  40. :id="`section-title-input-${element?.sectionTag?.id || 'default'}`"
  41. type="text"
  42. :value="element?.sectionTag?.title || ''"
  43. :placeholder="lang.ssSectName"
  44. @blur="$event => saveSection($event)"
  45. @keydown.enter.stop="$event => saveSection($event)"
  46. v-if="editingSectionId === element?.sectionTag?.id || (index === 0 && editingSectionId === 'default')"
  47. >
  48. <span class="text" v-else>
  49. <div class="text-content">{{ element?.sectionTag ? (element?.sectionTag?.title || lang.ssUntitledSec) : lang.ssDefSec }}</div>
  50. </span>
  51. </div>
  52. <div
  53. class="thumbnail-item"
  54. :class="{
  55. 'active': slideIndex === index,
  56. 'selected': selectedSlidesIndex.includes(index),
  57. }"
  58. @mousedown="$event => handleClickSlideThumbnail($event, index)"
  59. v-contextmenu2="contextmenusThumbnailItem"
  60. >
  61. <!-- @dblclick="enterScreening()" -->
  62. <div class="label" :class="{ 'offset-left': index >= 99 }">{{ fillDigit(index + 1, 2) }}</div>
  63. <ThumbnailSlide class="thumbnail" :slide="element" :size="120" :visible="index < slidesLoadLimit" />
  64. <div class="page_menu" @click.stop="$event => showPageMenu($event, index)">
  65. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  66. <circle cx="12" cy="12" r="1"></circle>
  67. <circle cx="12" cy="5" r="1"></circle>
  68. <circle cx="12" cy="19" r="1"></circle>
  69. </svg>
  70. </div>
  71. <div class="note-flag" v-if="element.notes && element.notes.length" @click="openNotesPanel()">{{ element.notes.length }}</div>
  72. </div>
  73. <div class="add-page-between">+</div>
  74. </div>
  75. </template>
  76. </Draggable>
  77. <div class="page-number">{{ lang.ssSlidePage }} {{slideIndex + 1}} {{ lang.lang == 'en' ? 'of' : '/' }} {{slides.length}}</div>
  78. </div>
  79. </template>
  80. <script lang="ts" setup>
  81. import { computed, nextTick, ref, watch, useTemplateRef, createVNode, render } from 'vue'
  82. import { storeToRefs } from 'pinia'
  83. import { useMainStore, useSlidesStore, useKeyboardStore } from '@/store'
  84. import { fillDigit } from '@/utils/common'
  85. import { isElementInViewport } from '@/utils/element'
  86. import type { ContextmenuItem } from '@/components/Contextmenu/types'
  87. import useSlideHandler from '@/hooks/useSlideHandler'
  88. import useSectionHandler from '@/hooks/useSectionHandler'
  89. import useScreening from '@/hooks/useScreening'
  90. import useLoadSlides from '@/hooks/useLoadSlides'
  91. import useAddSlidesOrElements from '@/hooks/useAddSlidesOrElements'
  92. import type { Slide } from '@/types/slides'
  93. import { lang } from '@/main'
  94. import ThumbnailSlide from '@/views/components/ThumbnailSlide/index.vue'
  95. import Templates from './Templates.vue'
  96. import Popover from '@/components/Popover.vue'
  97. import Draggable from 'vuedraggable'
  98. import ContextmenuComponent from '@/components/Contextmenu2/index.vue'
  99. // 检测是否为移动设备(包括iPad和手机)
  100. const isMobileDevice = computed(() => {
  101. const userAgent = navigator.userAgent
  102. return /iPhone|iPad|iPod|Android|Mobile/.test(userAgent) || (navigator.maxTouchPoints > 1 && /Macintosh/.test(userAgent))
  103. })
  104. const mainStore = useMainStore()
  105. const slidesStore = useSlidesStore()
  106. const keyboardStore = useKeyboardStore()
  107. const { selectedSlidesIndex: _selectedSlidesIndex, thumbnailsFocus } = storeToRefs(mainStore)
  108. const { slides, slideIndex, currentSlide } = storeToRefs(slidesStore)
  109. const { ctrlKeyState, shiftKeyState } = storeToRefs(keyboardStore)
  110. const { slidesLoadLimit } = useLoadSlides()
  111. const selectedSlidesIndex = computed(() => [..._selectedSlidesIndex.value, slideIndex.value])
  112. const presetLayoutPopoverVisible = ref(false)
  113. const hasSection = computed(() => {
  114. return slides.value.some(item => item.sectionTag)
  115. })
  116. const { addSlidesFromData } = useAddSlidesOrElements()
  117. const {
  118. copySlide,
  119. pasteSlide,
  120. createSlide,
  121. createSlideByTemplate,
  122. copyAndPasteSlide,
  123. deleteSlide,
  124. cutSlide,
  125. selectAllSlide,
  126. sortSlides,
  127. isEmptySlide,
  128. } = useSlideHandler()
  129. const {
  130. createSection,
  131. removeSection,
  132. removeAllSection,
  133. removeSectionSlides,
  134. updateSectionTitle,
  135. } = useSectionHandler()
  136. // 页面被切换时
  137. const thumbnailsRef = useTemplateRef<InstanceType<typeof Draggable>>('thumbnailsRef')
  138. watch(() => slideIndex.value, () => {
  139. // 清除多选状态的幻灯片
  140. if (selectedSlidesIndex.value.length) {
  141. mainStore.updateSelectedSlidesIndex([])
  142. }
  143. // 检查当前页缩略图是否在可视范围,不在的话需要滚动到对应的位置
  144. nextTick(() => {
  145. const activeThumbnailRef: HTMLElement = thumbnailsRef.value?.$el?.querySelector('.thumbnail-item.active')
  146. if (thumbnailsRef.value && activeThumbnailRef && !isElementInViewport(activeThumbnailRef, thumbnailsRef.value.$el)) {
  147. setTimeout(() => {
  148. activeThumbnailRef.scrollIntoView({ behavior: 'smooth' })
  149. }, 100)
  150. }
  151. })
  152. }, { immediate: true })
  153. // 切换页面
  154. const changeSlideIndex = (index: number) => {
  155. mainStore.setActiveElementIdList([])
  156. if (slideIndex.value === index) return
  157. slidesStore.updateSlideIndex(index)
  158. }
  159. // 点击缩略图
  160. const handleClickSlideThumbnail = (e: MouseEvent, index: number) => {
  161. if (editingSectionId.value) return
  162. const isMultiSelected = selectedSlidesIndex.value.length > 1
  163. if (isMultiSelected && selectedSlidesIndex.value.includes(index) && e.button !== 0) return
  164. // 按住Ctrl键,点选幻灯片,再次点击已选中的页面则取消选中
  165. // 如果被取消选中的页面刚好是当前激活页面,则需要从其他被选中的页面中选择第一个作为当前激活页面
  166. if (ctrlKeyState.value) {
  167. if (slideIndex.value === index) {
  168. if (!isMultiSelected) return
  169. const newSelectedSlidesIndex = selectedSlidesIndex.value.filter(item => item !== index)
  170. mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
  171. changeSlideIndex(selectedSlidesIndex.value[0])
  172. }
  173. else {
  174. if (selectedSlidesIndex.value.includes(index)) {
  175. const newSelectedSlidesIndex = selectedSlidesIndex.value.filter(item => item !== index)
  176. mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
  177. }
  178. else {
  179. const newSelectedSlidesIndex = [...selectedSlidesIndex.value, index]
  180. mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
  181. }
  182. }
  183. }
  184. // 按住Shift键,选择范围内的全部幻灯片
  185. else if (shiftKeyState.value) {
  186. if (slideIndex.value === index && !isMultiSelected) return
  187. let minIndex = Math.min(...selectedSlidesIndex.value)
  188. let maxIndex = index
  189. if (index < minIndex) {
  190. maxIndex = Math.max(...selectedSlidesIndex.value)
  191. minIndex = index
  192. }
  193. const newSelectedSlidesIndex = []
  194. for (let i = minIndex; i <= maxIndex; i++) newSelectedSlidesIndex.push(i)
  195. mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
  196. }
  197. // 正常切换页面
  198. else {
  199. mainStore.updateSelectedSlidesIndex([])
  200. changeSlideIndex(index)
  201. }
  202. }
  203. // 设置缩略图工具栏聚焦状态(只有聚焦状态下,该部分的快捷键才能生效)
  204. const setThumbnailsFocus = (focus: boolean) => {
  205. if (thumbnailsFocus.value === focus) return
  206. mainStore.setThumbnailsFocus(focus)
  207. if (!focus) mainStore.updateSelectedSlidesIndex([])
  208. }
  209. // 拖拽调整顺序后进行数据的同步
  210. const handleDragEnd = (eventData: { newIndex: number; oldIndex: number }) => {
  211. const { newIndex, oldIndex } = eventData
  212. if (newIndex === undefined || oldIndex === undefined || newIndex === oldIndex) return
  213. sortSlides(newIndex, oldIndex)
  214. }
  215. // 打开批注面板
  216. const openNotesPanel = () => {
  217. mainStore.setNotesPanelState(true)
  218. }
  219. const editingSectionId = ref('')
  220. const editSection = (id: string) => {
  221. mainStore.setDisableHotkeysState(true)
  222. editingSectionId.value = id || 'default'
  223. nextTick(() => {
  224. const inputRef = document.querySelector(`#section-title-input-${id || 'default'}`) as HTMLInputElement
  225. inputRef.focus()
  226. })
  227. }
  228. const saveSection = (e: FocusEvent | KeyboardEvent) => {
  229. const title = (e.target as HTMLInputElement).value
  230. updateSectionTitle(editingSectionId.value, title)
  231. editingSectionId.value = ''
  232. mainStore.setDisableHotkeysState(false)
  233. }
  234. const insertAllTemplates = (slides: Slide[]) => {
  235. if (isEmptySlide.value) slidesStore.setSlides(slides)
  236. else addSlidesFromData(slides)
  237. }
  238. const contextmenusSection = (el: HTMLElement): ContextmenuItem[] => {
  239. const sectionId = el.dataset.sectionId!
  240. return [
  241. {
  242. text: lang.ssDelSect,
  243. handler: () => removeSection(sectionId),
  244. },
  245. {
  246. text: lang.ssDelSectSlides,
  247. handler: () => {
  248. mainStore.setActiveElementIdList([])
  249. removeSectionSlides(sectionId)
  250. },
  251. },
  252. {
  253. text: lang.ssDelAllSect,
  254. handler: removeAllSection,
  255. },
  256. {
  257. text: lang.ssRenSect,
  258. handler: () => editSection(sectionId),
  259. },
  260. ]
  261. }
  262. const { enterScreening, enterScreeningFromStart } = useScreening()
  263. const contextmenusThumbnails = (): ContextmenuItem[] => {
  264. return [
  265. {
  266. text: lang.ssPaste,
  267. subText: 'Ctrl + V',
  268. handler: pasteSlide,
  269. },
  270. {
  271. text: lang.ssSelectAll,
  272. subText: 'Ctrl + A',
  273. handler: selectAllSlide,
  274. },
  275. {
  276. text: lang.ssNewPage,
  277. subText: 'Enter',
  278. handler: createSlide,
  279. },
  280. {
  281. text: lang.ssSlideShow,
  282. subText: 'F5',
  283. handler: enterScreeningFromStart,
  284. },
  285. ]
  286. }
  287. const showPageMenu = (event: MouseEvent, index: number) => {
  288. event.stopPropagation()
  289. const menus = contextmenusThumbnailItem()
  290. if (!menus) return
  291. let container: HTMLDivElement | null = null
  292. const removeContextmenu = () => {
  293. if (container) {
  294. document.body.removeChild(container)
  295. container = null
  296. }
  297. document.body.removeEventListener('scroll', removeContextmenu)
  298. window.removeEventListener('resize', removeContextmenu)
  299. }
  300. const options = {
  301. axis: { x: event.clientX - 20, y: event.clientY - 20 },
  302. el: event.currentTarget as HTMLElement,
  303. menus,
  304. removeContextmenu,
  305. }
  306. container = document.createElement('div')
  307. const vm = createVNode(ContextmenuComponent, options, null)
  308. render(vm, container)
  309. document.body.appendChild(container)
  310. document.body.addEventListener('scroll', removeContextmenu)
  311. window.addEventListener('resize', removeContextmenu)
  312. }
  313. import { showConfirmDialog } from '@/utils/confirmDialog'
  314. const confirmDeleteSlide = () => {
  315. showConfirmDialog({
  316. title: lang.ssConfirmDel,
  317. content: lang.ssConfirmDelContent,
  318. confirmText: lang.ssConfirm,
  319. cancelText: lang.ssCancel,
  320. width: 400,
  321. onConfirm: () => {
  322. deleteSlide()
  323. },
  324. onCancel: () => {
  325. console.log('取消删除')
  326. },
  327. })
  328. }
  329. const contextmenusThumbnailItem = (): ContextmenuItem[] => {
  330. return [
  331. // {
  332. // text: lang.ssCut,
  333. // subText: 'Ctrl + X',
  334. // handler: cutSlide,
  335. // },
  336. // {
  337. // text: lang.ssCopy,
  338. // subText: 'Ctrl + C',
  339. // handler: copySlide,
  340. // },
  341. // {
  342. // text: lang.ssPaste,
  343. // subText: 'Ctrl + V',
  344. // handler: pasteSlide,
  345. // },
  346. // {
  347. // text: lang.ssSelectAll,
  348. // subText: 'Ctrl + A',
  349. // handler: selectAllSlide,
  350. // },
  351. // { divider: true },
  352. // {
  353. // text: lang.ssNewPage,
  354. // subText: 'Enter',
  355. // handler: createSlide,
  356. // },
  357. // {
  358. // text: lang.ssDupPage,
  359. // subText: 'Ctrl + D',
  360. // handler: copyAndPasteSlide,
  361. // },
  362. // {
  363. // text: lang.ssDelPage,
  364. // subText: 'Delete',
  365. // handler: () => deleteSlide(),
  366. // },
  367. // {
  368. // text: lang.ssAddSect,
  369. // handler: createSection,
  370. // disable: !!currentSlide.value.sectionTag,
  371. // },
  372. // { divider: true },
  373. // {
  374. // text: lang.ssPlayFromCur,
  375. // subText: 'Shift + F5',
  376. // handler: enterScreening,
  377. // },
  378. {
  379. text: lang.ssDupPage2,
  380. handler: copyAndPasteSlide,
  381. },
  382. {
  383. text: lang.ssNewPage2,
  384. handler: createSlide,
  385. },
  386. {
  387. text: lang.ssDelPage2,
  388. handler: confirmDeleteSlide,
  389. },
  390. ]
  391. }
  392. </script>
  393. <style lang="scss" scoped>
  394. .thumbnails {
  395. border-right: solid 1px $borderColor;
  396. background-color: #fff;
  397. display: flex;
  398. // flex-direction: column;
  399. user-select: none;
  400. width: 100%;
  401. position: relative;
  402. }
  403. .add-slide {
  404. height: 100%;
  405. width: 40px;
  406. font-size: 12px;
  407. display: flex;
  408. flex-shrink: 0;
  409. border-right: 1px solid $borderColor;
  410. cursor: pointer;
  411. .btn {
  412. flex: 1;
  413. display: flex;
  414. justify-content: center;
  415. align-items: center;
  416. writing-mode: vertical-rl;
  417. text-orientation: upright;
  418. letter-spacing: 4px;
  419. &:hover {
  420. background-color: $lightGray;
  421. }
  422. }
  423. .select-btn {
  424. width: 30px;
  425. height: 100%;
  426. display: flex;
  427. justify-content: center;
  428. align-items: center;
  429. border-left: 1px solid $borderColor;
  430. &:hover {
  431. background-color: $lightGray;
  432. }
  433. }
  434. .icon {
  435. margin-bottom: 5px;
  436. font-size: 14px;
  437. }
  438. }
  439. .thumbnail-list {
  440. padding: 0 10px;
  441. flex: 1;
  442. overflow: auto;
  443. display: flex;
  444. align-items: center;
  445. gap: 10px;
  446. .thumbnail-container {
  447. display: flex;
  448. align-items: center;
  449. gap: 10px;
  450. &:hover {
  451. .add-page-between {
  452. display: flex;
  453. }
  454. }
  455. }
  456. }
  457. .thumbnail-item {
  458. display: flex;
  459. justify-content: center;
  460. align-items: center;
  461. padding: 5px 0;
  462. position: relative;
  463. .thumbnail {
  464. border-radius: $borderRadius;
  465. outline: 2px solid rgba($color: $themeColor2, $alpha: .15);
  466. }
  467. &.active {
  468. .label {
  469. color: $themeColor2;
  470. }
  471. .thumbnail {
  472. outline-color: $themeColor2;
  473. }
  474. }
  475. &.selected {
  476. .thumbnail {
  477. outline-color: $themeColor2;
  478. }
  479. .note-flag {
  480. background-color: $themeColor2;
  481. &::after {
  482. border-top-color: $themeColor2;
  483. }
  484. }
  485. }
  486. .note-flag {
  487. width: 16px;
  488. height: 12px;
  489. border-radius: 1px;
  490. position: absolute;
  491. left: 8px;
  492. top: 13px;
  493. font-size: 8px;
  494. background-color: rgba($color: $themeColor2, $alpha: .75);
  495. color: #fff;
  496. text-align: center;
  497. line-height: 12px;
  498. cursor: pointer;
  499. &::after {
  500. content: '';
  501. width: 0;
  502. height: 0;
  503. position: absolute;
  504. top: 10px;
  505. left: 4px;
  506. border: 4px solid transparent;
  507. border-top-color: rgba($color: $themeColor2, $alpha: .75);
  508. }
  509. }
  510. }
  511. .label {
  512. font-size: 12px;
  513. color: #999;
  514. width: 20px;
  515. cursor: grab;
  516. position: absolute;
  517. z-index: 999;
  518. top: 10px;
  519. left: 10px;
  520. &.offset-left {
  521. position: relative;
  522. left: -4px;
  523. }
  524. &:active {
  525. cursor: grabbing;
  526. }
  527. }
  528. .page_menu{
  529. width: 24px;
  530. height: 24px;
  531. display: flex;
  532. justify-content: center;
  533. align-items: center;
  534. cursor: pointer;
  535. position: absolute;
  536. z-index: 999;
  537. bottom: 10px;
  538. right: 10px;
  539. background: rgba(255, 255, 255, 0.95);
  540. border: 1px solid #e5e7eb;
  541. transition: .3s;
  542. border-radius: 5px;
  543. svg{
  544. color: #6b7280;
  545. height: 15px;
  546. }
  547. .active,
  548. &:hover{
  549. border-color: $themeColor2;
  550. background-color: $themeColor2;
  551. svg{
  552. color: #fff;
  553. }
  554. }
  555. }
  556. .page-number {
  557. /* height: 100%; */
  558. font-size: 12px;
  559. /* border-top: 1px solid #e5e7eb; */
  560. // line-height: 40px;
  561. text-align: center;
  562. color: #666;
  563. position: absolute;
  564. bottom: 10px;
  565. left: 50%;
  566. transform: translateX(-50%);
  567. }
  568. .section-title {
  569. height: 26px;
  570. font-size: 12px;
  571. padding: 6px 8px 2px 18px;
  572. color: #555;
  573. &.contextmenu-active {
  574. color: $themeColor2;
  575. .text::before {
  576. border-bottom-color: $themeColor2;
  577. border-right-color: $themeColor2;
  578. }
  579. }
  580. .text {
  581. display: flex;
  582. align-items: center;
  583. position: relative;
  584. &::before {
  585. content: '';
  586. width: 0;
  587. height: 0;
  588. border-top: 3px solid transparent;
  589. border-left: 3px solid transparent;
  590. border-bottom: 3px solid #555;
  591. border-right: 3px solid #555;
  592. margin-right: 5px;
  593. }
  594. .text-content {
  595. display: inline-block;
  596. @include ellipsis-oneline();
  597. }
  598. }
  599. input {
  600. width: 100%;
  601. border: 0;
  602. outline: 0;
  603. padding: 0;
  604. font-size: 12px;
  605. }
  606. }
  607. .add-page-between {
  608. width: 30px;
  609. height: 30px;
  610. border-radius: 50%;
  611. border: 2px dashed #d1d5db;
  612. background: white;
  613. cursor: pointer;
  614. display: none;
  615. // align-items: center;
  616. justify-content: center;
  617. color: #9ca3af;
  618. font-size: 20px;
  619. transition: all 0.2s;
  620. flex-shrink: 0;
  621. line-height: 23px;
  622. &:hover {
  623. border-color: #F78B22;
  624. border-style: solid;
  625. color: #F78B22;
  626. background: #FFF8F0;
  627. }
  628. }
  629. </style>