| <template> | |
| <div class="outline-editor"> | |
| <div class="item" | |
| :class="[{ 'title': item.title }, `lv-${item.lv}`]" | |
| v-for="item in data" | |
| :key="item.id" | |
| :data-lv="item.lv" | |
| :data-id="item.id" | |
| v-contextmenu="contextmenus" | |
| > | |
| <Input | |
| class="editable-text" | |
| :value="item.content" | |
| v-if="activeItemId === item.id" | |
| @blur="$event => handleBlur($event, item)" | |
| @enter="$event => handleEnter($event, item)" | |
| @backspace="$event => handleBackspace($event, item)" | |
| /> | |
| <div class="text" @click="handleFocus(item.id)" v-else>{{ item.content }}</div> | |
| <div class="flag"></div> | |
| </div> | |
| </div> | |
| </template> | |
| <script lang="ts" setup> | |
| import { ref, nextTick, onMounted, watch } from 'vue' | |
| import { nanoid } from 'nanoid' | |
| import type { ContextmenuItem } from '@/components/Contextmenu/types' | |
| import Input from './Input.vue' | |
| interface OutlineItem { | |
| id: string | |
| content: string | |
| lv: number | |
| title?: boolean | |
| } | |
| const props = defineProps<{ | |
| value: string | |
| }>() | |
| const emit = defineEmits<{ | |
| (event: 'update:value', payload: string): void | |
| }>() | |
| const data = ref<OutlineItem[]>([]) | |
| const activeItemId = ref('') | |
| watch(data, () => { | |
| let markdown = '' | |
| const prefixTitle = '#' | |
| const prefixItem = '-' | |
| for (const item of data.value) { | |
| if (item.lv !== 1) markdown += '\n' | |
| if (item.title) markdown += `${prefixTitle.repeat(item.lv)} ${item.content}` | |
| else markdown += `${prefixItem} ${item.content}` | |
| } | |
| emit('update:value', markdown) | |
| }) | |
| onMounted(() => { | |
| const lines = props.value.split('\n') | |
| const result: OutlineItem[] = [] | |
| for (const line of lines) { | |
| if (!line.trim()) continue | |
| const headerMatch = line.match(/^(#+)\s*(.*)/) | |
| const listMatch = line.match(/^-\s*(.*)/) | |
| if (headerMatch) { | |
| const lv = headerMatch[1].length | |
| const content = headerMatch[2] | |
| result.push({ | |
| id: nanoid(), | |
| content, | |
| title: true, | |
| lv, | |
| }) | |
| } | |
| else if (listMatch) { | |
| const content = listMatch[1] | |
| result.push({ | |
| id: nanoid(), | |
| content, | |
| lv: 4, | |
| }) | |
| } | |
| else { | |
| result.push({ | |
| id: nanoid(), | |
| content: line.trim(), | |
| lv: 4 | |
| }) | |
| } | |
| } | |
| data.value = result | |
| }) | |
| const handleFocus = (id: string) => { | |
| activeItemId.value = id | |
| nextTick(() => { | |
| const editableRef = document.querySelector('.editable-text input') as HTMLInputElement | |
| editableRef.focus() | |
| }) | |
| } | |
| const handleBlur = (e: Event, item: OutlineItem) => { | |
| activeItemId.value = '' | |
| const value = (e.target as HTMLInputElement).value | |
| data.value = data.value.map(_item => { | |
| if (_item.id === item.id) return { ..._item, content: value } | |
| return _item | |
| }) | |
| } | |
| const handleEnter = (e: Event, item: OutlineItem) => { | |
| const value = (e.target as HTMLInputElement).value | |
| if (!value) return | |
| activeItemId.value = '' | |
| if (!item.title) { | |
| const index = data.value.findIndex(_item => _item.id === item.id) | |
| const newItemId = nanoid() | |
| data.value.splice(index + 1, 0, { id: newItemId, content: '', lv: 4 }) | |
| nextTick(() => { | |
| handleFocus(newItemId) | |
| }) | |
| } | |
| } | |
| const handleBackspace = (e: Event, item: OutlineItem) => { | |
| if (!item.title) { | |
| const value = (e.target as HTMLInputElement).value | |
| if (!value) deleteItem(item.id) | |
| } | |
| } | |
| const addItem = (itemId: string, pos: 'next' | 'prev', content: string) => { | |
| const index = data.value.findIndex(_item => _item.id === itemId) | |
| const item = data.value[index] | |
| if (!item) return | |
| const id = nanoid() | |
| let lv = 4 | |
| let i = 0 | |
| let title = false | |
| if (pos === 'prev') i = index | |
| else i = index + 1 | |
| if (item.lv === 1) lv = 2 | |
| else if (item.lv === 2) { | |
| if (pos === 'prev') lv = 2 | |
| else lv = 3 | |
| } | |
| else if (item.lv === 3) { | |
| if (pos === 'prev') lv = 3 | |
| else lv = 4 | |
| } | |
| else lv = 4 | |
| if (lv < 4) title = true | |
| data.value.splice(i, 0, { id, content, lv, title }) | |
| } | |
| const deleteItem = (itemId: string, isTitle?: boolean) => { | |
| if (isTitle) { | |
| const index = data.value.findIndex(item => item.id === itemId) | |
| const targetIds = [itemId] | |
| const item = data.value[index] | |
| for (let i = index + 1; i < data.value.length; i++) { | |
| const afterItem = data.value[i] | |
| if (afterItem && afterItem.lv > item.lv) { | |
| targetIds.push(afterItem.id) | |
| } | |
| else break | |
| } | |
| data.value = data.value.filter(item => !targetIds.includes(item.id)) | |
| } | |
| else { | |
| data.value = data.value.filter(item => item.id !== itemId) | |
| } | |
| } | |
| const contextmenus = (el: HTMLElement): ContextmenuItem[] => { | |
| const lv = +el.dataset.lv! | |
| const id = el.dataset.id! | |
| if (lv === 1) { | |
| return [ | |
| { | |
| text: '添加子级大纲(章)', | |
| handler: () => addItem(id, 'next', '新的一章'), | |
| }, | |
| ] | |
| } | |
| else if (lv === 2) { | |
| return [ | |
| { | |
| text: '上方添加同级大纲(章)', | |
| handler: () => addItem(id, 'prev', '新的一章'), | |
| }, | |
| { | |
| text: '添加子级大纲(节)', | |
| handler: () => addItem(id, 'next', '新的一节'), | |
| }, | |
| { | |
| text: '删除此章', | |
| handler: () => deleteItem(id, true), | |
| }, | |
| ] | |
| } | |
| else if (lv === 3) { | |
| return [ | |
| { | |
| text: '上方添加同级大纲(节)', | |
| handler: () => addItem(id, 'prev', '新的一节'), | |
| }, | |
| { | |
| text: '添加子级大纲(项)', | |
| handler: () => addItem(id, 'next', '新的一项'), | |
| }, | |
| { | |
| text: '删除此节', | |
| handler: () => deleteItem(id, true), | |
| }, | |
| ] | |
| } | |
| return [ | |
| { | |
| text: '上方添加同级大纲(项)', | |
| handler: () => addItem(id, 'prev', '新的一项'), | |
| }, | |
| { | |
| text: '下方添加同级大纲(项)', | |
| handler: () => addItem(id, 'next', '新的一项'), | |
| }, | |
| { | |
| text: '删除此项', | |
| handler: () => deleteItem(id), | |
| }, | |
| ] | |
| } | |
| </script> | |
| <style lang="scss"> | |
| .outline-editor { | |
| padding: 0 10px; | |
| padding-left: 40px; | |
| position: relative; | |
| .item { | |
| height: 32px; | |
| position: relative; | |
| &.contextmenu-active { | |
| color: $themeColor; | |
| .text { | |
| background-color: rgba($color: $themeColor, $alpha: .08); | |
| } | |
| } | |
| &.title { | |
| font-weight: 700; | |
| } | |
| &.lv-1 { | |
| font-size: 22px; | |
| } | |
| &.lv-2 { | |
| font-size: 17px; | |
| } | |
| &.lv-3 { | |
| font-size: 15px; | |
| } | |
| &.lv-4 { | |
| font-size: 13px; | |
| padding-left: 20px; | |
| } | |
| } | |
| .text { | |
| height: 100%; | |
| padding: 0 11px; | |
| line-height: 32px; | |
| border-radius: $borderRadius; | |
| transition: background-color .2s; | |
| cursor: pointer; | |
| @include ellipsis-oneline(); | |
| &:hover { | |
| background-color: rgba($color: $themeColor, $alpha: .08); | |
| } | |
| } | |
| .flag { | |
| width: 32px; | |
| height: 32px; | |
| position: absolute; | |
| top: 50%; | |
| left: -40px; | |
| margin-top: -16px; | |
| z-index: 1; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| &::before { | |
| content: ''; | |
| width: 1px; | |
| height: 100%; | |
| position: absolute; | |
| left: 50%; | |
| background-color: rgba($color: $themeColor, $alpha: .1); | |
| } | |
| &::after { | |
| content: ''; | |
| width: 32px; | |
| height: 22px; | |
| border-radius: 2px; | |
| background-color: #fff; | |
| border: 1px solid $themeColor; | |
| color: $themeColor; | |
| position: relative; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| font-size: 12px; | |
| font-weight: 400; | |
| } | |
| } | |
| .item.lv-1 .flag::after { | |
| content: '主题'; | |
| } | |
| .item.lv-2 .flag::after { | |
| content: '章'; | |
| } | |
| .item.lv-3 .flag::after { | |
| content: '节'; | |
| } | |
| .item.lv-4 .flag::after { | |
| opacity: 0; | |
| } | |
| } | |
| </style> |