Skip to content

Commit c19e05d

Browse files
Allows editing of start/end tag simultaneously
Under the preference xml.autoSelectingMatchingTags Fixes #130 Signed-off-by: Nikolas Komonen <nikolaskomonen@gmail.com>
1 parent 6a7db6e commit c19e05d

File tree

3 files changed

+172
-3
lines changed

3 files changed

+172
-3
lines changed

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,12 @@
192192
"description": "Enable/disable autoclosing of XML tags. \n\nIMPORTANT: Turn off editor.autoClosingTags for this to work",
193193
"scope": "window"
194194
},
195+
"xml.autoSelectingMatchingTags": {
196+
"type": "boolean",
197+
"scope": "resource",
198+
"default": true,
199+
"description": "Adds an additional cursor on the matching tag, allows for start/end tag editing."
200+
},
195201
"xml.codeLens.enabled": {
196202
"type": "boolean",
197203
"default": false,

src/extension.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { activateTagClosing, AutoCloseResult } from './tagClosing';
2020
import { Commands } from './commands';
2121
import { onConfigurationChange, subscribeJDKChangeConfiguration } from './settings';
2222
import { collectXmlJavaExtensions, onExtensionChange } from './plugin';
23+
import { activateMatchingTagPosition } from './matchingTag';
2324

2425
export interface ScopeInfo {
2526
scope : "default" | "global" | "workspace" | "folder";
@@ -30,7 +31,9 @@ namespace TagCloseRequest {
3031
export const type: RequestType<TextDocumentPositionParams, AutoCloseResult, any, any> = new RequestType('xml/closeTag');
3132
}
3233

33-
34+
namespace MatchingTagPositionRequest {
35+
export const type: RequestType<TextDocumentPositionParams, Position | null, any, any> = new RequestType('xml/matchingTagPosition');
36+
}
3437

3538
export function activate(context: ExtensionContext) {
3639
let storagePath = context.storagePath;
@@ -114,14 +117,27 @@ export function activate(context: ExtensionContext) {
114117
return text;
115118
};
116119

120+
disposable = activateTagClosing(tagProvider, { xml: true, xsl: true }, Commands.AUTO_CLOSE_TAGS);
121+
toDispose.push(disposable);
122+
123+
//Setup mirrored tag rename request
124+
const matchingTagPositionRequestor = (document: TextDocument, position: Position) => {
125+
let param = languageClient.code2ProtocolConverter.asTextDocumentPositionParams(document, position);
126+
return languageClient.sendRequest(MatchingTagPositionRequest.type, param);
127+
};
128+
129+
disposable = activateMatchingTagPosition(matchingTagPositionRequestor, { xml: true}, 'xml.autoSelectingMatchingTags');
130+
toDispose.push(disposable);
131+
117132
if (extensions.onDidChange) {// Theia doesn't support this API yet
118133
extensions.onDidChange(() => {
119134
onExtensionChange(extensions.all);
120135
});
121136
}
122137

123-
disposable = activateTagClosing(tagProvider, { xml: true, xsl: true }, Commands.AUTO_CLOSE_TAGS);
124-
toDispose.push(disposable);
138+
139+
140+
125141
});
126142
languages.setLanguageConfiguration('xml', getIndentationRules());
127143
languages.setLanguageConfiguration('xsl', getIndentationRules());

src/matchingTag.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import {
7+
window,
8+
workspace,
9+
Disposable,
10+
TextDocument,
11+
Position,
12+
TextEditorSelectionChangeEvent,
13+
Selection,
14+
Range,
15+
WorkspaceEdit
16+
} from 'vscode';
17+
18+
export function activateMatchingTagPosition(
19+
matchingTagPositionProvider: (document: TextDocument, position: Position) => Thenable<Position | null>,
20+
supportedLanguages: { [id: string]: boolean },
21+
configName: string
22+
): Disposable {
23+
let disposables: Disposable[] = [];
24+
25+
window.onDidChangeTextEditorSelection(event => onDidChangeTextEditorSelection(event), null, disposables);
26+
27+
let isEnabled = false;
28+
updateEnabledState();
29+
30+
window.onDidChangeActiveTextEditor(updateEnabledState, null, disposables);
31+
32+
function updateEnabledState() {
33+
isEnabled = false;
34+
let editor = window.activeTextEditor;
35+
if (!editor) {
36+
return;
37+
}
38+
let document = editor.document;
39+
if (!supportedLanguages[document.languageId]) {
40+
return;
41+
}
42+
if (!workspace.getConfiguration(undefined, document.uri).get<boolean>(configName)) {
43+
return;
44+
}
45+
isEnabled = true;
46+
}
47+
48+
// let prevCursorCount = 0;
49+
let cursorCount = 0;
50+
let inMirrorMode = false;
51+
52+
function onDidChangeTextEditorSelection(event: TextEditorSelectionChangeEvent) {
53+
if (!isEnabled) {
54+
return;
55+
}
56+
57+
// prevCursorCount = cursorCount;
58+
cursorCount = event.selections.length;
59+
60+
if (cursorCount === 1) {
61+
if (event.selections[0].isEmpty) {
62+
matchingTagPositionProvider(event.textEditor.document, event.selections[0].active).then(position => {
63+
if (position && window.activeTextEditor) {
64+
inMirrorMode = true;
65+
const newCursor = new Selection(position.line, position.character, position.line, position.character);
66+
window.activeTextEditor.selections = [...window.activeTextEditor.selections, newCursor];
67+
}
68+
});
69+
}
70+
}
71+
72+
if (cursorCount === 2 && inMirrorMode) {
73+
// Check two cases
74+
if (event.selections[0].isEmpty && event.selections[1].isEmpty) {
75+
const charBeforePrimarySelection = getCharBefore(event.textEditor.document, event.selections[0].anchor);
76+
const charAfterPrimarySelection = getCharAfter(event.textEditor.document, event.selections[0].anchor);
77+
const charBeforeSecondarySelection = getCharBefore(event.textEditor.document, event.selections[1].anchor);
78+
const charAfterSecondarySelection = getCharAfter(event.textEditor.document, event.selections[1].anchor);
79+
80+
// Exit mirror mode when cursor position no longer mirror
81+
// Unless it's in the case of `<|></|>`
82+
const charBeforeBothPositionRoughlyEqual =
83+
charBeforePrimarySelection === charBeforeSecondarySelection ||
84+
(charBeforePrimarySelection === '/' && charBeforeSecondarySelection === '<') ||
85+
(charBeforeSecondarySelection === '/' && charBeforePrimarySelection === '<');
86+
const charAfterBothPositionRoughlyEqual =
87+
charAfterPrimarySelection === charAfterSecondarySelection ||
88+
(charAfterPrimarySelection === ' ' && charAfterSecondarySelection === '>') ||
89+
(charAfterSecondarySelection === ' ' && charAfterPrimarySelection === '>');
90+
91+
if (!charBeforeBothPositionRoughlyEqual || !charAfterBothPositionRoughlyEqual) {
92+
inMirrorMode = false;
93+
window.activeTextEditor!.selections = [window.activeTextEditor!.selections[0]];
94+
return;
95+
} else {
96+
// Need to cleanup in the case of <div |></div |>
97+
if (
98+
charBeforePrimarySelection === ' ' &&
99+
charAfterPrimarySelection === '>' &&
100+
charBeforeSecondarySelection === ' ' &&
101+
charAfterSecondarySelection === '>'
102+
) {
103+
inMirrorMode = false;
104+
const cleanupEdit = new WorkspaceEdit();
105+
106+
const primaryBeforeSecondary =
107+
event.textEditor.document.offsetAt(event.selections[0].anchor) <
108+
event.textEditor.document.offsetAt(event.selections[1].anchor);
109+
const cleanupRange = primaryBeforeSecondary
110+
? new Range(event.selections[1].anchor.translate(0, -1), event.selections[1].anchor)
111+
: new Range(event.selections[0].anchor.translate(0, -1), event.selections[0].anchor);
112+
113+
cleanupEdit.replace(event.textEditor.document.uri, cleanupRange, '');
114+
window.activeTextEditor!.selections = primaryBeforeSecondary
115+
? [window.activeTextEditor!.selections[0]]
116+
: [window.activeTextEditor!.selections[1]];
117+
workspace.applyEdit(cleanupEdit);
118+
}
119+
}
120+
}
121+
}
122+
}
123+
124+
return Disposable.from(...disposables);
125+
}
126+
127+
function getCharBefore(document: TextDocument, position: Position) {
128+
const offset = document.offsetAt(position);
129+
if (offset === 0) {
130+
return '';
131+
}
132+
133+
return document.getText(
134+
new Range(document.positionAt(offset - 1), position)
135+
);
136+
}
137+
138+
function getCharAfter(document: TextDocument, position: Position) {
139+
const offset = document.offsetAt(position);
140+
if (offset === document.getText().length) {
141+
return '';
142+
}
143+
144+
return document.getText(
145+
new Range(position, document.positionAt(offset + 1))
146+
);
147+
}

0 commit comments

Comments
 (0)