Skip to content

Commit f355a2c

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 f355a2c

File tree

3 files changed

+300
-9
lines changed

3 files changed

+300
-9
lines changed

package.json

Lines changed: 14 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.matchingTagEditing": {
196+
"type": "boolean",
197+
"scope": "window",
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,
@@ -294,6 +300,14 @@
294300
"fileMatch": "package.json",
295301
"url": "./schemas/package.schema.json"
296302
}
303+
],
304+
"keybindings":[
305+
{
306+
"command": "xml.toggleMatchingTagEdit",
307+
"key": "ctrl+shift+f2",
308+
"mac": "cmd+shift+f2",
309+
"when": "editorFocus"
310+
}
297311
]
298312
}
299313
}

src/extension.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,21 @@ import { activateTagClosing, AutoCloseResult } from './tagClosing';
2020
import { Commands } from './commands';
2121
import { onConfigurationChange, subscribeJDKChangeConfiguration } from './settings';
2222
import { collectXmlJavaExtensions, onExtensionChange } from './plugin';
23+
import { activateMirrorCursor } from './mirrorCursor';
24+
import { error } from 'util';
2325

2426
export interface ScopeInfo {
25-
scope : "default" | "global" | "workspace" | "folder";
27+
scope: "default" | "global" | "workspace" | "folder";
2628
configurationTarget: boolean;
2729
}
2830

2931
namespace TagCloseRequest {
3032
export const type: RequestType<TextDocumentPositionParams, AutoCloseResult, any, any> = new RequestType('xml/closeTag');
3133
}
3234

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

3539
export function activate(context: ExtensionContext) {
3640
let storagePath = context.storagePath;
@@ -70,7 +74,7 @@ export function activate(context: ExtensionContext) {
7074
}
7175
}
7276
}
73-
},
77+
},
7478
synchronize: {
7579
//preferences starting with these will trigger didChangeConfiguration
7680
configurationSection: ['xml', '[xml]']
@@ -114,14 +118,33 @@ export function activate(context: ExtensionContext) {
114118
return text;
115119
};
116120

121+
disposable = activateTagClosing(tagProvider, { xml: true, xsl: true }, Commands.AUTO_CLOSE_TAGS);
122+
toDispose.push(disposable);
123+
124+
//Setup mirrored tag rename request
125+
const matchingTagPositionRequestor = (document: TextDocument, position: Position) => {
126+
let param = languageClient.code2ProtocolConverter.asTextDocumentPositionParams(document, position);
127+
return languageClient.sendRequest(MatchingTagPositionRequest.type, param);
128+
};
129+
130+
disposable = activateMirrorCursor(matchingTagPositionRequestor, { xml: true }, 'xml.matchingTagEditing');
131+
toDispose.push(disposable);
132+
133+
const matchingTagEditCommand = 'xml.toggleMatchingTagEdit';
134+
135+
const matchingTagEditHandler = () => {
136+
const xmlConfiguration = workspace.getConfiguration('xml');
137+
const current = xmlConfiguration.matchingTagEditing;
138+
xmlConfiguration.update("matchingTagEditing", !current);
139+
}
140+
141+
toDispose.push(commands.registerCommand(matchingTagEditCommand, matchingTagEditHandler));
142+
117143
if (extensions.onDidChange) {// Theia doesn't support this API yet
118144
extensions.onDidChange(() => {
119145
onExtensionChange(extensions.all);
120146
});
121147
}
122-
123-
disposable = activateTagClosing(tagProvider, { xml: true, xsl: true }, Commands.AUTO_CLOSE_TAGS);
124-
toDispose.push(disposable);
125148
});
126149
languages.setLanguageConfiguration('xml', getIndentationRules());
127150
languages.setLanguageConfiguration('xsl', getIndentationRules());
@@ -139,7 +162,7 @@ export function activate(context: ExtensionContext) {
139162
let configXML = workspace.getConfiguration().get('xml');
140163
let xml;
141164
if (!configXML) { //Set default preferences if not provided
142-
const defaultValue =
165+
const defaultValue =
143166
{
144167
xml: {
145168
trace: {
@@ -160,7 +183,7 @@ export function activate(context: ExtensionContext) {
160183
xml = defaultValue;
161184
} else {
162185
let x = JSON.stringify(configXML); //configXML is not a JSON type
163-
xml = { "xml" : JSON.parse(x)};
186+
xml = { "xml": JSON.parse(x) };
164187
}
165188
xml['xml']['logs']['file'] = logfile;
166189
xml['xml']['useCache'] = true;
@@ -170,7 +193,7 @@ export function activate(context: ExtensionContext) {
170193

171194
function getIndentationRules(): LanguageConfiguration {
172195
return {
173-
196+
174197
// indentationRules referenced from:
175198
// https://github.com/microsoft/vscode/blob/d00558037359acceea329e718036c19625f91a1a/extensions/html-language-features/client/src/htmlMain.ts#L114-L115
176199
indentationRules: {

src/mirrorCursor.ts

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
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 activateMirrorCursor(
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+
let previousState = workspace.getConfiguration().get<boolean>(configName);
27+
let wasNotified = false;
28+
let isEnabled = false;
29+
updateEnabledState();
30+
31+
workspace.onDidChangeConfiguration(updateEnabledState, null, disposables);
32+
33+
function updateEnabledState() {
34+
updateStateSetting();
35+
promptUpdateMessage();
36+
}
37+
38+
function updateStateSetting() {
39+
isEnabled = false;
40+
let editor = window.activeTextEditor;
41+
if (!editor) {
42+
return;
43+
}
44+
let document = editor.document;
45+
if (!supportedLanguages[document.languageId]) {
46+
return;
47+
}
48+
if (!workspace.getConfiguration(undefined, document.uri).get<boolean>(configName)) {
49+
return;
50+
}
51+
isEnabled = true;
52+
}
53+
54+
function promptUpdateMessage() {
55+
if(!wasNotified && previousState != isEnabled) {
56+
window.showInformationMessage("Toggled the `xml.matchingTagEditing` preference in the Workspace settings.")
57+
wasNotified = true;
58+
}
59+
previousState = isEnabled;
60+
}
61+
62+
let prevCursors: readonly Selection[] = [];
63+
let cursors: readonly Selection[] = [];
64+
let inMirrorMode = false;
65+
66+
function onDidChangeTextEditorSelection(event: TextEditorSelectionChangeEvent) {
67+
if (!isEnabled) {
68+
return;
69+
}
70+
71+
prevCursors = cursors;
72+
cursors = event.selections;
73+
74+
if (cursors.length === 1) {
75+
if (inMirrorMode && prevCursors.length === 2) {
76+
if (cursors[0].isEqual(prevCursors[0]) || cursors[0].isEqual(prevCursors[1])) {
77+
return;
78+
}
79+
}
80+
if (event.selections[0].isEmpty) {
81+
matchingTagPositionProvider(event.textEditor.document, event.selections[0].active).then(matchingTagPosition => {
82+
if (matchingTagPosition && window.activeTextEditor) {
83+
const charBeforeAndAfterPositionsRoughtlyEqual = isCharBeforeAndAfterPositionsRoughtlyEqual(
84+
event.textEditor.document,
85+
event.selections[0].anchor,
86+
new Position(matchingTagPosition.line, matchingTagPosition.character)
87+
);
88+
89+
if (charBeforeAndAfterPositionsRoughtlyEqual) {
90+
inMirrorMode = true;
91+
const newCursor = new Selection(
92+
matchingTagPosition.line,
93+
matchingTagPosition.character,
94+
matchingTagPosition.line,
95+
matchingTagPosition.character
96+
);
97+
window.activeTextEditor.selections = [...window.activeTextEditor.selections, newCursor];
98+
}
99+
}
100+
}).then(undefined, err => {
101+
const msg = err.message ;
102+
// mutes "rejected promise not handled within 1 second"
103+
if (msg && !msg.endsWith('has been cancelled')){
104+
console.log(err);
105+
}
106+
return;
107+
});
108+
}
109+
}
110+
111+
const exitMirrorMode = () => {
112+
inMirrorMode = false;
113+
window.activeTextEditor!.selections = [window.activeTextEditor!.selections[0]];
114+
};
115+
116+
if (cursors.length === 2 && inMirrorMode) {
117+
if (event.selections[0].isEmpty && event.selections[1].isEmpty) {
118+
if (
119+
prevCursors.length === 2 &&
120+
event.selections[0].anchor.line !== prevCursors[0].anchor.line &&
121+
event.selections[1].anchor.line !== prevCursors[0].anchor.line
122+
) {
123+
exitMirrorMode();
124+
return;
125+
}
126+
127+
const charBeforeAndAfterPositionsRoughtlyEqual = isCharBeforeAndAfterPositionsRoughtlyEqual(
128+
event.textEditor.document,
129+
event.selections[0].anchor,
130+
event.selections[1].anchor
131+
);
132+
133+
if (!charBeforeAndAfterPositionsRoughtlyEqual) {
134+
exitMirrorMode();
135+
return;
136+
} else {
137+
// Need to cleanup in the case of <div |></div |>
138+
if (
139+
shouldDoCleanupForHtmlAttributeInput(
140+
event.textEditor.document,
141+
event.selections[0].anchor,
142+
event.selections[1].anchor
143+
)
144+
) {
145+
const cleanupEdit = new WorkspaceEdit();
146+
const cleanupRange = new Range(event.selections[1].anchor.translate(0, -1), event.selections[1].anchor);
147+
cleanupEdit.replace(event.textEditor.document.uri, cleanupRange, '');
148+
exitMirrorMode();
149+
workspace.applyEdit(cleanupEdit);
150+
}
151+
}
152+
}
153+
}
154+
}
155+
156+
return Disposable.from(...disposables);
157+
}
158+
159+
function getCharBefore(document: TextDocument, position: Position) {
160+
const offset = document.offsetAt(position);
161+
if (offset === 0) {
162+
return '';
163+
}
164+
165+
return document.getText(new Range(document.positionAt(offset - 1), position));
166+
}
167+
168+
function getCharAfter(document: TextDocument, position: Position) {
169+
const offset = document.offsetAt(position);
170+
if (offset === document.getText().length) {
171+
return '';
172+
}
173+
174+
return document.getText(new Range(position, document.positionAt(offset + 1)));
175+
}
176+
177+
// Check if chars before and after the two positions are equal
178+
// For the chars before, `<` and `/` are consiered equal to handle the case of `<|></|>`
179+
function isCharBeforeAndAfterPositionsRoughtlyEqual(document: TextDocument, firstPos: Position, secondPos: Position) {
180+
const charBeforePrimarySelection = getCharBefore(document, firstPos);
181+
const charAfterPrimarySelection = getCharAfter(document, firstPos);
182+
const charBeforeSecondarySelection = getCharBefore(document, secondPos);
183+
const charAfterSecondarySelection = getCharAfter(document, secondPos);
184+
185+
/**
186+
* Special case for exiting
187+
* |<div>
188+
* |</div>
189+
*/
190+
if (
191+
charBeforePrimarySelection === ' ' &&
192+
charBeforeSecondarySelection === ' ' &&
193+
charAfterPrimarySelection === '<' &&
194+
charAfterSecondarySelection === '<'
195+
) {
196+
return false;
197+
}
198+
/**
199+
* Special case for exiting
200+
* | <div>
201+
* | </div>
202+
*/
203+
if (charBeforePrimarySelection === '\n' && charBeforeSecondarySelection === '\n') {
204+
return false;
205+
}
206+
/**
207+
* Special case for exiting
208+
* <div>|
209+
* </div>|
210+
*/
211+
if (charAfterPrimarySelection === '\n' && charAfterSecondarySelection === '\n') {
212+
return false;
213+
}
214+
215+
// Exit mirror mode when cursor position no longer mirror
216+
// Unless it's in the case of `<|></|>`
217+
const charBeforeBothPositionRoughlyEqual =
218+
charBeforePrimarySelection === charBeforeSecondarySelection ||
219+
(charBeforePrimarySelection === '/' && charBeforeSecondarySelection === '<') ||
220+
(charBeforeSecondarySelection === '/' && charBeforePrimarySelection === '<');
221+
const charAfterBothPositionRoughlyEqual =
222+
charAfterPrimarySelection === charAfterSecondarySelection ||
223+
(charAfterPrimarySelection === ' ' && charAfterSecondarySelection === '>') ||
224+
(charAfterSecondarySelection === ' ' && charAfterPrimarySelection === '>');
225+
226+
return charBeforeBothPositionRoughlyEqual && charAfterBothPositionRoughlyEqual;
227+
}
228+
229+
function shouldDoCleanupForHtmlAttributeInput(document: TextDocument, firstPos: Position, secondPos: Position) {
230+
// Need to cleanup in the case of <div |></div |>
231+
const charBeforePrimarySelection = getCharBefore(document, firstPos);
232+
const charAfterPrimarySelection = getCharAfter(document, firstPos);
233+
const charBeforeSecondarySelection = getCharBefore(document, secondPos);
234+
const charAfterSecondarySelection = getCharAfter(document, secondPos);
235+
236+
const primaryBeforeSecondary = document.offsetAt(firstPos) < document.offsetAt(secondPos);
237+
238+
/**
239+
* Check two cases
240+
* <div |></div >
241+
* <div | id="a"></div >
242+
* Before 1st cursor: ` `
243+
* After 1st cursor: `>` or ` `
244+
* Before 2nd cursor: ` `
245+
* After 2nd cursor: `>`
246+
*/
247+
return (
248+
primaryBeforeSecondary &&
249+
charBeforePrimarySelection === ' ' &&
250+
(charAfterPrimarySelection === '>' || charAfterPrimarySelection === ' ') &&
251+
charBeforeSecondarySelection === ' ' &&
252+
charAfterSecondarySelection === '>'
253+
);
254+
}

0 commit comments

Comments
 (0)