返回 slidev
SideEditor.vue
根目录 / packages / client / internals / SideEditor.vue
1 <script setup lang="ts">
2 import { throttledWatch, useEventListener } from '@vueuse/core'
3 import { computed, ref, watch } from 'vue'
4 import { useNav } from '../composables/useNav'
5 import { useDynamicSlideInfo } from '../composables/useSlideInfo'
6 import { parseSideEditorContent } from '../logic/sideEditor'
7 import { activeElement, editorHeight, editorWidth, isInputting, showEditor, isEditorVertical as vertical } from '../state'
8 import IconButton from './IconButton.vue'
9 import ShikiEditor from './ShikiEditor.vue'
10
11 const props = defineProps<{
12 resize?: boolean
13 }>()
14
15 const { currentSlideNo, openInEditor } = useNav()
16
17 const tab = ref<'content' | 'note'>('content')
18 const content = ref('')
19 const note = ref('')
20 const dirty = ref(false)
21
22 const { info, update } = useDynamicSlideInfo(currentSlideNo)
23
24 watch(
25 info,
26 (v) => {
27 if (!isInputting.value) {
28 note.value = (v?.note || '').trim()
29 const frontmatterPart = v?.frontmatterRaw?.trim() ? `---\n${v.frontmatterRaw.trim()}\n---\n\n` : ''
30 content.value = frontmatterPart + (v?.source.contentRaw || '').trim()
31 dirty.value = false
32 }
33 },
34 { immediate: true },
35 )
36
37 async function save() {
38 dirty.value = false
39
40 const { content: contentOnly, frontmatterRaw } = parseSideEditorContent(content.value)
41
42 await update({
43 note: note.value || undefined,
44 content: contentOnly,
45 frontmatterRaw,
46 })
47 }
48
49 function close() {
50 showEditor.value = false
51 }
52
53 useEventListener('keydown', (e) => {
54 if (activeElement.value?.tagName === 'TEXTAREA' && e.code === 'KeyS' && (e.ctrlKey || e.metaKey)) {
55 save()
56 e.preventDefault()
57 }
58 })
59
60 const contentRef = computed({
61 get() { return content.value },
62 set(v) {
63 if (content.value.trim() !== v.trim())
64 dirty.value = true
65 content.value = v
66 },
67 })
68
69 const noteRef = computed({
70 get() { return note.value },
71 set(v) {
72 note.value = v
73 dirty.value = true
74 },
75 })
76
77 const handlerDown = ref(false)
78 function onHandlerDown() {
79 handlerDown.value = true
80 }
81 function updateSize(v?: number) {
82 if (vertical.value)
83 editorHeight.value = Math.min(Math.max(300, v ?? editorHeight.value), window.innerHeight - 200)
84 else
85 editorWidth.value = Math.min(Math.max(318, v ?? editorWidth.value), window.innerWidth - 200)
86 }
87 function switchTab(newTab: typeof tab.value) {
88 tab.value = newTab
89 // @ts-expect-error force cast
90 document.activeElement?.blur?.()
91 }
92
93 if (props.resize) {
94 useEventListener('pointermove', (e) => {
95 if (handlerDown.value) {
96 updateSize(vertical.value
97 ? window.innerHeight - e.pageY
98 : window.innerWidth - e.pageX)
99 }
100 }, { passive: true })
101 useEventListener('pointerup', () => {
102 handlerDown.value = false
103 })
104 useEventListener('resize', () => {
105 updateSize()
106 })
107 }
108
109 throttledWatch(
110 [content, note],
111 () => {
112 if (dirty.value)
113 save()
114 },
115 { throttle: 500 },
116 )
117 </script>
118
119 <template>
120 <div
121 v-if="resize" class="fixed bg-gray-400 select-none opacity-0 hover:opacity-10 z-dragging"
122 :class="vertical ? 'left-0 right-0 w-full h-10px' : 'top-0 bottom-0 w-10px h-full'" :style="{
123 opacity: handlerDown ? '0.3' : undefined,
124 bottom: vertical ? `${editorHeight - 5}px` : undefined,
125 right: !vertical ? `${editorWidth - 5}px` : undefined,
126 cursor: vertical ? 'row-resize' : 'col-resize',
127 }" @pointerdown="onHandlerDown"
128 />
129 <div
130 class="shadow bg-main p-2 pt-4 grid grid-rows-[max-content_1fr] h-full overflow-hidden"
131 :class="resize ? 'border-l border-gray-400 border-opacity-20' : ''"
132 :style="resize ? {
133 height: vertical ? `${editorHeight}px` : undefined,
134 width: !vertical ? `${editorWidth}px` : undefined,
135 } : {}"
136 >
137 <div class="flex pb-2 text-xl -mt-1">
138 <div class="mr-4 rounded flex">
139 <IconButton
140 title="Switch to content tab" :class="tab === 'content' ? 'text-primary' : ''"
141 @click="switchTab('content')"
142 >
143 <div class="i-carbon:account" />
144 </IconButton>
145 <IconButton
146 title="Switch to notes tab" :class="tab === 'note' ? 'text-primary' : ''"
147 @click="switchTab('note')"
148 >
149 <div class="i-carbon:align-box-bottom-right" />
150 </IconButton>
151 </div>
152 <span class="text-2xl pt-1">
153 {{ tab === 'content' ? 'Slide' : 'Notes' }}
154 </span>
155 <div class="flex-auto" />
156 <template v-if="resize">
157 <IconButton v-if="vertical" title="Dock to right" @click="vertical = false">
158 <div class="i-carbon:open-panel-right" />
159 </IconButton>
160 <IconButton v-else title="Dock to bottom" @click="vertical = true">
161 <div class="i-carbon:open-panel-bottom" />
162 </IconButton>
163 </template>
164 <IconButton title="Open in editor" @click="openInEditor()">
165 <div class="i-carbon:launch" />
166 </IconButton>
167 <IconButton title="Close" @click="close">
168 <div class="i-carbon:close" />
169 </IconButton>
170 </div>
171 <div class="relative overflow-hidden rounded" style="background-color: var(--slidev-code-background)">
172 <ShikiEditor v-show="tab === 'content'" v-model="contentRef" placeholder="Create slide content..." />
173 <ShikiEditor v-show="tab === 'note'" v-model="noteRef" placeholder="Write some notes..." />
174 </div>
175 </div>
176 </template>
177
177 lines Plain Text