xref: /plugin/catmenu/script/prosemirror.js (revision 6983cdfd4483215ff5a1e573925c9c612964e790)
1(function () {
2    function initializeCatmenuProsemirror() {
3        if (window.__catmenuProsemirrorInitialized) return;
4        if (!window.Prosemirror || !window.Prosemirror.classes) return;
5        window.__catmenuProsemirrorInitialized = true;
6
7        const {classes: {MenuItem, AbstractMenuItemDispatcher}} = window.Prosemirror;
8        const i18n = (window.LANG && LANG.plugins && LANG.plugins.catmenu) ? LANG.plugins.catmenu : {};
9        function hiddenMenuItem() {
10            return new MenuItem({
11                label: '',
12                render: () => {
13                    const el = document.createElement('span');
14                    el.style.display = 'none';
15                    return el;
16                },
17                command: () => false
18            });
19        }
20
21        function t(key, fallback) {
22            return i18n[key] || fallback;
23        }
24
25        function shouldShowInEditorMenu() {
26            const raw = window.JSINFO &&
27                JSINFO.plugins &&
28                JSINFO.plugins.catmenu
29                ? JSINFO.plugins.catmenu.show_in_editor_menu
30                : true;
31
32            if (typeof raw === 'boolean') return raw;
33            const normalized = String(raw).trim().toLowerCase();
34            return !(normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no');
35        }
36
37        window.Prosemirror.pluginSchemas.push((nodes, marks) => {
38            nodes = nodes.addToEnd('catmenu', {
39                group: 'protected_block',
40                inline: false,
41                selectable: true,
42                draggable: true,
43                defining: true,
44                isolating: true,
45                code: true,
46                attrs: {
47                    syntax: {default: '{{catmenu>.}}'}
48                },
49                toDOM: (node) => ['pre', {class: 'dwplugin', 'data-pluginname': 'catmenu'}, node.attrs.syntax],
50                parseDOM: [{
51                    tag: 'pre.dwplugin[data-pluginname="catmenu"]',
52                    getAttrs: (dom) => ({syntax: (dom.textContent || '{{catmenu>.}}').trim()})
53                }]
54            });
55            return {nodes, marks};
56        });
57
58        function parseCatmenuSyntax(syntax) {
59            const m = (syntax || '').match(/^\{\{catmenu>(.*?)\}\}$/i);
60            if (!m) return null;
61            return {namespace: (m[1] || '.').trim() || '.'};
62        }
63
64        function buildCatmenuSyntax(values) {
65            return '{{catmenu>' + ((values && values.namespace) ? values.namespace : '.') + '}}';
66        }
67
68        function formatCatmenuLabel(values) {
69            return 'CatMenu: ' + (values.namespace || '.');
70        }
71
72        function getFolderIconUrl() {
73            const svg = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path fill='%232f6fae' d='M10 4l2 2h8a2 2 0 0 1 2 2v2H2V6a2 2 0 0 1 2-2h6z'/><path fill='%233f88c8' d='M2 10h20v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-8z'/></svg>";
74            return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg);
75        }
76
77        function getFolderMenuIcon() {
78            const ns = 'http://www.w3.org/2000/svg';
79            const svg = document.createElementNS(ns, 'svg');
80            svg.setAttribute('viewBox', '0 0 24 24');
81
82            const path1 = document.createElementNS(ns, 'path');
83            path1.setAttribute('d', 'M10 4l2 2h8a2 2 0 0 1 2 2v2H2V6a2 2 0 0 1 2-2h6z');
84            path1.setAttribute('fill', 'currentColor');
85            svg.appendChild(path1);
86
87            const path2 = document.createElementNS(ns, 'path');
88            path2.setAttribute('d', 'M2 10h20v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-8z');
89            path2.setAttribute('fill', 'currentColor');
90            svg.appendChild(path2);
91            return svg;
92        }
93
94        function isLegacyCatmenuPluginNode(node) {
95            return !!(
96                node &&
97                node.type &&
98                (node.type.name === 'dwplugin_inline' || node.type.name === 'dwplugin_block') &&
99                node.attrs &&
100                node.attrs['data-pluginname'] === 'catmenu'
101            );
102        }
103
104        function isCatmenuNode(node) {
105            return !!(node && node.type && node.type.name === 'catmenu') || isLegacyCatmenuPluginNode(node);
106        }
107
108        function syntaxFromNode(node) {
109            if (!node) return '{{catmenu>.}}';
110            if (node.type && node.type.name === 'catmenu') {
111                return String((node.attrs && node.attrs.syntax) || '{{catmenu>.}}');
112            }
113            return String(node.textContent || '{{catmenu>.}}');
114        }
115
116        function createCatmenuNode(schema, syntax) {
117            const normalized = String(syntax || '{{catmenu>.}}').trim() || '{{catmenu>.}}';
118            if (schema.nodes.catmenu) {
119                return schema.nodes.catmenu.createChecked({syntax: normalized});
120            }
121            const fallback = schema.nodes.dwplugin_block;
122            if (!fallback) return null;
123            return fallback.createChecked(
124                {class: 'dwplugin', 'data-pluginname': 'catmenu'},
125                schema.text(normalized)
126            );
127        }
128
129        function findCatmenuAtSelection(state) {
130            const {selection} = state;
131            if (isCatmenuNode(selection.node)) {
132                return {node: selection.node, pos: selection.from};
133            }
134
135            const $from = selection.$from;
136            if ($from.depth > 0 && isCatmenuNode($from.parent)) {
137                return {node: $from.parent, pos: $from.before($from.depth)};
138            }
139            if (isCatmenuNode($from.nodeBefore)) {
140                return {node: $from.nodeBefore, pos: $from.pos - $from.nodeBefore.nodeSize};
141            }
142            if (isCatmenuNode($from.nodeAfter)) {
143                return {node: $from.nodeAfter, pos: $from.pos};
144            }
145
146            for (let depth = $from.depth; depth > 0; depth -= 1) {
147                const ancestor = $from.node(depth);
148                if (isCatmenuNode(ancestor)) {
149                    return {node: ancestor, pos: $from.before(depth)};
150                }
151            }
152            return null;
153        }
154
155        function insertParagraphAfterSelectedCatmenu(view) {
156            if (!view || !view.state) return false;
157            const selected = findCatmenuAtSelection(view.state);
158            if (!selected) return false;
159
160            const {schema} = view.state;
161            const paragraph = schema.nodes.paragraph && schema.nodes.paragraph.createAndFill();
162            if (!paragraph) return false;
163
164            const insertPos = selected.pos + selected.node.nodeSize;
165            let tr = view.state.tr.insert(insertPos, paragraph).scrollIntoView();
166            view.dispatch(tr);
167
168            try {
169                const SelectionClass = view.state.selection.constructor;
170                const $target = view.state.doc.resolve(insertPos + 1);
171                const selection = SelectionClass.near($target, 1);
172                view.dispatch(view.state.tr.setSelection(selection).scrollIntoView());
173            } catch (e) {
174                // Keep default selection on fallback.
175            }
176
177            view.focus();
178            return true;
179        }
180
181        function insertCatmenuBlock(view, pluginNode) {
182            const state = view.state;
183            const {$from} = state.selection;
184            const index = $from.index();
185
186            if ($from.parent.canReplaceWith(index, index, pluginNode.type)) {
187                view.dispatch(state.tr.replaceSelectionWith(pluginNode));
188                return true;
189            }
190
191            for (let depth = $from.depth; depth > 0; depth -= 1) {
192                const insertPos = $from.after(depth);
193                try {
194                    view.dispatch(state.tr.insert(insertPos, pluginNode));
195                    return true;
196                } catch (e) {
197                    // try a higher ancestor
198                }
199            }
200            return false;
201        }
202
203        function showCatmenuDialog(initialValues, onSubmit) {
204            const values = {namespace: '.', ...initialValues};
205            const $dialog = jQuery('<div class="plugin_catmenu_form" title="' + t('toolbar_popup_title', 'CatMenu') + '"></div>');
206
207            $dialog.append('<label>' + t('toolbar_namespace', 'Namespace') + '</label>');
208            const $namespace = jQuery('<input type="text" class="edit" style="width:100%;" />').val(values.namespace);
209            $dialog.append($namespace);
210            $dialog.append('<div style="font-size:.9em;color:#555;margin-top:4px;">' + t('toolbar_namespace_help', 'Folder. "." = current folder.') + '</div>');
211
212            $dialog.dialog({
213                modal: true,
214                width: 460,
215                close: function () {
216                    jQuery(this).dialog('destroy').remove();
217                },
218                buttons: [
219                    {
220                        text: t('toolbar_insert', 'Insert'),
221                        click: function () {
222                            onSubmit({namespace: String($namespace.val() || '.').trim() || '.'});
223                            jQuery(this).dialog('close');
224                        }
225                    },
226                    {
227                        text: t('toolbar_cancel', 'Cancel'),
228                        click: function () {
229                            jQuery(this).dialog('close');
230                        }
231                    }
232                ]
233            });
234        }
235
236        class CatmenuNodeView {
237            constructor(node, view, getPos) {
238                this.node = node;
239                this.view = view;
240                this.getPos = getPos;
241                this.dom = document.createElement('div');
242                const typeClass = (node.type && node.type.name === 'dwplugin_inline') ? 'pm_catmenu_inline' : 'pm_catmenu_block';
243                this.dom.className = 'plugin_catmenu pm_catmenu_node nodeHasForm ' + typeClass;
244                this.dom.setAttribute('contenteditable', 'false');
245                this.render();
246
247                this.dom.addEventListener('click', (event) => {
248                    event.preventDefault();
249                    event.stopPropagation();
250                    this.openEditor();
251                });
252            }
253
254            render() {
255                const syntax = syntaxFromNode(this.node);
256                const parsed = parseCatmenuSyntax(syntax);
257                const label = parsed ? formatCatmenuLabel(parsed) : syntax;
258                this.dom.textContent = '';
259
260                const icon = document.createElement('img');
261                icon.className = 'pm_catmenu_icon';
262                icon.src = getFolderIconUrl();
263                icon.alt = '';
264                icon.setAttribute('aria-hidden', 'true');
265                this.dom.appendChild(icon);
266
267                const text = document.createElement('span');
268                text.textContent = label;
269                this.dom.appendChild(text);
270                this.dom.setAttribute('title', syntax);
271            }
272
273            openEditor() {
274                const parsed = parseCatmenuSyntax(syntaxFromNode(this.node)) || {namespace: '.'};
275                showCatmenuDialog(parsed, (values) => {
276                    const syntax = buildCatmenuSyntax(values);
277                    const replacement = createCatmenuNode(this.view.state.schema, syntax);
278                    if (!replacement) return;
279
280                    const pos = this.getPos();
281                    this.view.dispatch(this.view.state.tr.replaceWith(pos, pos + this.node.nodeSize, replacement));
282                    this.view.focus();
283                });
284            }
285
286            update(node) {
287                if (!isCatmenuNode(node)) return false;
288                this.node = node;
289                const typeClass = (node.type && node.type.name === 'dwplugin_inline') ? 'pm_catmenu_inline' : 'pm_catmenu_block';
290                this.dom.className = 'plugin_catmenu pm_catmenu_node nodeHasForm ' + typeClass;
291                this.render();
292                return true;
293            }
294
295            selectNode() { this.dom.classList.add('ProseMirror-selectednode'); }
296            deselectNode() { this.dom.classList.remove('ProseMirror-selectednode'); }
297            stopEvent() { return true; }
298            ignoreMutation() { return true; }
299        }
300
301        class CatmenuMenuItemDispatcher extends AbstractMenuItemDispatcher {
302            static isAvailable(schema) {
303                return !!(schema.nodes.catmenu || schema.nodes.dwplugin_block);
304            }
305
306            static getIcon() {
307                const wrapper = document.createElement('span');
308                wrapper.className = 'menuicon';
309                wrapper.appendChild(getFolderMenuIcon());
310                return wrapper;
311            }
312
313            static getMenuItem(schema) {
314                if (!this.isAvailable(schema)) return hiddenMenuItem();
315
316                return new MenuItem({
317                    label: t('toolbar_button', 'CatMenu'),
318                    icon: this.getIcon(),
319                    command: (state, dispatch, view) => {
320                        const existing = findCatmenuAtSelection(state);
321                        if (!dispatch || !view) return true;
322
323                        const initialValues = existing
324                            ? (parseCatmenuSyntax(syntaxFromNode(existing.node)) || {namespace: '.'})
325                            : {namespace: '.'};
326
327                        showCatmenuDialog(initialValues, (values) => {
328                            const syntax = buildCatmenuSyntax(values);
329                            const pluginNode = createCatmenuNode(schema, syntax);
330                            if (!pluginNode) return;
331
332                            if (existing) {
333                                view.dispatch(view.state.tr.replaceWith(existing.pos, existing.pos + existing.node.nodeSize, pluginNode));
334                            } else if (!insertCatmenuBlock(view, pluginNode)) {
335                                const endPos = view.state.doc.content.size;
336                                view.dispatch(view.state.tr.insert(endPos, pluginNode));
337                            }
338                            view.focus();
339                        });
340
341                        return true;
342                    }
343                });
344            }
345        }
346
347        window.Prosemirror.pluginNodeViews.catmenu = (node, view, getPos) => new CatmenuNodeView(node, view, getPos);
348
349        const originalInline = window.Prosemirror.pluginNodeViews.dwplugin_inline;
350        window.Prosemirror.pluginNodeViews.dwplugin_inline = (node, view, getPos) => {
351            if (isLegacyCatmenuPluginNode(node)) return new CatmenuNodeView(node, view, getPos);
352            return typeof originalInline === 'function' ? originalInline(node, view, getPos) : undefined;
353        };
354
355        const originalBlock = window.Prosemirror.pluginNodeViews.dwplugin_block;
356        window.Prosemirror.pluginNodeViews.dwplugin_block = (node, view, getPos) => {
357            if (isLegacyCatmenuPluginNode(node)) return new CatmenuNodeView(node, view, getPos);
358            return typeof originalBlock === 'function' ? originalBlock(node, view, getPos) : undefined;
359        };
360
361        if (shouldShowInEditorMenu()) {
362            window.Prosemirror.pluginMenuItemDispatchers.push(CatmenuMenuItemDispatcher);
363        }
364
365        if (!window.__catmenuKeyboardGuardInstalled) {
366            window.__catmenuKeyboardGuardInstalled = true;
367            document.addEventListener('keydown', (event) => {
368                if (event.key !== 'Enter') return;
369                const view = window.Prosemirror && window.Prosemirror.view;
370                if (!view || !view.state) return;
371                if (!findCatmenuAtSelection(view.state)) return;
372                event.preventDefault();
373                event.stopPropagation();
374                insertParagraphAfterSelectedCatmenu(view);
375            }, true);
376        }
377    }
378
379    jQuery(document).on('PROSEMIRROR_API_INITIALIZED', initializeCatmenuProsemirror);
380    initializeCatmenuProsemirror();
381})();
382