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