@jvs-milkdown/crepe 1.2.12 → 1.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/lib/cjs/builder.js +41 -2
  2. package/lib/cjs/builder.js.map +1 -1
  3. package/lib/cjs/feature/block-edit/index.js +10 -2
  4. package/lib/cjs/feature/block-edit/index.js.map +1 -1
  5. package/lib/cjs/feature/code-mirror/index.js +9 -2
  6. package/lib/cjs/feature/code-mirror/index.js.map +1 -1
  7. package/lib/cjs/feature/cursor/index.js +9 -2
  8. package/lib/cjs/feature/cursor/index.js.map +1 -1
  9. package/lib/cjs/feature/image-block/index.js +10 -3
  10. package/lib/cjs/feature/image-block/index.js.map +1 -1
  11. package/lib/cjs/feature/inline-diff/index.js +1298 -0
  12. package/lib/cjs/feature/inline-diff/index.js.map +1 -0
  13. package/lib/cjs/feature/latex/index.js +9 -2
  14. package/lib/cjs/feature/latex/index.js.map +1 -1
  15. package/lib/cjs/feature/link-tooltip/index.js +10 -2
  16. package/lib/cjs/feature/link-tooltip/index.js.map +1 -1
  17. package/lib/cjs/feature/list-item/index.js +9 -2
  18. package/lib/cjs/feature/list-item/index.js.map +1 -1
  19. package/lib/cjs/feature/placeholder/index.js +9 -2
  20. package/lib/cjs/feature/placeholder/index.js.map +1 -1
  21. package/lib/cjs/feature/table/index.js +10 -2
  22. package/lib/cjs/feature/table/index.js.map +1 -1
  23. package/lib/cjs/feature/toolbar/index.js +134 -12
  24. package/lib/cjs/feature/toolbar/index.js.map +1 -1
  25. package/lib/cjs/index.js +1410 -241
  26. package/lib/cjs/index.js.map +1 -1
  27. package/lib/esm/builder.js +41 -2
  28. package/lib/esm/builder.js.map +1 -1
  29. package/lib/esm/feature/block-edit/index.js +10 -2
  30. package/lib/esm/feature/block-edit/index.js.map +1 -1
  31. package/lib/esm/feature/code-mirror/index.js +9 -2
  32. package/lib/esm/feature/code-mirror/index.js.map +1 -1
  33. package/lib/esm/feature/cursor/index.js +9 -2
  34. package/lib/esm/feature/cursor/index.js.map +1 -1
  35. package/lib/esm/feature/image-block/index.js +10 -3
  36. package/lib/esm/feature/image-block/index.js.map +1 -1
  37. package/lib/esm/feature/inline-diff/index.js +1274 -0
  38. package/lib/esm/feature/inline-diff/index.js.map +1 -0
  39. package/lib/esm/feature/latex/index.js +9 -2
  40. package/lib/esm/feature/latex/index.js.map +1 -1
  41. package/lib/esm/feature/link-tooltip/index.js +10 -2
  42. package/lib/esm/feature/link-tooltip/index.js.map +1 -1
  43. package/lib/esm/feature/list-item/index.js +9 -2
  44. package/lib/esm/feature/list-item/index.js.map +1 -1
  45. package/lib/esm/feature/placeholder/index.js +9 -2
  46. package/lib/esm/feature/placeholder/index.js.map +1 -1
  47. package/lib/esm/feature/table/index.js +10 -2
  48. package/lib/esm/feature/table/index.js.map +1 -1
  49. package/lib/esm/feature/toolbar/index.js +134 -12
  50. package/lib/esm/feature/toolbar/index.js.map +1 -1
  51. package/lib/esm/index.js +1392 -242
  52. package/lib/esm/index.js.map +1 -1
  53. package/lib/theme/common/diff-block.css +41 -0
  54. package/lib/theme/common/inline-diff.css +142 -0
  55. package/lib/theme/common/style.css +2 -0
  56. package/lib/theme/common/table.css +4 -4
  57. package/lib/tsconfig.tsbuildinfo +1 -1
  58. package/lib/types/core/builder.d.ts +2 -0
  59. package/lib/types/core/builder.d.ts.map +1 -1
  60. package/lib/types/core/locale.d.ts +4 -0
  61. package/lib/types/core/locale.d.ts.map +1 -1
  62. package/lib/types/feature/diff-block/index.d.ts +10 -0
  63. package/lib/types/feature/diff-block/index.d.ts.map +1 -0
  64. package/lib/types/feature/fixed-toolbar/document-header.d.ts.map +1 -1
  65. package/lib/types/feature/fixed-toolbar/index.d.ts.map +1 -1
  66. package/lib/types/feature/fixed-toolbar/menu-bar.d.ts.map +1 -1
  67. package/lib/types/feature/fixed-toolbar/outline-panel.d.ts.map +1 -1
  68. package/lib/types/feature/index.d.ts +7 -1
  69. package/lib/types/feature/index.d.ts.map +1 -1
  70. package/lib/types/feature/inline-diff/change-panel.d.ts +4 -0
  71. package/lib/types/feature/inline-diff/change-panel.d.ts.map +1 -0
  72. package/lib/types/feature/inline-diff/config.d.ts +12 -0
  73. package/lib/types/feature/inline-diff/config.d.ts.map +1 -0
  74. package/lib/types/feature/inline-diff/diff-engine.d.ts +20 -0
  75. package/lib/types/feature/inline-diff/diff-engine.d.ts.map +1 -0
  76. package/lib/types/feature/inline-diff/diff-view.d.ts +2 -0
  77. package/lib/types/feature/inline-diff/diff-view.d.ts.map +1 -0
  78. package/lib/types/feature/inline-diff/doc-builder.d.ts +21 -0
  79. package/lib/types/feature/inline-diff/doc-builder.d.ts.map +1 -0
  80. package/lib/types/feature/inline-diff/index.d.ts +9 -0
  81. package/lib/types/feature/inline-diff/index.d.ts.map +1 -0
  82. package/lib/types/feature/loader.d.ts.map +1 -1
  83. package/lib/types/feature/toolbar/component.d.ts.map +1 -1
  84. package/lib/types/feature/toolbar/index.d.ts.map +1 -1
  85. package/lib/types/icons/remove.d.ts +1 -1
  86. package/lib/types/icons/remove.d.ts.map +1 -1
  87. package/lib/types/utils/fixed-toolbar-popup-state.d.ts +7 -0
  88. package/lib/types/utils/fixed-toolbar-popup-state.d.ts.map +1 -0
  89. package/package.json +15 -4
  90. package/src/core/builder.ts +19 -0
  91. package/src/core/locale.ts +7 -0
  92. package/src/feature/diff-block/index.ts +48 -0
  93. package/src/feature/fixed-toolbar/index.ts +97 -25
  94. package/src/feature/fixed-toolbar/menu-bar.tsx +13 -2
  95. package/src/feature/fixed-toolbar/outline-panel.tsx +3 -2
  96. package/src/feature/fixed-toolbar/shortcut-help-modal.tsx +1 -1
  97. package/src/feature/fixed-toolbar/view-menu-state.ts +1 -1
  98. package/src/feature/image-block/index.ts +1 -1
  99. package/src/feature/index.ts +12 -0
  100. package/src/feature/inline-diff/change-panel.ts +280 -0
  101. package/src/feature/inline-diff/config.ts +28 -0
  102. package/src/feature/inline-diff/diff-engine.ts +181 -0
  103. package/src/feature/inline-diff/diff-view.ts +2 -0
  104. package/src/feature/inline-diff/doc-builder.ts +139 -0
  105. package/src/feature/inline-diff/index.ts +514 -0
  106. package/src/feature/loader.ts +8 -0
  107. package/src/feature/toolbar/component.tsx +97 -9
  108. package/src/feature/toolbar/index.ts +33 -0
  109. package/src/icons/remove.ts +1 -0
  110. package/src/theme/common/diff-block.css +43 -0
  111. package/src/theme/common/inline-diff.css +148 -0
  112. package/src/theme/common/style.css +2 -0
  113. package/src/theme/common/table.css +4 -4
  114. package/src/utils/fixed-toolbar-popup-state.ts +27 -0
@@ -0,0 +1,139 @@
1
+ import type { Schema, Node as PMNode } from '@jvs-milkdown/kit/prose/model'
2
+
3
+ import type { DiffChunk, CharPart } from './diff-engine'
4
+
5
+ export type ChangeType = 'added' | 'removed'
6
+
7
+ export interface ContentRange {
8
+ from: number
9
+ to: number
10
+ }
11
+
12
+ export interface ChangeInfo {
13
+ type: ChangeType
14
+ from: number
15
+ oldText: string
16
+ newText: string
17
+ blockRange: ContentRange
18
+ inlineRanges: ContentRange[]
19
+ chunkId: string
20
+ }
21
+
22
+ interface PendingChange {
23
+ type: ChangeType
24
+ childIndex: number
25
+ oldText: string
26
+ newText: string
27
+ parts?: CharPart[]
28
+ chunkId: string
29
+ }
30
+
31
+ export function buildNewDoc(
32
+ chunks: DiffChunk[],
33
+ schema: Schema
34
+ ): { doc: PMNode; changes: ChangeInfo[] } {
35
+ const children: PMNode[] = []
36
+ const pending: PendingChange[] = []
37
+
38
+ for (const chunk of chunks) {
39
+ if (chunk.status === 'accepted') {
40
+ for (const diff of chunk.diffs) {
41
+ if (diff.type === 'added') children.push(diff.newNode!)
42
+ if (diff.type === 'unchanged') children.push(diff.newNode!)
43
+ }
44
+ } else if (chunk.status === 'rejected') {
45
+ for (const diff of chunk.diffs) {
46
+ if (diff.type === 'removed') children.push(diff.oldNode!)
47
+ if (diff.type === 'unchanged') children.push(diff.oldNode!)
48
+ }
49
+ } else {
50
+ // pending
51
+ for (const diff of chunk.diffs) {
52
+ if (diff.type === 'unchanged') {
53
+ children.push(diff.newNode!)
54
+ } else if (diff.type === 'added') {
55
+ pending.push({
56
+ type: 'added',
57
+ childIndex: children.length,
58
+ oldText: '',
59
+ newText: diff.newNode?.textContent ?? '',
60
+ parts: diff.parts,
61
+ chunkId: chunk.id,
62
+ })
63
+ children.push(diff.newNode!)
64
+ } else if (diff.type === 'removed') {
65
+ pending.push({
66
+ type: 'removed',
67
+ childIndex: children.length,
68
+ oldText: diff.oldNode?.textContent ?? '',
69
+ newText: '',
70
+ parts: diff.parts,
71
+ chunkId: chunk.id,
72
+ })
73
+ children.push(diff.oldNode!)
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ if (children.length === 0) {
80
+ children.push(schema.node('paragraph', null, []))
81
+ }
82
+
83
+ const doc = schema.node('doc', null, children)
84
+
85
+ let pos = 0
86
+ const positions: ContentRange[] = []
87
+ for (const child of children) {
88
+ positions.push({ from: pos, to: pos + child.nodeSize })
89
+ pos += child.nodeSize
90
+ }
91
+
92
+ const changes: ChangeInfo[] = pending.map((pc) => {
93
+ const cp = positions[pc.childIndex]!
94
+
95
+ const inlineRanges: ContentRange[] = []
96
+ let offset = cp.from + 1
97
+
98
+ if (pc.type === 'removed') {
99
+ for (const part of pc.parts ?? []) {
100
+ if (part.type === 'delete') {
101
+ inlineRanges.push({ from: offset, to: offset + part.value.length })
102
+ offset += part.value.length
103
+ } else if (part.type === 'equal') {
104
+ offset += part.value.length
105
+ }
106
+ }
107
+ return {
108
+ type: 'removed',
109
+ from: cp.from,
110
+ oldText: pc.oldText,
111
+ newText: '',
112
+ blockRange: { from: cp.from, to: cp.to },
113
+ inlineRanges,
114
+ chunkId: pc.chunkId,
115
+ }
116
+ }
117
+
118
+ // pc.type === 'added'
119
+ for (const part of pc.parts ?? []) {
120
+ if (part.type === 'insert') {
121
+ inlineRanges.push({ from: offset, to: offset + part.value.length })
122
+ offset += part.value.length
123
+ } else if (part.type === 'equal') {
124
+ offset += part.value.length
125
+ }
126
+ }
127
+ return {
128
+ type: 'added',
129
+ from: cp.from,
130
+ oldText: '',
131
+ newText: pc.newText,
132
+ blockRange: { from: cp.from, to: cp.to },
133
+ inlineRanges,
134
+ chunkId: pc.chunkId,
135
+ }
136
+ })
137
+
138
+ return { doc, changes }
139
+ }
@@ -0,0 +1,514 @@
1
+ import type { Ctx } from '@jvs-milkdown/kit/ctx'
2
+ import type { Node as PMNode } from '@jvs-milkdown/kit/prose/model'
3
+ import type { PluginView } from '@jvs-milkdown/kit/prose/state'
4
+ import type { EditorView } from '@jvs-milkdown/kit/prose/view'
5
+
6
+ import { parserCtx } from '@jvs-milkdown/kit/core'
7
+ import { Plugin, PluginKey } from '@jvs-milkdown/kit/prose/state'
8
+ import { Decoration, DecorationSet } from '@jvs-milkdown/kit/prose/view'
9
+ import { $prose } from '@jvs-milkdown/kit/utils'
10
+
11
+ import type { DefineFeature } from '../shared'
12
+
13
+ import { i18n } from '../../core/locale'
14
+ import { crepeFeatureConfig } from '../../core/slice'
15
+ import { CrepeFeature } from '../index'
16
+ import { mountChangePanel } from './change-panel'
17
+ import {
18
+ inlineDiffConfig,
19
+ inlineDiffApiCtx,
20
+ type InlineDiffConfig,
21
+ type InlineDiffApi,
22
+ } from './config'
23
+ import { computeDiff, type DiffChunk } from './diff-engine'
24
+ import { buildNewDoc, type ChangeInfo } from './doc-builder'
25
+
26
+ export type InlineDiffFeatureConfig = Partial<InlineDiffConfig>
27
+
28
+ export { inlineDiffApiCtx, type InlineDiffApi }
29
+
30
+ export const inlineDiffKey = new PluginKey('CREPE_INLINE_DIFF')
31
+
32
+ const PANEL_WIDTH = 280
33
+
34
+ let sharedDecorationSet = DecorationSet.empty
35
+
36
+ class InlineDiffView implements PluginView {
37
+ #ctx: Ctx
38
+ #view: EditorView
39
+ #isActive = false
40
+ #panelApp: ReturnType<typeof mountChangePanel> | null = null
41
+ #panelContainer: HTMLElement | null = null
42
+ #resizeObserver: ResizeObserver | null = null
43
+ #scrollContainers: Element[] = []
44
+ #updateGeometry: (() => void) | null = null
45
+ #originalEditable: ((state: any) => boolean) | null = null
46
+ #chunks: DiffChunk[] = []
47
+ #schema: any = null
48
+
49
+ constructor(ctx: Ctx, view: EditorView) {
50
+ this.#ctx = ctx
51
+ this.#view = view
52
+
53
+ const api: InlineDiffApi = {
54
+ showDiff: (oldMd: string, newMd?: string) => this.showDiff(oldMd, newMd),
55
+ hideDiff: () => this.hideDiff(),
56
+ isShowing: () => this.#isActive,
57
+ }
58
+ ctx.set(inlineDiffApiCtx.key, api)
59
+ }
60
+
61
+ showDiff = (oldMarkdown: string, newMarkdown?: string): void => {
62
+ if (this.#isActive) return
63
+
64
+ const parser = this.#ctx.get(parserCtx)
65
+ this.#schema = this.#view.state.schema
66
+
67
+ let oldDoc: PMNode
68
+ let newDoc: PMNode
69
+ try {
70
+ oldDoc = parser(oldMarkdown)
71
+ if (typeof newMarkdown === 'string' && newMarkdown.trim() !== '') {
72
+ newDoc = parser(newMarkdown)
73
+ } else {
74
+ newDoc = this.#view.state.doc
75
+ }
76
+ } catch {
77
+ return
78
+ }
79
+
80
+ const oldBlocks: PMNode[] = []
81
+ const newBlocks: PMNode[] = []
82
+ oldDoc.forEach((node) => oldBlocks.push(node))
83
+ newDoc.forEach((node) => newBlocks.push(node))
84
+
85
+ this.#chunks = computeDiff(oldBlocks, newBlocks)
86
+
87
+ const { changes } = buildNewDoc(this.#chunks, this.#schema)
88
+ if (changes.length === 0) {
89
+ return
90
+ }
91
+
92
+ if (!this.#originalEditable) {
93
+ this.#originalEditable =
94
+ (this.#view.someProp('editable') as any) || (() => true)
95
+ }
96
+ this.#view.setProps({
97
+ editable: () => false,
98
+ })
99
+
100
+ this.#isActive = true
101
+ this.#render(true)
102
+ }
103
+
104
+ #render = (initPanel = false) => {
105
+ const { doc: newDocWithPos, changes } = buildNewDoc(
106
+ this.#chunks,
107
+ this.#schema
108
+ )
109
+ const acceptText = i18n(this.#ctx, 'inlineDiff.accept')
110
+ const rejectText = i18n(this.#ctx, 'inlineDiff.reject')
111
+
112
+ const ds = buildDecorations(
113
+ newDocWithPos,
114
+ changes,
115
+ (id) => this.acceptChunk(id),
116
+ (id) => this.rejectChunk(id),
117
+ acceptText,
118
+ rejectText
119
+ )
120
+ sharedDecorationSet = ds
121
+
122
+ const tr = this.#view.state.tr.replaceWith(
123
+ 0,
124
+ this.#view.state.doc.content.size,
125
+ newDocWithPos.content
126
+ )
127
+ this.#view.dispatch(tr)
128
+
129
+ if (initPanel) {
130
+ this.#createPanel(changes)
131
+ } else if (this.#panelApp) {
132
+ this.#createPanel(changes)
133
+ }
134
+ }
135
+
136
+ acceptChunk = (id: string) => {
137
+ const chunk = this.#chunks.find((c) => c.id === id)
138
+ if (chunk) {
139
+ chunk.status = 'accepted'
140
+ const hasPending = this.#chunks.some(
141
+ (c) =>
142
+ c.status === 'pending' && c.diffs.some((d) => d.type !== 'unchanged')
143
+ )
144
+ if (!hasPending) {
145
+ this.hideDiff()
146
+ } else {
147
+ this.#render()
148
+ }
149
+ }
150
+ }
151
+
152
+ rejectChunk = (id: string) => {
153
+ const chunk = this.#chunks.find((c) => c.id === id)
154
+ if (chunk) {
155
+ chunk.status = 'rejected'
156
+ const hasPending = this.#chunks.some(
157
+ (c) =>
158
+ c.status === 'pending' && c.diffs.some((d) => d.type !== 'unchanged')
159
+ )
160
+ if (!hasPending) {
161
+ this.hideDiff()
162
+ } else {
163
+ this.#render()
164
+ }
165
+ }
166
+ }
167
+
168
+ hideDiff = (): void => {
169
+ if (!this.#isActive) return
170
+ this.#removePanel()
171
+
172
+ for (const chunk of this.#chunks) {
173
+ if (chunk.status === 'pending') {
174
+ chunk.status = 'accepted'
175
+ }
176
+ }
177
+
178
+ const { doc: finalDoc } = buildNewDoc(this.#chunks, this.#schema)
179
+ const tr = this.#view.state.tr.replaceWith(
180
+ 0,
181
+ this.#view.state.doc.content.size,
182
+ finalDoc.content
183
+ )
184
+
185
+ sharedDecorationSet = DecorationSet.empty
186
+ this.#isActive = false
187
+ this.#chunks = []
188
+
189
+ this.#view.dispatch(tr)
190
+
191
+ if (this.#originalEditable) {
192
+ this.#view.setProps({
193
+ editable: this.#originalEditable,
194
+ })
195
+ this.#originalEditable = null
196
+ } else {
197
+ this.#view.setProps({
198
+ editable: () => true,
199
+ })
200
+ }
201
+ }
202
+
203
+ update = (view: EditorView) => {
204
+ this.#view = view
205
+ }
206
+
207
+ destroy = () => {
208
+ this.#removePanel()
209
+ }
210
+
211
+ #scrollTo(pos: number) {
212
+ try {
213
+ const view = this.#view
214
+ const coords = view.coordsAtPos(pos)
215
+
216
+ let scrollTarget: HTMLElement | null = view.dom as HTMLElement
217
+ while (scrollTarget) {
218
+ const { overflowY } = getComputedStyle(scrollTarget)
219
+ if (
220
+ (overflowY === 'auto' || overflowY === 'scroll') &&
221
+ scrollTarget.scrollHeight > scrollTarget.clientHeight
222
+ ) {
223
+ break
224
+ }
225
+ scrollTarget = scrollTarget.parentElement
226
+ }
227
+
228
+ if (scrollTarget) {
229
+ const targetRect = scrollTarget.getBoundingClientRect()
230
+ const scrollOffset =
231
+ coords.top - targetRect.top + scrollTarget.scrollTop - 60
232
+ scrollTarget.scrollTo({ top: scrollOffset, behavior: 'smooth' })
233
+ } else {
234
+ window.scrollTo({
235
+ top: coords.top + window.scrollY - 100,
236
+ behavior: 'smooth',
237
+ })
238
+ }
239
+ } catch {
240
+ // ignore
241
+ }
242
+ }
243
+
244
+ #createPanel(changes: ChangeInfo[]) {
245
+ this.#removePanel()
246
+
247
+ const editorContainer = this.#view.dom.parentElement
248
+ if (!editorContainer) return
249
+ const root = editorContainer.parentElement
250
+ if (!root) return
251
+
252
+ // Create fixed-positioned container — same pattern as outline panel
253
+ const container = document.createElement('div')
254
+ container.style.position = 'fixed'
255
+ container.style.zIndex = '100'
256
+ container.style.width = `${PANEL_WIDTH}px`
257
+ container.style.overflow = 'hidden'
258
+ container.style.borderLeft =
259
+ '1px solid var(--crepe-color-outline-variant, color-mix(in srgb, var(--crepe-color-outline, #ddd), transparent 80%))'
260
+ container.style.backgroundColor = 'var(--crepe-color-surface, #fff)'
261
+ root.appendChild(container)
262
+ this.#panelContainer = container
263
+
264
+ // Push editor content left to make room for panel
265
+ editorContainer.style.paddingRight = `${PANEL_WIDTH}px`
266
+
267
+ this.#panelApp = mountChangePanel(
268
+ container,
269
+ changes,
270
+ (from) => this.#scrollTo(from),
271
+ () => this.hideDiff()
272
+ )
273
+
274
+ // Geometry: compute top/right/height to align with editor area
275
+ this.#updateGeometry = () => {
276
+ const rootRect = root.getBoundingClientRect()
277
+ let top = rootRect.top
278
+
279
+ const toolbar = root.querySelector(
280
+ '.milkdown-fixed-toolbar'
281
+ ) as HTMLElement | null
282
+ if (toolbar && toolbar.offsetHeight > 0) {
283
+ const toolbarRect = toolbar.getBoundingClientRect()
284
+ top = Math.max(top, toolbarRect.bottom)
285
+ }
286
+
287
+ const coverEl = root.querySelector(
288
+ '.milkdown-document-cover'
289
+ ) as HTMLElement | null
290
+ if (coverEl && coverEl.offsetHeight > 0) {
291
+ top = Math.max(top, coverEl.getBoundingClientRect().bottom)
292
+ }
293
+
294
+ let height = window.innerHeight - top
295
+ if (top + height > rootRect.bottom) {
296
+ height = Math.max(0, rootRect.bottom - top)
297
+ }
298
+ container.style.top = `${top}px`
299
+ container.style.height = `${height}px`
300
+ container.style.left = 'auto'
301
+
302
+ let baseOffset = window.innerWidth - rootRect.right
303
+ let rightOffset = baseOffset
304
+ let hasVScroll = false
305
+ if (document.documentElement.scrollHeight > window.innerHeight) {
306
+ hasVScroll = true
307
+ }
308
+ let parent: HTMLElement | null = root
309
+ while (parent && parent !== document.body) {
310
+ const { overflowY } = getComputedStyle(parent)
311
+ if (overflowY === 'auto' || overflowY === 'scroll') {
312
+ if (parent.scrollHeight > parent.clientHeight) {
313
+ hasVScroll = true
314
+ break
315
+ }
316
+ }
317
+ parent = parent.parentElement
318
+ }
319
+ if (hasVScroll) {
320
+ rightOffset = Math.max(rightOffset, 32)
321
+ } else {
322
+ rightOffset = Math.max(rightOffset, 0)
323
+ }
324
+ container.style.right = `${rightOffset}px`
325
+
326
+ // Ensure paddingRight is set to accommodate the differences panel
327
+ editorContainer.style.paddingRight = `${PANEL_WIDTH}px`
328
+ }
329
+
330
+ this.#updateGeometry()
331
+
332
+ setTimeout(() => {
333
+ this.#updateGeometry?.()
334
+ }, 100)
335
+
336
+ // Re-evaluate on resize / scroll
337
+ this.#resizeObserver = new ResizeObserver(this.#updateGeometry)
338
+ this.#resizeObserver.observe(root)
339
+ window.addEventListener('resize', this.#updateGeometry)
340
+ window.addEventListener('scroll', this.#updateGeometry, {
341
+ capture: true,
342
+ passive: true,
343
+ })
344
+
345
+ let scrollerNode: Node | null = root
346
+ while (scrollerNode && scrollerNode !== document) {
347
+ if (scrollerNode instanceof Element) {
348
+ scrollerNode.addEventListener('scroll', this.#updateGeometry, {
349
+ passive: true,
350
+ })
351
+ this.#scrollContainers.push(scrollerNode)
352
+ }
353
+ scrollerNode =
354
+ scrollerNode.parentNode || (scrollerNode as ShadowRoot).host
355
+ }
356
+ }
357
+
358
+ #removePanel() {
359
+ if (this.#panelApp) {
360
+ this.#panelApp.unmount()
361
+ this.#panelApp = null
362
+ }
363
+ if (this.#panelContainer) {
364
+ this.#panelContainer.remove()
365
+ this.#panelContainer = null
366
+ }
367
+
368
+ if (this.#resizeObserver) {
369
+ this.#resizeObserver.disconnect()
370
+ this.#resizeObserver = null
371
+ }
372
+
373
+ if (this.#updateGeometry) {
374
+ window.removeEventListener('resize', this.#updateGeometry)
375
+ window.removeEventListener('scroll', this.#updateGeometry, {
376
+ capture: true,
377
+ })
378
+
379
+ for (const container of this.#scrollContainers) {
380
+ container.removeEventListener('scroll', this.#updateGeometry)
381
+ }
382
+ this.#scrollContainers = []
383
+ this.#updateGeometry = null
384
+ }
385
+
386
+ const editorContainer = this.#view.dom.parentElement
387
+ if (editorContainer) {
388
+ editorContainer.style.paddingRight = ''
389
+ }
390
+ }
391
+ }
392
+
393
+ function buildDecorations(
394
+ doc: PMNode,
395
+ changes: ChangeInfo[],
396
+ onAccept: (id: string) => void,
397
+ onReject: (id: string) => void,
398
+ acceptText: string,
399
+ rejectText: string
400
+ ): DecorationSet {
401
+ const decorations: Decoration[] = []
402
+ const addedWidgets = new Set<string>()
403
+
404
+ for (const change of changes) {
405
+ if (!addedWidgets.has(change.chunkId)) {
406
+ addedWidgets.add(change.chunkId)
407
+
408
+ decorations.push(
409
+ Decoration.widget(
410
+ change.blockRange.from,
411
+ () => {
412
+ const wrapper = document.createElement('div')
413
+ wrapper.style.position = 'relative'
414
+ wrapper.style.width = '100%'
415
+ wrapper.style.height = '0'
416
+ wrapper.style.overflow = 'visible'
417
+
418
+ const container = document.createElement('div')
419
+ container.className = 'crepe-diff-actions'
420
+ container.style.position = 'absolute'
421
+ container.style.right = '4px'
422
+ container.style.top = '4px'
423
+ container.style.zIndex = '10'
424
+ container.style.display = 'flex'
425
+ container.style.gap = '4px'
426
+
427
+ const btnAccept = document.createElement('button')
428
+ btnAccept.textContent = acceptText
429
+ btnAccept.style.backgroundColor = '#2ea043'
430
+ btnAccept.style.color = 'white'
431
+ btnAccept.style.border = 'none'
432
+ btnAccept.style.borderRadius = '4px'
433
+ btnAccept.style.padding = '2px 8px'
434
+ btnAccept.style.cursor = 'pointer'
435
+ btnAccept.style.fontSize = '12px'
436
+ btnAccept.addEventListener('mousedown', (e) => {
437
+ e.preventDefault()
438
+ onAccept(change.chunkId)
439
+ })
440
+
441
+ const btnReject = document.createElement('button')
442
+ btnReject.textContent = rejectText
443
+ btnReject.style.backgroundColor = '#f85149'
444
+ btnReject.style.color = 'white'
445
+ btnReject.style.border = 'none'
446
+ btnReject.style.borderRadius = '4px'
447
+ btnReject.style.padding = '2px 8px'
448
+ btnReject.style.cursor = 'pointer'
449
+ btnReject.style.fontSize = '12px'
450
+ btnReject.addEventListener('mousedown', (e) => {
451
+ e.preventDefault()
452
+ onReject(change.chunkId)
453
+ })
454
+
455
+ container.appendChild(btnAccept)
456
+ container.appendChild(btnReject)
457
+ wrapper.appendChild(container)
458
+ return wrapper
459
+ },
460
+ { side: -1 }
461
+ )
462
+ )
463
+ }
464
+
465
+ const blockCls =
466
+ change.type === 'added' ? 'crepe-diff-added' : 'crepe-diff-deleted'
467
+ decorations.push(
468
+ Decoration.node(change.blockRange.from, change.blockRange.to, {
469
+ class: blockCls,
470
+ })
471
+ )
472
+
473
+ const inlineCls =
474
+ change.type === 'added'
475
+ ? 'crepe-diff-inline-added'
476
+ : 'crepe-diff-inline-deleted'
477
+ for (const r of change.inlineRanges) {
478
+ if (r.to > r.from) {
479
+ decorations.push(Decoration.inline(r.from, r.to, { class: inlineCls }))
480
+ }
481
+ }
482
+ }
483
+
484
+ return DecorationSet.create(doc, decorations)
485
+ }
486
+
487
+ export const inlineDiffPlugin = $prose((ctx) => {
488
+ return new Plugin({
489
+ key: inlineDiffKey,
490
+ view: (view) => new InlineDiffView(ctx, view),
491
+ props: {
492
+ decorations: () => sharedDecorationSet,
493
+ },
494
+ })
495
+ })
496
+
497
+ export const inlineDiff: DefineFeature<InlineDiffFeatureConfig> = (
498
+ editor,
499
+ config
500
+ ) => {
501
+ editor
502
+ .config(crepeFeatureConfig(CrepeFeature.InlineDiff))
503
+ .config((ctx) => {
504
+ if (config) {
505
+ ctx.update(inlineDiffConfig.key, (prev) => ({
506
+ ...prev,
507
+ ...config,
508
+ }))
509
+ }
510
+ })
511
+ .use(inlineDiffApiCtx)
512
+ .use(inlineDiffConfig)
513
+ .use(inlineDiffPlugin)
514
+ }
@@ -4,9 +4,11 @@ import { attachment } from './attachment'
4
4
  import { blockEdit } from './block-edit'
5
5
  import { codeMirror } from './code-mirror'
6
6
  import { cursor } from './cursor'
7
+ import { diffBlockFeature } from './diff-block'
7
8
  import { fixedToolbar } from './fixed-toolbar'
8
9
  import { imageBlock } from './image-block'
9
10
  import { CrepeFeature } from './index'
11
+ import { inlineDiff } from './inline-diff'
10
12
  import { latex } from './latex'
11
13
  import { linkTooltip } from './link-tooltip'
12
14
  import { listItem } from './list-item'
@@ -56,5 +58,11 @@ export function loadFeature(
56
58
  case CrepeFeature.Attachment: {
57
59
  return attachment(editor, config)
58
60
  }
61
+ case CrepeFeature.InlineDiff: {
62
+ return inlineDiff(editor, config)
63
+ }
64
+ case CrepeFeature.DiffBlock: {
65
+ return diffBlockFeature(editor, config)
66
+ }
59
67
  }
60
68
  }