Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"license": "MIT",
"scripts": {
"dev": "electron-vite dev",
"test": "set ELECTRON_RUN_AS_NODE=1&& electron --test electron/shared/continuation-import.test.mjs electron/main/workspace-types.test.mjs renderer/src/features/workspace/outlineReorder.test.mjs renderer/src/features/workspace/volumeCollapseState.test.mjs renderer/src/features/workspace/workbenchMenu.test.mjs renderer/src/features/ai/taskRegistry.test.mjs renderer/src/features/ai/outlineReferences.test.mjs renderer/src/features/tutorials/tutorials.test.mjs renderer/src/features/knowledge/knowledgeCenter.test.mjs electron/main/story-state-store.test.mjs electron/main/knowledge-document-schema.test.mjs electron/main/ai/settings.test.mjs electron/main/ai/generate.test.mjs electron/main/ai/provider.test.mjs electron/main/ai/protocol-adapter.test.mjs electron/main/ai/proxy-fetch.test.mjs electron/main/ai/knowledge-retrieval.test.mjs electron/main/ai/state-backfill-store.test.mjs electron/main/ai/state-backfill-task-controller.test.mjs electron/main/ai/runtime/background-task-coordinator.test.mjs electron/main/ai/runtime/chapter-processing-store.test.mjs electron/main/ai/tasks/chapter-session-note.test.mjs electron/main/ai/tasks/outline-context.test.mjs electron/main/ai/tasks/story-state-generation.test.mjs electron/main/ai/tasks/worldview-type.test.mjs electron/main/ai/tasks/ranking-analysis.test.mjs electron/main/ai/tasks/qimao-ranking.test.mjs electron/main/ai/tasks/zongheng-ranking.test.mjs electron/main/ai/runtime-v2/conversation-manager.test.mjs electron/main/ai/runtime-v2/staged-changes-store.test.mjs electron/main/ai/runtime-v2/context-builder.test.mjs electron/main/ai/runtime-v2/agent-loop.test.mjs electron/main/ai/agent/tools/text-window.test.mjs electron/main/ai/agent/tools/knowledge-tools.test.mjs electron/main/ai/agent/tools/chapter-data-access.test.mjs electron/main/ai/agent/tools/chapter-html-edit.test.mjs electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.test.mjs",
"test": "set ELECTRON_RUN_AS_NODE=1&& electron --test electron/shared/continuation-import.test.mjs electron/main/workspace-types.test.mjs renderer/src/features/workspace/outlineReorder.test.mjs renderer/src/features/workspace/volumeCollapseState.test.mjs renderer/src/features/workspace/workbenchMenu.test.mjs renderer/src/features/chapters/chapterFormatting.test.mjs renderer/src/features/ai/taskRegistry.test.mjs renderer/src/features/ai/outlineReferences.test.mjs renderer/src/features/tutorials/tutorials.test.mjs renderer/src/features/knowledge/knowledgeCenter.test.mjs electron/main/story-state-store.test.mjs electron/main/knowledge-document-schema.test.mjs electron/main/ai/settings.test.mjs electron/main/ai/generate.test.mjs electron/main/ai/provider.test.mjs electron/main/ai/protocol-adapter.test.mjs electron/main/ai/proxy-fetch.test.mjs electron/main/ai/knowledge-retrieval.test.mjs electron/main/ai/state-backfill-store.test.mjs electron/main/ai/state-backfill-task-controller.test.mjs electron/main/ai/runtime/background-task-coordinator.test.mjs electron/main/ai/runtime/chapter-processing-store.test.mjs electron/main/ai/tasks/chapter-session-note.test.mjs electron/main/ai/tasks/outline-context.test.mjs electron/main/ai/tasks/story-state-generation.test.mjs electron/main/ai/tasks/worldview-type.test.mjs electron/main/ai/tasks/ranking-analysis.test.mjs electron/main/ai/tasks/qimao-ranking.test.mjs electron/main/ai/tasks/zongheng-ranking.test.mjs electron/main/ai/runtime-v2/conversation-manager.test.mjs electron/main/ai/runtime-v2/staged-changes-store.test.mjs electron/main/ai/runtime-v2/context-builder.test.mjs electron/main/ai/runtime-v2/agent-loop.test.mjs electron/main/ai/agent/tools/text-window.test.mjs electron/main/ai/agent/tools/knowledge-tools.test.mjs electron/main/ai/agent/tools/chapter-data-access.test.mjs electron/main/ai/agent/tools/chapter-html-edit.test.mjs electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.test.mjs",
"test:ranking": "set ELECTRON_RUN_AS_NODE=1&& electron --test electron/main/ai/tasks/ranking-analysis.test.mjs",
"typecheck": "vue-tsc --noEmit",
"build": "pnpm run typecheck && electron-vite build",
Expand Down
35 changes: 34 additions & 1 deletion renderer/src/components/chapterWorkspace/ChapterEditorPane.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, h, onBeforeUnmount, onMounted, ref } from 'vue'
import { Check, ChevronDown, ChevronRight, Folder, FocusIcon, History, Maximize2, Menu, MessageSquareQuote, Minus, Minimize2, Plus, RefreshCw, ShieldAlert, Sparkles, Type, Wand2 } from 'lucide-vue-next'
import { AlignLeft, Check, ChevronDown, ChevronRight, Folder, FocusIcon, History, Maximize2, Menu, MessageSquareQuote, Minus, Minimize2, Plus, RefreshCw, ShieldAlert, Sparkles, Type, Wand2 } from 'lucide-vue-next'
import { NAlert, NDropdown, NTag, NTooltip, useMessage } from 'naive-ui'
import type { DropdownOption } from 'naive-ui'
import SimpleChapterEditor from './SimpleChapterEditor.vue'
Expand Down Expand Up @@ -323,6 +323,19 @@ function discardRecovery(): void {
recoverySnapshot.value = null
}

function formatCurrentChapter(): void {
const result = editorRef.value?.formatDocument()
if (result === 'empty' || !result) {
message.warning('当前章节还没有可排版的正文')
return
}
if (result === 'unchanged') {
message.info('当前正文已符合:首行缩进 2 字,段间空 1 行')
return
}
message.success('一键排版完成:首行缩进 2 字,段间空 1 行')
}

onMounted(() => {
document.addEventListener('selectionchange', handleSelectionChange)
document.addEventListener('mousedown', handleMouseDown)
Expand Down Expand Up @@ -380,6 +393,16 @@ onBeforeUnmount(() => {
<button @click="stepFont(1)"><Plus :size="11" /></button>
</div>

<button
class="toolbtn"
:disabled="!currentChapter"
title="整理段落:首行缩进 2 字,段间空 1 行"
@click="formatCurrentChapter"
>
<AlignLeft :size="13" />
<span>一键排版</span>
</button>

<n-tooltip placement="bottom">
<template #trigger>
<button class="toolbtn" @click="emit('toggleFocus')"><FocusIcon :size="13" /></button>
Expand Down Expand Up @@ -734,6 +757,16 @@ onBeforeUnmount(() => {
color: var(--arc-text-primary);
}

.toolbtn:disabled {
opacity: 0.45;
cursor: not-allowed;
}

.toolbtn:disabled:hover {
background: transparent;
color: var(--arc-text-secondary);
}

.toolbtn.primary {
background: var(--arc-primary-soft);
color: var(--arc-primary);
Expand Down
21 changes: 19 additions & 2 deletions renderer/src/components/chapterWorkspace/SimpleChapterEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Placeholder from '@tiptap/extension-placeholder'
import CharacterCount from '@tiptap/extension-character-count'
import Underline from '@tiptap/extension-underline'
import { ensureEditorHtmlContent } from '@/features/chapters/editorContent'
import { formatChapterEditorDocument } from '@/features/chapters/chapterFormatting'
import type { ChapterInsertionRequest, ChapterSelectionState } from '@/types/app'
import { EditorSearchExtension } from '@/features/chapters/editorSearch'

Expand Down Expand Up @@ -95,6 +96,18 @@ function discardRecovery(): void {
emit('recovery-available', null)
}

function formatDocument(): 'empty' | 'unchanged' | 'formatted' {
if (!editor.value) return 'empty'
if (!editor.value.getText().trim()) return 'empty'

const result = formatChapterEditorDocument(editor.value.getJSON())
if (!result.changed) return 'unchanged'

editor.value.commands.setContent(result.document, { emitUpdate: true })
editor.value.commands.focus()
return 'formatted'
}

function handleSelectionUpdate(): void {
if (!editor.value) return
const { from, to } = editor.value.state.selection
Expand Down Expand Up @@ -223,7 +236,7 @@ onBeforeUnmount(() => {
editor.value?.destroy()
})

defineExpose({ editor, restoreRecovery, discardRecovery })
defineExpose({ editor, restoreRecovery, discardRecovery, formatDocument })
</script>

<template>
Expand All @@ -242,10 +255,14 @@ defineExpose({ editor, restoreRecovery, discardRecovery })
}

:deep(.simple-editor p) {
margin-bottom: 16px;
margin: 0 0 1.8em;
text-indent: 2em;
}

:deep(.simple-editor p:last-child) {
margin-bottom: 0;
}

:deep(.simple-editor p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
float: left;
Expand Down
64 changes: 64 additions & 0 deletions renderer/src/features/chapters/chapterFormatting.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { formatChapterEditorDocument } from './chapterFormatting.ts'

test('一键排版将段内换行拆为段落并删除空段', () => {
const result = formatChapterEditorDocument({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: '  第一段 ' },
{ type: 'hardBreak' },
{ type: 'hardBreak' },
{ type: 'text', text: ' 第二段 ' }
]
},
{ type: 'paragraph' }
]
})

assert.equal(result.changed, true)
assert.equal(result.paragraphCount, 2)
assert.deepEqual(result.document.content, [
{ type: 'paragraph', content: [{ type: 'text', text: '第一段' }] },
{ type: 'paragraph', content: [{ type: 'text', text: '第二段' }] }
])
})

test('一键排版保留行内格式和非段落节点', () => {
const heading = { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '场景一' }] }
const result = formatChapterEditorDocument({
type: 'doc',
content: [
heading,
{
type: 'paragraph',
content: [
{ type: 'text', text: ' 重要', marks: [{ type: 'bold' }] },
{ type: 'text', text: '内容', marks: [{ type: 'underline' }] }
]
}
]
})

assert.deepEqual(result.document.content?.[0], heading)
assert.deepEqual(result.document.content?.[1]?.content, [
{ type: 'text', text: '重要', marks: [{ type: 'bold' }] },
{ type: 'text', text: '内容', marks: [{ type: 'underline' }] }
])
})

test('空正文排版后仍保留一个可编辑段落', () => {
const result = formatChapterEditorDocument({
type: 'doc',
content: [{ type: 'paragraph' }, { type: 'paragraph' }]
})

assert.deepEqual(result.document, {
type: 'doc',
content: [{ type: 'paragraph' }]
})
assert.equal(result.paragraphCount, 0)
})
115 changes: 115 additions & 0 deletions renderer/src/features/chapters/chapterFormatting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { JSONContent } from '@tiptap/core'

export type ChapterFormatResult = {
document: JSONContent
changed: boolean
paragraphCount: number
}

/**
* 整理正文的块级结构:
* - 段落内的硬换行拆为独立段落;
* - 删除多余空段和段首/段尾手工空格;
* - 保留文字的 marks(加粗、下划线等)和非段落块节点。
*
* 首行缩进和段间距由编辑器 CSS 统一呈现,避免在正文中插入
* 全角空格或空段落,导致字数和导出内容被污染。
*/
export function formatChapterEditorDocument(document: JSONContent): ChapterFormatResult {
const sourceContent = Array.isArray(document.content) ? document.content : []
const content: JSONContent[] = []
let paragraphCount = 0

for (const node of sourceContent) {
if (node.type !== 'paragraph') {
content.push(node)
continue
}

const paragraphs = splitParagraphNode(node)
paragraphCount += paragraphs.length
content.push(...paragraphs)
}

if (content.length === 0) {
content.push({ type: 'paragraph' })
}

const formatted: JSONContent = {
...document,
type: document.type || 'doc',
content
}

return {
document: formatted,
changed: JSON.stringify(formatted) !== JSON.stringify(document),
paragraphCount
}
}

function splitParagraphNode(node: JSONContent): JSONContent[] {
const segments: JSONContent[][] = [[]]

for (const inline of node.content ?? []) {
if (inline.type === 'hardBreak') {
segments.push([])
continue
}

if (inline.type === 'text' && typeof inline.text === 'string' && /[\r\n]/.test(inline.text)) {
const parts = inline.text.replace(/\r\n?/g, '\n').split('\n')
parts.forEach((part, index) => {
if (index > 0) segments.push([])
if (part) segments[segments.length - 1].push({ ...inline, text: part })
})
continue
}

segments[segments.length - 1].push(inline)
}

return segments
.map(trimInlineWhitespace)
.filter((segment) => hasVisibleInlineContent(segment))
.map((segment) => ({
...node,
content: segment
}))
}

function trimInlineWhitespace(content: JSONContent[]): JSONContent[] {
const trimmed = content.map((node) => ({ ...node }))

for (let index = 0; index < trimmed.length; index += 1) {
const node = trimmed[index]
if (node.type !== 'text' || typeof node.text !== 'string') continue
node.text = node.text.replace(/^[\s\u3000]+/u, '')
if (!node.text) {
trimmed.splice(index, 1)
index -= 1
continue
}
break
}

for (let index = trimmed.length - 1; index >= 0; index -= 1) {
const node = trimmed[index]
if (node.type !== 'text' || typeof node.text !== 'string') continue
node.text = node.text.replace(/[\s\u3000]+$/u, '')
if (!node.text) {
trimmed.splice(index, 1)
continue
}
break
}

return trimmed
}

function hasVisibleInlineContent(content: JSONContent[]): boolean {
return content.some((node) => {
if (node.type === 'text') return Boolean(node.text?.trim())
return node.type !== 'hardBreak'
})
}
Loading