|
|
@@ -0,0 +1,196 @@
|
|
|
+<template>
|
|
|
+ <NElement
|
|
|
+ tag="div"
|
|
|
+ class="et-outline m-4 p-2 rounded-lg border outline-none text-left overflow-auto"
|
|
|
+ @keydown="onKeyDown"
|
|
|
+ @keypress="onKeyPressed"
|
|
|
+ @keyup="onKeyUp"
|
|
|
+ contenteditable
|
|
|
+ style="width: 200px; height: 300px"
|
|
|
+ ref="el"
|
|
|
+ >
|
|
|
+ <p data-level="0" data-title="第一章"></p>
|
|
|
+ </NElement>
|
|
|
+</template>
|
|
|
+<script setup lang="ts">
|
|
|
+import { Ref, computed, onMounted, reactive, ref, watch, defineEmits } from 'vue'
|
|
|
+import { useMutationObserver, useDebounceFn } from '@vueuse/core'
|
|
|
+import { NConfigProvider, NElement, useThemeVars } from 'naive-ui'
|
|
|
+// @ts-ignore
|
|
|
+import * as NumberToChinese from '@siakhooi/number-to-chinese-words'
|
|
|
+
|
|
|
+const props = defineProps({
|
|
|
+ modelValue: {
|
|
|
+ type: Array<any>,
|
|
|
+ default: (): any[] => []
|
|
|
+ }
|
|
|
+})
|
|
|
+watch(props.modelValue, v => {
|
|
|
+ if (JSON.stringify(v) !== JSON.stringify(props.modelValue)) {
|
|
|
+ update(props.modelValue)
|
|
|
+ }
|
|
|
+})
|
|
|
+const themeVars = useThemeVars()
|
|
|
+const el: Ref<any> = ref(null)
|
|
|
+const et = computed(() => {
|
|
|
+ return el.value?.$el as HTMLElement
|
|
|
+})
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ useMutationObserver(
|
|
|
+ et.value,
|
|
|
+ mutations => {
|
|
|
+ if (mutations.flatMap(m => Array.from(m.addedNodes).concat(Array.from(m.removedNodes))).length > 0) {
|
|
|
+ arrange()
|
|
|
+ } else if (mutations.filter(m => m.type === 'characterData').length > 0) {
|
|
|
+ arrange()
|
|
|
+ }
|
|
|
+ // arrange()
|
|
|
+ },
|
|
|
+ {
|
|
|
+ attributes: true,
|
|
|
+ childList: true,
|
|
|
+ subtree: true,
|
|
|
+ characterData: true
|
|
|
+ }
|
|
|
+ )
|
|
|
+ update(props.modelValue)
|
|
|
+})
|
|
|
+
|
|
|
+const chapters: any[] = reactive([])
|
|
|
+
|
|
|
+const emit = defineEmits(['update:modelValue'])
|
|
|
+watch(chapters, v => {
|
|
|
+ emit('update:modelValue', v)
|
|
|
+})
|
|
|
+const arrange = useDebounceFn(() => {
|
|
|
+ const levels = [0, 0, 0, 0]
|
|
|
+ chapters.splice(0, chapters.length)
|
|
|
+ for (const child of Array.from(et.value?.children || [])) {
|
|
|
+ const e = child as Element
|
|
|
+ const level = parseInt(e.getAttribute('data-level') || '0')
|
|
|
+ levels[level] = levels[level] + 1
|
|
|
+ for (let i = level + 1; i < levels.length; i++) {
|
|
|
+ levels[i] = 0
|
|
|
+ }
|
|
|
+ if (level === 0) {
|
|
|
+ e.setAttribute(
|
|
|
+ 'data-title',
|
|
|
+ '第' + NumberToChinese.convertNumber(levels[0], { removeLeadingOne: levels[level] > 1 }) + '章'
|
|
|
+ )
|
|
|
+ } else {
|
|
|
+ e.setAttribute('data-title', levels.slice(0, level + 1).join('.'))
|
|
|
+ }
|
|
|
+
|
|
|
+ let node: any
|
|
|
+ let parent = chapters
|
|
|
+ levels.slice(0, level + 1).forEach((v: number) => {
|
|
|
+ node = parent[v - 1]
|
|
|
+ if (!node) {
|
|
|
+ node = {
|
|
|
+ children: []
|
|
|
+ }
|
|
|
+ parent[v - 1] = node
|
|
|
+ }
|
|
|
+ parent = node.children
|
|
|
+ })
|
|
|
+ if (node) node.title = e.textContent
|
|
|
+ }
|
|
|
+}, 10)
|
|
|
+
|
|
|
+function update(chaptersData: any) {
|
|
|
+ console.log('update')
|
|
|
+ chaptersData = chaptersData || []
|
|
|
+ const list: any[] = []
|
|
|
+ function _update(_data: any, level: number) {
|
|
|
+ _data.forEach((e: any) => {
|
|
|
+ list.push({
|
|
|
+ title: e.title,
|
|
|
+ level
|
|
|
+ })
|
|
|
+ _update(e.children, level + 1)
|
|
|
+ })
|
|
|
+ }
|
|
|
+ _update(chaptersData, 0)
|
|
|
+ et.value?.childNodes.forEach((e: any) => {
|
|
|
+ et.value?.removeChild(e)
|
|
|
+ })
|
|
|
+ list.forEach((e, i) => {
|
|
|
+ const p = document.createElement('p')
|
|
|
+ p.setAttribute('data-level', e.level.toString())
|
|
|
+ p.textContent = e.title
|
|
|
+ et.value?.appendChild(p)
|
|
|
+ })
|
|
|
+ arrange()
|
|
|
+ console.log(list)
|
|
|
+}
|
|
|
+
|
|
|
+function onKeyDown(e: KeyboardEvent) {
|
|
|
+ const sel = window.getSelection()!
|
|
|
+ let node = sel!.anchorNode!
|
|
|
+ if (node?.nodeName !== 'P') {
|
|
|
+ node = node.parentNode!
|
|
|
+ }
|
|
|
+ const idx = Array.from(node?.parentNode?.children || []).indexOf(node as any)
|
|
|
+
|
|
|
+ if (e.key === 'Enter') {
|
|
|
+ if (e.shiftKey) {
|
|
|
+ e.preventDefault()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // e.preventDefault()
|
|
|
+ // const p = document.createElement('p')
|
|
|
+ // et.value?.insertBefore(p, node!.parentNode!.children[idx + 1])
|
|
|
+ // let prev
|
|
|
+ // if (idx === 0) {
|
|
|
+ // prev = node
|
|
|
+ // } else {
|
|
|
+ // prev = node!.parentNode!.children[idx - 1]
|
|
|
+ // }
|
|
|
+ // p.setAttribute('data-title', '第二章')
|
|
|
+ // p.setAttribute('data-level', (prev as Element)?.getAttribute('data-level') || '0')
|
|
|
+ // range.setStart(p, 0)
|
|
|
+ // range.collapse(true)
|
|
|
+ // sel?.removeAllRanges()
|
|
|
+ // sel?.addRange(range)
|
|
|
+ } else if (e.key === 'Tab') {
|
|
|
+ e.preventDefault()
|
|
|
+ if (idx === 0) return
|
|
|
+ let level = parseInt((node as Element).getAttribute('data-level') || '0')
|
|
|
+ level += e.shiftKey ? -1 : 1
|
|
|
+ level = Math.min(Math.max(0, level), 3)
|
|
|
+ ;(node as Element).setAttribute('data-level', level.toString())
|
|
|
+ arrange()
|
|
|
+ console.log(e)
|
|
|
+ } else if (e.key === 'Backspace') {
|
|
|
+ if (!node.textContent && node.parentNode?.children.length === 1) {
|
|
|
+ e.preventDefault()
|
|
|
+ }
|
|
|
+ //
|
|
|
+ }
|
|
|
+}
|
|
|
+function onKeyPressed(e: KeyboardEvent) {}
|
|
|
+function onKeyUp(e: KeyboardEvent) {}
|
|
|
+</script>
|
|
|
+<style lang="less" scoped>
|
|
|
+.et-outline {
|
|
|
+ &:focus {
|
|
|
+ border-color: var(--primary-color);
|
|
|
+ }
|
|
|
+ :deep(p) {
|
|
|
+ &::before {
|
|
|
+ content: attr(data-title) ' ';
|
|
|
+ opacity: 0.6;
|
|
|
+ }
|
|
|
+ &[data-level='1'] {
|
|
|
+ padding-left: 16px;
|
|
|
+ }
|
|
|
+ &[data-level='2'] {
|
|
|
+ padding-left: 32px;
|
|
|
+ }
|
|
|
+ &[data-level='3'] {
|
|
|
+ padding-left: 48px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|