mirror of
https://github.com/microsoft/vscode.git
synced 2026-09-24 16:35:55 +01:00
Implements tri-state checkbox, some scrolling bug-fixing and checkbox styling improvements.
This commit is contained in:
@@ -98,6 +98,7 @@ export class Toggle extends Widget {
|
||||
readonly onKeyDown: Event<IKeyboardEvent> = this._onKeyDown.event;
|
||||
|
||||
private readonly _opts: IToggleOpts;
|
||||
private _icon: CSSIcon | undefined;
|
||||
readonly domNode: HTMLElement;
|
||||
|
||||
private _checked: boolean;
|
||||
@@ -110,7 +111,8 @@ export class Toggle extends Widget {
|
||||
|
||||
const classes = ['monaco-custom-toggle'];
|
||||
if (this._opts.icon) {
|
||||
classes.push(...CSSIcon.asClassNameArray(this._opts.icon));
|
||||
this._icon = this._opts.icon;
|
||||
classes.push(...CSSIcon.asClassNameArray(this._icon));
|
||||
}
|
||||
if (this._opts.actionClassName) {
|
||||
classes.push(...this._opts.actionClassName.split(' '));
|
||||
@@ -174,6 +176,16 @@ export class Toggle extends Widget {
|
||||
this.applyStyles();
|
||||
}
|
||||
|
||||
setIcon(icon: CSSIcon | undefined): void {
|
||||
if (this._icon) {
|
||||
this.domNode.classList.remove(...CSSIcon.asClassNameArray(this._icon));
|
||||
}
|
||||
this._icon = icon;
|
||||
if (this._icon) {
|
||||
this.domNode.classList.add(...CSSIcon.asClassNameArray(this._icon));
|
||||
}
|
||||
}
|
||||
|
||||
width(): number {
|
||||
return 2 /*margin left*/ + 2 /*border*/ + 2 /*padding*/ + 16 /* icon width */;
|
||||
}
|
||||
|
||||
@@ -71,8 +71,19 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.merge-accept-gutter-marker .checkbox .accept-conflict-group.monaco-custom-toggle.monaco-checkbox {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
.accept-conflict-group.monaco-custom-toggle {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
margin-right: 0px;
|
||||
margin-left: 0px;
|
||||
padding: 0px;
|
||||
opacity: 1;
|
||||
background-size: 16px !important;
|
||||
background-color: var(--vscode-checkbox-border);
|
||||
}
|
||||
|
||||
.checkbox-background {
|
||||
background: var(--vscode-editor-background);
|
||||
}
|
||||
|
||||
@@ -106,48 +106,40 @@ export class MergeEditor extends EditorPane {
|
||||
this._register(keepAlive(input1ResultMapping));
|
||||
this._register(keepAlive(input2ResultMapping));
|
||||
|
||||
this._store.add(this.input1View.editor.onDidScrollChange(c => {
|
||||
if (c.scrollTopChanged) {
|
||||
reentrancyBarrier.runExclusively(() => {
|
||||
const mapping = input1ResultMapping.get();
|
||||
if (!mapping) {
|
||||
return;
|
||||
this._store.add(
|
||||
this.input1View.editor.onDidScrollChange(
|
||||
reentrancyBarrier.makeExclusive((c) => {
|
||||
if (c.scrollTopChanged) {
|
||||
const mapping = input1ResultMapping.get();
|
||||
synchronizeScrolling(this.input1View.editor, this.inputResultView.editor, mapping, 1);
|
||||
this.input2View.editor.setScrollTop(c.scrollTop, ScrollType.Immediate);
|
||||
}
|
||||
synchronizeScrolling(this.input1View.editor, this.inputResultView.editor, mapping, 1);
|
||||
this.input2View.editor.setScrollTop(c.scrollTop, ScrollType.Immediate);
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
this._store.add(this.input2View.editor.onDidScrollChange(c => {
|
||||
if (c.scrollTopChanged) {
|
||||
reentrancyBarrier.runExclusively(() => {
|
||||
const mapping = input2ResultMapping.get();
|
||||
if (!mapping) {
|
||||
return;
|
||||
})
|
||||
)
|
||||
);
|
||||
this._store.add(
|
||||
this.input2View.editor.onDidScrollChange(
|
||||
reentrancyBarrier.makeExclusive((c) => {
|
||||
if (c.scrollTopChanged) {
|
||||
const mapping = input2ResultMapping.get();
|
||||
synchronizeScrolling(this.input2View.editor, this.inputResultView.editor, mapping, 1);
|
||||
this.input1View.editor.setScrollTop(c.scrollTop, ScrollType.Immediate);
|
||||
}
|
||||
synchronizeScrolling(this.input2View.editor, this.inputResultView.editor, mapping, 1);
|
||||
this.input1View.editor.setScrollTop(c.scrollTop, ScrollType.Immediate);
|
||||
});
|
||||
}
|
||||
}));
|
||||
this._store.add(this.inputResultView.editor.onDidScrollChange(c => {
|
||||
if (c.scrollTopChanged) {
|
||||
reentrancyBarrier.runExclusively(() => {
|
||||
const mapping = input1ResultMapping.get();
|
||||
if (!mapping) {
|
||||
return;
|
||||
})
|
||||
)
|
||||
);
|
||||
this._store.add(
|
||||
this.inputResultView.editor.onDidScrollChange(
|
||||
reentrancyBarrier.makeExclusive((c) => {
|
||||
if (c.scrollTopChanged) {
|
||||
const mapping1 = input1ResultMapping.get();
|
||||
synchronizeScrolling(this.inputResultView.editor, this.input1View.editor, mapping1, 2);
|
||||
const mapping2 = input2ResultMapping.get();
|
||||
synchronizeScrolling(this.inputResultView.editor, this.input2View.editor, mapping2, 2);
|
||||
}
|
||||
synchronizeScrolling(this.inputResultView.editor, this.input1View.editor, mapping, 2);
|
||||
|
||||
const mapping2 = input2ResultMapping.get();
|
||||
if (!mapping2) {
|
||||
return;
|
||||
}
|
||||
synchronizeScrolling(this.inputResultView.editor, this.input2View.editor, mapping2, 2);
|
||||
});
|
||||
}
|
||||
}));
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
// TODO@jrieken make this proper: add menu id and allow extensions to contribute
|
||||
@@ -307,11 +299,11 @@ export class MergeEditor extends EditorPane {
|
||||
}
|
||||
}
|
||||
|
||||
function flip(value: 1 | 2): 1 | 2 {
|
||||
return value === 1 ? 2 : 1;
|
||||
}
|
||||
function synchronizeScrolling(scrollingEditor: CodeEditorWidget, targetEditor: CodeEditorWidget, mapping: ModifiedBaseRange[] | undefined, sourceNumber: 1 | 2) {
|
||||
if (!mapping) {
|
||||
return;
|
||||
}
|
||||
|
||||
function synchronizeScrolling(scrollingEditor: CodeEditorWidget, targetEditor: CodeEditorWidget, mapping: ModifiedBaseRange[], sourceNumber: 1 | 2) {
|
||||
const visibleRanges = scrollingEditor.getVisibleRanges();
|
||||
if (visibleRanges.length === 0) {
|
||||
return;
|
||||
@@ -322,21 +314,24 @@ function synchronizeScrolling(scrollingEditor: CodeEditorWidget, targetEditor: C
|
||||
let sourceRange: LineRange;
|
||||
let targetRange: LineRange;
|
||||
|
||||
const targetNumber = flip(sourceNumber);
|
||||
const targetNumber = sourceNumber === 1 ? 2 : 1;
|
||||
|
||||
if (firstBefore && firstBefore.getInputRange(sourceNumber).contains(topLineNumber)) {
|
||||
sourceRange = firstBefore.getInputRange(sourceNumber);
|
||||
targetRange = firstBefore.getInputRange(targetNumber);
|
||||
} else if (firstBefore && firstBefore.getInputRange(sourceNumber).isEmpty && firstBefore.getInputRange(sourceNumber).startLineNumber === topLineNumber) {
|
||||
sourceRange = firstBefore.getInputRange(sourceNumber).deltaEnd(1);
|
||||
targetRange = firstBefore.getInputRange(targetNumber).deltaEnd(1);
|
||||
const firstBeforeSourceRange = firstBefore?.getInputRange(sourceNumber);
|
||||
const firstBeforeTargetRange = firstBefore?.getInputRange(targetNumber);
|
||||
|
||||
if (firstBeforeSourceRange && firstBeforeSourceRange.contains(topLineNumber)) {
|
||||
sourceRange = firstBeforeSourceRange;
|
||||
targetRange = firstBeforeTargetRange!;
|
||||
} else if (firstBeforeSourceRange && firstBeforeSourceRange.isEmpty && firstBeforeSourceRange.startLineNumber === topLineNumber) {
|
||||
sourceRange = firstBeforeSourceRange.deltaEnd(1);
|
||||
targetRange = firstBeforeTargetRange!.deltaEnd(1);
|
||||
} else {
|
||||
const delta = firstBefore ? firstBefore.getInputRange(targetNumber).endLineNumberExclusive - firstBefore.getInputRange(sourceNumber).endLineNumberExclusive : 0;
|
||||
const delta = firstBeforeSourceRange ? firstBeforeTargetRange!.endLineNumberExclusive - firstBeforeSourceRange.endLineNumberExclusive : 0;
|
||||
sourceRange = new LineRange(topLineNumber, 1);
|
||||
targetRange = new LineRange(topLineNumber + delta, 1);
|
||||
}
|
||||
|
||||
// sourceRange is not empty!
|
||||
// sourceRange contains topLineNumber!
|
||||
|
||||
const resultStartTopPx = targetEditor.getTopForLineNumber(targetRange.startLineNumber);
|
||||
const resultEndPx = targetEditor.getTopForLineNumber(targetRange.endLineNumberExclusive);
|
||||
@@ -344,8 +339,21 @@ function synchronizeScrolling(scrollingEditor: CodeEditorWidget, targetEditor: C
|
||||
const sourceStartTopPx = scrollingEditor.getTopForLineNumber(sourceRange.startLineNumber);
|
||||
const sourceEndPx = scrollingEditor.getTopForLineNumber(sourceRange.endLineNumberExclusive);
|
||||
|
||||
const factor = (scrollingEditor.getScrollTop() - sourceStartTopPx) / (sourceEndPx - sourceStartTopPx);
|
||||
const factor = Math.min((scrollingEditor.getScrollTop() - sourceStartTopPx) / (sourceEndPx - sourceStartTopPx), 1);
|
||||
const resultScrollPosition = resultStartTopPx + (resultEndPx - resultStartTopPx) * factor;
|
||||
/*
|
||||
console.log({
|
||||
topLineNumber,
|
||||
sourceRange: sourceRange.toString(),
|
||||
targetRange: targetRange.toString(),
|
||||
// resultStartTopPx,
|
||||
// resultEndPx,
|
||||
// sourceStartTopPx,
|
||||
// sourceEndPx,
|
||||
factor,
|
||||
resultScrollPosition,
|
||||
top: scrollingEditor.getScrollTop(),
|
||||
});*/
|
||||
|
||||
targetEditor.setScrollTop(resultScrollPosition, ScrollType.Immediate);
|
||||
}
|
||||
@@ -468,7 +476,7 @@ class InputCodeEditorView extends CodeEditorView {
|
||||
if (!model) { return []; }
|
||||
return model.modifiedBaseRanges
|
||||
.filter((r) => r.getInputDiffs(this.inputNumber).length > 0)
|
||||
.map<MergeConflictData>((baseRange, idx) => ({
|
||||
.map<ModifiedBaseRangeGutterItemInfo>((baseRange, idx) => ({
|
||||
id: idx.toString(),
|
||||
additionalHeightInPx: 0,
|
||||
offsetInPx: 0,
|
||||
@@ -497,25 +505,31 @@ class InputCodeEditorView extends CodeEditorView {
|
||||
}
|
||||
}
|
||||
|
||||
interface MergeConflictData extends IGutterItemInfo {
|
||||
interface ModifiedBaseRangeGutterItemInfo extends IGutterItemInfo {
|
||||
toggleState: IObservable<boolean | undefined>;
|
||||
setState(value: boolean, tx: ITransaction | undefined): void;
|
||||
}
|
||||
|
||||
class MergeConflictGutterItemView extends Disposable implements IGutterItemView<MergeConflictData> {
|
||||
constructor(private item: MergeConflictData, target: HTMLElement) {
|
||||
class MergeConflictGutterItemView extends Disposable implements IGutterItemView<ModifiedBaseRangeGutterItemInfo> {
|
||||
constructor(private item: ModifiedBaseRangeGutterItemInfo, private readonly target: HTMLElement) {
|
||||
super();
|
||||
|
||||
target.classList.add('merge-accept-gutter-marker');
|
||||
target.classList.add(item.range.lineCount > 1 ? 'multi-line' : 'single-line');
|
||||
|
||||
// TODO: Tri-State-Toggle, localized title
|
||||
const checkBox = new Toggle({ isChecked: false, title: 'Accept Merge', icon: Codicon.check, actionClassName: 'monaco-checkbox' });
|
||||
// TODO: localized title
|
||||
const checkBox = new Toggle({ isChecked: false, title: 'Accept Merge', icon: Codicon.check });
|
||||
checkBox.domNode.classList.add('accept-conflict-group');
|
||||
|
||||
this._register(
|
||||
autorun((reader) => {
|
||||
const value = this.item.toggleState.read(reader);
|
||||
checkBox.setIcon(
|
||||
value === true
|
||||
? Codicon.check
|
||||
: value === false
|
||||
? undefined
|
||||
: Codicon.circleFilled
|
||||
);
|
||||
checkBox.checked = value === true;
|
||||
}, 'Update Toggle State')
|
||||
);
|
||||
@@ -524,15 +538,19 @@ class MergeConflictGutterItemView extends Disposable implements IGutterItemView<
|
||||
this.item.setState(checkBox.checked, undefined);
|
||||
}));
|
||||
|
||||
target.appendChild($('div.background', {}, noBreakWhitespace));
|
||||
target.appendChild($('div.checkbox', {}, checkBox.domNode));
|
||||
target.appendChild(n('div.background', [noBreakWhitespace]).root);
|
||||
target.appendChild(
|
||||
n('div.checkbox', [n('div.checkbox-background', [checkBox.domNode])]).root
|
||||
);
|
||||
}
|
||||
|
||||
layout(top: number, height: number, viewTop: number, viewHeight: number): void {
|
||||
|
||||
this.target.classList.remove('multi-line');
|
||||
this.target.classList.remove('single-line');
|
||||
this.target.classList.add(height > 30 ? 'multi-line' : 'single-line');
|
||||
}
|
||||
|
||||
update(baseRange: MergeConflictData): void {
|
||||
update(baseRange: ModifiedBaseRangeGutterItemInfo): void {
|
||||
this.item = baseRange;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,8 @@ export class MergeEditorModel extends EditorModel {
|
||||
this.recomputeState();
|
||||
});
|
||||
this.recomputeState();
|
||||
|
||||
this.resetUnknown();
|
||||
}
|
||||
|
||||
private recomputeState(): void {
|
||||
@@ -135,6 +137,16 @@ export class MergeEditorModel extends EditorModel {
|
||||
});
|
||||
}
|
||||
|
||||
public resetUnknown(): void {
|
||||
transaction(tx => {
|
||||
for (const range of this.modifiedBaseRanges) {
|
||||
if (this.getState(range).get().conflicting) {
|
||||
this.setState(range, ModifiedBaseRangeState.default, tx);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public mergeNonConflictingDiffs(): void {
|
||||
transaction((tx) => {
|
||||
for (const m of this.modifiedBaseRanges) {
|
||||
@@ -215,7 +227,7 @@ export class MergeEditorModel extends EditorModel {
|
||||
if (!existingState) {
|
||||
throw new BugIndicatingError('object must be from this instance');
|
||||
}
|
||||
existingState.set(state, transaction);
|
||||
|
||||
|
||||
const conflictingDiffs = this.resultEdits.findTouchingDiffs(
|
||||
baseRange.baseRange
|
||||
@@ -224,13 +236,73 @@ export class MergeEditorModel extends EditorModel {
|
||||
this.resultEdits.removeDiffs(conflictingDiffs, transaction);
|
||||
}
|
||||
|
||||
const diff = state.input1
|
||||
? baseRange.input1CombinedDiff
|
||||
: state.input2
|
||||
? baseRange.input2CombinedDiff
|
||||
: undefined;
|
||||
if (diff) {
|
||||
this.resultEdits.applyEditRelativeToOriginal(diff.getLineEdit(), transaction);
|
||||
function getEdit(baseRange: ModifiedBaseRange, state: ModifiedBaseRangeState): { edit: LineEdit | undefined; effectiveState: ModifiedBaseRangeState } {
|
||||
interface LineDiffWithInputNumber {
|
||||
diff: LineDiff;
|
||||
inputNumber: 1 | 2;
|
||||
}
|
||||
|
||||
const diffs = new Array<LineDiffWithInputNumber>();
|
||||
if (state.input1) {
|
||||
if (baseRange.input1CombinedDiff) {
|
||||
diffs.push({ diff: baseRange.input1CombinedDiff, inputNumber: 1 });
|
||||
}
|
||||
}
|
||||
if (state.input2) {
|
||||
if (baseRange.input2CombinedDiff) {
|
||||
diffs.push({ diff: baseRange.input2CombinedDiff, inputNumber: 2 });
|
||||
}
|
||||
}
|
||||
if (state.input2First) {
|
||||
diffs.reverse();
|
||||
}
|
||||
const firstDiff: LineDiffWithInputNumber | undefined = diffs[0];
|
||||
const secondDiff: LineDiffWithInputNumber | undefined = diffs[1];
|
||||
diffs.sort(compareBy(d => d.diff.originalRange, LineRange.compareByStart));
|
||||
|
||||
if (!firstDiff) {
|
||||
return { edit: undefined, effectiveState: state };
|
||||
}
|
||||
|
||||
if (!secondDiff) {
|
||||
return { edit: firstDiff.diff.getLineEdit(), effectiveState: state };
|
||||
}
|
||||
|
||||
// Two inserts
|
||||
if (
|
||||
firstDiff.diff.originalRange.lineCount === 0 &&
|
||||
firstDiff.diff.originalRange.equals(secondDiff.diff.originalRange)
|
||||
) {
|
||||
return {
|
||||
edit: new LineEdit(
|
||||
firstDiff.diff.originalRange,
|
||||
firstDiff.diff
|
||||
.getLineEdit()
|
||||
.newLines.concat(secondDiff.diff.getLineEdit().newLines)
|
||||
),
|
||||
effectiveState: state,
|
||||
};
|
||||
}
|
||||
|
||||
// Technically non-conflicting diffs
|
||||
if (diffs.length === 2 && diffs[0].diff.originalRange.endLineNumberExclusive === diffs[1].diff.originalRange.startLineNumber) {
|
||||
return {
|
||||
edit: new LineEdit(
|
||||
LineRange.join(diffs.map(d => d.diff.originalRange))!,
|
||||
diffs.flatMap(d => d.diff.getLineEdit().newLines)
|
||||
),
|
||||
effectiveState: state,
|
||||
};
|
||||
}
|
||||
|
||||
return { edit: firstDiff.diff.getLineEdit(), effectiveState: state };
|
||||
}
|
||||
const { edit, effectiveState } = getEdit(baseRange, state);
|
||||
|
||||
existingState.set(effectiveState, transaction);
|
||||
|
||||
if (edit) {
|
||||
this.resultEdits.applyEditRelativeToOriginal(edit, transaction);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,11 +431,11 @@ class ResultEdits {
|
||||
new LineRange(edit.range.startLineNumber + delta, edit.newLines.length)
|
||||
));
|
||||
}
|
||||
this._diffs.set(newDiffs, transaction);
|
||||
|
||||
this.barrier.runExclusivelyOrThrow(() => {
|
||||
new LineEdit(edit.range.delta(delta), edit.newLines).apply(this.resultTextModel);
|
||||
});
|
||||
this._diffs.set(newDiffs, transaction);
|
||||
}
|
||||
|
||||
public findTouchingDiffs(baseRange: LineRange): LineDiff[] {
|
||||
|
||||
@@ -371,7 +371,10 @@ export class ModifiedBaseRangeState {
|
||||
public readonly conflicting: boolean,
|
||||
) { }
|
||||
|
||||
public getInput(inputNumber: 1 | 2): boolean {
|
||||
public getInput(inputNumber: 1 | 2): boolean | undefined {
|
||||
if (this.conflicting) {
|
||||
return undefined;
|
||||
}
|
||||
if (inputNumber === 1) {
|
||||
return this.input1;
|
||||
} else {
|
||||
@@ -386,17 +389,17 @@ export class ModifiedBaseRangeState {
|
||||
public withInput1(value: boolean): ModifiedBaseRangeState {
|
||||
return new ModifiedBaseRangeState(
|
||||
value,
|
||||
false,
|
||||
value && this.isEmpty ? false : this.input2First,
|
||||
this.input2,
|
||||
value !== this.input2 ? this.input2 : this.input2First,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
public withInput2(value: boolean): ModifiedBaseRangeState {
|
||||
return new ModifiedBaseRangeState(
|
||||
false,
|
||||
this.input1,
|
||||
value,
|
||||
value && this.isEmpty ? true : this.input2First,
|
||||
value !== this.input1 ? value : this.input2First,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,20 @@ import { IDisposable } from 'xterm';
|
||||
export class ReentrancyBarrier {
|
||||
private isActive = false;
|
||||
|
||||
public makeExclusive<TFunction extends Function>(fn: TFunction): TFunction {
|
||||
return ((...args: any[]) => {
|
||||
if (this.isActive) {
|
||||
return;
|
||||
}
|
||||
this.isActive = true;
|
||||
try {
|
||||
return fn(...args);
|
||||
} finally {
|
||||
this.isActive = false;
|
||||
}
|
||||
}) as any;
|
||||
}
|
||||
|
||||
public runExclusively(fn: () => void): void {
|
||||
if (this.isActive) {
|
||||
return;
|
||||
@@ -40,7 +54,7 @@ export class ReentrancyBarrier {
|
||||
}
|
||||
|
||||
export function n<TTag extends string>(tag: TTag): never;
|
||||
export function n<TTag extends string, T extends any[]>(
|
||||
export function n<TTag extends string, T extends (HTMLElement | string | Record<string, HTMLElement>)[]>(
|
||||
tag: TTag,
|
||||
children: T
|
||||
): (ArrayToObj<T> & Record<'root', TagToElement<TTag>>) extends infer Y ? { [TKey in keyof Y]: Y[TKey] } : never;
|
||||
@@ -48,7 +62,7 @@ export function n<TTag extends string, TId extends string>(
|
||||
tag: TTag,
|
||||
attributes: { $: TId }
|
||||
): Record<TId, TagToElement<TTag>>;
|
||||
export function n<TTag extends string, TId extends string, T extends any[]>(
|
||||
export function n<TTag extends string, TId extends string, T extends (HTMLElement | string | Record<string, HTMLElement>)[]>(
|
||||
tag: TTag,
|
||||
attributes: { $: TId },
|
||||
children: T
|
||||
@@ -77,6 +91,8 @@ export function n(tag: string, ...args: [] | [attributes: { $: string } | Record
|
||||
for (const c of children) {
|
||||
if (c instanceof HTMLElement) {
|
||||
el.appendChild(c);
|
||||
} else if (typeof c === 'string') {
|
||||
el.append(c);
|
||||
} else {
|
||||
Object.assign(result, c);
|
||||
el.appendChild(c.root);
|
||||
@@ -84,8 +100,6 @@ export function n(tag: string, ...args: [] | [attributes: { $: string } | Record
|
||||
}
|
||||
}
|
||||
|
||||
result['root'] = el;
|
||||
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
if (key === '$') {
|
||||
result[value] = el;
|
||||
@@ -94,6 +108,8 @@ export function n(tag: string, ...args: [] | [attributes: { $: string } | Record
|
||||
el.setAttribute(key, value);
|
||||
}
|
||||
|
||||
result['root'] = el;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user