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
64 changes: 32 additions & 32 deletions web-editor/dist/tiptap-editor.js

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions web-editor/src/extensions/voice-block.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { Node, mergeAttributes } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Slice, Fragment } from '@tiptap/pm/model'
import voiceBlockCss from './voice-block.css?inline'
import { getVoiceBridge, subscribeVoiceEvents } from '../runtime/tiptap-channel.js'

Expand Down Expand Up @@ -58,6 +59,26 @@ function buildVoiceInfoJson(attrs) {
})
}

/**
* 由节点 attrs 构造宿主菜单可解析的 voice JSON(MetaDataParser 兼容格式)。
* 与 buildVoiceInfoJson 的区别:使用键 type(而非 dataType),并补齐 text/state,
* 供右键菜单探测脚本通过 data-voice-meta 属性读取后下发给 C++ processVoiceMenuRequest。
* @param {object} attrs
* @returns {string}
*/
function buildVoiceMenuJson(attrs) {
return JSON.stringify({
type: 2,
voiceId: attrs.voiceId,
voicePath: attrs.voicePath,
voiceSize: attrs.voiceSize,
title: attrs.title,
createTime: attrs.createTime,
text: attrs.text,
state: !!attrs.text,
})
}

export const VoiceBlock = Node.create({
name: 'voiceBlock',

Expand Down Expand Up @@ -103,6 +124,7 @@ export const VoiceBlock = Node.create({
const box = document.createElement('div')
box.className = 'voiceInfoBox'
box.setAttribute('data-type', 'voice-block')
box.setAttribute('data-voice-meta', buildVoiceMenuJson(node.attrs))

const playback = document.createElement('div')
playback.className = 'voicePlayback'
Expand Down Expand Up @@ -320,6 +342,7 @@ export const VoiceBlock = Node.create({
currentNode = updatedNode
titleEl.textContent = updatedNode.attrs.title || ''
translateText.textContent = updatedNode.attrs.text || ''
box.setAttribute('data-voice-meta', buildVoiceMenuJson(updatedNode.attrs))
refreshState()
return true
},
Expand Down Expand Up @@ -355,6 +378,42 @@ export const VoiceBlock = Node.create({

addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('voiceBlockCopyClear'),
props: {
transformCopied(slice) {
let modified = false
function transformFragment(fragment) {
const newNodes = []
for (let i = 0; i < fragment.childCount; i++) {
const node = fragment.child(i)
if (node.type.name === 'voiceBlock') {
modified = true
newNodes.push(node.type.create({
...node.attrs,
text: null,
voiceId: generateVoiceId(),
translateUnfold: true,
}, node.content, node.marks))
} else if (node.content && node.content.size > 0) {
const newContent = transformFragment(node.content)
if (newContent !== node.content) {
newNodes.push(node.copy(newContent))
} else {
newNodes.push(node)
}
} else {
newNodes.push(node)
}
}
return Fragment.from(newNodes)
}
const newContent = transformFragment(slice.content)
if (!modified) return slice
return new Slice(newContent, slice.openStart, slice.openEnd)
},
},
}),
new Plugin({
key: new PluginKey('voiceBlockPasteId'),
appendTransaction(transactions, oldState, newState) {
Expand Down
14 changes: 7 additions & 7 deletions web-editor/src/runtime/format-toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ function buildColorPicker(editor, kind, colors, apply, clear, readActive) {
const panel = createEl('div', {
class: 'tiptap-color-panel',
'data-panel': kind,
style: 'display: none; position: absolute; top: 100%; left: 0; background: #fff; border: 1px solid #ccc; padding: 4px; z-index: 20;',
style: 'display: none; position: absolute; top: 100%; left: 0; background: var(--dvn-panel-bg, #fff); border: 1px solid var(--dvn-panel-border, #ccc); padding: 4px; z-index: 20;',
})

function openPanel() {
Expand All @@ -142,7 +142,7 @@ function buildColorPicker(editor, kind, colors, apply, clear, readActive) {
})

// 清除按钮
const clearBtn = createEl('button', { type: 'button', 'data-action': `clear-${kind}`, title: '清除颜色', style: 'grid-column: 1 / -1; margin-top: 2px; border: 1px solid #ccc; background: #fafafa;' }, '清除颜色')
const clearBtn = createEl('button', { type: 'button', 'data-action': `clear-${kind}`, title: '清除颜色', style: 'grid-column: 1 / -1; margin-top: 2px; border: 1px solid var(--dvn-panel-border, #ccc); background: var(--dvn-clear-btn-bg, #fafafa);' }, '清除颜色')
clearBtn.addEventListener('click', () => {
clear(editor)
closePanel()
Expand Down Expand Up @@ -188,8 +188,8 @@ export function createFormatToolbar(editor, host) {
'align-items: center',
'gap: 4px',
'padding: 6px 8px',
'border-bottom: 1px solid #d0d0d0',
'background: #fafafa',
'border-bottom: 1px solid var(--dvn-toolbar-border, #d0d0d0)',
'background: var(--dvn-clear-btn-bg, #fafafa)',
'position: sticky',
'top: 0',
'z-index: 10',
Expand Down Expand Up @@ -281,7 +281,7 @@ export function createFormatToolbar(editor, host) {
toolbar.appendChild(backPicker.wrapper)

// 列表/待办区(区段顺序:富文本格式区 → 列表/待办区 → 资源插入区)
const listGroup = createEl('span', { class: 'tiptap-toolbar-group', style: 'display: inline-flex; gap: 2px; margin-left: 8px; border-left: 1px solid #d0d0d0; padding-left: 8px;' })
const listGroup = createEl('span', { class: 'tiptap-toolbar-group', style: 'display: inline-flex; gap: 2px; margin-left: 8px; border-left: 1px solid var(--dvn-toolbar-border, #d0d0d0); padding-left: 8px;' })
const listButtons = {}
for (const { format, label, title } of LIST_TOGGLE_BUTTONS) {
const btn = createEl('button', { type: 'button', 'data-format': format, title }, label)
Expand All @@ -305,7 +305,7 @@ export function createFormatToolbar(editor, host) {
toolbar.appendChild(listGroup)

// 资源插入区:图片按钮(区段顺序:富文本格式区 → 列表/待办区 → 资源插入区)
const resourceGroup = createEl('span', { class: 'tiptap-toolbar-group', style: 'display: inline-flex; gap: 2px; margin-left: 8px; border-left: 1px solid #d0d0d0; padding-left: 8px;' })
const resourceGroup = createEl('span', { class: 'tiptap-toolbar-group', style: 'display: inline-flex; gap: 2px; margin-left: 8px; border-left: 1px solid var(--dvn-toolbar-border, #d0d0d0); padding-left: 8px;' })
const imageBtn = createEl('button', { type: 'button', 'data-format': 'insertImage', title: '插入图片' }, '图片')
let onPickImage = null
imageBtn.addEventListener('click', () => {
Expand Down Expand Up @@ -374,7 +374,7 @@ export function createFormatToolbar(editor, host) {
const color = cell.getAttribute('data-color')
const isActive = activeColor && color === activeColor
cell.setAttribute('aria-pressed', isActive ? 'true' : 'false')
cell.style.outline = isActive ? '2px solid #0086cc' : ''
cell.style.outline = isActive ? `2px solid var(--dvn-active-outline, #0086cc)` : ''
}
}

Expand Down
8 changes: 4 additions & 4 deletions web-editor/src/runtime/image-interactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,8 @@ function createImageContextMenu({ onView, onDelete, x, y }) {
position: 'fixed',
left: `${x}px`,
top: `${y}px`,
background: '#fff',
border: '1px solid #ccc',
background: 'var(--dvn-panel-bg, #fff)',
border: '1px solid var(--dvn-panel-border, #ccc)',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
zIndex: '1000',
padding: '2px 0',
Expand All @@ -195,15 +195,15 @@ function createImageContextMenu({ onView, onDelete, x, y }) {
viewItem.textContent = '查看原图'
viewItem.setAttribute('data-action', 'view-original')
Object.assign(viewItem.style, { padding: '6px 18px', cursor: 'pointer' })
viewItem.addEventListener('mouseenter', () => { viewItem.style.background = '#f0f0f0' })
viewItem.addEventListener('mouseenter', () => { viewItem.style.background = 'var(--dvn-hover-bg, #f0f0f0)' })
viewItem.addEventListener('mouseleave', () => { viewItem.style.background = 'transparent' })
viewItem.addEventListener('click', (e) => { e.stopPropagation(); onView() })

const deleteItem = document.createElement('div')
deleteItem.textContent = '删除'
deleteItem.setAttribute('data-action', 'delete-image')
Object.assign(deleteItem.style, { padding: '6px 18px', cursor: 'pointer' })
deleteItem.addEventListener('mouseenter', () => { deleteItem.style.background = '#f0f0f0' })
deleteItem.addEventListener('mouseenter', () => { deleteItem.style.background = 'var(--dvn-hover-bg, #f0f0f0)' })
deleteItem.addEventListener('mouseleave', () => { deleteItem.style.background = 'transparent' })
deleteItem.addEventListener('click', (e) => { e.stopPropagation(); onDelete() })

Expand Down
22 changes: 22 additions & 0 deletions web-editor/src/runtime/tiptap-channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,15 @@ function applyTheme(theme, highlightColor, disableHighlightColor, backgroundColo
if (highlightColor) root.style.setProperty('--highlightColor', highlightColor)
if (disableHighlightColor) root.style.setProperty('--color', disableHighlightColor)
if (backgroundColor) root.style.setProperty('--backgroundColor', backgroundColor)

// 主题联动:工具栏 / 语音块 / 滚动条 / 取色板 / 图片自绘菜单
const isDark = theme === 'dark'
root.style.setProperty('--dvn-panel-bg', isDark ? '#252525' : '#ffffff')
root.style.setProperty('--dvn-panel-border', isDark ? '#444444' : '#cccccc')
root.style.setProperty('--dvn-toolbar-border', isDark ? '#3d3d3d' : '#d0d0d0')
root.style.setProperty('--dvn-hover-bg', isDark ? '#3d3d3d' : '#f0f0f0')
root.style.setProperty('--dvn-clear-btn-bg', isDark ? '#2d2d2d' : '#fafafa')
root.style.setProperty('--dvn-active-outline', highlightColor || '#0086cc')
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -317,6 +326,19 @@ export function bindTiptapChannel(editor, channelFactory, options = {}) {
debounce.trigger()
})

// 滚动位置上报:前端主动上报 scrollTop,宿主驱动标题栏阴影状态
const scrollTarget = editor.view.dom
scrollTarget.addEventListener('scroll', () => {
if (bridge.jsReportScroll) {
bridge.jsReportScroll(scrollTarget.scrollTop)
}
})
window.addEventListener('scroll', () => {
if (bridge.jsReportScroll) {
bridge.jsReportScroll(window.scrollY || document.documentElement.scrollTop)
}
})

// --- 事件 3:保存往返 ---
bridge.requestContent.connect(function () {
const normalized = walkNormalize(editor.getJSON(), saveNormalizers)
Expand Down
3 changes: 3 additions & 0 deletions web-editor/src/runtime/tiptap-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ function createTiptapEditor(element) {
// ---------------------------------------------------------------------------

const editor = createTiptapEditor(document.getElementById('app'))
if (typeof window !== 'undefined') {
window.__dvnTiptapEditor = editor
}
const toolbar = createFormatToolbar(editor, document.getElementById('toolbar-host'))

bindTiptapChannel(editor, undefined, {
Expand Down
56 changes: 56 additions & 0 deletions web-editor/test/image-interactions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -295,3 +295,59 @@ test('setupImagePaste destroy restores default paste (not permanently blocked)',
assert.notEqual(typeof editor.view.props.handlePaste, 'function' === false, 'destroyed handlePaste should not be () => false')
editor.destroy()
})

// ---------------------------------------------------------------------------
// 主题变量消费:图片自绘菜单使用 CSS 变量
// ---------------------------------------------------------------------------

test('image context menu uses CSS variables for theme-aware styling', () => {
const window = new Window()
defineGlobal('window', window)
defineGlobal('document', window.document)
defineGlobal('navigator', window.navigator)
defineGlobal('Node', window.Node)
defineGlobal('Element', window.Element)
defineGlobal('HTMLElement', window.HTMLElement)
defineGlobal('DocumentFragment', window.DocumentFragment)
defineGlobal('Event', window.Event)
defineGlobal('CustomEvent', window.CustomEvent)
defineGlobal('MutationObserver', window.MutationObserver)
defineGlobal('getComputedStyle', window.getComputedStyle.bind(window))
defineGlobal('requestAnimationFrame', (cb) => setTimeout(cb, 0))
defineGlobal('cancelAnimationFrame', (id) => clearTimeout(id))

// 设置主题 CSS 变量
document.documentElement.style.setProperty('--dvn-panel-bg', '#252525')
document.documentElement.style.setProperty('--dvn-panel-border', '#444444')
document.documentElement.style.setProperty('--dvn-hover-bg', '#3d3d3d')

const element = document.createElement('div')
document.body.appendChild(element)
const editor = new Editor({
element,
extensions: createTiptapExtensions(),
content: createEmptyDoc(),
})
editor.commands.insertContent({
type: 'image',
attrs: { src: 'file:///usr/share/x/images/photo.png', relPath: 'images/photo.png', alt: '', title: null },
})

const bridge = { jsRequestViewPicture() {} }
const destroy = setupImageViewAndMenu(editor, bridge)

const img = editor.view.dom.querySelector('img[data-rel-path]')
img.dispatchEvent(new Event('contextmenu', { bubbles: true, cancelable: true }))

const menu = document.querySelector('[data-testid="tiptap-image-menu"]')
assert.ok(menu, 'context menu should be mounted')
// happy-dom 不完全解析 var(),检查 cssText 中包含 CSS 变量引用
const styleText = menu.style.cssText || menu.getAttribute('style') || ''
assert.ok(
styleText.includes('--dvn-panel'),
'menu style should reference dvn CSS variables'
)

destroy()
editor.destroy()
})
75 changes: 75 additions & 0 deletions web-editor/test/voice-block-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -474,3 +474,78 @@ test('to-text completed: writes text to voiceBlock attrs', () => {
assert.equal(voiceNode.attrs.text, '写回的转写文本')
editor.destroy()
})

// ---------------------------------------------------------------------------
// 复制清态:transformCopied 清 text、生成新 voiceId、重置 translateUnfold
// ---------------------------------------------------------------------------

test('copy: transformCopied clears text and generates new voiceId for voiceBlock', () => {
const { editor } = createEditor()
const { bridge } = createMockBridge()
setVoiceBridge(bridge)

editor.commands.insertContent({
type: 'voiceBlock',
attrs: {
voiceId: 'voice-copy-test',
voicePath: 'voicenote/test.mp3',
voiceSize: 5000,
createTime: '2026-07-17 10:00:00',
title: '复制测试',
text: '需要清除的转写文本',
translateUnfold: false,
},
})

// 获取文档中 voiceBlock 节点位置的 slice
const { doc, selection } = editor.state
let voicePos = -1
doc.descendants((node, pos) => {
if (node.type.name === 'voiceBlock' && voicePos < 0) voicePos = pos
})
assert.ok(voicePos >= 0, 'voiceBlock should exist')

const node = doc.nodeAt(voicePos)
const slice = editor.state.doc.slice(voicePos, voicePos + node.nodeSize)

// 获取 transformCopied 插件 prop
const transformCopied = editor.view.someProp('transformCopied', f => f)
assert.ok(typeof transformCopied === 'function', 'transformCopied should be registered')

const transformed = transformCopied(slice)
const transformedNode = transformed.content.firstChild
assert.ok(transformedNode, 'transformed slice should contain a node')
assert.equal(transformedNode.attrs.text, null, 'text should be cleared')
assert.notEqual(transformedNode.attrs.voiceId, 'voice-copy-test', 'voiceId should be regenerated')
assert.notEqual(transformedNode.attrs.voiceId, '', 'new voiceId should be non-empty')
assert.equal(transformedNode.attrs.translateUnfold, true, 'translateUnfold should be reset to true')

// 原文档不应被修改
const originalNode = editor.state.doc.nodeAt(voicePos)
assert.equal(originalNode.attrs.text, '需要清除的转写文本', 'original text should be unchanged')
assert.equal(originalNode.attrs.voiceId, 'voice-copy-test', 'original voiceId should be unchanged')

editor.destroy()
})

test('copy: transformCopied does not modify non-voiceBlock content', () => {
const { editor } = createEditor()
const { bridge } = createMockBridge()
setVoiceBridge(bridge)

editor.commands.insertContent({ type: 'paragraph', content: [{ type: 'text', text: '普通文本' }] })

const { doc } = editor.state
let paraPos = -1
doc.descendants((node, pos) => {
if (node.type.name === 'paragraph' && paraPos < 0) paraPos = pos
})
const node = doc.nodeAt(paraPos)
const slice = doc.slice(paraPos, paraPos + node.nodeSize)

const transformCopied = editor.view.someProp('transformCopied', f => f)
const transformed = transformCopied(slice)
assert.equal(transformed, slice, 'non-voiceBlock slice should be returned as-is')

editor.destroy()
})
Loading