xref: /plugin/visualindex/script/prosemirror.js (revision 3c9c7f3beeea1dce712c368cb507b309c63f5d06)
1(function () {
2    function initializeVisualIndexProsemirror() {
3        if (window.__visualindexProsemirrorInitialized) return;
4        if (!window.Prosemirror || !window.Prosemirror.classes) return;
5        window.__visualindexProsemirrorInitialized = true;
6
7    const {classes: {MenuItem, AbstractMenuItemDispatcher}} = window.Prosemirror;
8    function hiddenMenuItem() {
9        return new MenuItem({
10            label: '',
11            render: () => {
12                const el = document.createElement('span');
13                el.style.display = 'none';
14                return el;
15            },
16            command: () => false
17        });
18    }
19
20    function shouldShowInEditorMenu() {
21        const raw = window.JSINFO &&
22            JSINFO.plugins &&
23            JSINFO.plugins.visualindex
24            ? JSINFO.plugins.visualindex.show_in_editor_menu
25            : true;
26
27        if (typeof raw === 'boolean') return raw;
28        const normalized = String(raw).trim().toLowerCase();
29        return !(normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no');
30    }
31
32    window.Prosemirror.pluginSchemas.push((nodes, marks) => {
33        nodes = nodes.addToEnd('visualindex', {
34            group: 'protected_block',
35            inline: false,
36            selectable: true,
37            draggable: true,
38            defining: true,
39            isolating: true,
40            code: true,
41            attrs: {
42                syntax: {default: '{{visualindex>.}}'}
43            },
44            toDOM: (node) => ['pre', {class: 'dwplugin', 'data-pluginname': 'visualindex'}, node.attrs.syntax],
45            parseDOM: [{
46                tag: 'pre.dwplugin[data-pluginname="visualindex"]',
47                getAttrs: (dom) => ({syntax: (dom.textContent || '{{visualindex>.}}').trim()})
48            }]
49        });
50        return {nodes, marks};
51    });
52
53    function parseVisualIndexSyntax(syntax) {
54        const m = (syntax || '').match(/^\{\{visualindex>(.*?)\}\}$/i);
55        if (!m) return null;
56
57        const parts = m[1].split(';').map((p) => p.trim()).filter(Boolean);
58        const namespace = parts.shift() || '.';
59        const options = {namespace, filter: '', desc: false, medias: false};
60
61        parts.forEach((part) => {
62            const [keyRaw, valRaw] = part.split('=', 2);
63            const key = (keyRaw || '').trim().toLowerCase();
64            const val = valRaw === undefined ? '1' : String(valRaw).trim();
65            if (key === 'filter') options.filter = val;
66            if (key === 'desc') options.desc = (val !== '0' && val !== 'false' && val !== '');
67            if (key === 'medias') options.medias = (val !== '0' && val !== 'false' && val !== '');
68        });
69
70        return options;
71    }
72
73    function buildVisualIndexSyntax(values) {
74        let syntax = `{{visualindex>${values.namespace || '.'}`;
75        if (values.filter) syntax += `;filter=${values.filter}`;
76        if (values.desc) syntax += ';desc=1';
77        if (values.medias) syntax += ';medias=1';
78        syntax += '}}';
79        return syntax;
80    }
81
82    function formatVisualIndexLabel(values) {
83        const parts = [`VisualIndex: ${values.namespace || '.'}`];
84        if (values.filter) parts.push(`filter=${values.filter}`);
85        if (values.desc) parts.push('desc');
86        if (values.medias) parts.push('medias');
87        return parts.join(' | ');
88    }
89
90    function getFolderIconUrl() {
91        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>";
92        return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
93    }
94
95    function getFolderMenuIcon() {
96        const ns = 'http://www.w3.org/2000/svg';
97        const svg = document.createElementNS(ns, 'svg');
98        svg.setAttribute('viewBox', '0 0 24 24');
99
100        const path1 = document.createElementNS(ns, 'path');
101        path1.setAttribute('d', 'M10 4l2 2h8a2 2 0 0 1 2 2v2H2V6a2 2 0 0 1 2-2h6z');
102        path1.setAttribute('fill', 'currentColor');
103        svg.appendChild(path1);
104
105        const path2 = document.createElementNS(ns, 'path');
106        path2.setAttribute('d', 'M2 10h20v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-8z');
107        path2.setAttribute('fill', 'currentColor');
108        svg.appendChild(path2);
109
110        return svg;
111    }
112
113    function isLegacyVisualIndexPluginNode(node) {
114        return !!(
115            node &&
116            node.type &&
117            (node.type.name === 'dwplugin_inline' || node.type.name === 'dwplugin_block') &&
118            node.attrs &&
119            node.attrs['data-pluginname'] === 'visualindex'
120        );
121    }
122
123    function isVisualIndexNode(node) {
124        return !!(node && node.type && node.type.name === 'visualindex') || isLegacyVisualIndexPluginNode(node);
125    }
126
127    function syntaxFromNode(node) {
128        if (!node) return '{{visualindex>.}}';
129        if (node.type && node.type.name === 'visualindex') {
130            return String((node.attrs && node.attrs.syntax) || '{{visualindex>.}}');
131        }
132        return String(node.textContent || '{{visualindex>.}}');
133    }
134
135    function createVisualIndexNode(schema, syntax) {
136        const normalized = String(syntax || '{{visualindex>.}}').trim() || '{{visualindex>.}}';
137        if (schema.nodes.visualindex) {
138            return schema.nodes.visualindex.createChecked({syntax: normalized});
139        }
140
141        const fallback = schema.nodes.dwplugin_block;
142        if (!fallback) return null;
143
144        return fallback.createChecked(
145            {class: 'dwplugin', 'data-pluginname': 'visualindex'},
146            schema.text(normalized)
147        );
148    }
149
150    function selectionIsVisualIndex(state) {
151        const selected = findVisualIndexAtSelection(state);
152        return !!selected;
153    }
154
155    function insertParagraphAfterSelectedVisualIndex(view) {
156        if (!view || !view.state) return false;
157        const selected = findVisualIndexAtSelection(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        // Move cursor into the newly inserted paragraph.
169        try {
170            const SelectionClass = view.state.selection.constructor;
171            const $target = view.state.doc.resolve(insertPos + 1);
172            const selection = SelectionClass.near($target, 1);
173            view.dispatch(view.state.tr.setSelection(selection).scrollIntoView());
174        } catch (e) {
175            // Keep default selection if we can't safely resolve a text position.
176        }
177
178        view.focus();
179        return true;
180    }
181
182    function findVisualIndexAtSelection(state) {
183        const {selection} = state;
184
185        if (isVisualIndexNode(selection.node)) {
186            return {node: selection.node, pos: selection.from};
187        }
188
189        const $from = selection.$from;
190
191        if ($from.depth > 0 && isVisualIndexNode($from.parent)) {
192            return {node: $from.parent, pos: $from.before($from.depth)};
193        }
194
195        if (isVisualIndexNode($from.nodeBefore)) {
196            return {node: $from.nodeBefore, pos: $from.pos - $from.nodeBefore.nodeSize};
197        }
198
199        if (isVisualIndexNode($from.nodeAfter)) {
200            return {node: $from.nodeAfter, pos: $from.pos};
201        }
202
203        for (let depth = $from.depth; depth > 0; depth -= 1) {
204            const ancestor = $from.node(depth);
205            if (isVisualIndexNode(ancestor)) {
206                return {node: ancestor, pos: $from.before(depth)};
207            }
208        }
209
210        return null;
211    }
212
213    function insertVisualIndexBlock(view, pluginNode) {
214        const state = view.state;
215        const {$from} = state.selection;
216        const index = $from.index();
217
218        if ($from.parent.canReplaceWith(index, index, pluginNode.type)) {
219            view.dispatch(state.tr.replaceSelectionWith(pluginNode));
220            return true;
221        }
222
223        for (let depth = $from.depth; depth > 0; depth -= 1) {
224            const insertPos = $from.after(depth);
225            try {
226                view.dispatch(state.tr.insert(insertPos, pluginNode));
227                return true;
228            } catch (e) {
229                // try a higher ancestor
230            }
231        }
232
233        return false;
234    }
235
236    function showVisualIndexDialog(initialValues, onSubmit) {
237        const values = {
238            namespace: '.',
239            filter: '',
240            desc: false,
241            medias: false,
242            ...initialValues
243        };
244
245        const $dialog = jQuery('<div class="plugin_visualindex_form" title="Visualindex"></div>');
246        $dialog.append('<label>Namespace</label>');
247        const $namespace = jQuery('<input type="text" class="edit" style="width:100%;" />').val(values.namespace);
248        $dialog.append($namespace);
249        $dialog.append('<div style="font-size:.9em;color:#555;margin-top:4px;">Dossier. "." = dossier courant.</div>');
250
251        $dialog.append('<label style="display:block;margin-top:8px;">Filtre</label>');
252        const $filter = jQuery('<input type="text" class="edit" style="width:100%;" />').val(values.filter);
253        $dialog.append($filter);
254
255        const $descWrap = jQuery('<label style="display:block;margin-top:10px;"></label>');
256        const $desc = jQuery('<input type="checkbox" />').prop('checked', !!values.desc);
257        $descWrap.append($desc).append(' Ordre descendant');
258        $dialog.append($descWrap);
259
260        const $mediasWrap = jQuery('<label style="display:block;margin-top:6px;"></label>');
261        const $medias = jQuery('<input type="checkbox" />').prop('checked', !!values.medias);
262        $mediasWrap.append($medias).append(' Afficher les medias');
263        $dialog.append($mediasWrap);
264
265        $dialog.dialog({
266            modal: true,
267            width: 460,
268            close: function () {
269                jQuery(this).dialog('destroy').remove();
270            },
271            buttons: [
272                {
273                    text: 'Insérer',
274                    click: function () {
275                        onSubmit({
276                            namespace: String($namespace.val() || '.').trim() || '.',
277                            filter: String($filter.val() || '').trim(),
278                            desc: $desc.is(':checked'),
279                            medias: $medias.is(':checked')
280                        });
281                        jQuery(this).dialog('close');
282                    }
283                },
284                {
285                    text: 'Annuler',
286                    click: function () {
287                        jQuery(this).dialog('close');
288                    }
289                }
290            ]
291        });
292    }
293
294    class VisualIndexNodeView {
295        constructor(node, view, getPos) {
296            this.node = node;
297            this.view = view;
298            this.getPos = getPos;
299            this.dom = document.createElement('div');
300            const typeClass = (node.type && node.type.name === 'dwplugin_inline') ? 'pm_visualindex_inline' : 'pm_visualindex_block';
301            this.dom.className = 'plugin_visualindex pm_visualindex_node nodeHasForm ' + typeClass;
302            this.dom.setAttribute('contenteditable', 'false');
303            this.render();
304
305            this.dom.addEventListener('click', (event) => {
306                event.preventDefault();
307                event.stopPropagation();
308                this.openEditor();
309            });
310        }
311
312        render() {
313            const syntax = syntaxFromNode(this.node);
314            const parsed = parseVisualIndexSyntax(syntax);
315            const label = parsed ? formatVisualIndexLabel(parsed) : syntax;
316            this.dom.textContent = '';
317
318            const icon = document.createElement('img');
319            icon.className = 'pm_visualindex_icon';
320            icon.src = getFolderIconUrl();
321            icon.alt = '';
322            icon.setAttribute('aria-hidden', 'true');
323            this.dom.appendChild(icon);
324
325            const text = document.createElement('span');
326            text.textContent = label;
327            this.dom.appendChild(text);
328            this.dom.setAttribute('title', syntax);
329        }
330
331        openEditor() {
332            const parsed = parseVisualIndexSyntax(syntaxFromNode(this.node)) || {
333                namespace: '.',
334                filter: '',
335                desc: false,
336                medias: false
337            };
338
339            showVisualIndexDialog(parsed, (values) => {
340                const syntax = buildVisualIndexSyntax(values);
341                const replacement = createVisualIndexNode(this.view.state.schema, syntax);
342                if (!replacement) return;
343
344                const pos = this.getPos();
345                this.view.dispatch(this.view.state.tr.replaceWith(pos, pos + this.node.nodeSize, replacement));
346                this.view.focus();
347            });
348        }
349
350        update(node) {
351            if (!isVisualIndexNode(node)) return false;
352            this.node = node;
353            const typeClass = (node.type && node.type.name === 'dwplugin_inline') ? 'pm_visualindex_inline' : 'pm_visualindex_block';
354            this.dom.className = 'plugin_visualindex pm_visualindex_node nodeHasForm ' + typeClass;
355            this.render();
356            return true;
357        }
358
359        selectNode() { this.dom.classList.add('ProseMirror-selectednode'); }
360        deselectNode() { this.dom.classList.remove('ProseMirror-selectednode'); }
361        stopEvent() { return true; }
362        ignoreMutation() { return true; }
363    }
364
365    class VisualIndexMenuItemDispatcher extends AbstractMenuItemDispatcher {
366        static isAvailable(schema) {
367            return !!(schema.nodes.visualindex || schema.nodes.dwplugin_block);
368        }
369
370        static getIcon() {
371            const wrapper = document.createElement('span');
372            wrapper.className = 'menuicon';
373            wrapper.appendChild(getFolderMenuIcon());
374            return wrapper;
375        }
376
377        static getMenuItem(schema) {
378            if (!this.isAvailable(schema)) return hiddenMenuItem();
379
380            return new MenuItem({
381                label: 'VisualIndex',
382                icon: this.getIcon(),
383                command: (state, dispatch, view) => {
384                    const existing = findVisualIndexAtSelection(state);
385                    if (!dispatch || !view) return true;
386
387                    let initialValues = {namespace: '.', filter: '', desc: false, medias: false};
388                    if (existing) {
389                        const parsed = parseVisualIndexSyntax(syntaxFromNode(existing.node));
390                        if (parsed) initialValues = parsed;
391                    }
392
393                    showVisualIndexDialog(initialValues, (values) => {
394                        const syntax = buildVisualIndexSyntax(values);
395                        const pluginNode = createVisualIndexNode(schema, syntax);
396                        if (!pluginNode) return;
397
398                        if (existing) {
399                            view.dispatch(view.state.tr.replaceWith(existing.pos, existing.pos + existing.node.nodeSize, pluginNode));
400                        } else if (!insertVisualIndexBlock(view, pluginNode)) {
401                            const endPos = view.state.doc.content.size;
402                            view.dispatch(view.state.tr.insert(endPos, pluginNode));
403                        }
404
405                        view.focus();
406                    });
407
408                    return true;
409                }
410            });
411        }
412    }
413
414    if (shouldShowInEditorMenu()) {
415        window.Prosemirror.pluginMenuItemDispatchers.push(VisualIndexMenuItemDispatcher);
416    }
417    window.Prosemirror.pluginNodeViews.visualindex = (node, view, getPos) => new VisualIndexNodeView(node, view, getPos);
418
419    const originalInline = window.Prosemirror.pluginNodeViews.dwplugin_inline;
420    window.Prosemirror.pluginNodeViews.dwplugin_inline = (node, view, getPos) => {
421        if (isLegacyVisualIndexPluginNode(node)) return new VisualIndexNodeView(node, view, getPos);
422        return typeof originalInline === 'function' ? originalInline(node, view, getPos) : undefined;
423    };
424
425    const originalBlock = window.Prosemirror.pluginNodeViews.dwplugin_block;
426    window.Prosemirror.pluginNodeViews.dwplugin_block = (node, view, getPos) => {
427        if (isLegacyVisualIndexPluginNode(node)) return new VisualIndexNodeView(node, view, getPos);
428        return typeof originalBlock === 'function' ? originalBlock(node, view, getPos) : undefined;
429    };
430
431    if (!window.__visualindexKeyboardGuardInstalled) {
432        window.__visualindexKeyboardGuardInstalled = true;
433        document.addEventListener('keydown', (event) => {
434            const view = window.Prosemirror && window.Prosemirror.view;
435            if (!view || !view.state) return;
436            if (!selectionIsVisualIndex(view.state)) return;
437
438            if (event.key === 'Enter') {
439                event.preventDefault();
440                event.stopPropagation();
441                insertParagraphAfterSelectedVisualIndex(view);
442                return;
443            }
444
445            if (event.key === 'Backspace' || event.key === 'Delete') {
446                event.preventDefault();
447                event.stopPropagation();
448            }
449        }, true);
450    }
451    }
452
453    jQuery(document).on('PROSEMIRROR_API_INITIALIZED', initializeVisualIndexProsemirror);
454    initializeVisualIndexProsemirror();
455})();
456