Skip to content

Commit

Permalink
Allows editing of start/end tag simultaneously
Browse files Browse the repository at this point in the history
Under the preference xml.autoSelectingMatchingTags

Fixes redhat-developer#130

Signed-off-by: Nikolas Komonen <[email protected]>
  • Loading branch information
NikolasKomonen committed Dec 11, 2019
1 parent 6a7db6e commit f355a2c
Show file tree
Hide file tree
Showing 3 changed files with 300 additions and 9 deletions.
14 changes: 14 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@
"description": "Enable/disable autoclosing of XML tags. \n\nIMPORTANT: Turn off editor.autoClosingTags for this to work",
"scope": "window"
},
"xml.matchingTagEditing": {
"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,
Expand Down Expand Up @@ -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"
}
]
}
}
41 changes: 32 additions & 9 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,21 @@ 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;
}

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


namespace MatchingTagPositionRequest {
export const type: RequestType<TextDocumentPositionParams, Position | null, any, any> = new RequestType('xml/matchingTagPosition');
}

export function activate(context: ExtensionContext) {
let storagePath = context.storagePath;
Expand Down Expand Up @@ -70,7 +74,7 @@ export function activate(context: ExtensionContext) {
}
}
}
},
},
synchronize: {
//preferences starting with these will trigger didChangeConfiguration
configurationSection: ['xml', '[xml]']
Expand Down Expand Up @@ -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.matchingTagEditing');
toDispose.push(disposable);

const matchingTagEditCommand = 'xml.toggleMatchingTagEdit';

const matchingTagEditHandler = () => {
const xmlConfiguration = workspace.getConfiguration('xml');
const current = xmlConfiguration.matchingTagEditing;
xmlConfiguration.update("matchingTagEditing", !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());
Expand All @@ -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: {
Expand All @@ -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;
Expand All @@ -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: {
Expand Down
254 changes: 254 additions & 0 deletions src/mirrorCursor.ts
Original file line number Diff line number Diff line change
@@ -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<Position | null>,
supportedLanguages: { [id: string]: boolean },
configName: string
): Disposable {
let disposables: Disposable[] = [];

window.onDidChangeTextEditorSelection(event => onDidChangeTextEditorSelection(event), null, disposables);
let previousState = workspace.getConfiguration().get<boolean>(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<boolean>(configName)) {
return;
}
isEnabled = true;
}

function promptUpdateMessage() {
if(!wasNotified && previousState != isEnabled) {
window.showInformationMessage("Toggled the `xml.matchingTagEditing` 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 <div |></div |>
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
* |<div>
* |</div>
*/
if (
charBeforePrimarySelection === ' ' &&
charBeforeSecondarySelection === ' ' &&
charAfterPrimarySelection === '<' &&
charAfterSecondarySelection === '<'
) {
return false;
}
/**
* Special case for exiting
* | <div>
* | </div>
*/
if (charBeforePrimarySelection === '\n' && charBeforeSecondarySelection === '\n') {
return false;
}
/**
* Special case for exiting
* <div>|
* </div>|
*/
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 <div |></div |>
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
* <div |></div >
* <div | id="a"></div >
* Before 1st cursor: ` `
* After 1st cursor: `>` or ` `
* Before 2nd cursor: ` `
* After 2nd cursor: `>`
*/
return (
primaryBeforeSecondary &&
charBeforePrimarySelection === ' ' &&
(charAfterPrimarySelection === '>' || charAfterPrimarySelection === ' ') &&
charBeforeSecondarySelection === ' ' &&
charAfterSecondarySelection === '>'
);
}

0 comments on commit f355a2c

Please sign in to comment.