index.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  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. <LayoutPool
  13. @select="
  14. slide => {
  15. createSlideByTemplate(slide)
  16. presetLayoutPopoverVisible = false
  17. }
  18. "
  19. />
  20. </template>
  21. <div class="select-btn">
  22. <img src="./imgs/list.png" alt="" />
  23. </div>
  24. </Popover>
  25. </div>
  26. <Draggable
  27. class="thumbnail-list"
  28. ref="thumbnailsRef"
  29. :modelValue="slides"
  30. :animation="200"
  31. :scroll="true"
  32. :scrollSensitivity="50"
  33. :disabled="editingSectionId"
  34. @end="handleDragEnd"
  35. itemKey="id"
  36. >
  37. <template #item="{ element, index }">
  38. <div class="thumbnail-container">
  39. <div
  40. class="section-title"
  41. :data-section-id="element?.sectionTag?.id || ''"
  42. v-if="element.sectionTag || (hasSection && index === 0)"
  43. v-contextmenu="contextmenusSection"
  44. >
  45. <input
  46. :id="`section-title-input-${element?.sectionTag?.id || 'default'}`"
  47. type="text"
  48. :value="element?.sectionTag?.title || ''"
  49. placeholder="输入节名称"
  50. @blur="$event => saveSection($event)"
  51. @keydown.enter.stop="$event => saveSection($event)"
  52. v-if="editingSectionId === element?.sectionTag?.id || (index === 0 && editingSectionId === 'default')"
  53. />
  54. <span class="text" v-else>
  55. <div class="text-content">{{ element?.sectionTag ? element?.sectionTag?.title || "无标题节" : "默认节" }}</div>
  56. </span>
  57. </div>
  58. <div
  59. class="thumbnail-item"
  60. :class="{
  61. active: slideIndex === index,
  62. selected: selectedSlidesIndex.includes(index)
  63. }"
  64. @mousedown="$event => handleClickSlideThumbnail($event, index)"
  65. @dblclick="enterScreening()"
  66. v-contextmenu="contextmenusThumbnailItem"
  67. >
  68. <div class="label" :class="{ 'offset-left': index >= 99 }">{{ fillDigit(index + 1, 2) }}</div>
  69. <div class="thumbnail">
  70. <ThumbnailSlide :id="`thumbnailSlide_${index}`" :slide="element" :size="180" :visible="index < slidesLoadLimit" />
  71. <div class="tools" v-if="slideIndex === index">
  72. <img v-tooltip="'预览'" src="./imgs/play.png" @click="enterScreening" alt="" />
  73. <img v-tooltip="'添加幻灯片'" src="./imgs/add.png" @click="createSlide" alt="" />
  74. </div>
  75. </div>
  76. <div class="note-flag" v-if="element.notes && element.notes.length" @click="openNotesPanel()">{{ element.notes.length }}</div>
  77. </div>
  78. </div>
  79. </template>
  80. </Draggable>
  81. <div class="page-number">幻灯片 {{ slideIndex + 1 }} / {{ slides.length }}</div>
  82. </div>
  83. </template>
  84. <script lang="ts" setup>
  85. import { computed, nextTick, onMounted, ref, watch } from "vue"
  86. import { storeToRefs } from "pinia"
  87. import { useMainStore, useSlidesStore, useKeyboardStore } from "@/store"
  88. import { fillDigit } from "@/utils/common"
  89. import { isElementInViewport } from "@/utils/element"
  90. import type { ContextmenuItem } from "@/components/Contextmenu/types"
  91. import useSlideHandler from "@/hooks/useSlideHandler"
  92. import useSectionHandler from "@/hooks/useSectionHandler"
  93. import useScreening from "@/hooks/useScreening"
  94. import useLoadSlides from "@/hooks/useLoadSlides"
  95. import ThumbnailSlide from "@/views/components/ThumbnailSlide/index.vue"
  96. import LayoutPool from "./LayoutPool.vue"
  97. import Popover from "@/components/Popover.vue"
  98. import Draggable from "vuedraggable"
  99. const mainStore = useMainStore()
  100. const slidesStore = useSlidesStore()
  101. const keyboardStore = useKeyboardStore()
  102. const { selectedSlidesIndex: _selectedSlidesIndex, thumbnailsFocus } = storeToRefs(mainStore)
  103. const { slides, slideIndex, currentSlide } = storeToRefs(slidesStore)
  104. const { ctrlKeyState, shiftKeyState } = storeToRefs(keyboardStore)
  105. const { slidesLoadLimit } = useLoadSlides()
  106. const selectedSlidesIndex = computed(() => [..._selectedSlidesIndex.value, slideIndex.value])
  107. const presetLayoutPopoverVisible = ref(false)
  108. const hasSection = computed(() => {
  109. return slides.value.some(item => item.sectionTag)
  110. })
  111. const { copySlide, pasteSlide, createSlide, createSlideByTemplate, copyAndPasteSlide, deleteSlide, cutSlide, selectAllSlide, sortSlides } =
  112. useSlideHandler()
  113. const { createSection, removeSection, removeAllSection, removeSectionSlides, updateSectionTitle } = useSectionHandler()
  114. // 页面被切换时
  115. const thumbnailsRef = ref<InstanceType<typeof Draggable>>()
  116. watch(
  117. () => slideIndex.value,
  118. () => {
  119. // 清除多选状态的幻灯片
  120. if (selectedSlidesIndex.value.length) {
  121. mainStore.updateSelectedSlidesIndex([])
  122. }
  123. // 检查当前页缩略图是否在可视范围,不在的话需要滚动到对应的位置
  124. nextTick(() => {
  125. const activeThumbnailRef: HTMLElement = thumbnailsRef.value?.$el?.querySelector(".thumbnail-item.active")
  126. if (thumbnailsRef.value && activeThumbnailRef && !isElementInViewport(activeThumbnailRef, thumbnailsRef.value.$el)) {
  127. setTimeout(() => {
  128. activeThumbnailRef.scrollIntoView({ behavior: "smooth" })
  129. }, 100)
  130. }
  131. })
  132. }
  133. )
  134. // 从预览切换回来的时候 滚动到对应的位置
  135. onMounted(() => {
  136. const activeThumbnailRef: HTMLElement = thumbnailsRef.value?.$el?.querySelector(".thumbnail-item.active")
  137. if (thumbnailsRef.value && activeThumbnailRef && !isElementInViewport(activeThumbnailRef, thumbnailsRef.value.$el)) {
  138. setTimeout(() => {
  139. activeThumbnailRef.scrollIntoView()
  140. }, 100)
  141. }
  142. })
  143. // 切换页面
  144. const changeSlideIndex = (index: number) => {
  145. mainStore.setActiveElementIdList([])
  146. if (slideIndex.value === index) return
  147. slidesStore.updateSlideIndex(index)
  148. }
  149. // 点击缩略图
  150. const handleClickSlideThumbnail = (e: MouseEvent, index: number) => {
  151. if (editingSectionId.value) return
  152. const isMultiSelected = selectedSlidesIndex.value.length > 1
  153. if (isMultiSelected && selectedSlidesIndex.value.includes(index) && e.button !== 0) return
  154. // 按住Ctrl键,点选幻灯片,再次点击已选中的页面则取消选中
  155. // 如果被取消选中的页面刚好是当前激活页面,则需要从其他被选中的页面中选择第一个作为当前激活页面
  156. if (ctrlKeyState.value) {
  157. if (slideIndex.value === index) {
  158. if (!isMultiSelected) return
  159. const newSelectedSlidesIndex = selectedSlidesIndex.value.filter(item => item !== index)
  160. mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
  161. changeSlideIndex(selectedSlidesIndex.value[0])
  162. } else {
  163. if (selectedSlidesIndex.value.includes(index)) {
  164. const newSelectedSlidesIndex = selectedSlidesIndex.value.filter(item => item !== index)
  165. mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
  166. } else {
  167. const newSelectedSlidesIndex = [...selectedSlidesIndex.value, index]
  168. mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
  169. }
  170. }
  171. }
  172. // 按住Shift键,选择范围内的全部幻灯片
  173. else if (shiftKeyState.value) {
  174. if (slideIndex.value === index && !isMultiSelected) return
  175. let minIndex = Math.min(...selectedSlidesIndex.value)
  176. let maxIndex = index
  177. if (index < minIndex) {
  178. maxIndex = Math.max(...selectedSlidesIndex.value)
  179. minIndex = index
  180. }
  181. const newSelectedSlidesIndex = []
  182. for (let i = minIndex; i <= maxIndex; i++) newSelectedSlidesIndex.push(i)
  183. mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
  184. }
  185. // 正常切换页面
  186. else {
  187. mainStore.updateSelectedSlidesIndex([])
  188. changeSlideIndex(index)
  189. }
  190. }
  191. // 设置缩略图工具栏聚焦状态(只有聚焦状态下,该部分的快捷键才能生效)
  192. const setThumbnailsFocus = (focus: boolean) => {
  193. if (thumbnailsFocus.value === focus) return
  194. mainStore.setThumbnailsFocus(focus)
  195. if (!focus) mainStore.updateSelectedSlidesIndex([])
  196. }
  197. // 拖拽调整顺序后进行数据的同步
  198. const handleDragEnd = (eventData: { newIndex: number; oldIndex: number }) => {
  199. const { newIndex, oldIndex } = eventData
  200. if (newIndex === undefined || oldIndex === undefined || newIndex === oldIndex) return
  201. sortSlides(newIndex, oldIndex)
  202. }
  203. // 打开批注面板
  204. const openNotesPanel = () => {
  205. mainStore.setNotesPanelState(true)
  206. }
  207. const editingSectionId = ref("")
  208. const editSection = (id: string) => {
  209. mainStore.setDisableHotkeysState(true)
  210. editingSectionId.value = id || "default"
  211. nextTick(() => {
  212. const inputRef = document.querySelector(`#section-title-input-${id || "default"}`) as HTMLInputElement
  213. inputRef.focus()
  214. })
  215. }
  216. const saveSection = (e: FocusEvent | KeyboardEvent) => {
  217. const title = (e.target as HTMLInputElement).value
  218. updateSectionTitle(editingSectionId.value, title)
  219. editingSectionId.value = ""
  220. mainStore.setDisableHotkeysState(false)
  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. }
  331. .add-slide {
  332. margin-top: 16px;
  333. margin-bottom: 8px;
  334. font-size: 12px;
  335. display: flex;
  336. flex-shrink: 0;
  337. justify-content: center;
  338. align-items: center;
  339. .btn {
  340. cursor: pointer;
  341. flex-shrink: 1;
  342. display: flex;
  343. justify-content: center;
  344. align-items: center;
  345. width: 180px;
  346. height: 30px;
  347. background: #ffffff;
  348. border-radius: 4px;
  349. border: 1px solid #c4c4c4;
  350. &:hover {
  351. background-color: $lightGray;
  352. }
  353. }
  354. .select-btn {
  355. cursor: pointer;
  356. margin-left: 12px;
  357. width: 30px;
  358. height: 100%;
  359. display: flex;
  360. justify-content: center;
  361. align-items: center;
  362. width: 30px;
  363. height: 30px;
  364. background: #ffffff;
  365. border-radius: 4px;
  366. border: 1px solid #c4c4c4;
  367. &:hover {
  368. background-color: $lightGray;
  369. }
  370. & > img {
  371. width: 20px;
  372. height: 20px;
  373. }
  374. }
  375. .icon {
  376. margin-right: 3px;
  377. font-size: 14px;
  378. }
  379. }
  380. .thumbnail-list {
  381. flex: 1;
  382. overflow: auto;
  383. }
  384. .thumbnail-item {
  385. display: flex;
  386. justify-content: center;
  387. align-items: center;
  388. padding: 8px 0;
  389. position: relative;
  390. .thumbnail {
  391. overflow: hidden;
  392. border-radius: 4px;
  393. outline: 1px solid #dedede;
  394. position: relative;
  395. .tools {
  396. position: absolute;
  397. bottom: 8px;
  398. left: 50%;
  399. transform: translateX(-50%);
  400. display: flex;
  401. & > img {
  402. cursor: pointer;
  403. width: 28px;
  404. height: 28px;
  405. &:hover {
  406. opacity: 0.8;
  407. }
  408. &:last-child {
  409. margin-left: 40px;
  410. }
  411. }
  412. }
  413. }
  414. &.active {
  415. .label {
  416. color: $themeColor;
  417. }
  418. .thumbnail {
  419. outline-color: $themeColor;
  420. }
  421. }
  422. &.selected {
  423. .thumbnail {
  424. outline: 2px solid $themeColor;
  425. }
  426. .note-flag {
  427. background-color: $themeColor;
  428. &::after {
  429. border-top-color: $themeColor;
  430. }
  431. }
  432. }
  433. .note-flag {
  434. width: 16px;
  435. height: 12px;
  436. border-radius: 1px;
  437. position: absolute;
  438. left: 22px;
  439. top: 26px;
  440. font-size: 8px;
  441. background-color: rgba($color: $themeColor, $alpha: 0.75);
  442. color: #fff;
  443. text-align: center;
  444. line-height: 12px;
  445. cursor: pointer;
  446. &::after {
  447. content: "";
  448. width: 0;
  449. height: 0;
  450. position: absolute;
  451. top: 10px;
  452. left: 4px;
  453. border: 4px solid transparent;
  454. border-top-color: rgba($color: $themeColor, $alpha: 0.75);
  455. }
  456. }
  457. }
  458. .label {
  459. font-size: 12px;
  460. color: #999;
  461. width: 20px;
  462. font-weight: 600;
  463. font-size: 14px;
  464. cursor: grab;
  465. margin-right: 18px;
  466. &.offset-left {
  467. position: relative;
  468. left: -4px;
  469. }
  470. &:active {
  471. cursor: grabbing;
  472. }
  473. }
  474. .page-number {
  475. height: 50px;
  476. border-top: 1px solid $borderColor;
  477. line-height: 50px;
  478. padding-left: 24px;
  479. font-weight: 400;
  480. font-size: 12px;
  481. color: #131415;
  482. }
  483. .section-title {
  484. height: 26px;
  485. font-size: 12px;
  486. padding: 6px 8px 2px 18px;
  487. color: #555;
  488. &.contextmenu-active {
  489. color: $themeColor;
  490. .text::before {
  491. border-bottom-color: $themeColor;
  492. border-right-color: $themeColor;
  493. }
  494. }
  495. .text {
  496. display: flex;
  497. align-items: center;
  498. position: relative;
  499. &::before {
  500. content: "";
  501. width: 0;
  502. height: 0;
  503. border-top: 3px solid transparent;
  504. border-left: 3px solid transparent;
  505. border-bottom: 3px solid #555;
  506. border-right: 3px solid #555;
  507. margin-right: 5px;
  508. }
  509. .text-content {
  510. display: inline-block;
  511. @include ellipsis-oneline();
  512. }
  513. }
  514. input {
  515. width: 100%;
  516. border: 0;
  517. outline: 0;
  518. padding: 0;
  519. font-size: 12px;
  520. }
  521. }
  522. </style>