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.