@bagelink/vue 1.17.2 → 1.17.6
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.
- package/dist/assetTransport.d.ts +9 -31
- package/dist/assetTransport.d.ts.map +1 -1
- package/dist/assets.d.ts +39 -20
- package/dist/assets.d.ts.map +1 -1
- package/dist/components/AddressSearch.vue.d.ts +40 -36
- package/dist/components/AddressSearch.vue.d.ts.map +1 -1
- package/dist/components/ContextMenu.vue.d.ts +518 -0
- package/dist/components/ContextMenu.vue.d.ts.map +1 -0
- package/dist/components/Dropdown.types.d.ts +25 -0
- package/dist/components/Dropdown.types.d.ts.map +1 -0
- package/dist/components/Dropdown.vue.d.ts +20 -15
- package/dist/components/Dropdown.vue.d.ts.map +1 -1
- package/dist/components/FilterQuery.vue.d.ts +1 -1
- package/dist/components/FilterQuery.vue.d.ts.map +1 -1
- package/dist/components/Icon/Icon.vue.d.ts +0 -1
- package/dist/components/Icon/Icon.vue.d.ts.map +1 -1
- package/dist/components/Icon/constants.d.ts +0 -1
- package/dist/components/Icon/constants.d.ts.map +1 -1
- package/dist/components/Icon/types.d.ts +2 -3
- package/dist/components/Icon/types.d.ts.map +1 -1
- package/dist/components/Swiper.vue.d.ts +1 -1
- package/dist/components/draggable/useDraggable.d.ts.map +1 -1
- package/dist/components/form/inputs/IconPickerInput.vue.d.ts +48 -37
- package/dist/components/form/inputs/IconPickerInput.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/components/EditorToolbar.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/composables/useCommands.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/composables/useEditor.d.ts +3 -0
- package/dist/components/form/inputs/RichText/composables/useEditor.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/composables/useEditorKeyboard.d.ts +1 -1
- package/dist/components/form/inputs/RichText/composables/useEditorKeyboard.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/index.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/utils/commands.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/utils/selection.d.ts +2 -0
- package/dist/components/form/inputs/RichText/utils/selection.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/utils/table.d.ts +34 -8
- package/dist/components/form/inputs/RichText/utils/table.d.ts.map +1 -1
- package/dist/components/form/inputs/SelectInput.vue.d.ts +71 -63
- package/dist/components/form/inputs/SelectInput.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/TelInput.vue.d.ts +2 -2
- package/dist/components/index.d.ts +3 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/composables/index.d.ts +1 -1
- package/dist/composables/index.d.ts.map +1 -1
- package/dist/composables/useBoundedRectDrag.d.ts.map +1 -1
- package/dist/composables/usePointerSession.d.ts +14 -3
- package/dist/composables/usePointerSession.d.ts.map +1 -1
- package/dist/core.css +3 -3
- package/dist/directives/vResize.d.ts.map +1 -1
- package/dist/form-flow/form-flow.d.ts +4 -2
- package/dist/form-flow/form-flow.d.ts.map +1 -1
- package/dist/form-flow/schema-fields.d.ts +4 -1
- package/dist/form-flow/schema-fields.d.ts.map +1 -1
- package/dist/index.cjs +240 -153
- package/dist/index.css +1 -1
- package/dist/index.mjs +18459 -18281
- package/dist/style.css +3 -3
- package/dist/types/BagelForm.d.ts +2 -0
- package/dist/types/BagelForm.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/assetTransport.test.ts +33 -83
- package/src/assetTransport.ts +24 -90
- package/src/assets.test.ts +58 -99
- package/src/assets.ts +78 -161
- package/src/components/ContextMenu.vue +113 -0
- package/src/components/Dropdown.types.ts +27 -0
- package/src/components/Dropdown.vue +129 -64
- package/src/components/FilterQuery.vue +1 -1
- package/src/components/Icon/Icon.vue +8 -30
- package/src/components/Icon/constants.ts +0 -166
- package/src/components/Icon/types.ts +2 -3
- package/src/components/draggable/useDraggable.ts +67 -100
- package/src/components/form/inputs/IconPickerInput.vue +14 -9
- package/src/components/form/inputs/RichText/components/EditorToolbar.vue +43 -111
- package/src/components/form/inputs/RichText/composables/useCommands.ts +32 -54
- package/src/components/form/inputs/RichText/composables/useEditor.ts +45 -171
- package/src/components/form/inputs/RichText/composables/useEditorKeyboard.ts +90 -14
- package/src/components/form/inputs/RichText/index.vue +1195 -1210
- package/src/components/form/inputs/RichText/utils/commands.ts +689 -1028
- package/src/components/form/inputs/RichText/utils/selection.ts +66 -41
- package/src/components/form/inputs/RichText/utils/table.ts +798 -136
- package/src/components/form/inputs/SelectInput.vue +1 -1
- package/src/components/form/inputs/Upload/AssetUploadInput.vue +2 -2
- package/src/components/index.ts +3 -1
- package/src/composables/index.ts +1 -1
- package/src/composables/useBoundedRectDrag.ts +9 -12
- package/src/composables/usePointerSession.ts +57 -12
- package/src/directives/vResize.ts +6 -7
- package/src/form-flow/form-flow.ts +8 -3
- package/src/form-flow/schema-fields.ts +9 -4
- package/src/styles/core.css +1 -18
- package/src/types/BagelForm.ts +2 -0
- package/dist/components/form/inputs/RichText/utils/formatting.d.ts +0 -10
- package/dist/components/form/inputs/RichText/utils/formatting.d.ts.map +0 -1
- package/src/components/form/inputs/RichText/components/gridBox.vue +0 -58
- package/src/components/form/inputs/RichText/utils/formatting.ts +0 -331
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import type { EditorState } from '../richTextTypes'
|
|
2
|
+
import { pushHistorySnapshot, restoreHistoryNodes } from '../composables/useEditor'
|
|
2
3
|
import { insertLink } from './media'
|
|
3
|
-
import {
|
|
4
|
+
import { isStyleActive } from './selection'
|
|
5
|
+
import {
|
|
6
|
+
addRow,
|
|
7
|
+
alignColumn,
|
|
8
|
+
deleteColumn,
|
|
9
|
+
deleteRow,
|
|
10
|
+
deleteTable,
|
|
11
|
+
focusTableCell,
|
|
12
|
+
insertColumn,
|
|
13
|
+
insertTable,
|
|
14
|
+
mergeCells,
|
|
15
|
+
splitCell
|
|
16
|
+
} from './table'
|
|
4
17
|
|
|
5
18
|
export interface Command {
|
|
6
19
|
name: string
|
|
@@ -19,7 +32,6 @@ export interface CommandExecutor {
|
|
|
19
32
|
getValue: (command: string) => string | null
|
|
20
33
|
}
|
|
21
34
|
|
|
22
|
-
// Helper functions
|
|
23
35
|
function getCurrentSelection(state: EditorState): { selection: Selection, range: Range } | null {
|
|
24
36
|
if (!state.doc) { return null }
|
|
25
37
|
|
|
@@ -30,14 +42,12 @@ function getCurrentSelection(state: EditorState): { selection: Selection, range:
|
|
|
30
42
|
return { selection, range }
|
|
31
43
|
}
|
|
32
44
|
|
|
33
|
-
|
|
34
|
-
function applyFormattingToWordAtCursor(doc: Document, range: Range, tagName: string): boolean {
|
|
45
|
+
function applyFormattingToWordAtCursor(doc: Document, range: Range, tagSelector: string, createTag: string): boolean {
|
|
35
46
|
if (!range.collapsed) { return false }
|
|
36
47
|
|
|
37
48
|
let textNode = range.startContainer
|
|
38
49
|
let offset = range.startOffset
|
|
39
50
|
|
|
40
|
-
// If we're in an element node, try to find a text node
|
|
41
51
|
if (textNode.nodeType !== Node.TEXT_NODE) {
|
|
42
52
|
const element = textNode as Element
|
|
43
53
|
if (element.childNodes.length > 0 && offset < element.childNodes.length) {
|
|
@@ -53,24 +63,20 @@ function applyFormattingToWordAtCursor(doc: Document, range: Range, tagName: str
|
|
|
53
63
|
}
|
|
54
64
|
}
|
|
55
65
|
|
|
56
|
-
// Check if we're already inside a formatting element
|
|
57
66
|
const { parentElement } = textNode
|
|
58
|
-
const existingFormatElement = parentElement?.closest(
|
|
67
|
+
const existingFormatElement = parentElement?.closest(tagSelector)
|
|
59
68
|
|
|
60
69
|
if (existingFormatElement) {
|
|
61
|
-
// Remove existing formatting
|
|
62
70
|
try {
|
|
63
71
|
const parent = existingFormatElement.parentNode
|
|
64
72
|
if (parent) {
|
|
65
|
-
// Move all children of the format element to its parent
|
|
66
73
|
while (existingFormatElement.firstChild) {
|
|
67
74
|
parent.insertBefore(existingFormatElement.firstChild, existingFormatElement)
|
|
68
75
|
}
|
|
69
76
|
parent.removeChild(existingFormatElement)
|
|
70
77
|
|
|
71
|
-
// Position cursor at the same relative position
|
|
72
78
|
const newRange = doc.createRange()
|
|
73
|
-
newRange.setStart(textNode, offset)
|
|
79
|
+
newRange.setStart(textNode, Math.min(offset, textNode.textContent?.length ?? 0))
|
|
74
80
|
newRange.collapse(true)
|
|
75
81
|
|
|
76
82
|
const selection = doc.getSelection()
|
|
@@ -90,64 +96,48 @@ function applyFormattingToWordAtCursor(doc: Document, range: Range, tagName: str
|
|
|
90
96
|
const text = textNode.textContent || ''
|
|
91
97
|
if (!text) { return false }
|
|
92
98
|
|
|
93
|
-
// Find word boundaries - support Hebrew, English, and numbers
|
|
94
99
|
let start = offset
|
|
95
100
|
let end = offset
|
|
96
|
-
|
|
97
|
-
// Regular expression for word characters (Hebrew, English, numbers)
|
|
98
101
|
const wordChar = /[\u0590-\u05FF\u0600-\u06FF\w]/
|
|
99
102
|
|
|
100
|
-
// If we're at the end of a word (cursor right after the last character)
|
|
101
|
-
// move back one position to include that word
|
|
102
103
|
if (offset > 0 && wordChar.test(text[offset - 1])
|
|
103
104
|
&& (offset >= text.length || !wordChar.test(text[offset]))) {
|
|
104
105
|
start = offset - 1
|
|
105
106
|
end = offset - 1
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
// Move start backwards to find word beginning
|
|
109
109
|
while (start > 0 && wordChar.test(text[start - 1])) {
|
|
110
110
|
start--
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
// Move end forwards to find word ending
|
|
114
113
|
while (end < text.length && wordChar.test(text[end])) {
|
|
115
114
|
end++
|
|
116
115
|
}
|
|
117
116
|
|
|
118
|
-
|
|
119
|
-
if (start < end && end > start) {
|
|
117
|
+
if (start < end) {
|
|
120
118
|
try {
|
|
121
|
-
// Split the text node into three parts: before, word, after
|
|
122
119
|
const beforeText = text.substring(0, start)
|
|
123
120
|
const wordText = text.substring(start, end)
|
|
124
121
|
const afterText = text.substring(end)
|
|
122
|
+
const relativeCursorOffset = Math.max(0, Math.min(wordText.length, offset - start))
|
|
125
123
|
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
formatElement.
|
|
124
|
+
const formatElement = doc.createElement(createTag)
|
|
125
|
+
const innerTextNode = doc.createTextNode(wordText)
|
|
126
|
+
formatElement.appendChild(innerTextNode)
|
|
129
127
|
|
|
130
|
-
// Replace the text node with the new structure
|
|
131
128
|
const parent = textNode.parentNode
|
|
132
129
|
if (parent) {
|
|
133
|
-
// Create text nodes for before and after
|
|
134
130
|
if (beforeText) {
|
|
135
|
-
|
|
136
|
-
parent.insertBefore(beforeNode, textNode)
|
|
131
|
+
parent.insertBefore(doc.createTextNode(beforeText), textNode)
|
|
137
132
|
}
|
|
138
|
-
|
|
139
133
|
parent.insertBefore(formatElement, textNode)
|
|
140
|
-
|
|
141
134
|
if (afterText) {
|
|
142
|
-
|
|
143
|
-
parent.insertBefore(afterNode, textNode)
|
|
135
|
+
parent.insertBefore(doc.createTextNode(afterText), textNode)
|
|
144
136
|
}
|
|
145
|
-
|
|
146
137
|
parent.removeChild(textNode)
|
|
147
138
|
|
|
148
|
-
// Position cursor after the formatted word
|
|
149
139
|
const newRange = doc.createRange()
|
|
150
|
-
newRange.
|
|
140
|
+
newRange.setStart(innerTextNode, relativeCursorOffset)
|
|
151
141
|
newRange.collapse(true)
|
|
152
142
|
|
|
153
143
|
const selection = doc.getSelection()
|
|
@@ -166,13 +156,134 @@ function applyFormattingToWordAtCursor(doc: Document, range: Range, tagName: str
|
|
|
166
156
|
return false
|
|
167
157
|
}
|
|
168
158
|
|
|
169
|
-
function
|
|
159
|
+
function toggleInlineFormatting(
|
|
160
|
+
state: EditorState,
|
|
161
|
+
tagName: 'b' | 'i' | 'u',
|
|
162
|
+
matchSelector: string
|
|
163
|
+
) {
|
|
164
|
+
const selectionInfo = getCurrentSelection(state)
|
|
165
|
+
if (!selectionInfo || !state.doc) { return }
|
|
166
|
+
|
|
167
|
+
const { doc } = state
|
|
168
|
+
const { range, selection } = selectionInfo
|
|
169
|
+
|
|
170
|
+
if (range.collapsed) {
|
|
171
|
+
applyFormattingToWordAtCursor(doc, range, matchSelector, tagName)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const commonAncestor = range.commonAncestorContainer
|
|
176
|
+
const parentElement = commonAncestor.nodeType === Node.TEXT_NODE
|
|
177
|
+
? commonAncestor.parentElement
|
|
178
|
+
: commonAncestor as Element
|
|
179
|
+
|
|
180
|
+
const activeFormatEl = parentElement?.closest(matchSelector) as HTMLElement | null
|
|
181
|
+
|
|
182
|
+
if (activeFormatEl) {
|
|
183
|
+
// Unwrap only the selected portion if the selection is strictly inside the formatted element
|
|
184
|
+
const selectedText = range.toString()
|
|
185
|
+
const fullText = activeFormatEl.textContent || ''
|
|
186
|
+
|
|
187
|
+
if (selectedText === fullText || range.startContainer !== range.endContainer || range.startContainer.parentElement !== activeFormatEl) {
|
|
188
|
+
const parent = activeFormatEl.parentNode
|
|
189
|
+
const firstMoved = activeFormatEl.firstChild
|
|
190
|
+
const lastMoved = activeFormatEl.lastChild
|
|
191
|
+
while (activeFormatEl.firstChild) {
|
|
192
|
+
parent?.insertBefore(activeFormatEl.firstChild, activeFormatEl)
|
|
193
|
+
}
|
|
194
|
+
parent?.removeChild(activeFormatEl)
|
|
195
|
+
|
|
196
|
+
if (firstMoved && lastMoved) {
|
|
197
|
+
const nextRange = doc.createRange()
|
|
198
|
+
nextRange.setStartBefore(firstMoved)
|
|
199
|
+
nextRange.setEndAfter(lastMoved)
|
|
200
|
+
selection.removeAllRanges()
|
|
201
|
+
selection.addRange(nextRange)
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
// Selection is a substring of a single text node inside activeFormatEl: split into [before, unwrapped, after]
|
|
205
|
+
const textNode = range.startContainer as Text
|
|
206
|
+
const text = textNode.textContent || ''
|
|
207
|
+
const before = text.slice(0, range.startOffset)
|
|
208
|
+
const middle = text.slice(range.startOffset, range.endOffset)
|
|
209
|
+
const after = text.slice(range.endOffset)
|
|
210
|
+
const parent = activeFormatEl.parentNode
|
|
211
|
+
|
|
212
|
+
if (parent) {
|
|
213
|
+
const middleNode = doc.createTextNode(middle)
|
|
214
|
+
if (before) {
|
|
215
|
+
const beforeEl = activeFormatEl.cloneNode(false) as HTMLElement
|
|
216
|
+
beforeEl.textContent = before
|
|
217
|
+
parent.insertBefore(beforeEl, activeFormatEl)
|
|
218
|
+
}
|
|
219
|
+
parent.insertBefore(middleNode, activeFormatEl)
|
|
220
|
+
if (after) {
|
|
221
|
+
const afterEl = activeFormatEl.cloneNode(false) as HTMLElement
|
|
222
|
+
afterEl.textContent = after
|
|
223
|
+
parent.insertBefore(afterEl, activeFormatEl)
|
|
224
|
+
}
|
|
225
|
+
parent.removeChild(activeFormatEl)
|
|
226
|
+
|
|
227
|
+
const nextRange = doc.createRange()
|
|
228
|
+
nextRange.selectNodeContents(middleNode)
|
|
229
|
+
selection.removeAllRanges()
|
|
230
|
+
selection.addRange(nextRange)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Check if selection spans across multiple block elements
|
|
237
|
+
const blockSelector = 'p,h1,h2,h3,h4,h5,h6,blockquote,li,td,th'
|
|
238
|
+
const blocks = Array.from(doc.body.querySelectorAll<HTMLElement>(blockSelector))
|
|
239
|
+
.filter(b => !b.querySelector(blockSelector) && range.intersectsNode(b))
|
|
240
|
+
|
|
241
|
+
if (blocks.length > 1) {
|
|
242
|
+
blocks.forEach((block) => {
|
|
243
|
+
const wrapper = doc.createElement(tagName)
|
|
244
|
+
while (block.firstChild) {
|
|
245
|
+
wrapper.appendChild(block.firstChild)
|
|
246
|
+
}
|
|
247
|
+
block.appendChild(wrapper)
|
|
248
|
+
})
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Single-block inline wrap
|
|
253
|
+
try {
|
|
254
|
+
const wrapper = doc.createElement(tagName)
|
|
255
|
+
try {
|
|
256
|
+
range.surroundContents(wrapper)
|
|
257
|
+
} catch {
|
|
258
|
+
const fragment = range.extractContents()
|
|
259
|
+
// Unwrap redundant nested tags of the same type inside the fragment
|
|
260
|
+
fragment.querySelectorAll(matchSelector).forEach((nested) => {
|
|
261
|
+
const p = nested.parentNode
|
|
262
|
+
while (nested.firstChild) {
|
|
263
|
+
p?.insertBefore(nested.firstChild, nested)
|
|
264
|
+
}
|
|
265
|
+
nested.remove()
|
|
266
|
+
})
|
|
267
|
+
wrapper.appendChild(fragment)
|
|
268
|
+
range.insertNode(wrapper)
|
|
269
|
+
}
|
|
270
|
+
range.selectNodeContents(wrapper)
|
|
271
|
+
selection.removeAllRanges()
|
|
272
|
+
selection.addRange(range)
|
|
273
|
+
} catch (error) {
|
|
274
|
+
console.error(`Error applying ${tagName}:`, error)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function updateStateAfterCommand(state: EditorState, preCommandContent?: string) {
|
|
170
279
|
if (!state.doc) { return }
|
|
171
280
|
|
|
172
|
-
|
|
173
|
-
|
|
281
|
+
const newContent = state.doc.body.innerHTML
|
|
282
|
+
if (preCommandContent !== undefined && newContent !== preCommandContent) {
|
|
283
|
+
pushHistorySnapshot(state, preCommandContent)
|
|
284
|
+
}
|
|
285
|
+
state.content = newContent
|
|
174
286
|
|
|
175
|
-
// Update selection state
|
|
176
287
|
const selection = state.doc.getSelection()
|
|
177
288
|
if (selection && selection.rangeCount > 0) {
|
|
178
289
|
state.selection = selection
|
|
@@ -181,8 +292,8 @@ function updateStateAfterCommand(state: EditorState) {
|
|
|
181
292
|
}
|
|
182
293
|
}
|
|
183
294
|
|
|
184
|
-
function findBlockElement(node: Node):
|
|
185
|
-
let current = node.nodeType === Node.TEXT_NODE ? node.parentElement : node as
|
|
295
|
+
function findBlockElement(node: Node): HTMLElement | null {
|
|
296
|
+
let current = node.nodeType === Node.TEXT_NODE ? node.parentElement : node as HTMLElement | null
|
|
186
297
|
while (current) {
|
|
187
298
|
if (current.matches && current.matches('p,h1,h2,h3,h4,h5,h6,blockquote,li,div')) {
|
|
188
299
|
return current
|
|
@@ -192,7 +303,41 @@ function findBlockElement(node: Node): Element | null {
|
|
|
192
303
|
return null
|
|
193
304
|
}
|
|
194
305
|
|
|
195
|
-
const
|
|
306
|
+
const CONVERTIBLE_BLOCK_SELECTOR = 'p,h1,h2,h3,h4,h5,h6,blockquote'
|
|
307
|
+
|
|
308
|
+
function getSelectedBlocks(doc: Document, range: Range): HTMLElement[] {
|
|
309
|
+
if (!range.collapsed) {
|
|
310
|
+
const blocks = Array.from(doc.body.querySelectorAll<HTMLElement>(CONVERTIBLE_BLOCK_SELECTOR))
|
|
311
|
+
.filter(block => !block.closest('li') && range.intersectsNode(block))
|
|
312
|
+
.filter(block => !block.parentElement?.closest(CONVERTIBLE_BLOCK_SELECTOR))
|
|
313
|
+
if (blocks.length > 0) {
|
|
314
|
+
return blocks
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const single = findBlockElement(range.commonAncestorContainer)
|
|
318
|
+
if (single && single !== doc.body) {
|
|
319
|
+
return [single]
|
|
320
|
+
}
|
|
321
|
+
return []
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function convertBlockTag(doc: Document, currentBlock: HTMLElement, newTag: string): HTMLElement {
|
|
325
|
+
const newBlock = doc.createElement(newTag)
|
|
326
|
+
if (currentBlock.dir) {
|
|
327
|
+
newBlock.dir = currentBlock.dir
|
|
328
|
+
}
|
|
329
|
+
if (currentBlock.hasAttribute('data-dir-explicit')) {
|
|
330
|
+
newBlock.setAttribute('data-dir-explicit', 'true')
|
|
331
|
+
}
|
|
332
|
+
if (currentBlock.style.textAlign) {
|
|
333
|
+
newBlock.style.textAlign = currentBlock.style.textAlign
|
|
334
|
+
}
|
|
335
|
+
while (currentBlock.firstChild) {
|
|
336
|
+
newBlock.appendChild(currentBlock.firstChild)
|
|
337
|
+
}
|
|
338
|
+
currentBlock.parentNode?.replaceChild(newBlock, currentBlock)
|
|
339
|
+
return newBlock
|
|
340
|
+
}
|
|
196
341
|
|
|
197
342
|
function convertSelectedBlocksToList(
|
|
198
343
|
state: EditorState,
|
|
@@ -202,24 +347,20 @@ function convertSelectedBlocksToList(
|
|
|
202
347
|
): boolean {
|
|
203
348
|
if (!state.doc || range.collapsed) { return false }
|
|
204
349
|
|
|
205
|
-
const blocks = Array.from(state.doc.body.querySelectorAll(
|
|
350
|
+
const blocks = Array.from(state.doc.body.querySelectorAll<HTMLElement>(CONVERTIBLE_BLOCK_SELECTOR))
|
|
206
351
|
.filter(block => !block.closest('li') && range.intersectsNode(block))
|
|
207
|
-
.filter(block => !
|
|
208
|
-
|
|
209
|
-
function blocksContainAncestor(block: Element) {
|
|
210
|
-
return block.parentElement?.closest(LIST_BLOCK_SELECTOR) !== null
|
|
211
|
-
}
|
|
352
|
+
.filter(block => !block.parentElement?.closest(CONVERTIBLE_BLOCK_SELECTOR))
|
|
212
353
|
|
|
213
354
|
if (!blocks.length) { return false }
|
|
214
355
|
|
|
215
356
|
const list = state.doc.createElement(listTag)
|
|
216
|
-
const direction =
|
|
357
|
+
const direction = blocks[0].dir
|
|
217
358
|
if (direction === 'rtl' || direction === 'ltr') { list.dir = direction }
|
|
218
359
|
|
|
219
360
|
blocks[0].parentNode?.insertBefore(list, blocks[0])
|
|
220
361
|
blocks.forEach((block) => {
|
|
221
362
|
const item = state.doc!.createElement('li')
|
|
222
|
-
const blockDirection =
|
|
363
|
+
const blockDirection = block.dir
|
|
223
364
|
if (blockDirection === 'rtl' || blockDirection === 'ltr') { item.dir = blockDirection }
|
|
224
365
|
while (block.firstChild) {
|
|
225
366
|
item.appendChild(block.firstChild)
|
|
@@ -235,21 +376,136 @@ function convertSelectedBlocksToList(
|
|
|
235
376
|
return true
|
|
236
377
|
}
|
|
237
378
|
|
|
238
|
-
|
|
239
|
-
|
|
379
|
+
function toggleListCommand(state: EditorState, listTag: 'ol' | 'ul') {
|
|
380
|
+
const selectionInfo = getCurrentSelection(state)
|
|
381
|
+
if (!selectionInfo || !state.doc) { return }
|
|
382
|
+
|
|
383
|
+
const { doc } = state
|
|
384
|
+
const { range, selection } = selectionInfo
|
|
385
|
+
if (convertSelectedBlocksToList(state, selection, range, listTag)) { return }
|
|
386
|
+
|
|
387
|
+
const currentBlock = findBlockElement(range.commonAncestorContainer)
|
|
388
|
+
if (!currentBlock) { return }
|
|
389
|
+
|
|
390
|
+
const listItem = currentBlock.closest('li') as HTMLLIElement | null
|
|
391
|
+
if (listItem) {
|
|
392
|
+
const listParent = listItem.parentElement as HTMLElement | null
|
|
393
|
+
if (!listParent) { return }
|
|
394
|
+
|
|
395
|
+
if (listParent.tagName.toLowerCase() === listTag) {
|
|
396
|
+
// Toggle off this list item: if it's in the middle of a list, split the list around the new paragraph
|
|
397
|
+
const p = doc.createElement('p')
|
|
398
|
+
if (listItem.dir || listParent.dir) {
|
|
399
|
+
p.dir = listItem.dir || listParent.dir
|
|
400
|
+
}
|
|
401
|
+
while (listItem.firstChild) {
|
|
402
|
+
p.appendChild(listItem.firstChild)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const allItems = Array.from(listParent.children).filter(el => el.tagName === 'LI')
|
|
406
|
+
const itemIndex = allItems.indexOf(listItem)
|
|
407
|
+
const trailingItems = allItems.slice(itemIndex + 1)
|
|
408
|
+
|
|
409
|
+
if (itemIndex === 0) {
|
|
410
|
+
listParent.parentNode?.insertBefore(p, listParent)
|
|
411
|
+
listItem.remove()
|
|
412
|
+
if (!listParent.querySelector('li')) {
|
|
413
|
+
listParent.remove()
|
|
414
|
+
}
|
|
415
|
+
} else if (trailingItems.length === 0) {
|
|
416
|
+
listParent.insertAdjacentElement('afterend', p)
|
|
417
|
+
listItem.remove()
|
|
418
|
+
if (!listParent.querySelector('li')) {
|
|
419
|
+
listParent.remove()
|
|
420
|
+
}
|
|
421
|
+
} else {
|
|
422
|
+
// Split list into [beforeList, p, afterList]
|
|
423
|
+
const afterList = doc.createElement(listTag)
|
|
424
|
+
if (listParent.dir) { afterList.dir = listParent.dir }
|
|
425
|
+
trailingItems.forEach(item => afterList.appendChild(item))
|
|
426
|
+
listItem.remove()
|
|
427
|
+
listParent.insertAdjacentElement('afterend', p)
|
|
428
|
+
p.insertAdjacentElement('afterend', afterList)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const nextRange = doc.createRange()
|
|
432
|
+
nextRange.selectNodeContents(p)
|
|
433
|
+
nextRange.collapse(false)
|
|
434
|
+
selection.removeAllRanges()
|
|
435
|
+
selection.addRange(nextRange)
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Convert between ul and ol
|
|
440
|
+
const convertedList = doc.createElement(listTag)
|
|
441
|
+
if (listParent.dir) { convertedList.dir = listParent.dir }
|
|
442
|
+
const currentOffset = range.startOffset
|
|
443
|
+
const currentContainer = range.startContainer
|
|
444
|
+
|
|
445
|
+
while (listParent.firstChild) {
|
|
446
|
+
convertedList.appendChild(listParent.firstChild)
|
|
447
|
+
}
|
|
448
|
+
listParent.parentNode?.replaceChild(convertedList, listParent)
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
const newRange = doc.createRange()
|
|
452
|
+
newRange.setStart(currentContainer, currentOffset)
|
|
453
|
+
newRange.collapse(true)
|
|
454
|
+
selection.removeAllRanges()
|
|
455
|
+
selection.addRange(newRange)
|
|
456
|
+
} catch {
|
|
457
|
+
const firstLi = convertedList.querySelector('li')
|
|
458
|
+
if (firstLi) {
|
|
459
|
+
const newRange = doc.createRange()
|
|
460
|
+
newRange.selectNodeContents(firstLi)
|
|
461
|
+
newRange.collapse(true)
|
|
462
|
+
selection.removeAllRanges()
|
|
463
|
+
selection.addRange(newRange)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Convert single block to list
|
|
470
|
+
const listEl = doc.createElement(listTag)
|
|
471
|
+
const li = doc.createElement('li')
|
|
472
|
+
if (currentBlock.dir) {
|
|
473
|
+
listEl.dir = currentBlock.dir
|
|
474
|
+
li.dir = currentBlock.dir
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
while (currentBlock.firstChild) {
|
|
478
|
+
li.appendChild(currentBlock.firstChild)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
listEl.appendChild(li)
|
|
482
|
+
currentBlock.parentNode?.replaceChild(listEl, currentBlock)
|
|
483
|
+
|
|
484
|
+
range.selectNodeContents(li)
|
|
485
|
+
range.collapse(false)
|
|
486
|
+
selection.removeAllRanges()
|
|
487
|
+
selection.addRange(range)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function createCommand(
|
|
491
|
+
name: string,
|
|
492
|
+
execute: Command['execute'],
|
|
493
|
+
isActive?: Command['isActive'],
|
|
494
|
+
options?: { skipHistory?: boolean }
|
|
495
|
+
): Command {
|
|
240
496
|
return {
|
|
241
497
|
name,
|
|
242
498
|
execute: (state: EditorState, value?: string) => {
|
|
243
499
|
if (!state.doc) { return }
|
|
500
|
+
const preCommandContent = options?.skipHistory ? undefined : state.doc.body.innerHTML
|
|
244
501
|
execute(state, value)
|
|
245
|
-
updateStateAfterCommand(state)
|
|
502
|
+
updateStateAfterCommand(state, preCommandContent)
|
|
246
503
|
},
|
|
247
504
|
isActive
|
|
248
505
|
}
|
|
249
506
|
}
|
|
250
507
|
|
|
251
508
|
export function createCommandRegistry(state: EditorState): CommandRegistry {
|
|
252
|
-
// History commands
|
|
253
509
|
const historyCommands = {
|
|
254
510
|
undo: createCommand('Undo', () => {
|
|
255
511
|
if (state.undoStack.length > 0 && state.doc) {
|
|
@@ -257,1084 +513,489 @@ export function createCommandRegistry(state: EditorState): CommandRegistry {
|
|
|
257
513
|
if (lastContent !== undefined) {
|
|
258
514
|
state.redoStack.push(state.content)
|
|
259
515
|
state.content = lastContent
|
|
260
|
-
state.doc
|
|
516
|
+
restoreHistoryNodes(state.doc, lastContent)
|
|
261
517
|
}
|
|
262
518
|
}
|
|
263
|
-
}),
|
|
519
|
+
}, undefined, { skipHistory: true }),
|
|
264
520
|
redo: createCommand('Redo', () => {
|
|
265
521
|
if (state.redoStack.length > 0 && state.doc) {
|
|
266
522
|
const nextContent = state.redoStack.pop()
|
|
267
523
|
if (nextContent !== undefined) {
|
|
268
524
|
state.undoStack.push(state.content)
|
|
269
525
|
state.content = nextContent
|
|
270
|
-
state.doc
|
|
526
|
+
restoreHistoryNodes(state.doc, nextContent)
|
|
271
527
|
}
|
|
272
528
|
}
|
|
273
|
-
})
|
|
529
|
+
}, undefined, { skipHistory: true })
|
|
274
530
|
}
|
|
275
531
|
|
|
276
|
-
// Basic text formatting commands with toggle functionality
|
|
277
532
|
const textCommands = {
|
|
278
|
-
bold: createCommand(
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
: commonAncestor as Element
|
|
296
|
-
|
|
297
|
-
const boldElement = parentElement?.closest('b, strong')
|
|
298
|
-
|
|
299
|
-
if (boldElement) {
|
|
300
|
-
// Remove bold formatting
|
|
301
|
-
try {
|
|
302
|
-
const parent = boldElement.parentNode
|
|
303
|
-
while (boldElement.firstChild) {
|
|
304
|
-
parent?.insertBefore(boldElement.firstChild, boldElement)
|
|
305
|
-
}
|
|
306
|
-
parent?.removeChild(boldElement)
|
|
307
|
-
|
|
308
|
-
// Restore selection
|
|
309
|
-
selection.removeAllRanges()
|
|
310
|
-
selection.addRange(range)
|
|
311
|
-
} catch (error) {
|
|
312
|
-
console.error('Error removing bold:', error)
|
|
313
|
-
}
|
|
314
|
-
} else {
|
|
315
|
-
// Apply bold formatting
|
|
316
|
-
try {
|
|
317
|
-
const b = state.doc!.createElement('b')
|
|
318
|
-
|
|
319
|
-
try {
|
|
320
|
-
range.surroundContents(b)
|
|
321
|
-
} catch {
|
|
322
|
-
const fragment = range.extractContents()
|
|
323
|
-
b.appendChild(fragment)
|
|
324
|
-
range.insertNode(b)
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
range.selectNodeContents(b)
|
|
328
|
-
selection.removeAllRanges()
|
|
329
|
-
selection.addRange(range)
|
|
330
|
-
} catch (error) {
|
|
331
|
-
console.error('Error applying bold:', error)
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
}, (state) => {
|
|
335
|
-
// isActive function for bold
|
|
336
|
-
const selectionInfo = getCurrentSelection(state)
|
|
337
|
-
if (!selectionInfo) { return false }
|
|
338
|
-
|
|
339
|
-
const commonAncestor = selectionInfo.range.commonAncestorContainer
|
|
340
|
-
const parentElement = commonAncestor.nodeType === Node.TEXT_NODE
|
|
341
|
-
? commonAncestor.parentElement
|
|
342
|
-
: commonAncestor as Element
|
|
343
|
-
|
|
344
|
-
return !!parentElement?.closest('b, strong')
|
|
345
|
-
}),
|
|
346
|
-
|
|
347
|
-
italic: createCommand('Italic', (state) => {
|
|
348
|
-
const selectionInfo = getCurrentSelection(state)
|
|
349
|
-
if (!selectionInfo) { return }
|
|
350
|
-
|
|
351
|
-
const { range, selection } = selectionInfo
|
|
352
|
-
|
|
353
|
-
// If no text is selected, try to apply formatting to word at cursor
|
|
354
|
-
if (range.collapsed) {
|
|
355
|
-
const success = applyFormattingToWordAtCursor(state.doc!, range, 'i')
|
|
356
|
-
if (success) { return }
|
|
357
|
-
return
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Check if the selection is already italic
|
|
361
|
-
const commonAncestor = range.commonAncestorContainer
|
|
362
|
-
const parentElement = commonAncestor.nodeType === Node.TEXT_NODE
|
|
363
|
-
? commonAncestor.parentElement
|
|
364
|
-
: commonAncestor as Element
|
|
365
|
-
|
|
366
|
-
const italicElement = parentElement?.closest('i, em')
|
|
367
|
-
|
|
368
|
-
if (italicElement) {
|
|
369
|
-
// Remove italic formatting
|
|
370
|
-
try {
|
|
371
|
-
const parent = italicElement.parentNode
|
|
372
|
-
while (italicElement.firstChild) {
|
|
373
|
-
parent?.insertBefore(italicElement.firstChild, italicElement)
|
|
374
|
-
}
|
|
375
|
-
parent?.removeChild(italicElement)
|
|
376
|
-
|
|
377
|
-
// Restore selection
|
|
378
|
-
selection.removeAllRanges()
|
|
379
|
-
selection.addRange(range)
|
|
380
|
-
} catch (error) {
|
|
381
|
-
console.error('Error removing italic:', error)
|
|
382
|
-
}
|
|
383
|
-
} else {
|
|
384
|
-
// Apply italic formatting
|
|
385
|
-
try {
|
|
386
|
-
const i = state.doc!.createElement('i')
|
|
387
|
-
|
|
388
|
-
try {
|
|
389
|
-
range.surroundContents(i)
|
|
390
|
-
} catch {
|
|
391
|
-
const fragment = range.extractContents()
|
|
392
|
-
i.appendChild(fragment)
|
|
393
|
-
range.insertNode(i)
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
range.selectNodeContents(i)
|
|
397
|
-
selection.removeAllRanges()
|
|
398
|
-
selection.addRange(range)
|
|
399
|
-
} catch (error) {
|
|
400
|
-
console.error('Error applying italic:', error)
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
}, (state) => {
|
|
404
|
-
// isActive function for italic
|
|
405
|
-
const selectionInfo = getCurrentSelection(state)
|
|
406
|
-
if (!selectionInfo) { return false }
|
|
407
|
-
|
|
408
|
-
const commonAncestor = selectionInfo.range.commonAncestorContainer
|
|
409
|
-
const parentElement = commonAncestor.nodeType === Node.TEXT_NODE
|
|
410
|
-
? commonAncestor.parentElement
|
|
411
|
-
: commonAncestor as Element
|
|
412
|
-
|
|
413
|
-
return !!parentElement?.closest('i, em')
|
|
414
|
-
}),
|
|
415
|
-
|
|
416
|
-
underline: createCommand('Underline', (state) => {
|
|
417
|
-
const selectionInfo = getCurrentSelection(state)
|
|
418
|
-
if (!selectionInfo) { return }
|
|
419
|
-
|
|
420
|
-
const { range, selection } = selectionInfo
|
|
421
|
-
|
|
422
|
-
// If no text is selected, try to apply formatting to word at cursor
|
|
423
|
-
if (range.collapsed) {
|
|
424
|
-
const success = applyFormattingToWordAtCursor(state.doc!, range, 'u')
|
|
425
|
-
if (success) { return }
|
|
426
|
-
return
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// Check if the selection is already underlined
|
|
430
|
-
const commonAncestor = range.commonAncestorContainer
|
|
431
|
-
const parentElement = commonAncestor.nodeType === Node.TEXT_NODE
|
|
432
|
-
? commonAncestor.parentElement
|
|
433
|
-
: commonAncestor as Element
|
|
434
|
-
|
|
435
|
-
const underlineElement = parentElement?.closest('u')
|
|
436
|
-
|
|
437
|
-
if (underlineElement) {
|
|
438
|
-
// Remove underline formatting
|
|
439
|
-
try {
|
|
440
|
-
const parent = underlineElement.parentNode
|
|
441
|
-
while (underlineElement.firstChild) {
|
|
442
|
-
parent?.insertBefore(underlineElement.firstChild, underlineElement)
|
|
443
|
-
}
|
|
444
|
-
parent?.removeChild(underlineElement)
|
|
445
|
-
|
|
446
|
-
// Restore selection
|
|
447
|
-
selection.removeAllRanges()
|
|
448
|
-
selection.addRange(range)
|
|
449
|
-
} catch (error) {
|
|
450
|
-
console.error('Error removing underline:', error)
|
|
451
|
-
}
|
|
452
|
-
} else {
|
|
453
|
-
// Apply underline formatting
|
|
454
|
-
try {
|
|
455
|
-
const u = state.doc!.createElement('u')
|
|
456
|
-
|
|
457
|
-
try {
|
|
458
|
-
range.surroundContents(u)
|
|
459
|
-
} catch {
|
|
460
|
-
const fragment = range.extractContents()
|
|
461
|
-
u.appendChild(fragment)
|
|
462
|
-
range.insertNode(u)
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
range.selectNodeContents(u)
|
|
466
|
-
selection.removeAllRanges()
|
|
467
|
-
selection.addRange(range)
|
|
468
|
-
} catch (error) {
|
|
469
|
-
console.error('Error applying underline:', error)
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
}, (state) => {
|
|
473
|
-
// isActive function for underline
|
|
474
|
-
const selectionInfo = getCurrentSelection(state)
|
|
475
|
-
if (!selectionInfo) { return false }
|
|
476
|
-
|
|
477
|
-
const commonAncestor = selectionInfo.range.commonAncestorContainer
|
|
478
|
-
const parentElement = commonAncestor.nodeType === Node.TEXT_NODE
|
|
479
|
-
? commonAncestor.parentElement
|
|
480
|
-
: commonAncestor as Element
|
|
481
|
-
|
|
482
|
-
return !!parentElement?.closest('u')
|
|
483
|
-
}),
|
|
484
|
-
|
|
485
|
-
link: createCommand('Link', (state) => {
|
|
486
|
-
const { openLinkModal } = state as any
|
|
533
|
+
bold: createCommand(
|
|
534
|
+
'Bold',
|
|
535
|
+
s => toggleInlineFormatting(s, 'b', 'b, strong'),
|
|
536
|
+
s => Boolean(s.doc && isStyleActive('bold', s.doc))
|
|
537
|
+
),
|
|
538
|
+
italic: createCommand(
|
|
539
|
+
'Italic',
|
|
540
|
+
s => toggleInlineFormatting(s, 'i', 'i, em'),
|
|
541
|
+
s => Boolean(s.doc && isStyleActive('italic', s.doc))
|
|
542
|
+
),
|
|
543
|
+
underline: createCommand(
|
|
544
|
+
'Underline',
|
|
545
|
+
s => toggleInlineFormatting(s, 'u', 'u'),
|
|
546
|
+
s => Boolean(s.doc && isStyleActive('underline', s.doc))
|
|
547
|
+
),
|
|
548
|
+
link: createCommand('Link', (s) => {
|
|
549
|
+
const { openLinkModal } = s as any
|
|
487
550
|
if (openLinkModal) {
|
|
488
|
-
insertLink(openLinkModal,
|
|
551
|
+
insertLink(openLinkModal, s)
|
|
489
552
|
}
|
|
490
|
-
}, (
|
|
491
|
-
// isActive function for link
|
|
492
|
-
const selectionInfo = getCurrentSelection(state)
|
|
493
|
-
if (!selectionInfo) { return false }
|
|
494
|
-
|
|
495
|
-
const commonAncestor = selectionInfo.range.commonAncestorContainer
|
|
496
|
-
const parentElement = commonAncestor.nodeType === Node.TEXT_NODE
|
|
497
|
-
? commonAncestor.parentElement
|
|
498
|
-
: commonAncestor as Element
|
|
499
|
-
|
|
500
|
-
return !!parentElement?.closest('a')
|
|
501
|
-
})
|
|
553
|
+
}, s => Boolean(s.doc && isStyleActive('link', s.doc)), { skipHistory: true })
|
|
502
554
|
}
|
|
503
555
|
|
|
504
|
-
|
|
505
|
-
const headingCommands = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].reduce((acc, cmd) => ({
|
|
556
|
+
const headingCommands = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].reduce<CommandRegistry>((acc, cmd) => ({
|
|
506
557
|
...acc,
|
|
507
|
-
[cmd]: createCommand(`Heading ${cmd}`, (
|
|
508
|
-
const selectionInfo = getCurrentSelection(
|
|
509
|
-
if (!selectionInfo) { return }
|
|
558
|
+
[cmd]: createCommand(`Heading ${cmd}`, (s) => {
|
|
559
|
+
const selectionInfo = getCurrentSelection(s)
|
|
560
|
+
if (!selectionInfo || !s.doc) { return }
|
|
510
561
|
|
|
511
562
|
const { range, selection } = selectionInfo
|
|
512
|
-
const
|
|
513
|
-
if (!
|
|
514
|
-
|
|
515
|
-
// Store the original selection information before modification
|
|
516
|
-
const { startContainer } = range
|
|
517
|
-
const { endContainer } = range
|
|
518
|
-
const { startOffset } = range
|
|
519
|
-
const { endOffset } = range
|
|
520
|
-
const isCollapsed = range.collapsed
|
|
521
|
-
|
|
522
|
-
// Check if we need to toggle off (already in the same heading)
|
|
523
|
-
const isToggleOff = currentBlock.tagName.toLowerCase() === cmd.toLowerCase()
|
|
524
|
-
const newTag = isToggleOff ? 'p' : cmd
|
|
525
|
-
|
|
526
|
-
try {
|
|
527
|
-
const newBlock = state.doc!.createElement(newTag)
|
|
528
|
-
|
|
529
|
-
// Copy content from current block to new block
|
|
530
|
-
while (currentBlock.firstChild) {
|
|
531
|
-
newBlock.appendChild(currentBlock.firstChild)
|
|
532
|
-
}
|
|
563
|
+
const blocks = getSelectedBlocks(s.doc, range)
|
|
564
|
+
if (!blocks.length) { return }
|
|
533
565
|
|
|
534
|
-
|
|
535
|
-
|
|
566
|
+
const allMatch = blocks.every(b => b.tagName.toLowerCase() === cmd.toLowerCase())
|
|
567
|
+
const newTag = allMatch ? 'p' : cmd
|
|
536
568
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
const newRange = state.doc!.createRange()
|
|
540
|
-
|
|
541
|
-
// Find corresponding nodes in the new block
|
|
542
|
-
const findCorrespondingNode = (originalNode: Node, originalBlock: Element, newBlock: Element): Node | null => {
|
|
543
|
-
if (originalNode === originalBlock) { return newBlock }
|
|
544
|
-
|
|
545
|
-
// If it's a text node, find it by traversing the tree
|
|
546
|
-
if (originalNode.nodeType === Node.TEXT_NODE) {
|
|
547
|
-
const walker = state.doc!.createTreeWalker(
|
|
548
|
-
newBlock,
|
|
549
|
-
NodeFilter.SHOW_TEXT,
|
|
550
|
-
null
|
|
551
|
-
)
|
|
552
|
-
|
|
553
|
-
let textNodeIndex = 0
|
|
554
|
-
const originalWalker = state.doc!.createTreeWalker(
|
|
555
|
-
originalBlock,
|
|
556
|
-
NodeFilter.SHOW_TEXT,
|
|
557
|
-
null
|
|
558
|
-
)
|
|
559
|
-
|
|
560
|
-
let currentOriginalNode = originalWalker.nextNode()
|
|
561
|
-
while (currentOriginalNode && currentOriginalNode !== originalNode) {
|
|
562
|
-
textNodeIndex++
|
|
563
|
-
currentOriginalNode = originalWalker.nextNode()
|
|
564
|
-
}
|
|
569
|
+
const converted = blocks.map(block => convertBlockTag(s.doc!, block, newTag))
|
|
570
|
+
const lastConverted = converted[converted.length - 1]
|
|
565
571
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
return currentNewNode
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
return newBlock.firstChild
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
const newStartContainer = findCorrespondingNode(startContainer, currentBlock, newBlock)
|
|
580
|
-
const newEndContainer = findCorrespondingNode(endContainer, currentBlock, newBlock)
|
|
581
|
-
|
|
582
|
-
if (newStartContainer && newEndContainer) {
|
|
583
|
-
// Set start position
|
|
584
|
-
if (newStartContainer.nodeType === Node.TEXT_NODE) {
|
|
585
|
-
const maxOffset = Math.min(startOffset, newStartContainer.textContent?.length || 0)
|
|
586
|
-
newRange.setStart(newStartContainer, maxOffset)
|
|
587
|
-
} else {
|
|
588
|
-
newRange.setStart(newStartContainer, 0)
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// Set end position
|
|
592
|
-
if (!isCollapsed) {
|
|
593
|
-
if (newEndContainer.nodeType === Node.TEXT_NODE) {
|
|
594
|
-
const maxOffset = Math.min(endOffset, newEndContainer.textContent?.length || 0)
|
|
595
|
-
newRange.setEnd(newEndContainer, maxOffset)
|
|
596
|
-
} else {
|
|
597
|
-
newRange.setEnd(newEndContainer, 0)
|
|
598
|
-
}
|
|
599
|
-
} else {
|
|
600
|
-
newRange.collapse(true)
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
// Apply the restored selection
|
|
604
|
-
selection.removeAllRanges()
|
|
605
|
-
selection.addRange(newRange)
|
|
606
|
-
} else {
|
|
607
|
-
// Fallback: select all content in the new block
|
|
608
|
-
newRange.selectNodeContents(newBlock)
|
|
609
|
-
if (isCollapsed) {
|
|
610
|
-
newRange.collapse(true)
|
|
611
|
-
}
|
|
612
|
-
selection.removeAllRanges()
|
|
613
|
-
selection.addRange(newRange)
|
|
614
|
-
}
|
|
615
|
-
} catch (selectionError) {
|
|
616
|
-
console.warn('Error restoring selection after heading change:', selectionError)
|
|
617
|
-
// Fallback: place cursor at start of the new block
|
|
618
|
-
const fallbackRange = state.doc!.createRange()
|
|
619
|
-
fallbackRange.selectNodeContents(newBlock)
|
|
620
|
-
fallbackRange.collapse(true)
|
|
621
|
-
selection.removeAllRanges()
|
|
622
|
-
selection.addRange(fallbackRange)
|
|
623
|
-
}
|
|
572
|
+
if (!allMatch && lastConverted && !lastConverted.nextElementSibling) {
|
|
573
|
+
const p = s.doc.createElement('p')
|
|
574
|
+
p.dir = lastConverted.dir || s.doc.body.dir || 'ltr'
|
|
575
|
+
p.innerHTML = '<br>'
|
|
576
|
+
lastConverted.parentNode?.insertBefore(p, lastConverted.nextSibling)
|
|
577
|
+
}
|
|
624
578
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
p.dir = newBlock.dir || state.doc!.body.dir
|
|
632
|
-
newBlock.parentNode?.insertBefore(p, newBlock.nextSibling)
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
} catch (error) {
|
|
636
|
-
console.error(`Error applying ${cmd} heading:`, error)
|
|
579
|
+
if (lastConverted) {
|
|
580
|
+
const nextRange = s.doc.createRange()
|
|
581
|
+
nextRange.selectNodeContents(lastConverted)
|
|
582
|
+
nextRange.collapse(false)
|
|
583
|
+
selection.removeAllRanges()
|
|
584
|
+
selection.addRange(nextRange)
|
|
637
585
|
}
|
|
638
|
-
})
|
|
586
|
+
}, s => Boolean(s.doc && isStyleActive(cmd, s.doc)))
|
|
639
587
|
}), {})
|
|
640
588
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
if (!selectionInfo) { return }
|
|
646
|
-
|
|
647
|
-
const { range } = selectionInfo
|
|
648
|
-
const currentBlock = findBlockElement(range.commonAncestorContainer)
|
|
649
|
-
if (!currentBlock) { return }
|
|
589
|
+
const blockCommands: CommandRegistry = {
|
|
590
|
+
p: createCommand('Paragraph', (s) => {
|
|
591
|
+
const selectionInfo = getCurrentSelection(s)
|
|
592
|
+
if (!selectionInfo || !s.doc) { return }
|
|
650
593
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
594
|
+
const { range, selection } = selectionInfo
|
|
595
|
+
const blocks = getSelectedBlocks(s.doc, range)
|
|
596
|
+
if (!blocks.length) { return }
|
|
597
|
+
|
|
598
|
+
const converted = blocks.map(block =>
|
|
599
|
+
block.tagName.toLowerCase() === 'p' ? block : convertBlockTag(s.doc!, block, 'p')
|
|
600
|
+
)
|
|
601
|
+
const lastBlock = converted[converted.length - 1]
|
|
602
|
+
if (lastBlock) {
|
|
603
|
+
const nextRange = s.doc.createRange()
|
|
604
|
+
nextRange.selectNodeContents(lastBlock)
|
|
605
|
+
nextRange.collapse(false)
|
|
606
|
+
selection.removeAllRanges()
|
|
607
|
+
selection.addRange(nextRange)
|
|
654
608
|
}
|
|
609
|
+
}, s => Boolean(s.doc && isStyleActive('p', s.doc))),
|
|
655
610
|
|
|
656
|
-
|
|
657
|
-
|
|
611
|
+
blockquote: createCommand('Blockquote', (s) => {
|
|
612
|
+
const selectionInfo = getCurrentSelection(s)
|
|
613
|
+
if (!selectionInfo || !s.doc) { return }
|
|
658
614
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
615
|
+
const { range, selection } = selectionInfo
|
|
616
|
+
const blocks = getSelectedBlocks(s.doc, range)
|
|
617
|
+
if (!blocks.length) { return }
|
|
618
|
+
|
|
619
|
+
const allBlockquotes = blocks.every(b => b.tagName.toLowerCase() === 'blockquote')
|
|
620
|
+
const newTag = allBlockquotes ? 'p' : 'blockquote'
|
|
621
|
+
|
|
622
|
+
const converted = blocks.map(block => convertBlockTag(s.doc!, block, newTag))
|
|
623
|
+
const lastBlock = converted[converted.length - 1]
|
|
624
|
+
if (lastBlock) {
|
|
625
|
+
const nextRange = s.doc.createRange()
|
|
626
|
+
nextRange.selectNodeContents(lastBlock)
|
|
627
|
+
nextRange.collapse(false)
|
|
628
|
+
selection.removeAllRanges()
|
|
629
|
+
selection.addRange(nextRange)
|
|
674
630
|
}
|
|
675
|
-
}),
|
|
631
|
+
}, s => Boolean(s.doc && isStyleActive('blockquote', s.doc))),
|
|
676
632
|
|
|
677
|
-
|
|
678
|
-
const selectionInfo = getCurrentSelection(
|
|
633
|
+
indent: createCommand('Indent', (s) => {
|
|
634
|
+
const selectionInfo = getCurrentSelection(s)
|
|
679
635
|
if (!selectionInfo) { return }
|
|
636
|
+
const block = findBlockElement(selectionInfo.range.commonAncestorContainer)
|
|
637
|
+
if (!block) { return }
|
|
638
|
+
const isRTL = (block.dir || s.doc?.body.dir) === 'rtl'
|
|
639
|
+
const prop = isRTL ? 'marginRight' : 'marginLeft'
|
|
640
|
+
const current = Number.parseInt(block.style[prop] || '0', 10) || 0
|
|
641
|
+
block.style[prop] = `${current + 24}px`
|
|
642
|
+
}),
|
|
680
643
|
|
|
681
|
-
|
|
682
|
-
const
|
|
683
|
-
if (!
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
const
|
|
687
|
-
const
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
// Copy content from current block to new block
|
|
693
|
-
while (currentBlock.firstChild) {
|
|
694
|
-
newBlock.appendChild(currentBlock.firstChild)
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
// Replace the old block with the new one
|
|
698
|
-
currentBlock.parentNode?.replaceChild(newBlock, currentBlock)
|
|
699
|
-
|
|
700
|
-
// Move cursor into the new block
|
|
701
|
-
range.selectNodeContents(newBlock)
|
|
702
|
-
range.collapse(false)
|
|
703
|
-
selectionInfo.selection.removeAllRanges()
|
|
704
|
-
selectionInfo.selection.addRange(range)
|
|
705
|
-
} catch (error) {
|
|
706
|
-
console.error('Error applying blockquote:', error)
|
|
707
|
-
}
|
|
644
|
+
outdent: createCommand('Outdent', (s) => {
|
|
645
|
+
const selectionInfo = getCurrentSelection(s)
|
|
646
|
+
if (!selectionInfo) { return }
|
|
647
|
+
const block = findBlockElement(selectionInfo.range.commonAncestorContainer)
|
|
648
|
+
if (!block) { return }
|
|
649
|
+
const isRTL = (block.dir || s.doc?.body.dir) === 'rtl'
|
|
650
|
+
const prop = isRTL ? 'marginRight' : 'marginLeft'
|
|
651
|
+
const current = Number.parseInt(block.style[prop] || '0', 10) || 0
|
|
652
|
+
const next = Math.max(0, current - 24)
|
|
653
|
+
block.style[prop] = next > 0 ? `${next}px` : ''
|
|
708
654
|
})
|
|
709
655
|
}
|
|
710
656
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
const listItem = currentBlock.closest('li')
|
|
724
|
-
if (listItem) {
|
|
725
|
-
const listParent = listItem.parentElement as HTMLElement
|
|
726
|
-
if (!listParent) { return }
|
|
727
|
-
|
|
728
|
-
// If it's already an ordered list, toggle off to paragraph
|
|
729
|
-
if (listParent.tagName.toLowerCase() === 'ol') {
|
|
730
|
-
const p = state.doc!.createElement('p')
|
|
731
|
-
while (listItem.firstChild) {
|
|
732
|
-
p.appendChild(listItem.firstChild)
|
|
733
|
-
}
|
|
734
|
-
listParent.parentNode?.insertBefore(p, listParent)
|
|
735
|
-
listItem.remove()
|
|
736
|
-
|
|
737
|
-
if (!listParent.querySelector('li')) {
|
|
738
|
-
listParent.remove()
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
range.selectNodeContents(p)
|
|
742
|
-
range.collapse(false)
|
|
743
|
-
selectionInfo.selection.removeAllRanges()
|
|
744
|
-
selectionInfo.selection.addRange(range)
|
|
745
|
-
return
|
|
746
|
-
}
|
|
657
|
+
const listCommands: CommandRegistry = {
|
|
658
|
+
orderedList: createCommand(
|
|
659
|
+
'Ordered List',
|
|
660
|
+
s => toggleListCommand(s, 'ol'),
|
|
661
|
+
s => Boolean(s.doc && isStyleActive('orderedList', s.doc))
|
|
662
|
+
),
|
|
663
|
+
unorderedList: createCommand(
|
|
664
|
+
'Unordered List',
|
|
665
|
+
s => toggleListCommand(s, 'ul'),
|
|
666
|
+
s => Boolean(s.doc && isStyleActive('unorderedList', s.doc))
|
|
667
|
+
)
|
|
668
|
+
}
|
|
747
669
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
670
|
+
const formatCommands: CommandRegistry = {
|
|
671
|
+
clear: createCommand('Clear Formatting', (s) => {
|
|
672
|
+
const selectionInfo = getCurrentSelection(s)
|
|
673
|
+
if (!selectionInfo || !s.doc) { return }
|
|
751
674
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
const currentContainer = range.startContainer
|
|
675
|
+
const { doc } = s
|
|
676
|
+
const { range, selection } = selectionInfo
|
|
755
677
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
// Fallback: put cursor at beginning of first list item
|
|
770
|
-
const firstLi = ol.querySelector('li')
|
|
771
|
-
if (firstLi) {
|
|
772
|
-
const newRange = state.doc!.createRange()
|
|
773
|
-
newRange.selectNodeContents(firstLi)
|
|
774
|
-
newRange.collapse(true)
|
|
775
|
-
selectionInfo.selection.removeAllRanges()
|
|
776
|
-
selectionInfo.selection.addRange(newRange)
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
return
|
|
780
|
-
}
|
|
678
|
+
const formattingTags = new Set([
|
|
679
|
+
'b', 'i', 'u', 'strong', 'em', 'span', 'font', 'strike', 's', 'sub', 'sup', 'mark', 'del', 'ins', 'small', 'big'
|
|
680
|
+
])
|
|
681
|
+
const structuralTags = new Set([
|
|
682
|
+
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'div',
|
|
683
|
+
'table', 'tr', 'td', 'th', 'thead', 'tbody', 'colgroup', 'col',
|
|
684
|
+
'a', 'img', 'iframe', 'video', 'audio', 'figure', 'figcaption', 'br'
|
|
685
|
+
])
|
|
686
|
+
|
|
687
|
+
function cleanText(text: string): string {
|
|
688
|
+
return text
|
|
689
|
+
.replace(/[\u200B-\u200D\uFEFF]/g, '')
|
|
690
|
+
.replace(/\u00A0/g, ' ')
|
|
781
691
|
}
|
|
782
692
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
const li = state.doc!.createElement('li')
|
|
787
|
-
|
|
788
|
-
// Copy content to list item
|
|
789
|
-
while (currentBlock.firstChild) {
|
|
790
|
-
li.appendChild(currentBlock.firstChild)
|
|
693
|
+
function cleanNode(node: Node): Node {
|
|
694
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
695
|
+
return doc!.createTextNode(cleanText(node.textContent || ''))
|
|
791
696
|
}
|
|
792
697
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
// Move cursor into the list item
|
|
797
|
-
range.selectNodeContents(li)
|
|
798
|
-
range.collapse(false)
|
|
799
|
-
selectionInfo.selection.removeAllRanges()
|
|
800
|
-
selectionInfo.selection.addRange(range)
|
|
801
|
-
} catch (error) {
|
|
802
|
-
console.error('Error creating ordered list:', error)
|
|
803
|
-
}
|
|
804
|
-
}),
|
|
805
|
-
|
|
806
|
-
unorderedList: createCommand('Unordered List', (state) => {
|
|
807
|
-
const selectionInfo = getCurrentSelection(state)
|
|
808
|
-
if (!selectionInfo) { return }
|
|
809
|
-
|
|
810
|
-
const { range } = selectionInfo
|
|
811
|
-
if (convertSelectedBlocksToList(state, selectionInfo.selection, range, 'ul')) { return }
|
|
812
|
-
const currentBlock = findBlockElement(range.commonAncestorContainer)
|
|
813
|
-
if (!currentBlock) { return }
|
|
814
|
-
|
|
815
|
-
// Check if we're inside a list item
|
|
816
|
-
const listItem = currentBlock.closest('li')
|
|
817
|
-
if (listItem) {
|
|
818
|
-
const listParent = listItem.parentElement as HTMLElement
|
|
819
|
-
if (!listParent) { return }
|
|
820
|
-
|
|
821
|
-
// If it's already an unordered list, toggle off to paragraph
|
|
822
|
-
if (listParent.tagName.toLowerCase() === 'ul') {
|
|
823
|
-
const p = state.doc!.createElement('p')
|
|
824
|
-
while (listItem.firstChild) {
|
|
825
|
-
p.appendChild(listItem.firstChild)
|
|
826
|
-
}
|
|
827
|
-
listParent.parentNode?.insertBefore(p, listParent)
|
|
828
|
-
listItem.remove()
|
|
698
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
699
|
+
const element = node as HTMLElement
|
|
700
|
+
const tagName = element.tagName.toLowerCase()
|
|
829
701
|
|
|
830
|
-
if (
|
|
831
|
-
|
|
702
|
+
if (formattingTags.has(tagName)) {
|
|
703
|
+
const frag = doc!.createDocumentFragment()
|
|
704
|
+
Array.from(element.childNodes).forEach((child) => {
|
|
705
|
+
frag.appendChild(cleanNode(child))
|
|
706
|
+
})
|
|
707
|
+
return frag
|
|
832
708
|
}
|
|
833
709
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
// Convert ordered list to unordered list
|
|
842
|
-
if (listParent.tagName.toLowerCase() === 'ol') {
|
|
843
|
-
const ul = state.doc!.createElement('ul')
|
|
844
|
-
|
|
845
|
-
// Store current cursor position before conversion
|
|
846
|
-
const currentOffset = range.startOffset
|
|
847
|
-
const currentContainer = range.startContainer
|
|
848
|
-
|
|
849
|
-
while (listParent.firstChild) {
|
|
850
|
-
ul.appendChild(listParent.firstChild)
|
|
851
|
-
}
|
|
852
|
-
listParent.parentNode?.replaceChild(ul, listParent)
|
|
853
|
-
|
|
854
|
-
// Restore cursor position
|
|
855
|
-
try {
|
|
856
|
-
const newRange = state.doc!.createRange()
|
|
857
|
-
newRange.setStart(currentContainer, currentOffset)
|
|
858
|
-
newRange.collapse(true)
|
|
859
|
-
selectionInfo.selection.removeAllRanges()
|
|
860
|
-
selectionInfo.selection.addRange(newRange)
|
|
861
|
-
} catch (e) {
|
|
862
|
-
// Fallback: put cursor at beginning of first list item
|
|
863
|
-
console.warn('Could not restore cursor position:', e)
|
|
864
|
-
const firstLi = ul.querySelector('li')
|
|
865
|
-
if (firstLi) {
|
|
866
|
-
const newRange = state.doc!.createRange()
|
|
867
|
-
newRange.selectNodeContents(firstLi)
|
|
868
|
-
newRange.collapse(true)
|
|
869
|
-
selectionInfo.selection.removeAllRanges()
|
|
870
|
-
selectionInfo.selection.addRange(newRange)
|
|
710
|
+
if (structuralTags.has(tagName)) {
|
|
711
|
+
// Convert headings/blockquotes inside cleared region to plain <p>
|
|
712
|
+
const targetTag = /^h[1-6]|blockquote$/.test(tagName) ? 'p' : tagName
|
|
713
|
+
const cleanElement = doc!.createElement(targetTag)
|
|
714
|
+
if (element.dir) {
|
|
715
|
+
cleanElement.dir = element.dir
|
|
871
716
|
}
|
|
872
|
-
}
|
|
873
|
-
return
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
// Convert current block to unordered list
|
|
878
|
-
try {
|
|
879
|
-
const ul = state.doc!.createElement('ul')
|
|
880
|
-
const li = state.doc!.createElement('li')
|
|
881
|
-
|
|
882
|
-
// Copy content to list item
|
|
883
|
-
while (currentBlock.firstChild) {
|
|
884
|
-
li.appendChild(currentBlock.firstChild)
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
ul.appendChild(li)
|
|
888
|
-
currentBlock.parentNode?.replaceChild(ul, currentBlock)
|
|
889
|
-
|
|
890
|
-
// Move cursor into the list item
|
|
891
|
-
range.selectNodeContents(li)
|
|
892
|
-
range.collapse(false)
|
|
893
|
-
selectionInfo.selection.removeAllRanges()
|
|
894
|
-
selectionInfo.selection.addRange(range)
|
|
895
|
-
} catch (error) {
|
|
896
|
-
console.error('Error creating unordered list:', error)
|
|
897
|
-
}
|
|
898
|
-
})
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
// Clear formatting command - enhanced
|
|
902
|
-
const formatCommands = {
|
|
903
|
-
clear: createCommand('Clear Formatting', (state) => {
|
|
904
|
-
const selectionInfo = getCurrentSelection(state)
|
|
905
|
-
if (!selectionInfo) { return }
|
|
906
|
-
|
|
907
|
-
const { range, selection } = selectionInfo
|
|
908
|
-
|
|
909
|
-
try {
|
|
910
|
-
// Function to clean text from unwanted characters
|
|
911
|
-
function cleanText(text: string): string {
|
|
912
|
-
return text
|
|
913
|
-
// Remove zero-width characters
|
|
914
|
-
.replace(/[\u200B-\u200D\uFEFF]/g, '')
|
|
915
|
-
// Remove extra whitespace
|
|
916
|
-
.replace(/\s+/g, ' ')
|
|
917
|
-
// Remove invisible characters
|
|
918
|
-
.replace(/\u00A0/g, ' ') // non-breaking space to regular space
|
|
919
|
-
.trim()
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
// Function to clean a node recursively
|
|
923
|
-
function cleanNode(node: Node): Node {
|
|
924
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
925
|
-
const cleanedText = cleanText(node.textContent || '')
|
|
926
|
-
return state.doc!.createTextNode(cleanedText)
|
|
927
|
-
}
|
|
928
717
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
return state.doc!.createTextNode(cleanedText)
|
|
942
|
-
} if (structuralTags.includes(tagName)) {
|
|
943
|
-
// For structural elements, keep the element but remove all styling attributes
|
|
944
|
-
const cleanElement = state.doc!.createElement(tagName)
|
|
945
|
-
|
|
946
|
-
// Only preserve essential attributes
|
|
947
|
-
if (tagName === 'a' && element.hasAttribute('href')) {
|
|
948
|
-
cleanElement.setAttribute('href', element.getAttribute('href') || '')
|
|
949
|
-
if (element.hasAttribute('target')) {
|
|
950
|
-
cleanElement.setAttribute('target', element.getAttribute('target') || '')
|
|
718
|
+
if (tagName === 'a' && element.hasAttribute('href')) {
|
|
719
|
+
cleanElement.setAttribute('href', element.getAttribute('href') || '')
|
|
720
|
+
if (element.hasAttribute('target')) {
|
|
721
|
+
cleanElement.setAttribute('target', element.getAttribute('target') || '')
|
|
722
|
+
}
|
|
723
|
+
if (element.hasAttribute('rel')) {
|
|
724
|
+
cleanElement.setAttribute('rel', element.getAttribute('rel') || '')
|
|
725
|
+
}
|
|
726
|
+
} else if (tagName === 'img') {
|
|
727
|
+
for (const attr of ['src', 'alt', 'width', 'height']) {
|
|
728
|
+
if (element.hasAttribute(attr)) {
|
|
729
|
+
cleanElement.setAttribute(attr, element.getAttribute(attr) || '')
|
|
951
730
|
}
|
|
952
|
-
|
|
953
|
-
|
|
731
|
+
}
|
|
732
|
+
} else if (tagName === 'iframe') {
|
|
733
|
+
for (const attr of ['src', 'width', 'height', 'frameborder', 'allowfullscreen']) {
|
|
734
|
+
if (element.hasAttribute(attr)) {
|
|
735
|
+
cleanElement.setAttribute(attr, element.getAttribute(attr) || '')
|
|
954
736
|
}
|
|
955
|
-
} else if (tagName === 'img') {
|
|
956
|
-
if (element.hasAttribute('src')) cleanElement.setAttribute('src', element.getAttribute('src') || '')
|
|
957
|
-
if (element.hasAttribute('alt')) cleanElement.setAttribute('alt', element.getAttribute('alt') || '')
|
|
958
|
-
if (element.hasAttribute('width')) cleanElement.setAttribute('width', element.getAttribute('width') || '')
|
|
959
|
-
if (element.hasAttribute('height')) cleanElement.setAttribute('height', element.getAttribute('height') || '')
|
|
960
|
-
} else if (tagName === 'iframe') {
|
|
961
|
-
if (element.hasAttribute('src')) cleanElement.setAttribute('src', element.getAttribute('src') || '')
|
|
962
|
-
if (element.hasAttribute('width')) cleanElement.setAttribute('width', element.getAttribute('width') || '')
|
|
963
|
-
if (element.hasAttribute('height')) cleanElement.setAttribute('height', element.getAttribute('height') || '')
|
|
964
|
-
if (element.hasAttribute('frameborder')) cleanElement.setAttribute('frameborder', element.getAttribute('frameborder') || '')
|
|
965
|
-
if (element.hasAttribute('allowfullscreen')) cleanElement.setAttribute('allowfullscreen', element.getAttribute('allowfullscreen') || '')
|
|
966
|
-
} else if (tagName === 'video' || tagName === 'audio') {
|
|
967
|
-
if (element.hasAttribute('src')) cleanElement.setAttribute('src', element.getAttribute('src') || '')
|
|
968
|
-
if (element.hasAttribute('controls')) cleanElement.setAttribute('controls', element.getAttribute('controls') || '')
|
|
969
|
-
if (element.hasAttribute('width')) cleanElement.setAttribute('width', element.getAttribute('width') || '')
|
|
970
|
-
if (element.hasAttribute('height')) cleanElement.setAttribute('height', element.getAttribute('height') || '')
|
|
971
737
|
}
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
if (cleanedChild.textContent?.trim() || cleanedChild.nodeType === Node.ELEMENT_NODE) {
|
|
977
|
-
cleanElement.appendChild(cleanedChild)
|
|
738
|
+
} else if (tagName === 'video' || tagName === 'audio') {
|
|
739
|
+
for (const attr of ['src', 'controls', 'width', 'height']) {
|
|
740
|
+
if (element.hasAttribute(attr)) {
|
|
741
|
+
cleanElement.setAttribute(attr, element.getAttribute(attr) || '')
|
|
978
742
|
}
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
return cleanElement
|
|
743
|
+
}
|
|
982
744
|
}
|
|
983
|
-
}
|
|
984
745
|
|
|
985
|
-
|
|
986
|
-
|
|
746
|
+
Array.from(element.childNodes).forEach((child) => {
|
|
747
|
+
cleanElement.appendChild(cleanNode(child))
|
|
748
|
+
})
|
|
987
749
|
|
|
988
|
-
|
|
989
|
-
// If no selection, clean the entire document
|
|
990
|
-
const { body } = (state.doc!)
|
|
991
|
-
const cleanedFragment = state.doc!.createDocumentFragment()
|
|
992
|
-
|
|
993
|
-
Array.from(body.childNodes).forEach((node) => {
|
|
994
|
-
const cleanedNode = cleanNode(node)
|
|
995
|
-
if (cleanedNode.textContent?.trim() || cleanedNode.nodeType === Node.ELEMENT_NODE) {
|
|
996
|
-
cleanedFragment.appendChild(cleanedNode)
|
|
997
|
-
}
|
|
998
|
-
})
|
|
999
|
-
|
|
1000
|
-
// Clear body and append cleaned content
|
|
1001
|
-
body.innerHTML = ''
|
|
1002
|
-
body.appendChild(cleanedFragment)
|
|
1003
|
-
|
|
1004
|
-
// If body is empty, add a paragraph
|
|
1005
|
-
if (!body.firstElementChild) {
|
|
1006
|
-
const p = state.doc!.createElement('p')
|
|
1007
|
-
body.appendChild(p)
|
|
1008
|
-
|
|
1009
|
-
// Set cursor in the paragraph
|
|
1010
|
-
const newRange = state.doc!.createRange()
|
|
1011
|
-
newRange.selectNodeContents(p)
|
|
1012
|
-
newRange.collapse(true)
|
|
1013
|
-
selection.removeAllRanges()
|
|
1014
|
-
selection.addRange(newRange)
|
|
750
|
+
return cleanElement
|
|
1015
751
|
}
|
|
1016
|
-
}
|
|
1017
|
-
// If there's a selection, clean only the selected content
|
|
1018
|
-
const fragment = range.cloneContents()
|
|
1019
|
-
const tempDiv = state.doc!.createElement('div')
|
|
1020
|
-
tempDiv.appendChild(fragment)
|
|
1021
|
-
|
|
1022
|
-
// Clean all nodes in the temp div
|
|
1023
|
-
const cleanedFragment = state.doc!.createDocumentFragment()
|
|
1024
|
-
Array.from(tempDiv.childNodes).forEach((node) => {
|
|
1025
|
-
const cleanedNode = cleanNode(node)
|
|
1026
|
-
if (cleanedNode.textContent?.trim() || cleanedNode.nodeType === Node.ELEMENT_NODE) {
|
|
1027
|
-
cleanedFragment.appendChild(cleanedNode)
|
|
1028
|
-
}
|
|
1029
|
-
})
|
|
752
|
+
}
|
|
1030
753
|
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
range.insertNode(cleanedFragment)
|
|
754
|
+
return doc!.createTextNode('')
|
|
755
|
+
}
|
|
1034
756
|
|
|
1035
|
-
|
|
1036
|
-
|
|
757
|
+
if (range.collapsed) {
|
|
758
|
+
// Clear formatting only on the current block or inline ancestors (never wipe the whole document)
|
|
759
|
+
const currentBlock = findBlockElement(range.commonAncestorContainer)
|
|
760
|
+
if (!currentBlock || currentBlock === doc.body) { return }
|
|
761
|
+
const cleanedBlock = cleanNode(currentBlock) as HTMLElement
|
|
762
|
+
if (cleanedBlock && cleanedBlock.nodeType === Node.ELEMENT_NODE) {
|
|
763
|
+
currentBlock.replaceWith(cleanedBlock)
|
|
764
|
+
const newRange = doc.createRange()
|
|
765
|
+
newRange.selectNodeContents(cleanedBlock)
|
|
766
|
+
newRange.collapse(false)
|
|
1037
767
|
selection.removeAllRanges()
|
|
1038
|
-
selection.addRange(
|
|
768
|
+
selection.addRange(newRange)
|
|
769
|
+
}
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// If selection is inside an inline formatting ancestor, unwrap it first
|
|
774
|
+
const startEl = range.startContainer.nodeType === Node.TEXT_NODE
|
|
775
|
+
? range.startContainer.parentElement
|
|
776
|
+
: range.startContainer as HTMLElement | null
|
|
777
|
+
const inlineAncestor = startEl?.closest('b, i, u, strong, em, span, font, strike, s, sub, sup, mark')
|
|
778
|
+
if (inlineAncestor && inlineAncestor.contains(range.endContainer)) {
|
|
779
|
+
const parent = inlineAncestor.parentNode
|
|
780
|
+
while (inlineAncestor.firstChild) {
|
|
781
|
+
parent?.insertBefore(inlineAncestor.firstChild, inlineAncestor)
|
|
1039
782
|
}
|
|
1040
|
-
|
|
1041
|
-
console.error('Error clearing formatting:', error)
|
|
783
|
+
inlineAncestor.remove()
|
|
1042
784
|
}
|
|
785
|
+
|
|
786
|
+
const fragment = range.extractContents()
|
|
787
|
+
const cleanedFragment = doc.createDocumentFragment()
|
|
788
|
+
Array.from(fragment.childNodes).forEach((node) => {
|
|
789
|
+
cleanedFragment.appendChild(cleanNode(node))
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
range.insertNode(cleanedFragment)
|
|
793
|
+
range.collapse(false)
|
|
794
|
+
selection.removeAllRanges()
|
|
795
|
+
selection.addRange(range)
|
|
1043
796
|
})
|
|
1044
797
|
}
|
|
1045
798
|
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
// If we have an openTableEditor function in state, use it for new tables
|
|
1052
|
-
if ((state as any).openTableEditor && !value) {
|
|
1053
|
-
console.log('Opening table editor modal for new table')
|
|
1054
|
-
; (state as any).openTableEditor(null)
|
|
799
|
+
const tableCommands: CommandRegistry = {
|
|
800
|
+
insertTable: createCommand('Insert Table', (s, value) => {
|
|
801
|
+
if ((s as any).openTableEditor && !value) {
|
|
802
|
+
;(s as any).openTableEditor(null)
|
|
1055
803
|
return
|
|
1056
804
|
}
|
|
1057
805
|
|
|
1058
806
|
if (!value) {
|
|
1059
|
-
|
|
1060
|
-
const [rows, cols] = [3, 3]
|
|
1061
|
-
insertTable(rows, cols, state)
|
|
807
|
+
insertTable(3, 3, s)
|
|
1062
808
|
return
|
|
1063
809
|
}
|
|
1064
810
|
|
|
1065
|
-
// Check if value is HTML string (from our advanced table editor)
|
|
1066
811
|
if (value.includes('<table')) {
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
const selectionInfo = getCurrentSelection(state)
|
|
1070
|
-
if (!selectionInfo || !state.doc) {
|
|
1071
|
-
console.error('No selection info or document')
|
|
1072
|
-
return
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
const { range } = selectionInfo
|
|
812
|
+
const selectionInfo = getCurrentSelection(s)
|
|
813
|
+
if (!s.doc) { return }
|
|
1076
814
|
|
|
1077
815
|
try {
|
|
1078
|
-
|
|
1079
|
-
const
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
816
|
+
const parsedDoc = new DOMParser().parseFromString(value, 'text/html')
|
|
817
|
+
const table = parsedDoc.querySelector('table')
|
|
818
|
+
if (!table) { return }
|
|
819
|
+
|
|
820
|
+
const insertedTable = s.doc.importNode(table, true) as HTMLTableElement
|
|
821
|
+
insertedTable.dir = s.doc.body.dir || 'ltr'
|
|
822
|
+
if (!insertedTable.style.tableLayout) {
|
|
823
|
+
insertedTable.style.tableLayout = 'fixed'
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const activeRange = selectionInfo?.range ?? s.range
|
|
827
|
+
if (activeRange) {
|
|
828
|
+
const hostBlock = (activeRange.startContainer.nodeType === Node.ELEMENT_NODE
|
|
829
|
+
? activeRange.startContainer as Element
|
|
830
|
+
: activeRange.startContainer.parentElement)?.closest('p,h1,h2,h3,h4,h5,h6')
|
|
831
|
+
if (hostBlock && hostBlock.parentElement === s.doc.body && !(hostBlock.textContent || '').trim()) {
|
|
832
|
+
hostBlock.replaceWith(insertedTable)
|
|
833
|
+
} else if (hostBlock && hostBlock.parentElement === s.doc.body) {
|
|
834
|
+
hostBlock.insertAdjacentElement('afterend', insertedTable)
|
|
835
|
+
} else {
|
|
836
|
+
activeRange.insertNode(insertedTable)
|
|
1097
837
|
}
|
|
1098
|
-
console.log('Table inserted successfully')
|
|
1099
838
|
} else {
|
|
1100
|
-
|
|
839
|
+
s.doc.body.appendChild(insertedTable)
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (!insertedTable.nextElementSibling) {
|
|
843
|
+
const trailingP = s.doc.createElement('p')
|
|
844
|
+
trailingP.dir = s.doc.body.dir || 'ltr'
|
|
845
|
+
trailingP.innerHTML = '<br>'
|
|
846
|
+
insertedTable.insertAdjacentElement('afterend', trailingP)
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const addButtons = (s.doc as any).__addEditButtonsToTables as (() => void) | undefined
|
|
850
|
+
addButtons?.()
|
|
851
|
+
|
|
852
|
+
const firstCell = insertedTable.querySelector('tbody td, td, th') as HTMLTableCellElement | null
|
|
853
|
+
if (firstCell) {
|
|
854
|
+
focusTableCell(firstCell, true)
|
|
1101
855
|
}
|
|
1102
856
|
} catch (error) {
|
|
1103
857
|
console.error('Error inserting HTML table:', error)
|
|
1104
858
|
}
|
|
1105
859
|
} else {
|
|
1106
|
-
// Original grid format (e.g., "3x3")
|
|
1107
860
|
const [rows, cols] = value.split('x').map(Number) || [3, 3]
|
|
1108
|
-
insertTable(rows, cols,
|
|
861
|
+
insertTable(rows || 3, cols || 3, s)
|
|
1109
862
|
}
|
|
1110
863
|
}),
|
|
1111
|
-
deleteTable: createCommand('Delete Table',
|
|
1112
|
-
mergeCells: createCommand('Merge Cells',
|
|
1113
|
-
splitCells: createCommand('Split Cells',
|
|
1114
|
-
addRowBefore: createCommand('Add Row Before',
|
|
1115
|
-
addRowAfter: createCommand('Add Row After',
|
|
1116
|
-
deleteRow: createCommand('Delete Row',
|
|
1117
|
-
insertColumnLeft: createCommand('Insert Column Left',
|
|
1118
|
-
insertColumnRight: createCommand('Insert Column Right',
|
|
1119
|
-
deleteColumn: createCommand('Delete Column',
|
|
864
|
+
deleteTable: createCommand('Delete Table', s => deleteTable(s.range)),
|
|
865
|
+
mergeCells: createCommand('Merge Cells', s => s.doc && mergeCells(s.range, s.doc)),
|
|
866
|
+
splitCells: createCommand('Split Cells', s => s.doc && splitCell(s.range, s.doc)),
|
|
867
|
+
addRowBefore: createCommand('Add Row Before', s => s.doc && addRow('before', s.range, s.doc)),
|
|
868
|
+
addRowAfter: createCommand('Add Row After', s => s.doc && addRow('after', s.range, s.doc)),
|
|
869
|
+
deleteRow: createCommand('Delete Row', s => deleteRow(s.range)),
|
|
870
|
+
insertColumnLeft: createCommand('Insert Column Left', s => insertColumn('before', s.range)),
|
|
871
|
+
insertColumnRight: createCommand('Insert Column Right', s => insertColumn('after', s.range)),
|
|
872
|
+
deleteColumn: createCommand('Delete Column', s => deleteColumn(s.range))
|
|
1120
873
|
}
|
|
1121
874
|
|
|
1122
|
-
|
|
1123
|
-
const alignmentCommands = ['Left', 'Center', 'Right', 'Justify'].reduce((acc, align) => ({
|
|
875
|
+
const alignmentCommands = ['Left', 'Center', 'Right', 'Justify'].reduce<CommandRegistry>((acc, align) => ({
|
|
1124
876
|
...acc,
|
|
1125
|
-
[`tableAlign${align}`]: createCommand(`Table Align ${align}`, (
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
alignColumn(state.range, alignment)
|
|
1129
|
-
}
|
|
877
|
+
[`tableAlign${align}`]: createCommand(`Table Align ${align}`, (s) => {
|
|
878
|
+
const alignment = align === 'Left' ? 'start' : align === 'Right' ? 'end' : align.toLowerCase() as 'center' | 'justify'
|
|
879
|
+
alignColumn(s.range, alignment)
|
|
1130
880
|
})
|
|
1131
881
|
}), {})
|
|
1132
882
|
|
|
1133
|
-
|
|
1134
|
-
const textAlignmentCommands = {
|
|
1135
|
-
alignLeft: createCommand('Align Left', (state) => {
|
|
1136
|
-
const selectionInfo = getCurrentSelection(state)
|
|
1137
|
-
if (!selectionInfo) { return }
|
|
1138
|
-
|
|
1139
|
-
const { range } = selectionInfo
|
|
1140
|
-
const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
|
|
1141
|
-
? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
|
|
1142
|
-
: (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
|
|
1143
|
-
|
|
1144
|
-
if (paragraph) {
|
|
1145
|
-
(paragraph as HTMLElement).style.textAlign = 'left'
|
|
1146
|
-
}
|
|
1147
|
-
}, (state) => {
|
|
1148
|
-
const selectionInfo = getCurrentSelection(state)
|
|
1149
|
-
if (!selectionInfo) { return false }
|
|
1150
|
-
|
|
1151
|
-
const { range } = selectionInfo
|
|
1152
|
-
const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
|
|
1153
|
-
? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
|
|
1154
|
-
: (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
|
|
1155
|
-
|
|
1156
|
-
return (paragraph as HTMLElement)?.style.textAlign === 'left'
|
|
1157
|
-
}),
|
|
1158
|
-
|
|
1159
|
-
alignCenter: createCommand('Align Center', (state) => {
|
|
1160
|
-
const selectionInfo = getCurrentSelection(state)
|
|
1161
|
-
if (!selectionInfo) { return }
|
|
1162
|
-
|
|
1163
|
-
const { range } = selectionInfo
|
|
1164
|
-
const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
|
|
1165
|
-
? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
|
|
1166
|
-
: (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
|
|
1167
|
-
|
|
1168
|
-
if (paragraph) {
|
|
1169
|
-
(paragraph as HTMLElement).style.textAlign = 'center'
|
|
1170
|
-
}
|
|
1171
|
-
}, (state) => {
|
|
1172
|
-
const selectionInfo = getCurrentSelection(state)
|
|
1173
|
-
if (!selectionInfo) { return false }
|
|
1174
|
-
|
|
1175
|
-
const { range } = selectionInfo
|
|
1176
|
-
const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
|
|
1177
|
-
? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
|
|
1178
|
-
: (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
|
|
1179
|
-
|
|
1180
|
-
return (paragraph as HTMLElement)?.style.textAlign === 'center'
|
|
1181
|
-
}),
|
|
1182
|
-
|
|
1183
|
-
alignRight: createCommand('Align Right', (state) => {
|
|
1184
|
-
const selectionInfo = getCurrentSelection(state)
|
|
1185
|
-
if (!selectionInfo) { return }
|
|
1186
|
-
|
|
1187
|
-
const { range } = selectionInfo
|
|
1188
|
-
const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
|
|
1189
|
-
? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
|
|
1190
|
-
: (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
|
|
1191
|
-
|
|
1192
|
-
if (paragraph) {
|
|
1193
|
-
(paragraph as HTMLElement).style.textAlign = 'right'
|
|
1194
|
-
}
|
|
1195
|
-
}, (state) => {
|
|
1196
|
-
const selectionInfo = getCurrentSelection(state)
|
|
1197
|
-
if (!selectionInfo) { return false }
|
|
1198
|
-
|
|
1199
|
-
const { range } = selectionInfo
|
|
1200
|
-
const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
|
|
1201
|
-
? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
|
|
1202
|
-
: (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
|
|
1203
|
-
|
|
1204
|
-
return (paragraph as HTMLElement)?.style.textAlign === 'right'
|
|
1205
|
-
}),
|
|
1206
|
-
|
|
1207
|
-
alignJustify: createCommand('Align Justify', (state) => {
|
|
1208
|
-
const selectionInfo = getCurrentSelection(state)
|
|
1209
|
-
if (!selectionInfo) { return }
|
|
1210
|
-
|
|
1211
|
-
const { range } = selectionInfo
|
|
1212
|
-
const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
|
|
1213
|
-
? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
|
|
1214
|
-
: (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
|
|
1215
|
-
|
|
1216
|
-
if (paragraph) {
|
|
1217
|
-
(paragraph as HTMLElement).style.textAlign = 'justify'
|
|
1218
|
-
}
|
|
1219
|
-
}, (state) => {
|
|
1220
|
-
const selectionInfo = getCurrentSelection(state)
|
|
1221
|
-
if (!selectionInfo) { return false }
|
|
1222
|
-
|
|
1223
|
-
const { range } = selectionInfo
|
|
1224
|
-
const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
|
|
1225
|
-
? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
|
|
1226
|
-
: (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
|
|
1227
|
-
|
|
1228
|
-
return (paragraph as HTMLElement)?.style.textAlign === 'justify'
|
|
1229
|
-
}),
|
|
1230
|
-
|
|
1231
|
-
textDirection: createCommand('Toggle Text Direction', (state) => {
|
|
1232
|
-
const selectionInfo = getCurrentSelection(state)
|
|
1233
|
-
if (!selectionInfo) { return }
|
|
883
|
+
const ALIGN_TARGET_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, blockquote, li, td, th'
|
|
1234
884
|
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
885
|
+
function applyBlockAlignment(s: EditorState, value: 'left' | 'center' | 'right' | 'justify') {
|
|
886
|
+
const selectionInfo = getCurrentSelection(s)
|
|
887
|
+
if (!selectionInfo || !s.doc) { return }
|
|
888
|
+
const { range } = selectionInfo
|
|
1239
889
|
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
890
|
+
if (!range.collapsed) {
|
|
891
|
+
const blocks = Array.from(s.doc.body.querySelectorAll<HTMLElement>(ALIGN_TARGET_SELECTOR))
|
|
892
|
+
.filter(el => range.intersectsNode(el) && !el.querySelector(ALIGN_TARGET_SELECTOR))
|
|
893
|
+
if (blocks.length > 0) {
|
|
894
|
+
blocks.forEach((b) => { b.style.textAlign = value })
|
|
895
|
+
return
|
|
1243
896
|
}
|
|
1244
|
-
}
|
|
1245
|
-
const selectionInfo = getCurrentSelection(state)
|
|
1246
|
-
if (!selectionInfo) { return false }
|
|
1247
|
-
|
|
1248
|
-
const { range } = selectionInfo
|
|
1249
|
-
const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
|
|
1250
|
-
? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
|
|
1251
|
-
: (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
|
|
1252
|
-
|
|
1253
|
-
return (paragraph as HTMLElement)?.dir === 'rtl'
|
|
1254
|
-
}),
|
|
1255
|
-
|
|
1256
|
-
ltrDirection: createCommand('Set Left to Right Direction', (state) => {
|
|
1257
|
-
const selectionInfo = getCurrentSelection(state)
|
|
1258
|
-
if (!selectionInfo) { return }
|
|
897
|
+
}
|
|
1259
898
|
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
899
|
+
const target = range.startContainer.nodeType === Node.TEXT_NODE
|
|
900
|
+
? range.startContainer.parentElement?.closest(ALIGN_TARGET_SELECTOR)
|
|
901
|
+
: (range.startContainer as Element).closest(ALIGN_TARGET_SELECTOR)
|
|
902
|
+
if (target) {
|
|
903
|
+
(target as HTMLElement).style.textAlign = value
|
|
904
|
+
}
|
|
905
|
+
}
|
|
1264
906
|
|
|
1265
|
-
|
|
1266
|
-
|
|
907
|
+
function applyBlockDirection(s: EditorState, dir: 'ltr' | 'rtl' | 'toggle') {
|
|
908
|
+
const selectionInfo = getCurrentSelection(s)
|
|
909
|
+
if (!selectionInfo || !s.doc) { return }
|
|
910
|
+
const { range } = selectionInfo
|
|
911
|
+
|
|
912
|
+
const setDirOnEl = (el: HTMLElement) => {
|
|
913
|
+
const nextDir = dir === 'toggle'
|
|
914
|
+
? ((el.dir || s.doc?.body.dir || 'ltr') === 'ltr' ? 'rtl' : 'ltr')
|
|
915
|
+
: dir
|
|
916
|
+
el.dir = nextDir
|
|
917
|
+
el.setAttribute('data-dir-explicit', 'true')
|
|
918
|
+
const listParent = el.closest('ul, ol') as HTMLElement | null
|
|
919
|
+
if (listParent) {
|
|
920
|
+
listParent.dir = nextDir
|
|
921
|
+
listParent.setAttribute('data-dir-explicit', 'true')
|
|
1267
922
|
}
|
|
1268
|
-
}
|
|
1269
|
-
const selectionInfo = getCurrentSelection(state)
|
|
1270
|
-
if (!selectionInfo) { return false }
|
|
1271
|
-
|
|
1272
|
-
const { range } = selectionInfo
|
|
1273
|
-
const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
|
|
1274
|
-
? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
|
|
1275
|
-
: (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
|
|
1276
|
-
|
|
1277
|
-
return (paragraph as HTMLElement)?.dir === 'ltr'
|
|
1278
|
-
}),
|
|
1279
|
-
|
|
1280
|
-
rtlDirection: createCommand('Set Right to Left Direction', (state) => {
|
|
1281
|
-
const selectionInfo = getCurrentSelection(state)
|
|
1282
|
-
if (!selectionInfo) { return }
|
|
1283
|
-
|
|
1284
|
-
const { range } = selectionInfo
|
|
1285
|
-
const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
|
|
1286
|
-
? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
|
|
1287
|
-
: (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
|
|
923
|
+
}
|
|
1288
924
|
|
|
1289
|
-
|
|
1290
|
-
|
|
925
|
+
if (!range.collapsed) {
|
|
926
|
+
const blocks = Array.from(s.doc.body.querySelectorAll<HTMLElement>(ALIGN_TARGET_SELECTOR))
|
|
927
|
+
.filter(el => range.intersectsNode(el) && !el.querySelector(ALIGN_TARGET_SELECTOR))
|
|
928
|
+
if (blocks.length > 0) {
|
|
929
|
+
blocks.forEach(setDirOnEl)
|
|
930
|
+
return
|
|
1291
931
|
}
|
|
1292
|
-
}
|
|
1293
|
-
const selectionInfo = getCurrentSelection(state)
|
|
1294
|
-
if (!selectionInfo) { return false }
|
|
932
|
+
}
|
|
1295
933
|
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
934
|
+
const target = range.startContainer.nodeType === Node.TEXT_NODE
|
|
935
|
+
? range.startContainer.parentElement?.closest(ALIGN_TARGET_SELECTOR)
|
|
936
|
+
: (range.startContainer as Element).closest(ALIGN_TARGET_SELECTOR)
|
|
937
|
+
if (target) {
|
|
938
|
+
setDirOnEl(target as HTMLElement)
|
|
939
|
+
}
|
|
940
|
+
}
|
|
1300
941
|
|
|
1301
|
-
|
|
1302
|
-
|
|
942
|
+
const textAlignmentCommands: CommandRegistry = {
|
|
943
|
+
alignLeft: createCommand(
|
|
944
|
+
'Align Left',
|
|
945
|
+
s => applyBlockAlignment(s, 'left'),
|
|
946
|
+
s => Boolean(s.doc && isStyleActive('alignLeft', s.doc))
|
|
947
|
+
),
|
|
948
|
+
alignCenter: createCommand(
|
|
949
|
+
'Align Center',
|
|
950
|
+
s => applyBlockAlignment(s, 'center'),
|
|
951
|
+
s => Boolean(s.doc && isStyleActive('alignCenter', s.doc))
|
|
952
|
+
),
|
|
953
|
+
alignRight: createCommand(
|
|
954
|
+
'Align Right',
|
|
955
|
+
s => applyBlockAlignment(s, 'right'),
|
|
956
|
+
s => Boolean(s.doc && isStyleActive('alignRight', s.doc))
|
|
957
|
+
),
|
|
958
|
+
alignJustify: createCommand(
|
|
959
|
+
'Align Justify',
|
|
960
|
+
s => applyBlockAlignment(s, 'justify'),
|
|
961
|
+
s => Boolean(s.doc && isStyleActive('alignJustify', s.doc))
|
|
962
|
+
),
|
|
963
|
+
textDirection: createCommand(
|
|
964
|
+
'Toggle Text Direction',
|
|
965
|
+
s => applyBlockDirection(s, 'toggle'),
|
|
966
|
+
s => Boolean(s.doc && isStyleActive('textDirection', s.doc))
|
|
967
|
+
),
|
|
968
|
+
ltrDirection: createCommand(
|
|
969
|
+
'Set Left to Right Direction',
|
|
970
|
+
s => applyBlockDirection(s, 'ltr'),
|
|
971
|
+
s => Boolean(s.doc && isStyleActive('ltrDirection', s.doc))
|
|
972
|
+
),
|
|
973
|
+
rtlDirection: createCommand(
|
|
974
|
+
'Set Right to Left Direction',
|
|
975
|
+
s => applyBlockDirection(s, 'rtl'),
|
|
976
|
+
s => Boolean(s.doc && isStyleActive('rtlDirection', s.doc))
|
|
977
|
+
)
|
|
1303
978
|
}
|
|
1304
979
|
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
codeView: createCommand('Code View', (state) => { state.isCodeView = !state.isCodeView }, state => state.isCodeView)
|
|
980
|
+
const viewCommands: CommandRegistry = {
|
|
981
|
+
fullScreen: createCommand('Full Screen', (s) => { s.isFullscreen = !s.isFullscreen }, s => s.isFullscreen, { skipHistory: true }),
|
|
982
|
+
splitView: createCommand('Split View', (s) => { s.isSplitView = !s.isSplitView }, s => s.isSplitView, { skipHistory: true }),
|
|
983
|
+
codeView: createCommand('Code View', (s) => { s.isCodeView = !s.isCodeView }, s => s.isCodeView, { skipHistory: true })
|
|
1310
984
|
}
|
|
1311
985
|
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
openVideoModal()
|
|
1326
|
-
} else {
|
|
1327
|
-
console.warn('Video insertion requires modal implementation')
|
|
1328
|
-
}
|
|
1329
|
-
}),
|
|
1330
|
-
embed: createCommand('Insert Embed', (state) => {
|
|
1331
|
-
const { openEmbedModal } = state as any
|
|
1332
|
-
if (openEmbedModal) {
|
|
1333
|
-
openEmbedModal()
|
|
1334
|
-
} else {
|
|
1335
|
-
console.warn('Embed insertion requires modal implementation')
|
|
1336
|
-
}
|
|
1337
|
-
})
|
|
986
|
+
const mediaCommands: CommandRegistry = {
|
|
987
|
+
image: createCommand('Insert Image', (s) => {
|
|
988
|
+
const { openImageModal } = s as any
|
|
989
|
+
openImageModal?.()
|
|
990
|
+
}, undefined, { skipHistory: true }),
|
|
991
|
+
video: createCommand('Insert Video', (s) => {
|
|
992
|
+
const { openVideoModal } = s as any
|
|
993
|
+
openVideoModal?.()
|
|
994
|
+
}, undefined, { skipHistory: true }),
|
|
995
|
+
embed: createCommand('Insert Embed', (s) => {
|
|
996
|
+
const { openEmbedModal } = s as any
|
|
997
|
+
openEmbedModal?.()
|
|
998
|
+
}, undefined, { skipHistory: true })
|
|
1338
999
|
}
|
|
1339
1000
|
|
|
1340
1001
|
return {
|
|
@@ -1362,11 +1023,11 @@ export function createCommandExecutor(state: EditorState, commands: CommandRegis
|
|
|
1362
1023
|
},
|
|
1363
1024
|
isActive(command: string): boolean {
|
|
1364
1025
|
const cmd = commands[command]
|
|
1365
|
-
return cmd
|
|
1026
|
+
return cmd?.isActive?.(state) || false
|
|
1366
1027
|
},
|
|
1367
1028
|
getValue(command: string): string | null {
|
|
1368
1029
|
const cmd = commands[command]
|
|
1369
|
-
return cmd
|
|
1030
|
+
return cmd?.getValue?.(state) || null
|
|
1370
1031
|
}
|
|
1371
1032
|
}
|
|
1372
1033
|
}
|