@licium/editor-plugin-details 1.0.2 → 1.0.13
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/cdn/toastui-editor-plugin-details.css +42 -0
- package/dist/cdn/toastui-editor-plugin-details.js +683 -0
- package/dist/cdn/toastui-editor-plugin-details.min.css +6 -0
- package/dist/cdn/toastui-editor-plugin-details.min.js +7 -0
- package/dist/toastui-editor-plugin-details.css +42 -0
- package/dist/toastui-editor-plugin-details.js +144 -101
- package/package.json +1 -1
- package/src/css/plugin.css +23 -0
- package/src/index.ts +185 -128
package/src/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
import type { PluginContext, PluginInfo, I18n, MdNode } from '@licium/editor';
|
|
2
3
|
import { PluginOptions } from '@t/index';
|
|
3
4
|
import { addLangs } from './i18n/langs';
|
|
5
|
+
import './css/plugin.css';
|
|
4
6
|
|
|
5
7
|
const PREFIX = 'toastui-editor-';
|
|
6
8
|
|
|
@@ -13,6 +15,136 @@ function createToolbarItemOption(i18n: I18n) {
|
|
|
13
15
|
};
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
function handleDetailsExit(context: PluginContext, event: KeyboardEvent): void {
|
|
19
|
+
const { instance } = context as any;
|
|
20
|
+
|
|
21
|
+
if (!instance) {
|
|
22
|
+
console.log('[Details Plugin] No instance found');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const editor = instance.getCurrentModeEditor();
|
|
27
|
+
|
|
28
|
+
if (!editor || !editor.view) {
|
|
29
|
+
console.log('[Details Plugin] No editor view found');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { view } = editor;
|
|
34
|
+
const { state } = view;
|
|
35
|
+
const { selection } = state;
|
|
36
|
+
const { $from } = selection;
|
|
37
|
+
|
|
38
|
+
// Find details node in ancestry
|
|
39
|
+
let detailsDepth = -1;
|
|
40
|
+
|
|
41
|
+
for (let d = $from.depth; d > 0; d -= 1) {
|
|
42
|
+
if ($from.node(d).type.name === 'details') {
|
|
43
|
+
detailsDepth = d;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (detailsDepth < 0) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const detailsNode = $from.node(detailsDepth);
|
|
53
|
+
const nodeStart = $from.before(detailsDepth);
|
|
54
|
+
const nodeEnd = nodeStart + detailsNode.nodeSize;
|
|
55
|
+
const afterPara = $from.after();
|
|
56
|
+
const isAtEnd = afterPara >= nodeEnd - 1;
|
|
57
|
+
|
|
58
|
+
console.log('[Details Plugin] Inside details:', {
|
|
59
|
+
key: event.key,
|
|
60
|
+
isAtEnd,
|
|
61
|
+
afterPara,
|
|
62
|
+
nodeEnd,
|
|
63
|
+
parentType: $from.parent.type.name,
|
|
64
|
+
parentEmpty: $from.parent.content.size === 0,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!isAtEnd) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { tr, schema } = state;
|
|
72
|
+
const SelectionClass = state.selection.constructor as any;
|
|
73
|
+
|
|
74
|
+
// Enter on empty paragraph
|
|
75
|
+
if (event.key === 'Enter') {
|
|
76
|
+
const isEmptyPara = $from.parent.type.name === 'paragraph' && $from.parent.content.size === 0;
|
|
77
|
+
|
|
78
|
+
if (!isEmptyPara) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check previous node to see if it's also an empty paragraph
|
|
83
|
+
// $from.index(0) gives the index in the parent details node
|
|
84
|
+
const index = $from.index(detailsDepth);
|
|
85
|
+
const prevNode = detailsNode.child(index - 1);
|
|
86
|
+
const isPrevEmptyPara = prevNode && prevNode.type.name === 'paragraph' && prevNode.content.size === 0;
|
|
87
|
+
|
|
88
|
+
if (!isPrevEmptyPara) {
|
|
89
|
+
console.log('[Details Plugin] Allowing one empty line. Not exiting yet.');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log('[Details Plugin] EXITING via Double Enter (Two empty paras)!');
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
|
|
96
|
+
const beforePara = $from.before();
|
|
97
|
+
|
|
98
|
+
// delete the current empty para and the previous one?
|
|
99
|
+
// Usually "Exit" means: Lift the current empty para out.
|
|
100
|
+
// But if we have TWO empty paras, maybe we want to keep one inside?
|
|
101
|
+
// Let's stick to: If two empty paras, we treat it as an exit signal.
|
|
102
|
+
// We should probably remove the *current* empty para and move the cursor out.
|
|
103
|
+
|
|
104
|
+
tr.delete(beforePara, afterPara);
|
|
105
|
+
|
|
106
|
+
const p = schema.nodes.paragraph.createAndFill()!;
|
|
107
|
+
const insertPos = nodeEnd - (afterPara - beforePara);
|
|
108
|
+
|
|
109
|
+
tr.insert(insertPos, p);
|
|
110
|
+
|
|
111
|
+
if (SelectionClass.near) {
|
|
112
|
+
tr.setSelection(SelectionClass.near(tr.doc.resolve(insertPos + 1)));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
view.dispatch(tr);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ArrowDown at end of content
|
|
119
|
+
if (event.key === 'ArrowDown') {
|
|
120
|
+
const atEndOfParent = $from.parentOffset === $from.parent.content.size;
|
|
121
|
+
|
|
122
|
+
if (!atEndOfParent) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log('[Details Plugin] EXITING via ArrowDown!');
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
|
|
129
|
+
if (nodeEnd < state.doc.content.size) {
|
|
130
|
+
if (SelectionClass.near) {
|
|
131
|
+
tr.setSelection(SelectionClass.near(tr.doc.resolve(nodeEnd)));
|
|
132
|
+
view.dispatch(tr);
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
const p = schema.nodes.paragraph.createAndFill()!;
|
|
136
|
+
|
|
137
|
+
tr.insert(nodeEnd, p);
|
|
138
|
+
|
|
139
|
+
if (SelectionClass.near) {
|
|
140
|
+
tr.setSelection(SelectionClass.near(tr.doc.resolve(nodeEnd + 1)));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
view.dispatch(tr);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
16
148
|
export default function detailsPlugin(
|
|
17
149
|
context: PluginContext,
|
|
18
150
|
options: PluginOptions = {}
|
|
@@ -23,10 +155,38 @@ export default function detailsPlugin(
|
|
|
23
155
|
|
|
24
156
|
const toolbarItem = createToolbarItemOption(i18n);
|
|
25
157
|
|
|
158
|
+
// Hook into the global keydown event for exit behavior
|
|
159
|
+
eventEmitter.listen('keydown', (editorType: string, event: KeyboardEvent) => {
|
|
160
|
+
console.log('[Details Plugin] keydown via eventEmitter!', { editorType, key: event.key });
|
|
161
|
+
|
|
162
|
+
if (editorType !== 'wysiwyg') {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (event.key !== 'Enter' && event.key !== 'ArrowDown') {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
handleDetailsExit(context, event);
|
|
171
|
+
});
|
|
172
|
+
|
|
26
173
|
return {
|
|
174
|
+
toHTMLRenderers: {
|
|
175
|
+
htmlBlock: {
|
|
176
|
+
renderer: (node) => {
|
|
177
|
+
const isDetails = /<(\/)?(details|summary)/i.test(node.literal || '');
|
|
178
|
+
return isDetails
|
|
179
|
+
? [{ type: 'html', content: node.literal || '' }]
|
|
180
|
+
: [
|
|
181
|
+
{ type: 'openTag', tagName: 'div', outerNewLine: true },
|
|
182
|
+
{ type: 'html', content: node.literal || '' },
|
|
183
|
+
{ type: 'closeTag', tagName: 'div', outerNewLine: true },
|
|
184
|
+
];
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
27
188
|
markdownCommands: {
|
|
28
189
|
details: (payload, { tr, selection, schema }, dispatch) => {
|
|
29
|
-
const { from, to } = selection;
|
|
30
190
|
const slice = selection.content();
|
|
31
191
|
const textContent =
|
|
32
192
|
slice.content.textBetween(0, slice.content.size, '\n') || i18n.get('content');
|
|
@@ -41,26 +201,18 @@ export default function detailsPlugin(
|
|
|
41
201
|
return true;
|
|
42
202
|
},
|
|
43
203
|
},
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
details: (node) => [
|
|
47
|
-
{ type: 'openTag', tagName: 'details', outerNewLine: true },
|
|
48
|
-
{ type: 'html', content: node.childrenHTML || '' },
|
|
49
|
-
{ type: 'closeTag', tagName: 'details', outerNewLine: true },
|
|
50
|
-
],
|
|
51
|
-
summary: (node) => [
|
|
52
|
-
{ type: 'openTag', tagName: 'summary', outerNewLine: true },
|
|
53
|
-
{ type: 'html', content: node.childrenHTML || '' },
|
|
54
|
-
{ type: 'closeTag', tagName: 'summary', outerNewLine: true },
|
|
55
|
-
],
|
|
56
|
-
},
|
|
57
|
-
},
|
|
204
|
+
|
|
205
|
+
|
|
58
206
|
wysiwygNodeViews: {
|
|
59
207
|
details: (node, view, getPos) => {
|
|
60
208
|
const dom = document.createElement('details');
|
|
61
|
-
const
|
|
209
|
+
const htmlAttrs = (node.attrs && node.attrs.htmlAttrs) || {};
|
|
210
|
+
const isOpenAttr = node.attrs.open; // Core definition uses direct attribute
|
|
62
211
|
|
|
63
|
-
|
|
212
|
+
// Check both direct attribute (priority) and htmlAttrs (legacy/plugin)
|
|
213
|
+
const isOpen = isOpenAttr === true || (htmlAttrs && htmlAttrs.open !== null && typeof htmlAttrs.open !== 'undefined');
|
|
214
|
+
|
|
215
|
+
if (isOpen) {
|
|
64
216
|
dom.open = true;
|
|
65
217
|
}
|
|
66
218
|
|
|
@@ -73,9 +225,18 @@ export default function detailsPlugin(
|
|
|
73
225
|
const pos = getPos();
|
|
74
226
|
|
|
75
227
|
if (typeof pos === 'number') {
|
|
228
|
+
const isOpen = htmlAttrs.open !== null && typeof htmlAttrs.open !== 'undefined';
|
|
229
|
+
const newHtmlAttrs = { ...htmlAttrs };
|
|
230
|
+
|
|
231
|
+
if (isOpen) {
|
|
232
|
+
delete newHtmlAttrs.open;
|
|
233
|
+
} else {
|
|
234
|
+
newHtmlAttrs.open = '';
|
|
235
|
+
}
|
|
236
|
+
|
|
76
237
|
const newAttrs = {
|
|
77
238
|
...node.attrs,
|
|
78
|
-
htmlAttrs:
|
|
239
|
+
htmlAttrs: newHtmlAttrs,
|
|
79
240
|
};
|
|
80
241
|
|
|
81
242
|
view.dispatch(view.state.tr.setNodeMarkup(pos, null, newAttrs));
|
|
@@ -83,115 +244,11 @@ export default function detailsPlugin(
|
|
|
83
244
|
}
|
|
84
245
|
});
|
|
85
246
|
|
|
86
|
-
// Simplified keydown handler with better logic
|
|
87
|
-
dom.addEventListener('keydown', (e) => {
|
|
88
|
-
const { state } = view;
|
|
89
|
-
const { selection } = state;
|
|
90
|
-
const pos = getPos();
|
|
91
|
-
|
|
92
|
-
if (typeof pos !== 'number') {
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Get fresh node
|
|
97
|
-
const currentNode = state.doc.nodeAt(pos);
|
|
98
|
-
|
|
99
|
-
if (!currentNode || currentNode.type.name !== 'details') {
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Calculate end position
|
|
104
|
-
const endPos = pos + currentNode.nodeSize;
|
|
105
|
-
|
|
106
|
-
// Get TextSelection constructor
|
|
107
|
-
const { constructor: SelectionCtor } = state.selection as any;
|
|
108
|
-
|
|
109
|
-
// Handle Enter key
|
|
110
|
-
if (e.key === 'Enter') {
|
|
111
|
-
const { $from } = selection;
|
|
112
|
-
|
|
113
|
-
// Check if we're in an empty paragraph
|
|
114
|
-
if ($from.parent.type.name === 'paragraph' && $from.parent.content.size === 0) {
|
|
115
|
-
// Get position after this paragraph
|
|
116
|
-
const afterPara = $from.after();
|
|
117
|
-
|
|
118
|
-
// Check if this empty paragraph is the last thing in the details block
|
|
119
|
-
// endPos - 1 is the position just before the closing tag
|
|
120
|
-
if (afterPara >= endPos - 1) {
|
|
121
|
-
e.preventDefault();
|
|
122
|
-
e.stopPropagation();
|
|
123
|
-
|
|
124
|
-
const { tr, schema } = state;
|
|
125
|
-
|
|
126
|
-
// Delete the empty paragraph
|
|
127
|
-
const beforePara = $from.before();
|
|
128
|
-
|
|
129
|
-
tr.delete(beforePara, afterPara);
|
|
130
|
-
|
|
131
|
-
// Insert new paragraph after the details block
|
|
132
|
-
const p = schema.nodes.paragraph.createAndFill()!;
|
|
133
|
-
const insertPos = endPos - (afterPara - beforePara);
|
|
134
|
-
|
|
135
|
-
tr.insert(insertPos, p);
|
|
136
|
-
|
|
137
|
-
// Move cursor into new paragraph
|
|
138
|
-
if (SelectionCtor && SelectionCtor.near) {
|
|
139
|
-
tr.setSelection(SelectionCtor.near(tr.doc.resolve(insertPos + 1)));
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
view.dispatch(tr);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Handle ArrowDown key
|
|
148
|
-
if (e.key === 'ArrowDown') {
|
|
149
|
-
const { $from } = selection;
|
|
150
|
-
|
|
151
|
-
// Check if cursor is at the end of its parent node
|
|
152
|
-
const atEndOfParent = $from.parentOffset === $from.parent.content.size;
|
|
153
|
-
|
|
154
|
-
if (!atEndOfParent) {
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Check if parent is the last child in details
|
|
159
|
-
const afterPara = $from.after();
|
|
160
|
-
|
|
161
|
-
if (afterPara >= endPos - 1) {
|
|
162
|
-
e.preventDefault();
|
|
163
|
-
e.stopPropagation();
|
|
164
|
-
|
|
165
|
-
const { tr, schema } = state;
|
|
166
|
-
|
|
167
|
-
// Try to move to position after details block
|
|
168
|
-
if (endPos < state.doc.content.size) {
|
|
169
|
-
// There's content after, just move there
|
|
170
|
-
if (SelectionCtor && SelectionCtor.near) {
|
|
171
|
-
tr.setSelection(SelectionCtor.near(tr.doc.resolve(endPos)));
|
|
172
|
-
view.dispatch(tr);
|
|
173
|
-
}
|
|
174
|
-
} else {
|
|
175
|
-
// No content after, create a new paragraph
|
|
176
|
-
const p = schema.nodes.paragraph.createAndFill()!;
|
|
177
|
-
|
|
178
|
-
tr.insert(endPos, p);
|
|
179
|
-
|
|
180
|
-
if (SelectionCtor && SelectionCtor.near) {
|
|
181
|
-
tr.setSelection(SelectionCtor.near(tr.doc.resolve(endPos + 1)));
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
view.dispatch(tr);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
});
|
|
189
|
-
|
|
190
247
|
return { dom, contentDOM: dom };
|
|
191
248
|
},
|
|
192
249
|
},
|
|
193
250
|
wysiwygCommands: {
|
|
194
|
-
details: (payload, { tr,
|
|
251
|
+
details: (payload, { tr, schema }, dispatch) => {
|
|
195
252
|
const summaryText = i18n.get('summary');
|
|
196
253
|
const contentText = i18n.get('content');
|
|
197
254
|
|
|
@@ -200,9 +257,9 @@ export default function detailsPlugin(
|
|
|
200
257
|
if (details && summary && paragraph) {
|
|
201
258
|
const summaryNode = summary.create({}, schema.text(summaryText));
|
|
202
259
|
const contentNode = paragraph.create({}, schema.text(contentText));
|
|
203
|
-
const
|
|
260
|
+
const detailsNode = details.create({}, [summaryNode, contentNode]);
|
|
204
261
|
|
|
205
|
-
tr.replaceSelectionWith(
|
|
262
|
+
tr.replaceSelectionWith(detailsNode);
|
|
206
263
|
dispatch!(tr);
|
|
207
264
|
return true;
|
|
208
265
|
}
|
|
@@ -212,8 +269,8 @@ export default function detailsPlugin(
|
|
|
212
269
|
},
|
|
213
270
|
toolbarItems: [
|
|
214
271
|
{
|
|
215
|
-
groupIndex:
|
|
216
|
-
itemIndex:
|
|
272
|
+
groupIndex: 4,
|
|
273
|
+
itemIndex: 3,
|
|
217
274
|
item: toolbarItem,
|
|
218
275
|
},
|
|
219
276
|
],
|