PageSourceSearch

https://www.policeconduct.gov.uk/modules/custom/ckeditor5_multilev…/multilevel/src/index.js?tltxgm

js policeconduct.gov.uk collected 2026-10-02 04:42:34 UTC 21,756 bytes, 596 lines download raw bytes

1/* eslint-disable */
2((Drupal, CKEditor5) => {
3  const { core, ui } = CKEditor5;
4  const { Plugin, Command } = core;
5  const { ButtonView, View, LabeledFieldView, createLabeledInputText, submitHandler, ContextualBalloon, clickOutsideHandler } = ui;
6
7  class MultiLevelStartCommand extends Command {
8    execute(options = {}) {
9      const model = this.editor.model;
10      const selection = model.document.selection;
11      const startNumber = options.startNumber;
12      
13      if (!startNumber) return;
14      
15      model.change(writer => {
16        const selectedBlocks = Array.from(selection.getSelectedBlocks());
17        const listItems = selectedBlocks.filter(block => block.getAttribute('listType'));
18        
19        if (listItems.length === 0) return;
20        
21        const rootListItem = this._findRootListItem(listItems[0]);
22        const currentAttrs = rootListItem.getAttribute('htmlOlAttributes') || {};
23        
24        writer.setAttribute('htmlOlAttributes', {
25          ...currentAttrs,
26          'data-start': startNumber
27        }, rootListItem);
28      });
29    }
30    
31    refresh() {
32      const model = this.editor.model;
33      const selection = model.document.selection;
34      const selectedBlocks = Array.from(selection.getSelectedBlocks());
35      
36      const inNumberedList = selectedBlocks.some(block => 
37        block.getAttribute('listType') === 'numbered'
38      );
39      this.isEnabled = inNumberedList;
40      
41      if (inNumberedList) {
42        const firstListItem = selectedBlocks.find(block => 
43          block.getAttribute('listType') === 'numbered'
44        );
45        
46        if (firstListItem) {
47          const rootListItem = this._findRootListItem(firstListItem);
48          const attrs = rootListItem.getAttribute('htmlOlAttributes') || {};
49          let classes = [];
50          if (attrs.classes) {
51            classes = typeof attrs.classes === 'string' 
52              ? attrs.classes.split(' ').filter(c => c)
53              : attrs.classes;
54          }
55          
56          this.value = attrs['data-start'] || '';
57          this.isMultiLevel = classes.includes('ml-legal') || classes.includes('ml-legal-simple');
58        } else {
59          this.value = '';
60          this.isMultiLevel = false;
61        }
62      } else {
63        this.value = '';
64        this.isMultiLevel = false;
65      }
66    }
67    
68    _findRootListItem(listItem) {
69      let root = listItem;
70      let current = listItem.previousSibling;
71      
72      while (current) {
73        const hasListType = current.getAttribute('listType');
74        
75        if (hasListType) {
76          const indent = current.getAttribute('listIndent');
77          
78          if (indent === 0) {
79            const listType = current.getAttribute('listType');
80            
81            if (listType === root.getAttribute('listType')) {
82              root = current;
83            } else {
84              break;
85            }
86          }
87        } else {
88          break;
89        }
90        
91        current = current.previousSibling;
92      }
93      
94      return root;
95    }
96  }
97
98  /**
99   * Command to toggle multi-level legal numbering on numbered lists.
100   */
101  class ToggleMultiLevelCommand extends Command {
102    /**
103     * Executes the command - toggles ml-legal or ml-legal-simple class on root list item.
104     * @param {Object} options - { style: 'ml-legal' | 'ml-legal-simple' | null }
105     */
106    execute(options = {}) {
107      const model = this.editor.model;
108      const selection = model.document.selection;
109      const style = options.style || 'ml-legal';
110      
111      model.change(writer => {
112        // Find all list items in selection
113        const selectedBlocks = Array.from(selection.getSelectedBlocks());
114        const listItems = selectedBlocks.filter(block => block.getAttribute('listType'));
115        
116        if (listItems.length === 0) return;
117        
118        // Find the root list item (indent = 0)
119        const rootListItem = this._findRootListItem(listItems[0]);
120        
121        // Get current classes from GHS attributes
122        const currentAttrs = rootListItem.getAttribute('htmlOlAttributes') || {};
123        
124        // Handle both string and array format for classes
125        let currentClasses = [];
126        if (currentAttrs.classes) {
127          currentClasses = typeof currentAttrs.classes === 'string'
128            ? currentAttrs.classes.split(' ').filter(c => c)
129            : currentAttrs.classes;
130        }
131        
132        const hasAnyMultiLevel = currentClasses.includes('ml-legal') || currentClasses.includes('ml-legal-simple');
133        const hasThisStyle = currentClasses.includes(style);
134        
135        // Toggle the style
136        let newClasses;
137        if (hasThisStyle) {
138          // Remove the current style
139          newClasses = currentClasses.filter(c => c !== 'ml-legal' && c !== 'ml-legal-simple');
140        } else {
141          // Remove any existing multi-level style and add the new one
142          newClasses = currentClasses.filter(c => c !== 'ml-legal' && c !== 'ml-legal-simple');
143          newClasses.push(style);
144        }
145        
146        // Update or remove the attribute
147        if (newClasses.length > 0) {
148          writer.setAttribute('htmlOlAttributes', {
149            ...currentAttrs,
150            classes: newClasses.join(' ')
151          }, rootListItem);
152        } else {
153          // No classes left, clean up attributes
154          const { classes, ...remainingAttrs } = currentAttrs;
155          if (Object.keys(remainingAttrs).length > 0) {
156            writer.setAttribute('htmlOlAttributes', remainingAttrs, rootListItem);
157          } else {
158            writer.removeAttribute('htmlOlAttributes', rootListItem);
159          }
160        }
161      });
162    }
163    
164    /**
165     * Updates button state based on current selection.
166     */
167    refresh() {
168      const model = this.editor.model;
169      const selection = model.document.selection;
170      const selectedBlocks = Array.from(selection.getSelectedBlocks());
171      
172      // Enable only when in a numbered list
173      const inNumberedList = selectedBlocks.some(block => 
174        block.getAttribute('listType') === 'numbered'
175      );
176      this.isEnabled = inNumberedList;
177      
178      // Update button pressed state and store current style
179      if (inNumberedList) {
180        const firstListItem = selectedBlocks.find(block => 
181          block.getAttribute('listType') === 'numbered'
182        );
183        
184        if (firstListItem) {
185          const rootListItem = this._findRootListItem(firstListItem);
186          const attrs = rootListItem.getAttribute('htmlOlAttributes') || {};
187          
188          // Handle both string and array format for classes
189          let classes = [];
190          if (attrs.classes) {
191            classes = typeof attrs.classes === 'string' 
192              ? attrs.classes.split(' ').filter(c => c)
193              : attrs.classes;
194          }
195          
196          // Check which style is active
197          if (classes.includes('ml-legal')) {
198            this.value = 'ml-legal';
199          } else if (classes.includes('ml-legal-simple')) {
200            this.value = 'ml-legal-simple';
201          } else {
202            this.value = false;
203          }
204        } else {
205          this.value = false;
206        }
207      } else {
208        this.value = false;
209      }
210    }
211    
212    /**
213     * Finds the root list item (indent = 0) by walking backwards.
214     * 
215     * @param {Element} listItem - Starting list item
216     * @returns {Element} Root list item
217     */
218    _findRootListItem(listItem) {
219      let root = listItem;
220      let current = listItem.previousSibling;
221      
222      // Walk backwards to find indent = 0
223      while (current) {
224        const hasListType = current.getAttribute('listType');
225        
226        if (hasListType) {
227          const indent = current.getAttribute('listIndent');
228          
229          if (indent === 0) {
230            const listType = current.getAttribute('listType');
231            
232            // Same list type? Update root
233            if (listType === root.getAttribute('listType')) {
234              root = current;
235            } else {
236              // Different list, stop here
237              break;
238            }
239          }
240        } else {
241          // Not a list item, stop
242          break;
243        }
244        
245        current = current.previousSibling;
246      }
247      
248      return root;
249    }
250  }
251
252  /**
253   * Form view for multi-level start number input.
254   * Displays as an additional field in the list properties.
255   */
256  class MultiLevelPropertiesView extends View {
257    constructor(locale, startCommand) {
258      super(locale);
259      
260      const t = locale.t || ((key) => key);
261      
262      this.startCommand = startCommand;
263      
264      // Create labeled input for start number
265      this.startInputView = this._createInput(t('Start at'));
266      
267      this.setTemplate({
268        tag: 'div',
269        attributes: {
270          class: ['ck', 'ck-list-properties-form__row']
271        },
272        children: [this.startInputView]
273      });
274    }
275    
276    _createInput(label) {
277      const labeledInput = new LabeledFieldView(this.locale, createLabeledInputText);
278      
279      labeledInput.label = label;
280      labeledInput.fieldView.placeholder = 'e.g., 1.1, 4.2.3, 6.1';
281      labeledInput.fieldView.inputMode = 'text';
282      
283      // Bind input value to command
284      labeledInput.fieldView.bind('value').to(this.startCommand, 'value');
285      
286      return labeledInput;
287    }
288    
289    render() {
290      super.render();
291      
292      // Update command when user types
293      const input = this.startInputView.fieldView.element;
294      if (input) {
295        input.addEventListener('input', () => {
296          const value = input.value;
297          // Validate format: numbers separated by dots
298          if (!value || /^(\d+\.)*\d*$/.test(value)) {
299            this.startCommand.execute({ startNumber: value || '' });
300          }
301        });
302      }
303    }
304    
305    focus() {
306      this.startInputView.fieldView.focus();
307    }
308  }
309
310  /**
311   * UI plugin - adds the multi-level list buttons to the toolbar.
312   */
313  class MultiLevelUI extends Plugin {
314    static get pluginName() {
315      return 'MultiLevelUI';
316    }
317    
318    init() {
319      const editor = this.editor;
320      const t = (key) => key; // Simple translation function
321      
322      // Button 1: Multi-level list starting from 1.1
323      editor.ui.componentFactory.add('multiLevelList', locale => {
324        const button = new ButtonView(locale);
325        const command = editor.commands.get('toggleMultiLevel');
326        
327        button.set({ 
328          label: 'Multi-level list (1.1)',
329          tooltip: true,
330          withText: false,
331          isToggleable: true,
332          icon: '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7 5.75c0 .414.336.75.75.75h9.5a.75.75 0 1 0 0-1.5h-9.5a.75.75 0 0 0-.75.75zM7.75 10a.75.75 0 0 0 0 1.5h9.5a.75.75 0 0 0 0-1.5h-9.5zM7 14.75c0 .414.336.75.75.75h9.5a.75.75 0 0 0 0-1.5h-9.5a.75.75 0 0 0-.75.75z"/><text x="1.5" y="7" font-size="3.5" fill="currentColor" font-weight="bold">1.1</text><text x="1.5" y="11.5" font-size="3.5" fill="currentColor" font-weight="bold">1.2</text><text x="1.5" y="16" font-size="3.5" fill="currentColor" font-weight="bold">2.1</text></svg>'
333        });
334        
335        button.bind('isEnabled').to(command, 'isEnabled');
336        button.bind('isOn').to(command, 'value', value => value === 'ml-legal');
337        
338        button.on('execute', () => {
339          editor.execute('toggleMultiLevel', { style: 'ml-legal' });
340          editor.editing.view.focus();
341        });
342        
343        return button;
344      });
345      
346      // Button 2: Multi-level list starting from 1
347      editor.ui.componentFactory.add('multiLevelListSimple', locale => {
348        const button = new ButtonView(locale);
349        const command = editor.commands.get('toggleMultiLevel');
350        
351        button.set({ 
352          label: 'Multi-level list (1)',
353          tooltip: true,
354          withText: false,
355          isToggleable: true,
356          icon: '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7 5.75c0 .414.336.75.75.75h9.5a.75.75 0 1 0 0-1.5h-9.5a.75.75 0 0 0-.75.75zM7.75 10a.75.75 0 0 0 0 1.5h9.5a.75.75 0 0 0 0-1.5h-9.5zM7 14.75c0 .414.336.75.75.75h9.5a.75.75 0 0 0 0-1.5h-9.5a.75.75 0 0 0-.75.75z"/><text x="2.5" y="7" font-size="4" fill="currentColor" font-weight="bold">1</text><text x="2.5" y="11.5" font-size="4" fill="currentColor" font-weight="bold">2</text><text x="2.5" y="16" font-size="4" fill="currentColor" font-weight="bold">3</text></svg>'
357        });
358        
359        button.bind('isEnabled').to(command, 'isEnabled');
360        button.bind('isOn').to(command, 'value', value => value === 'ml-legal-simple');
361        
362        button.on('execute', () => {
363          editor.execute('toggleMultiLevel', { style: 'ml-legal-simple' });
364          editor.editing.view.focus();
365        });
366        
367        return button;
368      });
369    }
370  }
371
372  /**
373   * Main plugin - registers command and schema.
374   */
375  class MultiLevel extends Plugin {
376    static get pluginName() {
377      return 'MultiLevel';
378    }
379    
380    static get requires() {
381      return [MultiLevelUI, ContextualBalloon];
382    }
383
384    init() {
385      const editor = this.editor;
386      
387      // Register commands
388      editor.commands.add('toggleMultiLevel', new ToggleMultiLevelCommand(editor));
389      editor.commands.add('multiLevelStart', new MultiLevelStartCommand(editor));
390      
391      // Allow GHS attributes on blocks (for <ol class="...">)
392      editor.model.schema.extend('$block', {
393        allowAttributes: ['htmlOlAttributes', 'htmlUlAttributes']
394      });
395      
396      // Integrate custom start number field with CKEditor's list properties
397      this._integrateWithListProperties();
398      
399      // Setup downcast conversion for custom start numbers
400      this._setupDowncast();
401    }
402    
403    /**
404     * Integrates the custom start number field as a contextual balloon.
405     * Only shows when double-clicking on the numbered part of the first item.
406     */
407    _integrateWithListProperties() {
408      const editor = this.editor;
409      const startCommand = editor.commands.get('multiLevelStart');
410      
411      // Create contextual balloon
412      this.balloon = editor.plugins.get(ContextualBalloon);
413      
414      // Create properties view
415      this.propertiesView = new MultiLevelPropertiesView(editor.locale, startCommand);
416      
417      // Show balloon only when double-clicking and on first item of multi-level list
418      this.listenTo(editor.editing.view.document, 'dblclick', (evt, data) => {
419        if (!startCommand.isEnabled || !startCommand.isMultiLevel) {
420          return;
421        }
422        
423        const selection = editor.model.document.selection;
424        const selectedBlocks = Array.from(selection.getSelectedBlocks());
425        const firstBlock = selectedBlocks[0];
426        
427        // Only show on first item (indent = 0)
428        if (!firstBlock || firstBlock.getAttribute('listIndent') !== 0) {
429          return;
430        }
431        
432        // Get the clicked DOM element
433        const domTarget = data.domTarget;
434        
435        // Check if clicked on the ::before pseudo-element area (the number part)
436        // This is approximate since we can't directly detect ::before clicks
437        const rect = domTarget.getBoundingClientRect();
438        const clickX = data.domEvent.clientX;
439        
440        // If click is to the left of the text (where the number would be)
441        // Allow some margin (30px) for the number area
442        if (clickX < rect.left + 30) {
443          this._showBalloon();
444          data.domEvent.preventDefault();
445        }
446      });
447      
448      // Also hide balloon when selection changes (user clicks elsewhere)
449      this.listenTo(editor.model.document.selection, 'change', () => {
450        if (this.balloon.hasView(this.propertiesView)) {
451          const selection = editor.model.document.selection;
452          const selectedBlocks = Array.from(selection.getSelectedBlocks());
453          const firstBlock = selectedBlocks[0];
454          
455          // Hide if moved away from first level items
456          if (!firstBlock || firstBlock.getAttribute('listIndent') !== 0 || !startCommand.isMultiLevel) {
457            this._hideBalloon();
458          }
459        }
460      });
461      
462      // Hide balloon on click outside
463      clickOutsideHandler({
464        emitter: this.propertiesView,
465        activator: () => this.balloon.visibleView === this.propertiesView,
466        contextElements: [this.balloon.view.element],
467        callback: () => this._hideBalloon()
468      });
469    }
470    
471    /**
472     * Shows the properties balloon.
473     */
474    _showBalloon() {
475      if (!this.balloon.hasView(this.propertiesView)) {
476        this.balloon.add({
477          view: this.propertiesView,
478          position: this._getBalloonPositionData()
479        });
480      }
481    }
482    
483    /**
484     * Hides the properties balloon.
485     */
486    _hideBalloon() {
487      if (this.balloon.hasView(this.propertiesView)) {
488        this.balloon.remove(this.propertiesView);
489      }
490    }
491    
492    /**
493     * Gets balloon position data.
494     */
495    _getBalloonPositionData() {
496      const editor = this.editor;
497      const view = editor.editing.view;
498      const viewDocument = view.document;
499      
500      return {
501        target: view.domConverter.viewRangeToDom(viewDocument.selection.getFirstRange())
502      };
503    }
504    
505    /**
506     * Sets up downcast conversion to apply custom counter values.
507     */
508    _setupDowncast() {
509      const editor = this.editor;
510      const conversion = editor.conversion;
511      
512      // Downcast for data-start attribute to apply counter-reset and custom content
513      conversion.for('downcast').add(dispatcher => {
514        dispatcher.on('attribute:htmlOlAttributes', (evt, data, conversionApi) => {
515          if (!data.attributeNewValue || !data.attributeNewValue['data-start']) {
516            return;
517          }
518          
519          const viewElement = conversionApi.mapper.toViewElement(data.item);
520          if (!viewElement || data.item.getAttribute('listIndent') !== 0) {
521            return;
522          }
523          
524          // Find the <ol> element in the view
525          let olElement = viewElement;
526          while (olElement && olElement.name !== 'ol') {
527            olElement = olElement.parent;
528          }
529          
530          if (!olElement) return;
531          
532          // Parse start number (e.g., "6.4" -> [6, 4] or "5" -> [5])
533          const startNumber = data.attributeNewValue['data-start'];
534          const parts = startNumber.split('.').map(n => parseInt(n, 10)).filter(n => !isNaN(n));
535          
536          if (parts.length >= 2) {
537            // For ml-legal style (e.g., "1.1", "2.5"):
538            // - First digit: we need to use CSS custom properties or inline content override
539            // - Second digit: set level2 counter to parts[1]-1
540            
541            // Set counter-reset for level2 (second digit)
542            const counterReset = `level1 0 level2 ${parts[1] - 1}`;
543            conversionApi.writer.setStyle('counter-reset', counterReset, olElement);
544            
545            // Set CSS custom property for first digit
546            conversionApi.writer.setStyle('--ml-first-digit', `"${parts[0]}"`, olElement);
547            
548            // Calculate dynamic width based on number lengths
549            // Each digit takes ~0.6em, dots take ~0.3em, space takes ~0.3em
550            const calculateWidth = (numParts) => {
551              const totalDigits = numParts.reduce((sum, num) => sum + num.toString().length, 0);
552              const totalDots = numParts.length; // dots + trailing space
553              return totalDigits * 0.6 + totalDots * 0.3 + 0.5; // +0.5em for safety margin
554            };
555            
556            // Set dynamic width for different nesting levels
557            // Level 1: first.second (e.g., 1.3333)
558            const width1 = calculateWidth([parts[0], parts[1] || 1]);
559            conversionApi.writer.setStyle('--ml-width-1', `${width1}em`, olElement);
560            
561            // Level 2: first.second.third (e.g., 1.3333.1)
562            const width2 = calculateWidth([parts[0], parts[1] || 1, 1]);
563            conversionApi.writer.setStyle('--ml-width-2', `${width2}em`, olElement);
564            
565            // Level 3: first.second.third.fourth (e.g., 1.3333.1.1)
566            const width3 = calculateWidth([parts[0], parts[1] || 1, 1, 1]);
567            conversionApi.writer.setStyle('--ml-width-3', `${width3}em`, olElement);
568            
569            // Level 4 and beyond
570            const width4 = calculateWidth([parts[0], parts[1] || 1, 1, 1, 1]);
571            conversionApi.writer.setStyle('--ml-width-4', `${width4}em`, olElement);
572            
573            const width5 = calculateWidth([parts[0], parts[1] || 1, 1, 1, 1, 1]);
574            conversionApi.writer.setStyle('--ml-width-5', `${width5}em`, olElement);
575            
576            const width6 = calculateWidth([parts[0], parts[1] || 1, 1, 1, 1, 1, 1]);
577            conversionApi.writer.setStyle('--ml-width-6', `${width6}em`, olElement);
578          } else if (parts.length === 1) {
579            // For ml-legal-simple style (e.g., "3", "5"):
580            // Set counter-reset for level1 to start from the specified number
581            const counterReset = `level1 ${parts[0] - 1}`;
582            conversionApi.writer.setStyle('counter-reset', counterReset, olElement);
583            
584            // Set CSS custom property for simple start
585            conversionApi.writer.setStyle('--ml-simple-start', parts[0] - 1, olElement);
586          }
587        }, { priority: 'low' });
588      });
589    }
590  }
591
592  // Export to global namespace
593  CKEditor5.multiLevel = CKEditor5.multiLevel || {};
594  CKEditor5.multiLevel.MultiLevel = MultiLevel;
595
596})(Drupal, CKEditor5);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.