1 | /**
|
2 | * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
|
3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
|
4 | */
|
5 | /**
|
6 | * @module block-quote/blockquotecommand
|
7 | */
|
8 | import { Command } from 'ckeditor5/src/core.js';
|
9 | import { first } from 'ckeditor5/src/utils.js';
|
10 | /**
|
11 | * The block quote command plugin.
|
12 | *
|
13 | * @extends module:core/command~Command
|
14 | */
|
15 | export default class BlockQuoteCommand extends Command {
|
16 | /**
|
17 | * @inheritDoc
|
18 | */
|
19 | refresh() {
|
20 | this.value = this._getValue();
|
21 | this.isEnabled = this._checkEnabled();
|
22 | }
|
23 | /**
|
24 | * Executes the command. When the command {@link #value is on}, all top-most block quotes within
|
25 | * the selection will be removed. If it is off, all selected blocks will be wrapped with
|
26 | * a block quote.
|
27 | *
|
28 | * @fires execute
|
29 | * @param options Command options.
|
30 | * @param options.forceValue If set, it will force the command behavior. If `true`, the command will apply a block quote,
|
31 | * otherwise the command will remove the block quote. If not set, the command will act basing on its current value.
|
32 | */
|
33 | execute(options = {}) {
|
34 | const model = this.editor.model;
|
35 | const schema = model.schema;
|
36 | const selection = model.document.selection;
|
37 | const blocks = Array.from(selection.getSelectedBlocks());
|
38 | const value = (options.forceValue === undefined) ? !this.value : options.forceValue;
|
39 | model.change(writer => {
|
40 | if (!value) {
|
41 | this._removeQuote(writer, blocks.filter(findQuote));
|
42 | }
|
43 | else {
|
44 | const blocksToQuote = blocks.filter(block => {
|
45 | // Already quoted blocks needs to be considered while quoting too
|
46 | // in order to reuse their <bQ> elements.
|
47 | return findQuote(block) || checkCanBeQuoted(schema, block);
|
48 | });
|
49 | this._applyQuote(writer, blocksToQuote);
|
50 | }
|
51 | });
|
52 | }
|
53 | /**
|
54 | * Checks the command's {@link #value}.
|
55 | */
|
56 | _getValue() {
|
57 | const selection = this.editor.model.document.selection;
|
58 | const firstBlock = first(selection.getSelectedBlocks());
|
59 | // In the current implementation, the block quote must be an immediate parent of a block element.
|
60 | return !!(firstBlock && findQuote(firstBlock));
|
61 | }
|
62 | /**
|
63 | * Checks whether the command can be enabled in the current context.
|
64 | *
|
65 | * @returns Whether the command should be enabled.
|
66 | */
|
67 | _checkEnabled() {
|
68 | if (this.value) {
|
69 | return true;
|
70 | }
|
71 | const selection = this.editor.model.document.selection;
|
72 | const schema = this.editor.model.schema;
|
73 | const firstBlock = first(selection.getSelectedBlocks());
|
74 | if (!firstBlock) {
|
75 | return false;
|
76 | }
|
77 | return checkCanBeQuoted(schema, firstBlock);
|
78 | }
|
79 | /**
|
80 | * Removes the quote from given blocks.
|
81 | *
|
82 | * If blocks which are supposed to be "unquoted" are in the middle of a quote,
|
83 | * start it or end it, then the quote will be split (if needed) and the blocks
|
84 | * will be moved out of it, so other quoted blocks remained quoted.
|
85 | */
|
86 | _removeQuote(writer, blocks) {
|
87 | // Unquote all groups of block. Iterate in the reverse order to not break following ranges.
|
88 | getRangesOfBlockGroups(writer, blocks).reverse().forEach(groupRange => {
|
89 | if (groupRange.start.isAtStart && groupRange.end.isAtEnd) {
|
90 | writer.unwrap(groupRange.start.parent);
|
91 | return;
|
92 | }
|
93 | // The group of blocks are at the beginning of an <bQ> so let's move them left (out of the <bQ>).
|
94 | if (groupRange.start.isAtStart) {
|
95 | const positionBefore = writer.createPositionBefore(groupRange.start.parent);
|
96 | writer.move(groupRange, positionBefore);
|
97 | return;
|
98 | }
|
99 | // The blocks are in the middle of an <bQ> so we need to split the <bQ> after the last block
|
100 | // so we move the items there.
|
101 | if (!groupRange.end.isAtEnd) {
|
102 | writer.split(groupRange.end);
|
103 | }
|
104 | // Now we are sure that groupRange.end.isAtEnd is true, so let's move the blocks right.
|
105 | const positionAfter = writer.createPositionAfter(groupRange.end.parent);
|
106 | writer.move(groupRange, positionAfter);
|
107 | });
|
108 | }
|
109 | /**
|
110 | * Applies the quote to given blocks.
|
111 | */
|
112 | _applyQuote(writer, blocks) {
|
113 | const quotesToMerge = [];
|
114 | // Quote all groups of block. Iterate in the reverse order to not break following ranges.
|
115 | getRangesOfBlockGroups(writer, blocks).reverse().forEach(groupRange => {
|
116 | let quote = findQuote(groupRange.start);
|
117 | if (!quote) {
|
118 | quote = writer.createElement('blockQuote');
|
119 | writer.wrap(groupRange, quote);
|
120 | }
|
121 | quotesToMerge.push(quote);
|
122 | });
|
123 | // Merge subsequent <bQ> elements. Reverse the order again because this time we want to go through
|
124 | // the <bQ> elements in the source order (due to how merge works – it moves the right element's content
|
125 | // to the first element and removes the right one. Since we may need to merge a couple of subsequent `<bQ>` elements
|
126 | // we want to keep the reference to the first (furthest left) one.
|
127 | quotesToMerge.reverse().reduce((currentQuote, nextQuote) => {
|
128 | if (currentQuote.nextSibling == nextQuote) {
|
129 | writer.merge(writer.createPositionAfter(currentQuote));
|
130 | return currentQuote;
|
131 | }
|
132 | return nextQuote;
|
133 | });
|
134 | }
|
135 | }
|
136 | function findQuote(elementOrPosition) {
|
137 | return elementOrPosition.parent.name == 'blockQuote' ? elementOrPosition.parent : null;
|
138 | }
|
139 | /**
|
140 | * Returns a minimal array of ranges containing groups of subsequent blocks.
|
141 | *
|
142 | * content: abcdefgh
|
143 | * blocks: [ a, b, d, f, g, h ]
|
144 | * output ranges: [ab]c[d]e[fgh]
|
145 | */
|
146 | function getRangesOfBlockGroups(writer, blocks) {
|
147 | let startPosition;
|
148 | let i = 0;
|
149 | const ranges = [];
|
150 | while (i < blocks.length) {
|
151 | const block = blocks[i];
|
152 | const nextBlock = blocks[i + 1];
|
153 | if (!startPosition) {
|
154 | startPosition = writer.createPositionBefore(block);
|
155 | }
|
156 | if (!nextBlock || block.nextSibling != nextBlock) {
|
157 | ranges.push(writer.createRange(startPosition, writer.createPositionAfter(block)));
|
158 | startPosition = null;
|
159 | }
|
160 | i++;
|
161 | }
|
162 | return ranges;
|
163 | }
|
164 | /**
|
165 | * Checks whether <bQ> can wrap the block.
|
166 | */
|
167 | function checkCanBeQuoted(schema, block) {
|
168 | // TMP will be replaced with schema.checkWrap().
|
169 | const isBQAllowed = schema.checkChild(block.parent, 'blockQuote');
|
170 | const isBlockAllowedInBQ = schema.checkChild(['$root', 'blockQuote'], block);
|
171 | return isBQAllowed && isBlockAllowedInBQ;
|
172 | }
|