From 25e4b9352dc5f63523a89b2637144af7ab7dadfe Mon Sep 17 00:00:00 2001 From: Nikolas Komonen Date: Mon, 9 Dec 2019 14:50:39 -0500 Subject: [PATCH] Allows editing of start/end tag simultaneously Under the preference xml.autoSelectingMatchingTags Fixes #130 Signed-off-by: Nikolas Komonen --- package.json | 14 +++ src/extension.ts | 41 +++++-- src/mirrorCursor.ts | 254 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 300 insertions(+), 9 deletions(-) create mode 100644 src/mirrorCursor.ts diff --git a/package.json b/package.json index 2f9b9be1..653d4b5b 100644 --- a/package.json +++ b/package.json @@ -192,6 +192,12 @@ "description": "Enable/disable autoclosing of XML tags. \n\nIMPORTANT: Turn off editor.autoClosingTags for this to work", "scope": "window" }, + "xml.autoSelectingMatchingTags": { + "type": "boolean", + "scope": "window", + "default": true, + "description": "Adds an additional cursor on the matching tag, allows for start/end tag editing." + }, "xml.codeLens.enabled": { "type": "boolean", "default": false, @@ -294,6 +300,14 @@ "fileMatch": "package.json", "url": "./schemas/package.schema.json" } + ], + "keybindings":[ + { + "command": "xml.toggleMatchingTagEdit", + "key": "ctrl+shift+f2", + "mac": "cmd+shift+f2", + "when": "editorFocus" + } ] } } diff --git a/src/extension.ts b/src/extension.ts index 25b5f3a1..9d631085 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -20,9 +20,11 @@ import { activateTagClosing, AutoCloseResult } from './tagClosing'; import { Commands } from './commands'; import { onConfigurationChange, subscribeJDKChangeConfiguration } from './settings'; import { collectXmlJavaExtensions, onExtensionChange } from './plugin'; +import { activateMirrorCursor } from './mirrorCursor'; +import { error } from 'util'; export interface ScopeInfo { - scope : "default" | "global" | "workspace" | "folder"; + scope: "default" | "global" | "workspace" | "folder"; configurationTarget: boolean; } @@ -30,7 +32,9 @@ namespace TagCloseRequest { export const type: RequestType = new RequestType('xml/closeTag'); } - +namespace MatchingTagPositionRequest { + export const type: RequestType = new RequestType('xml/matchingTagPosition'); +} export function activate(context: ExtensionContext) { let storagePath = context.storagePath; @@ -70,7 +74,7 @@ export function activate(context: ExtensionContext) { } } } - }, + }, synchronize: { //preferences starting with these will trigger didChangeConfiguration configurationSection: ['xml', '[xml]'] @@ -114,14 +118,33 @@ export function activate(context: ExtensionContext) { return text; }; + disposable = activateTagClosing(tagProvider, { xml: true, xsl: true }, Commands.AUTO_CLOSE_TAGS); + toDispose.push(disposable); + + //Setup mirrored tag rename request + const matchingTagPositionRequestor = (document: TextDocument, position: Position) => { + let param = languageClient.code2ProtocolConverter.asTextDocumentPositionParams(document, position); + return languageClient.sendRequest(MatchingTagPositionRequest.type, param); + }; + + disposable = activateMirrorCursor(matchingTagPositionRequestor, { xml: true }, 'xml.autoSelectingMatchingTags'); + toDispose.push(disposable); + + const matchingTagEditCommand = 'xml.toggleMatchingTagEdit'; + + const matchingTagEditHandler = () => { + const xmlConfiguration = workspace.getConfiguration('xml'); + const current = xmlConfiguration.autoSelectingMatchingTags; + xmlConfiguration.update("autoSelectingMatchingTags", !current); + } + + toDispose.push(commands.registerCommand(matchingTagEditCommand, matchingTagEditHandler)); + if (extensions.onDidChange) {// Theia doesn't support this API yet extensions.onDidChange(() => { onExtensionChange(extensions.all); }); } - - disposable = activateTagClosing(tagProvider, { xml: true, xsl: true }, Commands.AUTO_CLOSE_TAGS); - toDispose.push(disposable); }); languages.setLanguageConfiguration('xml', getIndentationRules()); languages.setLanguageConfiguration('xsl', getIndentationRules()); @@ -139,7 +162,7 @@ export function activate(context: ExtensionContext) { let configXML = workspace.getConfiguration().get('xml'); let xml; if (!configXML) { //Set default preferences if not provided - const defaultValue = + const defaultValue = { xml: { trace: { @@ -160,7 +183,7 @@ export function activate(context: ExtensionContext) { xml = defaultValue; } else { let x = JSON.stringify(configXML); //configXML is not a JSON type - xml = { "xml" : JSON.parse(x)}; + xml = { "xml": JSON.parse(x) }; } xml['xml']['logs']['file'] = logfile; xml['xml']['useCache'] = true; @@ -170,7 +193,7 @@ export function activate(context: ExtensionContext) { function getIndentationRules(): LanguageConfiguration { return { - + // indentationRules referenced from: // https://github.com/microsoft/vscode/blob/d00558037359acceea329e718036c19625f91a1a/extensions/html-language-features/client/src/htmlMain.ts#L114-L115 indentationRules: { diff --git a/src/mirrorCursor.ts b/src/mirrorCursor.ts new file mode 100644 index 00000000..d683dd16 --- /dev/null +++ b/src/mirrorCursor.ts @@ -0,0 +1,254 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + window, + workspace, + Disposable, + TextDocument, + Position, + TextEditorSelectionChangeEvent, + Selection, + Range, + WorkspaceEdit +} from 'vscode'; + +export function activateMirrorCursor( + matchingTagPositionProvider: (document: TextDocument, position: Position) => Thenable, + supportedLanguages: { [id: string]: boolean }, + configName: string +): Disposable { + let disposables: Disposable[] = []; + + window.onDidChangeTextEditorSelection(event => onDidChangeTextEditorSelection(event), null, disposables); + let previousState = workspace.getConfiguration().get(configName); + let wasNotified = false; + let isEnabled = false; + updateEnabledState(); + + workspace.onDidChangeConfiguration(updateEnabledState, null, disposables); + + function updateEnabledState() { + updateStateSetting(); + promptUpdateMessage(); + } + + function updateStateSetting() { + isEnabled = false; + let editor = window.activeTextEditor; + if (!editor) { + return; + } + let document = editor.document; + if (!supportedLanguages[document.languageId]) { + return; + } + if (!workspace.getConfiguration(undefined, document.uri).get(configName)) { + return; + } + isEnabled = true; + } + + function promptUpdateMessage() { + if(!wasNotified && previousState != isEnabled) { + window.showInformationMessage("Toggled the `xml.autoSelectingMatchingTags` preference in the Workspace settings.") + wasNotified = true; + } + previousState = isEnabled; + } + + let prevCursors: readonly Selection[] = []; + let cursors: readonly Selection[] = []; + let inMirrorMode = false; + + function onDidChangeTextEditorSelection(event: TextEditorSelectionChangeEvent) { + if (!isEnabled) { + return; + } + + prevCursors = cursors; + cursors = event.selections; + + if (cursors.length === 1) { + if (inMirrorMode && prevCursors.length === 2) { + if (cursors[0].isEqual(prevCursors[0]) || cursors[0].isEqual(prevCursors[1])) { + return; + } + } + if (event.selections[0].isEmpty) { + matchingTagPositionProvider(event.textEditor.document, event.selections[0].active).then(matchingTagPosition => { + if (matchingTagPosition && window.activeTextEditor) { + const charBeforeAndAfterPositionsRoughtlyEqual = isCharBeforeAndAfterPositionsRoughtlyEqual( + event.textEditor.document, + event.selections[0].anchor, + new Position(matchingTagPosition.line, matchingTagPosition.character) + ); + + if (charBeforeAndAfterPositionsRoughtlyEqual) { + inMirrorMode = true; + const newCursor = new Selection( + matchingTagPosition.line, + matchingTagPosition.character, + matchingTagPosition.line, + matchingTagPosition.character + ); + window.activeTextEditor.selections = [...window.activeTextEditor.selections, newCursor]; + } + } + }).then(undefined, err => { + const msg = err.message ; + // mutes "rejected promise not handled within 1 second" + if (msg && !msg.endsWith('has been cancelled')){ + console.log(err); + } + return; + }); + } + } + + const exitMirrorMode = () => { + inMirrorMode = false; + window.activeTextEditor!.selections = [window.activeTextEditor!.selections[0]]; + }; + + if (cursors.length === 2 && inMirrorMode) { + if (event.selections[0].isEmpty && event.selections[1].isEmpty) { + if ( + prevCursors.length === 2 && + event.selections[0].anchor.line !== prevCursors[0].anchor.line && + event.selections[1].anchor.line !== prevCursors[0].anchor.line + ) { + exitMirrorMode(); + return; + } + + const charBeforeAndAfterPositionsRoughtlyEqual = isCharBeforeAndAfterPositionsRoughtlyEqual( + event.textEditor.document, + event.selections[0].anchor, + event.selections[1].anchor + ); + + if (!charBeforeAndAfterPositionsRoughtlyEqual) { + exitMirrorMode(); + return; + } else { + // Need to cleanup in the case of
+ if ( + shouldDoCleanupForHtmlAttributeInput( + event.textEditor.document, + event.selections[0].anchor, + event.selections[1].anchor + ) + ) { + const cleanupEdit = new WorkspaceEdit(); + const cleanupRange = new Range(event.selections[1].anchor.translate(0, -1), event.selections[1].anchor); + cleanupEdit.replace(event.textEditor.document.uri, cleanupRange, ''); + exitMirrorMode(); + workspace.applyEdit(cleanupEdit); + } + } + } + } + } + + return Disposable.from(...disposables); +} + +function getCharBefore(document: TextDocument, position: Position) { + const offset = document.offsetAt(position); + if (offset === 0) { + return ''; + } + + return document.getText(new Range(document.positionAt(offset - 1), position)); +} + +function getCharAfter(document: TextDocument, position: Position) { + const offset = document.offsetAt(position); + if (offset === document.getText().length) { + return ''; + } + + return document.getText(new Range(position, document.positionAt(offset + 1))); +} + +// Check if chars before and after the two positions are equal +// For the chars before, `<` and `/` are consiered equal to handle the case of `<|>` +function isCharBeforeAndAfterPositionsRoughtlyEqual(document: TextDocument, firstPos: Position, secondPos: Position) { + const charBeforePrimarySelection = getCharBefore(document, firstPos); + const charAfterPrimarySelection = getCharAfter(document, firstPos); + const charBeforeSecondarySelection = getCharBefore(document, secondPos); + const charAfterSecondarySelection = getCharAfter(document, secondPos); + + /** + * Special case for exiting + * |
+ * |
+ */ + if ( + charBeforePrimarySelection === ' ' && + charBeforeSecondarySelection === ' ' && + charAfterPrimarySelection === '<' && + charAfterSecondarySelection === '<' + ) { + return false; + } + /** + * Special case for exiting + * |
+ * |
+ */ + if (charBeforePrimarySelection === '\n' && charBeforeSecondarySelection === '\n') { + return false; + } + /** + * Special case for exiting + *
| + *
| + */ + if (charAfterPrimarySelection === '\n' && charAfterSecondarySelection === '\n') { + return false; + } + + // Exit mirror mode when cursor position no longer mirror + // Unless it's in the case of `<|>` + const charBeforeBothPositionRoughlyEqual = + charBeforePrimarySelection === charBeforeSecondarySelection || + (charBeforePrimarySelection === '/' && charBeforeSecondarySelection === '<') || + (charBeforeSecondarySelection === '/' && charBeforePrimarySelection === '<'); + const charAfterBothPositionRoughlyEqual = + charAfterPrimarySelection === charAfterSecondarySelection || + (charAfterPrimarySelection === ' ' && charAfterSecondarySelection === '>') || + (charAfterSecondarySelection === ' ' && charAfterPrimarySelection === '>'); + + return charBeforeBothPositionRoughlyEqual && charAfterBothPositionRoughlyEqual; +} + +function shouldDoCleanupForHtmlAttributeInput(document: TextDocument, firstPos: Position, secondPos: Position) { + // Need to cleanup in the case of
+ const charBeforePrimarySelection = getCharBefore(document, firstPos); + const charAfterPrimarySelection = getCharAfter(document, firstPos); + const charBeforeSecondarySelection = getCharBefore(document, secondPos); + const charAfterSecondarySelection = getCharAfter(document, secondPos); + + const primaryBeforeSecondary = document.offsetAt(firstPos) < document.offsetAt(secondPos); + + /** + * Check two cases + *
+ *
+ * Before 1st cursor: ` ` + * After 1st cursor: `>` or ` ` + * Before 2nd cursor: ` ` + * After 2nd cursor: `>` + */ + return ( + primaryBeforeSecondary && + charBeforePrimarySelection === ' ' && + (charAfterPrimarySelection === '>' || charAfterPrimarySelection === ' ') && + charBeforeSecondarySelection === ' ' && + charAfterSecondarySelection === '>' + ); +} \ No newline at end of file